Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24f3311006 | ||
|
|
9a175925b6 | ||
|
|
716cc5f6f1 | ||
|
|
b3638f4d63 | ||
|
|
c6487dc258 | ||
|
|
cc7f09fa22 | ||
|
|
968f41d68d | ||
|
|
c1a17a262a | ||
|
|
3444265473 | ||
|
|
fcf6895feb | ||
|
|
5bd12dab15 | ||
|
|
7472b2c761 | ||
|
|
9ee672504f | ||
|
|
ff5a50bad8 | ||
|
|
68dcfc303c |
No files matched your search
@@ -2,6 +2,13 @@
|
||||
#/ public-key encryption for .env files /
|
||||
#/ [how it works](https://dotenvx.com/encryption) /
|
||||
#/----------------------------------------------------------/
|
||||
DOTENV_PUBLIC_KEY_DEV="02a9d0f6522086b04ac7763e008a6ad0273c58258715e613c977fd6c82a2030d92"
|
||||
|
||||
# .env.dev
|
||||
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
|
||||
#/ public-key encryption for .env files /
|
||||
#/ [how it works](https://dotenvx.com/encryption) /
|
||||
#/----------------------------------------------------------/
|
||||
DOTENV_PUBLIC_KEY="02a9d0f6522086b04ac7763e008a6ad0273c58258715e613c977fd6c82a2030d92"
|
||||
|
||||
# .env
|
||||
@@ -10,4 +17,5 @@ COM_ERMNVLDMR_STATIC_S3_REGION="encrypted:BAj3iuo7tWWNR2V4yQ+LHnFCB4JcZp7TTfUtvv
|
||||
COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL="encrypted:BNkQrgsrxtz544vuJbgwWUh5cPjHG5wbmHNcxTUWrzbFJJsdncaAitviCvIrS7Q41w+FiWkssv3D05Tu8Inn2P62E6G3PU4gNxO2oLdmGEdrzUqB/xvxOvLvos42j54QOZf7lOqMPKH6SL2/rheCAo10gSCicCqkfh4xsCAbYpyL"
|
||||
COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID="encrypted:BCsp8p4bp3cB+9grxHo634isRUaFGU3MOc1lr8ClSFRYLjqjqukyrbJCTZWV2Xdo8iQ33EylmbzUltbwzTWMQcjJSvIskesVLyV93q60o/Mu9L9jg/apwHuBgHv4zVuHstJ0zkHWgapbNTIlWD3RCm0ujeX/H1gBH3U="
|
||||
COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY="encrypted:BM7aPliOyiPrqFPZMfqpn534Nu+Y0Nbh/YRoxv2KY6XAi2dNO0LSEf97DqSUQYdsnu9iibZmVNktF2K/D6mtHr9+gumF6KsmaLH65KE7c9ly7NdFQHL9I26wxRq1Sr3Zgrfs954J6oURSlBRKqJFdcTsBltuuupGuqJHyLS9+DE0jD+bl0Qg0lA="
|
||||
SOPS_AGE_KEY="encrypted:BOJ7q79Tws4DMxpFuSymgv//IC4f+j713Bd7mNlUCEaUYVSfJMayMVPzks0FcsYo12XvlbH6gQ+tCf3t9yj/LcKKJzl3wtxlSVjvCRr0hasEBohcoxkLnFZ73DFQGzWcVC5Jf5IMK7qdYdNVzW0dVY/sn/NlTgl+A3YB6/08XYPhljl9993ky76zQqWDJQ66bKJ7YxWvst2TUNoT0lJEL0M4pg9ztRsuGqNZ"
|
||||
SOPS_AGE_KEY="encrypted:BKiJVxKYhiadu0kNCw8wVLYdaEWZreAVu2PaujxhvcXeCowCGjivEuQD/uhduAy/CwwDWyQkgQJ2FJMEKA47e7QDjS9TMdPCXO19/teR7LThgqTf6AbSM3sebln5zalgyplay4A/7cVytPXuOvVkaYyoh9CgzTVsPJKXRq5yMaDEx/9/91Yo8zUMF3z1vq3/preI5baK4/1xXewR3OwfZqnNGyd7LXkdqyT5"
|
||||
COM_ERMNVLDMR_DEV_ROOT_CA_KEY="encrypted:BP+GhCwutIMF2hSxIs92VNSoEAvfdhYRFSi5MUpFfCs+pNxJkZ7tFOAadMqSlPiLBcm9+A3yIn/04/oEtRNjXk/jlZQ2G21mt30sd89NARjJO+94ui6XbllW65E5/T+d8fDmRhz4c4OcNoKk70aXcdJqd1CJT7IlYQn++zQvB2el9cp9e7E22rvGtvum40iISEt/pVU84uHyQg6ZeCn+1czM+nLhPiWOAgveTz3EAgExlJW2de1ilDIuBCFXJy5+KvnCj3WNwRTAbIYgCwFnZMOUE5jYlhP6OZ2DQ3sWv7noG/Xaa/P+EomP9dwcamlAU32tRruyA8ZSp/ah9E8hFfmBqgKfSbRlWq3qGkq/F935DZfU/C+PjRcepS/eLZQ0mfD23B21VX2CjOhEkp5V+9I5L8XkXDjDg56JDRGvF/umpdqF/KpmtimqcgB0yDVY1Z3yT1U5IZ6Yf4WlqzhchdJXRWfo0bqK8Tk0b442/6AQgRP3n6DIrCjcpmr4jx+RJiejOr3xv3QtHGafDdkF2gO7DrYztdd6XpRjij0orBUiyYECgPsLHuwd+/2jXnxLVTavVTQ0Ddz0LLNjaYxqr8KK/B+0MeS/iuLXmACs8XCKwQmV8hPYQ0C/JXGiL4GOH0V70r6ghBxCDDMpJjPasCbiCBxSAMLkQ7ZmLbO0WVPPUTMAsZ8aKUy2MLe466zizXN640B4H6bkI7x/KC29sroEJWpUufD7f+t2tnVx/KLeAxAC6afuUCGi4ibFiwLHZjfimYKaYpuMcSZHQ64iONixyfUDIRNIOWQSiX1V5EBJgBDE0JVDnExmQYAIG5isYQEorC/7z6IJeZHc4NuiKDWuvd+jwwj8tN8PVAcACJKpQHpyUU20kvp4dYgBEeiQZpBfXWrnmOJZ0HgDHbvVxPW+SHTL/uoi/Tl9xdKe+z3Q/MoT8iYXggMPcFQIqVemQUTw9Tu0oNsSj3xueR9awJc2Le9aYbKTg54MAtcBnfKXN+3cYQkNVbqC5RfiAe2D3gfbT1OgApTUNz8n8v1dgAzTa+EB0i+w8nTsb3N/N4yAXMNSKdGeUl2rFRuQO7qlWorKc3pE+nBC2ex0sOMEUFYUXcyULaxsKM3ddakCAv9/jesnkrZl2gX7YDp8iqiFzh7l8jMAFHtFCMp3CSXlBAOpfQlGLY80qTSi1e8GrnV9Ow9t0DdS1wP7J6ES9DiNuEep5atHD+0MC6u0Dw0GtmsNnNRp77my/KI8ILhF4VBVBFQ5BiJDfdpAA3RNlANo2gK5ZCKPw4tXCYUknjYdwkqvUaIQCJcr2n+WkE2eCUCqjXTFr/KMvRByY6Fb1vAwcGoCWjP2g0Tliz3hcyh/72+umn05NO3cJSAfDEjfcYBUQZ/LCcy86G0EsHXP3NabgeHNXf/Z/VG7zWqQAPX0KaVrF9XC6I2FLkg1aNGWZfJGv4uohdIny7rWU7FWPaxy9GMk6x7xBh/Hk/5/rrGHCNGs+SegLglDs2+v4P56316k4kg//dmVl/vh3h0KC2P/1xiJZ10I91k7HqZLb2qdXToa4yOHB5Euu49UV/Qtv+cZU03fuT81sEv3YT819OdmwUZgkq0PT/Lrck+pbNDaUOakpgkGAbXy6SCvjM87jYN9Ia7druulWWO0pPi4T8daQ26ZQmZp57gdkpuh/He4kC5m1aWpT2s5STSbapChd9tFNjPhjCG1DZb7h0X8jg1VkxqutDDu17jOXrhElT1ejvYqYy9lIG1b1r7Jmjxo7LgGQuPMX6CIo/BLTsuNJyC5SfSCxEm56LvJgoNk2Vaeq6mLDOaKm2PNwPuYPnJw9xWBM0EetJBnsYMutPDMXhrB7x/8DKy5/hCsKICTfhlsboUHmbSfsikvGPYx7YOaOo8xFh4yAQ73T8hFX7kn2DBfRzGkJm8SQJRPj0Hjsf5mEFUt/OrieK/Jw10HKD6hDoEFmUDv5svC3nUY0FX0fTyi0jRAri+burrCQ0HXk/qj4Xs+WLiJgfQbprV2TwTIlzOdLb/jD8PeiElNzBxmOonRj76QKcbkOnPkCWCMFKapOJskxe0ASJVjYt142D0ndTa6F7oFu72ue5U+hyoToKeb7ONZilhF0+yL7akNfFKURUa/X+cEoewuSM7r8whWwZyG+M2cwGUTRMJiAC9wK7yE8MqdrEuQtILnCA0yRTPOh1Z9ifUY0asL1Tw9cbdGRW8svHH/oVHkWe3P+yepTNrn7xQlX/O1/p4FXRm0gSE3Fdnbli8VZdwVn2SET3WkPH9rJT2F7/hovfhx+lQzBQq9ZBpNLrWRdhXzCpiaC04o7q/eef+8dZwU62A1pd8uZ+0dI5T7lOVXoo03uF863YSOKX8ad2CiYUJ8gMvMA8FSWnsUbxiDaiXPj8kni5TLsvrkH/CKY/okG5SdRCF/XCcRcXP5dxdsS90tIWdd0xi/2wbalmn3CPQ2eVeDI3dP/jCRBkfiNtD70flhxVngZMZW+Rr1c0Cpo//fMCiV5ksDIBGin5AfOPmvSnPYWV5zcU3QEfl2F5fLXgDCbpaYjaOYjyRt9ZTqabSf1Ar4L9yQFBr4N9cNg4jKskvbha4BDmUL8J7xz/F/OXHFnEDV2YkMcpBfGM2XhKDevceOQcjrPkF5/e5LdwQeYhqgzzm6ur42OwHB2HBU7ulXn/LCk2eYCFa2D1f+2F3ExbwAvnDMhymdQdUniF/NzOfG/1L3yplFsqi270T/2gT1LdOQ/Fgp3FA81k/sMLbICcF/qCp/nS3x+iMZn9B+WLjUQvJy2PehXNcpXEvXWM/e1X4PWDUhwQi7+wjJwERX2W5SB1XjQa7n/osZqpOUG2ShssPNpS3mecaV+MTpRPVYTPFiDTIgkG7F/DSND0c2PDWsBLFus0h4jG70L94uDxVNiMJb/xq/7d2+7FMNPh9UJSK75XcKDhrRYJo3iU7mfl1U89vJB179ulGrdMpWd8v5DHWEDrn3NRBa0KWVYKpqeQ0195XXH1YlZjzK7615K/5ooOGUL42PlX2Hw1zxOJubsIl6LZt8V6wa6zJgywuVLsZb+kpjtHTHmVZyhY8IUnAn6Uj8erYXapvZ8rlhUpjvVuosn8gB9JM6vNct5dVn4e8Wbt6KnRcogHYUJT32QRSWsHCaZNEqwbX+dYaLx1FQZ5daYbXSWwiHhtAJRuYutbmWwWCRTVzyNNMh3sMAI1EcmMoOIX0DEhXYNe7daErR6vMyizQ+h5YKUYK4nUu270M0x/YKRHlxwK8WkEqWv8vfhZqzV0YGud1Hwd5JwWhIEZQ7cc1uPeYJJ5kAaIFqCffmwecS2gzVvOgDkNC5eENXSk7SzDpVgm8IcfZV47L17ni75tyJCS/kreODjap6VdpDNTyS1YJaaO2TMX/s4kWiEZ22Ma4CEv2eQq1uGazGamli+ZwExAtehw=="
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
ENDPOINT_FLAG=""
|
||||
[ -n "${S3_ENDPOINT}" ] && ENDPOINT_FLAG="--endpoint-url=${S3_ENDPOINT}"
|
||||
echo "Cleaning up older images in dev/images/ to free bucket storage..."
|
||||
aws ${ENDPOINT_FLAG} s3 rm "s3://${S3_BUCKET}/dev/images/" --recursive || true
|
||||
aws ${ENDPOINT_FLAG} s3 rm "s3://${S3_BUCKET}/dev/images/" --recursive --exclude "*" --include "com-ermnvldmr-debian-12-k3s-*.qcow2" || true
|
||||
aws ${ENDPOINT_FLAG} s3 cp "output-qemu/${IMAGE_NAME}" "s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}"
|
||||
echo "Golden image uploaded to s3://${S3_BUCKET}/dev/images/${IMAGE_NAME}"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [next]
|
||||
paths-ignore:
|
||||
- '**/kustomization.yaml'
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths-ignore:
|
||||
- '**/kustomization.yaml'
|
||||
schedule:
|
||||
- cron: '43 8 * * 3'
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['actions', 'javascript-typescript']
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
|
||||
PROMPT="You are operating via GitHub Actions in non-interactive headless mode on Issue #${ISSUE_NUMBER}.\n\nIssue Context:\n${ISSUE_DATA}\n\nLatest instruction:\n${COMMENT_BODY}\n\nPlease generate/update a detailed technical implementation plan adhering to project GEMINI.md guidelines."
|
||||
|
||||
PLAN_OUTPUT=$(agy $CONTINUE_FLAG --mode plan --print-timeout 20m -p "$PROMPT" || echo "Agent encountered an error generating the plan.")
|
||||
PLAN_OUTPUT=$(agy $CONTINUE_FLAG --mode plan --dangerously-skip-permissions --print-timeout 20m -p "$PROMPT" || echo "Agent encountered an error generating the plan.")
|
||||
|
||||
DISCLOSURE="\n\n---\n*Disclosure: I'm an AI assistant helping deytenit; I'm not deytenit.*"
|
||||
gh issue comment "$ISSUE_NUMBER" --body "${PLAN_OUTPUT}${DISCLOSURE}"
|
||||
|
||||
@@ -5,6 +5,10 @@ on:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'services/docs/**'
|
||||
- '.github/workflows/services-docs-next-required.yml'
|
||||
- 'ci/docker/docs/**'
|
||||
- 'ci/config/nginx/**'
|
||||
- 'ci/scripts/**'
|
||||
|
||||
concurrency:
|
||||
group: ci-docs-${{ github.ref_name }}
|
||||
@@ -139,23 +143,42 @@ jobs:
|
||||
|
||||
MAX_ATTEMPTS=45
|
||||
ATTEMPT=1
|
||||
CF_BLOCKED_COUNT=0
|
||||
|
||||
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
|
||||
echo "Attempt $ATTEMPT/$MAX_ATTEMPTS: Polling $ENDPOINT..."
|
||||
|
||||
RESPONSE=$(curl -sfL --max-time 10 -H "User-Agent: GitHub-Actions-Rollout-Verifier" "$ENDPOINT" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RESPONSE" ]; then
|
||||
LIVE_COMMIT=$(echo "$RESPONSE" | jq -r '.commit // empty')
|
||||
RESP_FILE=$(mktemp)
|
||||
HTTP_CODE=$(curl -sL --max-time 10 \
|
||||
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" \
|
||||
-H "Accept: application/json" \
|
||||
-o "$RESP_FILE" \
|
||||
-w "%{http_code}" \
|
||||
"$ENDPOINT" || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
LIVE_COMMIT=$(jq -r '.commit // empty' "$RESP_FILE" 2>/dev/null || true)
|
||||
echo " Live commit: ${LIVE_COMMIT:-<none>}"
|
||||
|
||||
if [ "$LIVE_COMMIT" = "$TARGET_COMMIT" ]; then
|
||||
echo "✅ Success! Production docs has successfully rolled out $TARGET_COMMIT."
|
||||
rm -f "$RESP_FILE"
|
||||
exit 0
|
||||
fi
|
||||
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "503" ]; then
|
||||
echo " HTTP $HTTP_CODE: Cloudflare WAF/challenge encountered from runner IP."
|
||||
CF_BLOCKED_COUNT=$((CF_BLOCKED_COUNT + 1))
|
||||
else
|
||||
echo " Endpoint did not return valid JSON or timed out."
|
||||
echo " HTTP $HTTP_CODE: Endpoint returned unexpected status code or timed out."
|
||||
fi
|
||||
|
||||
rm -f "$RESP_FILE"
|
||||
|
||||
if [ $CF_BLOCKED_COUNT -ge 5 ]; then
|
||||
echo "⚠️ Cloudflare WAF consistently blocked external probe requests from this runner IP (HTTP $HTTP_CODE)."
|
||||
echo "The GitOps release commit was already pushed to the repository; FluxCD will reconcile the cluster."
|
||||
echo "Skipping further external HTTP verification to avoid false negative."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-static-next-required.yml'
|
||||
- 'ci/scripts/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-static-pr-required.yml'
|
||||
- 'ci/scripts/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -9,6 +9,9 @@ on:
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-www-next-required.yml'
|
||||
- 'ci/docker/www/**'
|
||||
- 'ci/config/nginx/**'
|
||||
- 'ci/scripts/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -459,23 +462,42 @@ jobs:
|
||||
|
||||
MAX_ATTEMPTS=45
|
||||
ATTEMPT=1
|
||||
CF_BLOCKED_COUNT=0
|
||||
|
||||
while [ $ATTEMPT -le $MAX_ATTEMPTS ]; do
|
||||
echo "Attempt $ATTEMPT/$MAX_ATTEMPTS: Polling $ENDPOINT..."
|
||||
|
||||
RESPONSE=$(curl -sfL --max-time 10 -H "User-Agent: GitHub-Actions-Rollout-Verifier" "$ENDPOINT" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$RESPONSE" ]; then
|
||||
LIVE_COMMIT=$(echo "$RESPONSE" | jq -r '.commit // empty')
|
||||
RESP_FILE=$(mktemp)
|
||||
HTTP_CODE=$(curl -sL --max-time 10 \
|
||||
-H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" \
|
||||
-H "Accept: application/json" \
|
||||
-o "$RESP_FILE" \
|
||||
-w "%{http_code}" \
|
||||
"$ENDPOINT" || echo "000")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
LIVE_COMMIT=$(jq -r '.commit // empty' "$RESP_FILE" 2>/dev/null || true)
|
||||
echo " Live commit: ${LIVE_COMMIT:-<none>}"
|
||||
|
||||
if [ "$LIVE_COMMIT" = "$TARGET_COMMIT" ]; then
|
||||
echo "✅ Success! Production has successfully rolled out $TARGET_COMMIT."
|
||||
rm -f "$RESP_FILE"
|
||||
exit 0
|
||||
fi
|
||||
elif [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "503" ]; then
|
||||
echo " HTTP $HTTP_CODE: Cloudflare WAF/challenge encountered from runner IP."
|
||||
CF_BLOCKED_COUNT=$((CF_BLOCKED_COUNT + 1))
|
||||
else
|
||||
echo " Endpoint did not return valid JSON or timed out."
|
||||
echo " HTTP $HTTP_CODE: Endpoint returned unexpected status code or timed out."
|
||||
fi
|
||||
|
||||
rm -f "$RESP_FILE"
|
||||
|
||||
if [ $CF_BLOCKED_COUNT -ge 5 ]; then
|
||||
echo "⚠️ Cloudflare WAF consistently blocked external probe requests from this runner IP (HTTP $HTTP_CODE)."
|
||||
echo "The GitOps release commit was already pushed to the repository; FluxCD will reconcile the cluster."
|
||||
echo "Skipping further external HTTP verification to avoid false negative."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
|
||||
@@ -25,6 +25,9 @@ playwright-report/
|
||||
.env
|
||||
.env.keys
|
||||
|
||||
# local development certificates
|
||||
.certs/
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
@@ -43,3 +46,9 @@ docs/superpowers/
|
||||
.sops/keys.txt
|
||||
ci/k8s/**/secret.yaml
|
||||
ci/cloud-init/user-data.yaml
|
||||
|
||||
# Packer & QEMU Build Artifacts
|
||||
output-qemu/
|
||||
*.qcow2
|
||||
*.iso
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
creation_rules:
|
||||
- path_regex: ci/k8s/.*secret.*\.yaml$
|
||||
encrypted_regex: '^(data|stringData)$'
|
||||
age: "age1jpa7hqyzzdfq7lvnw3l8qy6648mtjgpcqnljl5jz92dcqjrnsqfsn7m0fl"
|
||||
age: "age15zq3cje3ttmx2fhqp9cpeght6feq2w8e5m38799stjetj8urcccs36r4rc,age1jpa7hqyzzdfq7lvnw3l8qy6648mtjgpcqnljl5jz92dcqjrnsqfsn7m0fl"
|
||||
@@ -35,186 +35,8 @@ write_files:
|
||||
# public key: age1jpa7hqyzzdfq7lvnw3l8qy6648mtjgpcqnljl5jz92dcqjrnsqfsn7m0fl
|
||||
AGE-SECRET-KEY-1PLACEHOLDERFORYOURPRIVATEAGEKEY
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 3. Security Hardening Configuration
|
||||
# ----------------------------------------------------------------------------
|
||||
- path: /etc/ssh/sshd_config.d/99-security-hardening.conf
|
||||
owner: root:root
|
||||
permissions: '0600'
|
||||
content: |
|
||||
PasswordAuthentication no
|
||||
PermitRootLogin prohibit-password
|
||||
KbdInteractiveAuthentication no
|
||||
X11Forwarding no
|
||||
MaxAuthTries 3
|
||||
ClientAliveInterval 300
|
||||
ClientAliveCountMax 2
|
||||
|
||||
- path: /etc/sysctl.d/99-k3s-hardening.conf
|
||||
owner: root:root
|
||||
permissions: '0644'
|
||||
content: |
|
||||
# Network SYN Flood Protection
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
# Disable ICMP redirects
|
||||
net.ipv4.conf.all.accept_redirects = 0
|
||||
net.ipv4.conf.default.accept_redirects = 0
|
||||
net.ipv4.conf.all.send_redirects = 0
|
||||
net.ipv4.conf.default.send_redirects = 0
|
||||
# Disable Source Routing
|
||||
net.ipv4.conf.all.accept_source_route = 0
|
||||
net.ipv4.conf.default.accept_source_route = 0
|
||||
# Anti-spoofing (Reverse Path Filtering)
|
||||
net.ipv4.conf.all.rp_filter = 1
|
||||
net.ipv4.conf.default.rp_filter = 1
|
||||
# Required for K8s pod networking
|
||||
net.ipv4.ip_forward = 1
|
||||
# System Hardening
|
||||
kernel.kptr_restrict = 2
|
||||
fs.protected_hardlinks = 1
|
||||
fs.protected_symlinks = 1
|
||||
fs.inotify.max_user_watches = 524288
|
||||
fs.inotify.max_user_instances = 512
|
||||
|
||||
- path: /etc/rancher/k3s/config.yaml
|
||||
owner: root:root
|
||||
permissions: '0600'
|
||||
content: |
|
||||
write-kubeconfig-mode: "0644"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 4. Bootstrap Cluster Script
|
||||
# ----------------------------------------------------------------------------
|
||||
- path: /usr/local/bin/bootstrap-cluster.sh
|
||||
owner: root:root
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== [Bootstrap] Ensuring Reliable DNS Resolution ==="
|
||||
if ! grep -q "nameserver" /etc/resolv.conf 2>/dev/null; then
|
||||
cat << 'EOF_DNS' > /etc/resolv.conf
|
||||
nameserver 1.1.1.1
|
||||
nameserver 8.8.8.8
|
||||
nameserver 77.88.8.8
|
||||
EOF_DNS
|
||||
fi
|
||||
|
||||
echo "=== [Bootstrap] Applying Sysctl Hardening ==="
|
||||
sysctl --system
|
||||
|
||||
echo "=== [Bootstrap] Ensuring SSH Host Keys & Starting SSH ==="
|
||||
ssh-keygen -A
|
||||
systemctl restart ssh || systemctl restart sshd || true
|
||||
|
||||
echo "=== [Bootstrap] Configuring UFW Firewall ==="
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw default allow routed
|
||||
ufw allow 22/tcp comment 'SSH'
|
||||
ufw allow 80/tcp comment 'HTTP Traefik'
|
||||
ufw allow 443/tcp comment 'HTTPS Traefik'
|
||||
# Allow Kubernetes Pod & Service traffic
|
||||
ufw allow in on cni0 || true
|
||||
ufw allow in on flannel.1 || true
|
||||
ufw allow from 10.42.0.0/16 comment 'K3s Pod CIDR'
|
||||
ufw allow from 10.43.0.0/16 comment 'K3s Service CIDR'
|
||||
ufw --force enable
|
||||
|
||||
echo "=== [Bootstrap] Enabling Fail2ban ==="
|
||||
systemctl enable --now fail2ban || true
|
||||
|
||||
echo "=== [Bootstrap] Checking for Secondary Persistent Data Disk ==="
|
||||
if [ -b /dev/vdb ]; then
|
||||
echo "Secondary disk /dev/vdb detected."
|
||||
if ! blkid /dev/vdb >/dev/null 2>&1; then
|
||||
echo "Formatting /dev/vdb with ext4 filesystem..."
|
||||
mkfs.ext4 -F -L k3s-storage /dev/vdb
|
||||
fi
|
||||
mkdir -p /var/lib/rancher/k3s/storage
|
||||
if ! grep -qs '/var/lib/rancher/k3s/storage' /proc/mounts; then
|
||||
mount /dev/vdb /var/lib/rancher/k3s/storage
|
||||
echo "LABEL=k3s-storage /var/lib/rancher/k3s/storage ext4 defaults,noatime 0 2" >> /etc/fstab
|
||||
echo "Mounted /dev/vdb to /var/lib/rancher/k3s/storage."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== [Bootstrap] Starting K3s Service ==="
|
||||
systemctl enable --now k3s.service
|
||||
|
||||
echo "=== [Bootstrap] Waiting for K3s API server ready ==="
|
||||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||
until kubectl get nodes >/dev/null 2>&1; do
|
||||
echo "Waiting for Kubernetes API..."
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "=== [Bootstrap] Cluster node is online ==="
|
||||
kubectl get nodes
|
||||
|
||||
echo "=== [Bootstrap] Creating flux-system namespace ==="
|
||||
kubectl create namespace flux-system --dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
echo "=== [Bootstrap] Injecting SOPS age decryption key ==="
|
||||
if [ -f /root/.config/sops/age/keys.txt ]; then
|
||||
kubectl create secret generic sops-age \
|
||||
--namespace=flux-system \
|
||||
--from-file=age.agekey=/root/.config/sops/age/keys.txt \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
echo "sops-age secret created."
|
||||
else
|
||||
echo "WARNING: /root/.config/sops/age/keys.txt not found. Inject key manually via kubectl."
|
||||
fi
|
||||
|
||||
echo "=== [Bootstrap] Installing Flux Operator / Custom Resources ==="
|
||||
kubectl apply -f https://github.com/fluxcd/flux2/releases/download/v2.4.0/install.yaml
|
||||
|
||||
echo "=== [Bootstrap] Setting up Kubectl access for non-root users ==="
|
||||
for home_dir in /home/*; do
|
||||
if [ -d "$home_dir" ]; then
|
||||
user_name=$(basename "$home_dir")
|
||||
mkdir -p "$home_dir/.kube"
|
||||
cp /etc/rancher/k3s/k3s.yaml "$home_dir/.kube/config"
|
||||
chown -R "$user_name:$user_name" "$home_dir/.kube"
|
||||
chmod 600 "$home_dir/.kube/config"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=== [Bootstrap] Applying initial GitOps root sync ==="
|
||||
kubectl apply -f - << 'EOF_GITOPS'
|
||||
apiVersion: source.toolkit.fluxcd.io/v1
|
||||
kind: GitRepository
|
||||
metadata:
|
||||
name: ermnvldmr-repo
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 1m
|
||||
url: https://github.com/deytenit/ermnvldmr.com.git
|
||||
ref:
|
||||
branch: next
|
||||
---
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: cluster-sync
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 5m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: ermnvldmr-repo
|
||||
path: ./ci/k8s/clusters/production
|
||||
prune: true
|
||||
wait: true
|
||||
timeout: 2m
|
||||
decryption:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
EOF_GITOPS
|
||||
|
||||
echo "=== [Bootstrap] First-Boot Bootstrap Sequence Complete ==="
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# 3. Bootstrap Cluster (Pre-baked script mounts disk, starts K3s, and syncs Flux)
|
||||
# ------------------------------------------------------------------------------
|
||||
runcmd:
|
||||
- /usr/local/bin/bootstrap-cluster.sh
|
||||
@@ -5,6 +5,16 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Prevent HTTP protocol downgrade behind reverse proxy on redirects
|
||||
absolute_redirect off;
|
||||
|
||||
# Security Headers (Defense in depth)
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -25,13 +35,15 @@ server {
|
||||
# root is inherited from server block, so /en/static/js/file.js resolves correctly
|
||||
location ~* \.(js|css|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Cache-Control "public, immutable" always;
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
}
|
||||
|
||||
# Cache images
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
add_header Cache-Control "public, no-transform" always;
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
}
|
||||
|
||||
# English locale — SPA fallback for non-asset routes
|
||||
@@ -48,6 +60,7 @@ server {
|
||||
location = /version.json {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||
add_header Content-Type "application/json" always;
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
try_files /version.json =404;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@ resources:
|
||||
- service.yaml
|
||||
images:
|
||||
- name: ghcr.io/deytenit/ermnvldmr.com/docs
|
||||
newTag: sha-4e4cac0
|
||||
newTag: sha-b3638f4
|
||||
@@ -12,11 +12,20 @@ sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxRXIvMEpaWU1XNjV2OFpp
|
||||
Z1lYb25kOFVVaXlRYkdzSmxWbmxqaXR6TEhRCnF0M2ovRVNmdTU3WnNOcy82OExV
|
||||
YXNjZVJrbTdXQWJxMjQvNEdZNlBwcjAKLS0tIEEzczNmZjFwT1d2aXVKV1Bvdm1Q
|
||||
UnZoNVNCYjJiYWd6WlNobGEyUGh5NmMKkUTDkYlNkXH8DegvBT7ymkL7h1B9x77N
|
||||
IxeZI5mp8vc347/620+cbMMr2+aELlgs9/bJhUcLRm0Bv8UDVLOleg==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBRWFNtaHhPbmlodUZXSFgy
|
||||
S1YwUjc4ZnZWdy9TM1NiejdrRHJuZnR5aG5rCjJrMWY2OXExZkpmUHpUNTE1T05x
|
||||
dzBYbmI3ZmVHRlhXaHk5dFFEMjIxUUUKLS0tIEd0L2N4TUsxV0tjWm81cUQ4dVdS
|
||||
WDVuSGxuNVAxeVk2UUdhQnFDV1FZOW8K4Fc8T/XM+E6F0iOqpSMbh/pOAI2vq3am
|
||||
9rMDxzwfIL9hwNDoy5LVPdnBfOXlCoZ8OnMR9Euj8FBW6pHErkmBUA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age15zq3cje3ttmx2fhqp9cpeght6feq2w8e5m38799stjetj8urcccs36r4rc
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA0K3k2cis1T3hrSmZGa2hq
|
||||
ME1VQy9HSG5MWUhjdXgzb0cwNitYY1ZvRVI4Cm0vYXptOW9TUDJFZzZvNEJLOHJB
|
||||
VnhnK1hOWUgxd2E1VmFDbFJPZUlQajAKLS0tIEszV0FRMHoyQ2lFUm5IWGtnVUNH
|
||||
Q244Nmd2czZXSmxEMnk3cWV1bm8rS28KGyANylD4EQkhWKDHwmGQHyAm1ZjA5acw
|
||||
VisJZnJ9KuFPB2Toi7FTMeHRNYPk1uXgwKoDFLSrzMElPXWDWm4B7A==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1jpa7hqyzzdfq7lvnw3l8qy6648mtjgpcqnljl5jz92dcqjrnsqfsn7m0fl
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
|
||||
@@ -6,3 +6,4 @@ metadata:
|
||||
data:
|
||||
DATABASE_TYPE: "postgresql"
|
||||
CLIENT_IP_HEADER: "X-Forwarded-For"
|
||||
TRACKER_SCRIPT_NAME: "beacon.js"
|
||||
@@ -17,6 +17,8 @@ spec:
|
||||
metadata:
|
||||
labels:
|
||||
app: umami
|
||||
annotations:
|
||||
config/tracker-script-name: "beacon.js"
|
||||
spec:
|
||||
containers:
|
||||
- name: umami
|
||||
|
||||
@@ -11,11 +11,20 @@ sops:
|
||||
age:
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGZ2h6THNGTnY2ay9Tc2Q3
|
||||
S0xxU3NEZGx2TS9MRFdqZDV6aEtUalBWRFVJCjZMd1VCdkg1cjIxSlliZXNRQnU0
|
||||
YnMzTXJOSGdsUDUwNkdDdWZWcmRaZjgKLS0tIDdEOFoyOWtvZytITys4bk9ra082
|
||||
WmFIREJMS1BVU0o1VnZHby9ZSHN4cGcKReBW7hdTPH8eGqPzEYkOdrIUYm9a1jOi
|
||||
du/1fhAyhjimbbCNdOSuSjrSP8QX6mxwPoDb1dVlKi0Rah5VGW+HWw==
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4SHJWSm54SHorbTZBOGtF
|
||||
VkRrQlpHbTgxSkh3L2ljZW42emEzQlBHa0hvCmxuQ3VjR2hQNzVlZ3FVS2Q3THp4
|
||||
SEZGa09EM1BYSlUxWEtuMGp0Tld3R3cKLS0tIEcwcUd6ZkdDdTAvMDFtSGpCZ3ls
|
||||
TVJYdHRDMXlWVmx4WStsT2hGUUZkeEkK26wJbgH0yvKPFXR4TC2rdeBD0axAs4WU
|
||||
rfJ8MWet6AbEAyOmiAjigNbM9bJwGlDW1UThnGQ/fFQfJW6U4T1Tgw==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age15zq3cje3ttmx2fhqp9cpeght6feq2w8e5m38799stjetj8urcccs36r4rc
|
||||
- enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBKVUt1UlNLTHZmV1djWEZp
|
||||
akVLUnliV2VQY0dzSmVMdmxMRTRoL2FWZm5rCm1WWkZPK2tEWW4rNVRObktuVVdk
|
||||
cEdNTHd6V2t6ZjVRMWhRbk5GNXpFdjgKLS0tIHZoZS9wblR1bTFkMzV4OWxLSm5B
|
||||
QWpwUG93dTc5MGNBbjAyVnNWMlRJblEK8ZRPL4/aCSKjPUg1K+kH9IigwZX1tB1U
|
||||
Y1P8IYiuTJ+YD/UcQyh0Pp2d8l5gQsTc1ES9388wcKBKMeKHV9FqYg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
recipient: age1jpa7hqyzzdfq7lvnw3l8qy6648mtjgpcqnljl5jz92dcqjrnsqfsn7m0fl
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
|
||||
@@ -6,4 +6,4 @@ resources:
|
||||
- service.yaml
|
||||
images:
|
||||
- name: ghcr.io/deytenit/ermnvldmr.com/www
|
||||
newTag: sha-a60b095
|
||||
newTag: sha-b3638f4
|
||||
@@ -0,0 +1,39 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: beacon-ingress
|
||||
namespace: com-ermnvldmr
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
||||
traefik.ingress.kubernetes.io/router.middlewares: com-ermnvldmr-strip-beacon@kubernetescrd
|
||||
traefik.ingress.kubernetes.io/router.priority: "100"
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- www.ermnvldmr.com
|
||||
secretName: ermnvldmr-tls
|
||||
- hosts:
|
||||
- docs.ermnvldmr.com
|
||||
secretName: docs-ermnvldmr-tls
|
||||
rules:
|
||||
- host: www.ermnvldmr.com
|
||||
http:
|
||||
paths:
|
||||
- path: /beacon
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: umami-service
|
||||
port:
|
||||
number: 3000
|
||||
- host: docs.ermnvldmr.com
|
||||
http:
|
||||
paths:
|
||||
- path: /beacon
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: umami-service
|
||||
port:
|
||||
number: 3000
|
||||
@@ -1,23 +1,18 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: main-ingress
|
||||
name: www-ingress
|
||||
namespace: com-ermnvldmr
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
||||
traefik.ingress.kubernetes.io/router.middlewares: com-ermnvldmr-sec-headers-common@kubernetescrd,com-ermnvldmr-sec-headers-www@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- www.ermnvldmr.com
|
||||
secretName: ermnvldmr-tls
|
||||
- hosts:
|
||||
- docs.ermnvldmr.com
|
||||
secretName: docs-ermnvldmr-tls
|
||||
- hosts:
|
||||
- umami.ermnvldmr.com
|
||||
secretName: umami-ermnvldmr-tls
|
||||
rules:
|
||||
- host: www.ermnvldmr.com
|
||||
http:
|
||||
@@ -29,6 +24,23 @@ spec:
|
||||
name: www-service
|
||||
port:
|
||||
number: 80
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: docs-ingress
|
||||
namespace: com-ermnvldmr
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
||||
traefik.ingress.kubernetes.io/router.middlewares: com-ermnvldmr-sec-headers-common@kubernetescrd,com-ermnvldmr-sec-headers-docs@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- docs.ermnvldmr.com
|
||||
secretName: docs-ermnvldmr-tls
|
||||
rules:
|
||||
- host: docs.ermnvldmr.com
|
||||
http:
|
||||
paths:
|
||||
@@ -39,6 +51,23 @@ spec:
|
||||
name: docs-service
|
||||
port:
|
||||
number: 80
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: umami-ingress
|
||||
namespace: com-ermnvldmr
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
|
||||
traefik.ingress.kubernetes.io/router.middlewares: com-ermnvldmr-sec-headers-common@kubernetescrd,com-ermnvldmr-sec-headers-umami@kubernetescrd
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- umami.ermnvldmr.com
|
||||
secretName: umami-ermnvldmr-tls
|
||||
rules:
|
||||
- host: umami.ermnvldmr.com
|
||||
http:
|
||||
paths:
|
||||
|
||||
@@ -2,3 +2,9 @@ apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- ingress.yaml
|
||||
- middleware-strip-beacon.yaml
|
||||
- ingress-beacon.yaml
|
||||
- middleware-sec-headers-common.yaml
|
||||
- middleware-sec-headers-www.yaml
|
||||
- middleware-sec-headers-docs.yaml
|
||||
- middleware-sec-headers-umami.yaml
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: sec-headers-common
|
||||
namespace: com-ermnvldmr
|
||||
spec:
|
||||
headers:
|
||||
stsSeconds: 63072000
|
||||
stsIncludeSubdomains: true
|
||||
stsPreload: true
|
||||
forceSTSHeader: true
|
||||
customFrameOptionsValue: "DENY"
|
||||
contentTypeNosniff: true
|
||||
browserXssFilter: false
|
||||
referrerPolicy: "strict-origin-when-cross-origin"
|
||||
customResponseHeaders:
|
||||
Cross-Origin-Opener-Policy: "same-origin"
|
||||
Permissions-Policy: "camera=(), microphone=(), geolocation=(), browsing-topics=()"
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: sec-headers-docs
|
||||
namespace: com-ermnvldmr
|
||||
spec:
|
||||
headers:
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://static.ermnvldmr.com; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'; trusted-types default; upgrade-insecure-requests;"
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: sec-headers-umami
|
||||
namespace: com-ermnvldmr
|
||||
spec:
|
||||
headers:
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;"
|
||||
customResponseHeaders:
|
||||
Content-Security-Policy-Report-Only: "require-trusted-types-for 'script'; trusted-types default;"
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: sec-headers-www
|
||||
namespace: com-ermnvldmr
|
||||
spec:
|
||||
headers:
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'self' 'sha256-gUpIj4OcSwECFlSp351SCYJAe1wTOCQlrKBlASuO3CY=' 'sha256-3VjVzYfJpuNMLKyrxQTIc8ACxv37paQ4Talwg1s/7N0='; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://static.ermnvldmr.com; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; require-trusted-types-for 'script'; trusted-types default; upgrade-insecure-requests;"
|
||||
@@ -0,0 +1,9 @@
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: strip-beacon
|
||||
namespace: com-ermnvldmr
|
||||
spec:
|
||||
stripPrefix:
|
||||
prefixes:
|
||||
- /beacon
|
||||
@@ -9,7 +9,7 @@ packer {
|
||||
|
||||
variable "debian_cloud_image_url" {
|
||||
type = string
|
||||
default = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-nocloud-amd64.qcow2"
|
||||
default = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
|
||||
}
|
||||
|
||||
variable "debian_cloud_image_checksum" {
|
||||
@@ -42,6 +42,8 @@ source "qemu" "debian_k3s" {
|
||||
ssh_password = "packer"
|
||||
ssh_timeout = "5m"
|
||||
vm_name = var.vm_name
|
||||
cpus = 2
|
||||
memory = 2048
|
||||
net_device = "virtio-net"
|
||||
disk_interface = "virtio"
|
||||
headless = true
|
||||
|
||||
@@ -85,4 +85,45 @@ auto eth0
|
||||
iface eth0 inet dhcp
|
||||
EOF
|
||||
|
||||
# Apply kernel hardening sysctl parameters
|
||||
cat << 'EOF' > /etc/sysctl.d/99-k3s-hardening.conf
|
||||
# Network SYN Flood Protection
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
# Disable ICMP redirects
|
||||
net.ipv4.conf.all.accept_redirects = 0
|
||||
net.ipv4.conf.default.accept_redirects = 0
|
||||
net.ipv4.conf.all.send_redirects = 0
|
||||
net.ipv4.conf.default.send_redirects = 0
|
||||
# Disable Source Routing
|
||||
net.ipv4.conf.all.accept_source_route = 0
|
||||
net.ipv4.conf.default.accept_source_route = 0
|
||||
# Anti-spoofing (Reverse Path Filtering)
|
||||
net.ipv4.conf.all.rp_filter = 1
|
||||
net.ipv4.conf.default.rp_filter = 1
|
||||
# Required for K8s pod networking
|
||||
net.ipv4.ip_forward = 1
|
||||
# System Hardening
|
||||
kernel.kptr_restrict = 2
|
||||
fs.protected_hardlinks = 1
|
||||
fs.protected_symlinks = 1
|
||||
fs.inotify.max_user_watches = 524288
|
||||
fs.inotify.max_user_instances = 512
|
||||
EOF
|
||||
sysctl --system || true
|
||||
|
||||
# Configure baseline UFW firewall & fail2ban
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw default allow routed
|
||||
ufw allow 22/tcp comment 'SSH'
|
||||
ufw allow 80/tcp comment 'HTTP Traefik'
|
||||
ufw allow 443/tcp comment 'HTTPS Traefik'
|
||||
ufw allow 6443/tcp comment 'K3s API'
|
||||
ufw allow in on cni0 || true
|
||||
ufw allow in on flannel.1 || true
|
||||
ufw allow from 10.42.0.0/16 comment 'K3s Pod CIDR'
|
||||
ufw allow from 10.43.0.0/16 comment 'K3s Service CIDR'
|
||||
ufw --force enable
|
||||
systemctl enable fail2ban || true
|
||||
|
||||
echo "=== Base System Configuration Complete ==="
|
||||
@@ -19,7 +19,116 @@ k3s --version
|
||||
systemctl stop k3s.service || true
|
||||
systemctl disable k3s.service || true
|
||||
|
||||
# Create configuration directory
|
||||
# Create configuration directory and default config
|
||||
mkdir -p /etc/rancher/k3s
|
||||
cat << 'EOF' > /etc/rancher/k3s/config.yaml
|
||||
write-kubeconfig-mode: "0644"
|
||||
EOF
|
||||
chmod 644 /etc/rancher/k3s/config.yaml
|
||||
|
||||
# Pre-stage default GitOps root sync manifest
|
||||
mkdir -p /etc/flux
|
||||
cat << 'EOF' > /etc/flux/cluster-sync.yaml
|
||||
apiVersion: source.toolkit.fluxcd.io/v1
|
||||
kind: GitRepository
|
||||
metadata:
|
||||
name: ermnvldmr-repo
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 1m
|
||||
url: https://github.com/deytenit/ermnvldmr.com.git
|
||||
ref:
|
||||
branch: next
|
||||
---
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: cluster-sync
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 1m
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: ermnvldmr-repo
|
||||
path: ./ci/k8s/clusters/production
|
||||
prune: true
|
||||
wait: true
|
||||
timeout: 3m
|
||||
decryption:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
EOF
|
||||
|
||||
# Install the cluster bootstrap script into /usr/local/bin
|
||||
cat << 'EOF' > /usr/local/bin/bootstrap-cluster.sh
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== [Bootstrap] Checking for Secondary Persistent Data Disk ==="
|
||||
if [ -b /dev/vdb ]; then
|
||||
echo "Secondary disk /dev/vdb detected."
|
||||
if ! blkid /dev/vdb >/dev/null 2>&1; then
|
||||
echo "Formatting /dev/vdb with ext4 filesystem..."
|
||||
mkfs.ext4 -F -L k3s-storage /dev/vdb
|
||||
fi
|
||||
mkdir -p /var/lib/rancher/k3s/storage
|
||||
if ! grep -qs '/var/lib/rancher/k3s/storage' /proc/mounts; then
|
||||
mount /dev/vdb /var/lib/rancher/k3s/storage
|
||||
echo "LABEL=k3s-storage /var/lib/rancher/k3s/storage ext4 defaults,noatime 0 2" >> /etc/fstab
|
||||
echo "Mounted /dev/vdb to /var/lib/rancher/k3s/storage."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== [Bootstrap] Starting K3s Service ==="
|
||||
systemctl enable --now k3s.service
|
||||
|
||||
echo "=== [Bootstrap] Waiting for K3s API server ready ==="
|
||||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||
until kubectl get nodes >/dev/null 2>&1; do
|
||||
echo "Waiting for Kubernetes API..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "=== [Bootstrap] Cluster node is online ==="
|
||||
kubectl get nodes
|
||||
|
||||
echo "=== [Bootstrap] Setting up Kubectl access for non-root users ==="
|
||||
for home_dir in /home/*; do
|
||||
if [ -d "$home_dir" ]; then
|
||||
user_name=$(basename "$home_dir")
|
||||
mkdir -p "$home_dir/.kube"
|
||||
cp /etc/rancher/k3s/k3s.yaml "$home_dir/.kube/config"
|
||||
chown -R "$user_name:$user_name" "$home_dir/.kube"
|
||||
chmod 600 "$home_dir/.kube/config"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=== [Bootstrap] Creating flux-system namespace ==="
|
||||
kubectl create namespace flux-system --dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
echo "=== [Bootstrap] Injecting SOPS age decryption key ==="
|
||||
if [ -f /root/.config/sops/age/keys.txt ]; then
|
||||
kubectl create secret generic sops-age \
|
||||
--namespace=flux-system \
|
||||
--from-file=age.agekey=/root/.config/sops/age/keys.txt \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
echo "sops-age secret created."
|
||||
else
|
||||
echo "WARNING: /root/.config/sops/age/keys.txt not found. Inject key manually via kubectl."
|
||||
fi
|
||||
|
||||
echo "=== [Bootstrap] Installing Flux Operator / Custom Resources ==="
|
||||
kubectl apply -k "https://github.com/fluxcd/flux2/manifests/install?ref=v2.4.0" || true
|
||||
|
||||
echo "=== [Bootstrap] Applying initial GitOps root sync ==="
|
||||
if [ -f /etc/flux/cluster-sync.yaml ]; then
|
||||
kubectl apply -f /etc/flux/cluster-sync.yaml
|
||||
fi
|
||||
|
||||
echo "=== [Bootstrap] First-Boot Bootstrap Sequence Complete ==="
|
||||
EOF
|
||||
|
||||
chmod 755 /usr/local/bin/bootstrap-cluster.sh
|
||||
|
||||
echo "=== K3s Pre-installation Complete ==="
|
||||
@@ -4,6 +4,21 @@ export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
echo "=== Sanitizing Machine Identity & Temporary Files ==="
|
||||
|
||||
# Lock temporary packer build user
|
||||
passwd -l debian || true
|
||||
|
||||
# Apply SSH security hardening before final image capture
|
||||
mkdir -p /etc/ssh/sshd_config.d
|
||||
cat << 'EOF' > /etc/ssh/sshd_config.d/99-security-hardening.conf
|
||||
PasswordAuthentication no
|
||||
PermitRootLogin prohibit-password
|
||||
KbdInteractiveAuthentication no
|
||||
X11Forwarding no
|
||||
MaxAuthTries 3
|
||||
ClientAliveInterval 300
|
||||
ClientAliveCountMax 2
|
||||
EOF
|
||||
|
||||
# Clean APT caches and logs
|
||||
apt-get autoremove -y --purge
|
||||
apt-get clean
|
||||
|
||||
@@ -29,14 +29,13 @@ fi
|
||||
|
||||
# Configure AWS region
|
||||
export AWS_DEFAULT_REGION="$AWS_REGION"
|
||||
ENDPOINT_URL="$S3_ENDPOINT_URL"
|
||||
ENDPOINT_URL="${S3_ENDPOINT_URL%/}"
|
||||
|
||||
# Upload all static files
|
||||
echo "Uploading static files..."
|
||||
aws s3 cp "$DIST_DIR/" "s3://$S3_BUCKET/" \
|
||||
--recursive \
|
||||
--endpoint-url="$ENDPOINT_URL" \
|
||||
--cache-control "max-age=300" \
|
||||
--metadata-directive REPLACE
|
||||
--cache-control "max-age=300"
|
||||
|
||||
echo "Static files deployed successfully!"
|
||||
@@ -0,0 +1,334 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
/**
|
||||
* Checks whether a hostname entry exists in the specified hosts file.
|
||||
*
|
||||
* @param {string} domain - Domain to search for
|
||||
* @param {string} hostsPath - Path to hosts file
|
||||
* @returns {boolean} Whether domain is present
|
||||
*/
|
||||
export function hasHostsEntry(domain = 'localhost.ermnvldmr.com', hostsPath = '/etc/hosts') {
|
||||
if (!existsSync(hostsPath)) return false;
|
||||
try {
|
||||
const content = readFileSync(hostsPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
const uncommented = (line.split('#')[0] || '').trim();
|
||||
if (!uncommented) continue;
|
||||
const tokens = uncommented.split(/\s+/);
|
||||
const hostnames = tokens.slice(1);
|
||||
if (tokens.length >= 2 && hostnames.some((host) => host === domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const DOMAIN_PATTERN = /^[a-zA-Z0-9.-]+$/;
|
||||
const IP_PATTERN = /^[\da-fA-F.:]+$/;
|
||||
|
||||
/**
|
||||
* Ensures a mapping for the domain exists in /etc/hosts.
|
||||
*
|
||||
* @param {string} domain - Domain to map
|
||||
* @param {string} ip - Target IP address
|
||||
* @param {string} hostsPath - Path to hosts file
|
||||
* @returns {boolean} Whether entry is present
|
||||
*/
|
||||
export function ensureHostsEntry(
|
||||
domain = 'localhost.ermnvldmr.com',
|
||||
ip = '127.0.0.1',
|
||||
hostsPath = '/etc/hosts'
|
||||
) {
|
||||
if (!DOMAIN_PATTERN.test(domain) || !IP_PATTERN.test(ip)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasHostsEntry(domain, hostsPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entry = `${ip} ${domain}`;
|
||||
|
||||
try {
|
||||
appendFileSync(hostsPath, `\n${entry}\n`);
|
||||
console.log(`[hosts] Added "${entry}" to ${hostsPath}`);
|
||||
return true;
|
||||
} catch {
|
||||
// Requires elevated permissions
|
||||
}
|
||||
|
||||
const isInteractive = Boolean(
|
||||
process.stdin && process.stdin.isTTY && process.stdout && process.stdout.isTTY
|
||||
);
|
||||
if (isInteractive) {
|
||||
try {
|
||||
console.log(`[hosts] Adding "${entry}" to ${hostsPath} (requires sudo)...`);
|
||||
execFileSync('sudo', ['tee', '-a', hostsPath], {
|
||||
input: `\n${entry}\n`,
|
||||
stdio: ['pipe', 'ignore', 'inherit'],
|
||||
});
|
||||
console.log(`[hosts] Successfully added "${entry}" to ${hostsPath}`);
|
||||
return true;
|
||||
} catch {
|
||||
console.warn(`[hosts] Could not automatically update ${hostsPath}.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[hosts] Notice: "${entry}" is missing from ${hostsPath}.\nPlease run:\n sudo sh -c 'echo "${entry}" >> ${hostsPath}'`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses upwards to find the monorepo root containing pnpm-workspace.yaml.
|
||||
*
|
||||
* @param {string} startDir - Directory to start searching from
|
||||
* @returns {string} Absolute path to workspace root
|
||||
*/
|
||||
export function findWorkspaceRoot(startDir = dirname(fileURLToPath(import.meta.url))) {
|
||||
let current = startDir;
|
||||
while (current !== dirname(current)) {
|
||||
if (existsSync(join(current, 'pnpm-workspace.yaml'))) {
|
||||
return current;
|
||||
}
|
||||
current = dirname(current);
|
||||
}
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the development Root CA private key from the environment or .env.dev.
|
||||
*
|
||||
* @param {string} workspaceRoot - Absolute path to workspace root
|
||||
* @returns {string | undefined} The PEM private key string, or undefined if unavailable
|
||||
*/
|
||||
export function loadDevRootCaKey(workspaceRoot = findWorkspaceRoot()) {
|
||||
if (process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY) {
|
||||
const raw = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
return raw.includes('\\n') ? raw.replace(/\\n/g, '\n') : raw;
|
||||
}
|
||||
|
||||
const envDevPath = join(workspaceRoot, '.env.dev');
|
||||
const envKeysPath = join(workspaceRoot, '.env.keys');
|
||||
|
||||
if (!existsSync(envDevPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidatePackages = [
|
||||
join(workspaceRoot, 'packages/cli/package.json'),
|
||||
join(workspaceRoot, 'package.json'),
|
||||
];
|
||||
|
||||
for (const pkgPath of candidatePackages) {
|
||||
if (!existsSync(pkgPath)) continue;
|
||||
try {
|
||||
const req = createRequire(pkgPath);
|
||||
const dotenvx = req('@dotenvx/dotenvx');
|
||||
if (dotenvx && typeof dotenvx.config === 'function') {
|
||||
dotenvx.config({ path: envDevPath, envKeysFile: envKeysPath });
|
||||
const key = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
if (key) {
|
||||
return key.includes('\\n') ? key.replace(/\\n/g, '\n') : key;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue to next candidate package
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote public URL for the Ermnvldmr development root CA certificate.
|
||||
*/
|
||||
export const REMOTE_ROOT_CA_URL =
|
||||
'https://static.ermnvldmr.com/ssl/certs/ErmnvldmrDevelopmentRootCA.pem';
|
||||
|
||||
/**
|
||||
* Ensures valid development certificates exist for localhost.ermnvldmr.com.
|
||||
*
|
||||
* @param {string} workspaceRoot - Absolute path to workspace root
|
||||
* @returns {{ certPath: string, keyPath: string }} Paths to cert and key
|
||||
*/
|
||||
export function ensureDevCertificates(workspaceRoot = findWorkspaceRoot()) {
|
||||
const certsDir = join(workspaceRoot, '.certs');
|
||||
const certPath = join(certsDir, 'localhost.ermnvldmr.com.pem');
|
||||
const keyPath = join(certsDir, 'localhost.ermnvldmr.com-key.pem');
|
||||
const caCertPath = join(certsDir, 'ErmnvldmrDevelopmentRootCA.pem');
|
||||
const caKeyPath = join(certsDir, 'ErmnvldmrDevelopmentRootCA-key.pem');
|
||||
|
||||
ensureHostsEntry('localhost.ermnvldmr.com', '127.0.0.1');
|
||||
|
||||
const home = homedir();
|
||||
const globalCaDir = join(home, '.local/share/mkcert');
|
||||
const globalCaCert = join(globalCaDir, 'ErmnvldmrDevelopmentRootCA.pem');
|
||||
const globalCaKey = join(globalCaDir, 'ErmnvldmrDevelopmentRootCA-key.pem');
|
||||
|
||||
mkdirSync(certsDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// 1. Sync CA cert from global cache if available, or download from remote static URL
|
||||
if (!existsSync(caCertPath)) {
|
||||
if (existsSync(globalCaCert)) {
|
||||
writeFileSync(caCertPath, readFileSync(globalCaCert));
|
||||
chmodSync(caCertPath, 0o644);
|
||||
} else {
|
||||
try {
|
||||
execFileSync('curl', ['-sSfL', REMOTE_ROOT_CA_URL, '-o', caCertPath], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
chmodSync(caCertPath, 0o644);
|
||||
try {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
writeFileSync(globalCaCert, readFileSync(caCertPath));
|
||||
chmodSync(globalCaCert, 0o644);
|
||||
} catch {
|
||||
// Best-effort global cache
|
||||
}
|
||||
} catch {
|
||||
// Failed to download, will fall back to generation if key also needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync CA key from global cache if available, or decrypt from .env.dev
|
||||
if (!existsSync(caKeyPath)) {
|
||||
if (existsSync(globalCaKey)) {
|
||||
writeFileSync(caKeyPath, readFileSync(globalCaKey));
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
} else {
|
||||
const decryptedKey = loadDevRootCaKey(workspaceRoot);
|
||||
if (decryptedKey) {
|
||||
writeFileSync(caKeyPath, decryptedKey);
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
try {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
writeFileSync(globalCaKey, decryptedKey);
|
||||
chmodSync(globalCaKey, 0o600);
|
||||
} catch {
|
||||
// Best-effort global cache
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If server cert and CA cert already exist, return them
|
||||
if (existsSync(certPath) && existsSync(keyPath) && existsSync(caCertPath)) {
|
||||
return { certPath, keyPath, caCertPath };
|
||||
}
|
||||
|
||||
// Ensure ErmnvldmrDevelopmentRootCA exists
|
||||
if (!existsSync(caCertPath) || !existsSync(caKeyPath)) {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-x509',
|
||||
'-nodes',
|
||||
'-newkey',
|
||||
'rsa:3072',
|
||||
'-keyout',
|
||||
caKeyPath,
|
||||
'-out',
|
||||
caCertPath,
|
||||
'-days',
|
||||
'3650',
|
||||
'-subj',
|
||||
'/CN=Ermnvldmr Development Root CA/O=ermnvldmr.com',
|
||||
'-batch',
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
chmodSync(caCertPath, 0o644);
|
||||
|
||||
writeFileSync(globalCaCert, readFileSync(caCertPath));
|
||||
writeFileSync(globalCaKey, readFileSync(caKeyPath));
|
||||
chmodSync(globalCaKey, 0o600);
|
||||
chmodSync(globalCaCert, 0o644);
|
||||
}
|
||||
|
||||
// Generate server certificate signed by ErmnvldmrDevelopmentRootCA
|
||||
const tempDir = tmpdir();
|
||||
const tempCsr = join(tempDir, `ermnvldmr-dev-${Date.now()}.csr`);
|
||||
const tempExt = join(tempDir, `ermnvldmr-dev-${Date.now()}.cnf`);
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
tempExt,
|
||||
'subjectAltName=DNS:localhost.ermnvldmr.com,DNS:*.localhost.ermnvldmr.com,DNS:localhost,IP:127.0.0.1,IP:::1\n'
|
||||
);
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-new',
|
||||
'-nodes',
|
||||
'-newkey',
|
||||
'rsa:2048',
|
||||
'-keyout',
|
||||
keyPath,
|
||||
'-out',
|
||||
tempCsr,
|
||||
'-subj',
|
||||
'/CN=localhost.ermnvldmr.com',
|
||||
'-batch',
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'x509',
|
||||
'-req',
|
||||
'-in',
|
||||
tempCsr,
|
||||
'-CA',
|
||||
caCertPath,
|
||||
'-CAkey',
|
||||
caKeyPath,
|
||||
'-CAcreateserial',
|
||||
'-out',
|
||||
certPath,
|
||||
'-days',
|
||||
'825',
|
||||
'-extfile',
|
||||
tempExt,
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
chmodSync(keyPath, 0o600);
|
||||
chmodSync(certPath, 0o644);
|
||||
} finally {
|
||||
rmSync(tempCsr, { force: true });
|
||||
rmSync(tempExt, { force: true });
|
||||
}
|
||||
|
||||
return { certPath, keyPath, caCertPath };
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (process.argv[1] && process.argv[1].endsWith('ensure-dev-certs.mjs')) {
|
||||
const { certPath, keyPath } = ensureDevCertificates();
|
||||
console.log(`[certs] Ready:\n Cert: ${certPath}\n Key: ${keyPath}`);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STATIC_DIR="$SCRIPT_DIR/../../services/www/public/static"
|
||||
STATIC_DIR="${1:-$SCRIPT_DIR/../../services/www/public/static}"
|
||||
|
||||
if [ ! -d "$STATIC_DIR" ]; then
|
||||
echo "Directory $STATIC_DIR does not exist. Skipping."
|
||||
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# End-to-End QEMU Verification Script for Golden Image & Cloud-Init User-Data
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_PATH="${1:-output-qemu/com-ermnvldmr-debian-12-k3s-amd64.qcow2}"
|
||||
USER_DATA="${2:-ci/cloud-init/user-data.yaml}"
|
||||
SSH_KEY="${3:-$HOME/.ssh/adam@ermnvldmr.com:ed25519}"
|
||||
SSH_PORT="${4:-22222}"
|
||||
|
||||
if [ ! -f "$IMAGE_PATH" ]; then
|
||||
echo "[-] Golden image not found at: $IMAGE_PATH"
|
||||
echo " Build it first using: packer build -var 'accelerator=kvm' ci/packer/debian-12-k3s.pkr.hcl"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$USER_DATA" ]; then
|
||||
echo "[-] User-data file not found at: $USER_DATA"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$SSH_KEY" ]; then
|
||||
echo "[-] SSH private key not found at: $SSH_KEY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure prerequisite tools exist
|
||||
for cmd in qemu-system-x86_64 qemu-img genisoimage ssh; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
echo "[-] Required command not found in PATH: $cmd"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
TMPDIR=$(mktemp -d /tmp/qemu-test-k3s-XXXXXX)
|
||||
echo "=== [QEMU Test] Workdir: $TMPDIR ==="
|
||||
|
||||
QEMU_PID=""
|
||||
cleanup() {
|
||||
echo "=== [QEMU Test] Cleaning up resources ==="
|
||||
if [ -n "$QEMU_PID" ] && kill -0 "$QEMU_PID" 2>/dev/null; then
|
||||
echo "Stopping QEMU process (PID: $QEMU_PID)..."
|
||||
kill "$QEMU_PID" 2>/dev/null || true
|
||||
wait "$QEMU_PID" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$TMPDIR"
|
||||
echo "=== [QEMU Test] Cleanup complete ==="
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "=== [QEMU Test] 1. Preparing Cloud-Init cidata ISO ==="
|
||||
cp "$USER_DATA" "$TMPDIR/user-data"
|
||||
cat << 'EOF' > "$TMPDIR/meta-data"
|
||||
instance-id: qemu-test-vm-01
|
||||
local-hostname: com-ermnvldmr
|
||||
EOF
|
||||
|
||||
genisoimage -output "$TMPDIR/cidata.iso" -volid cidata -joliet -rock "$TMPDIR/user-data" "$TMPDIR/meta-data" 2>/dev/null
|
||||
|
||||
echo "=== [QEMU Test] 2. Creating Ephemeral Disks ==="
|
||||
# CoW overlay for root OS disk (preserves original golden image intact)
|
||||
qemu-img create -f qcow2 -b "$(realpath "$IMAGE_PATH")" -F qcow2 "$TMPDIR/test-os.qcow2" 20G >/dev/null
|
||||
# Secondary data disk (/dev/vdb) to test persistent storage auto-mounting
|
||||
qemu-img create -f qcow2 "$TMPDIR/test-vdb.qcow2" 10G >/dev/null
|
||||
|
||||
echo "=== [QEMU Test] 3. Launching VM in QEMU (KVM accelerated) ==="
|
||||
qemu-system-x86_64 \
|
||||
-enable-kvm \
|
||||
-m 4096 \
|
||||
-smp 2 \
|
||||
-drive file="$TMPDIR/test-os.qcow2",if=virtio,format=qcow2 \
|
||||
-drive file="$TMPDIR/test-vdb.qcow2",if=virtio,format=qcow2 \
|
||||
-drive file="$TMPDIR/cidata.iso",if=virtio,format=raw \
|
||||
-netdev user,id=net0,hostfwd=tcp::${SSH_PORT}-:22 \
|
||||
-device virtio-net,netdev=net0 \
|
||||
-nographic \
|
||||
-serial file:"$TMPDIR/serial.log" &
|
||||
QEMU_PID=$!
|
||||
|
||||
echo "QEMU started with PID $QEMU_PID. Forwarding guest SSH to localhost:$SSH_PORT"
|
||||
|
||||
echo "=== [QEMU Test] 4. Waiting for SSH Availability (up to 120s) ==="
|
||||
MAX_WAIT=120
|
||||
WAIT_COUNT=0
|
||||
SSH_OPTS=(-i "$SSH_KEY" -p "$SSH_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 -o BatchMode=yes)
|
||||
|
||||
until ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "echo ssh_ready" 2>/dev/null; do
|
||||
sleep 3
|
||||
WAIT_COUNT=$((WAIT_COUNT + 3))
|
||||
if [ "$WAIT_COUNT" -ge "$MAX_WAIT" ]; then
|
||||
echo "[-] Timed out waiting for SSH on localhost:$SSH_PORT!"
|
||||
echo "--- Last 50 lines of serial console log ---"
|
||||
tail -n 50 "$TMPDIR/serial.log" || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "[+] SSH connection established!"
|
||||
|
||||
echo "=== [QEMU Test] 5. Waiting for Cloud-Init Completion ==="
|
||||
ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "sudo cloud-init status --wait"
|
||||
|
||||
echo "=== [QEMU Test] 6. Verifying System Assertions ==="
|
||||
|
||||
# Check Hostname
|
||||
HOSTNAME_ACTUAL=$(ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "hostname")
|
||||
echo "Hostname: $HOSTNAME_ACTUAL (Expected: com-ermnvldmr)"
|
||||
[ "$HOSTNAME_ACTUAL" = "com-ermnvldmr" ]
|
||||
|
||||
# Check Secondary Disk Mount
|
||||
MOUNT_CHECK=$(ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "grep -qs '/var/lib/rancher/k3s/storage' /proc/mounts && echo MOUNTED || echo NOT_MOUNTED")
|
||||
echo "Secondary disk mount: $MOUNT_CHECK"
|
||||
[ "$MOUNT_CHECK" = "MOUNTED" ]
|
||||
|
||||
# Check K3s Node Status
|
||||
NODE_STATUS=$(ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "kubectl get nodes -o jsonpath='{.items[0].status.conditions[?(@.type==\"Ready\")].status}'")
|
||||
echo "K3s Node Ready Status: $NODE_STATUS"
|
||||
[ "$NODE_STATUS" = "True" ]
|
||||
|
||||
# Check Non-root Kubectl access
|
||||
echo "Checking non-root kubectl execution without sudo:"
|
||||
ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "kubectl get nodes -o wide"
|
||||
|
||||
# Check Flux namespace and Age secret
|
||||
echo "Checking Flux namespace and SOPS Age secret:"
|
||||
ssh "${SSH_OPTS[@]}" adam@127.0.0.1 "kubectl get secret sops-age -n flux-system"
|
||||
|
||||
echo "=========================================================================="
|
||||
echo " [SUCCESS] All Golden Image & Cloud-Init Assertions Passed in QEMU!"
|
||||
echo "=========================================================================="
|
||||
+4
-1
@@ -14,7 +14,10 @@ export default [
|
||||
// Additional ignores for monorepo
|
||||
{
|
||||
ignores: [
|
||||
'services/docs/**/*', // Hugo content, no linting needed
|
||||
'services/docs/public/**',
|
||||
'services/docs/resources/**',
|
||||
'services/docs/assets/**',
|
||||
'services/docs/archetypes/**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"www:storybook": "pnpm --filter @ermnvldmr/www storybook",
|
||||
"docs:dev": "pnpm --filter @ermnvldmr/docs dev",
|
||||
"docs:build": "pnpm --filter @ermnvldmr/docs build",
|
||||
"docs:sync:static": "pnpm --filter @ermnvldmr/docs sync:static",
|
||||
"static:dev": "pnpm --filter @ermnvldmr/static dev",
|
||||
"static:build": "pnpm --filter @ermnvldmr/static build",
|
||||
"static:lint": "pnpm --filter @ermnvldmr/static lint",
|
||||
@@ -41,6 +42,7 @@
|
||||
"dev:www": "pnpm ui:build && pnpm kits:build && pnpm www:dev",
|
||||
"dev:static": "pnpm ui:build && pnpm kits:build && pnpm static:dev",
|
||||
"dev:docs": "pnpm docs:dev",
|
||||
"dev:certs": "node ci/scripts/ensure-dev-certs.mjs",
|
||||
"build": "pnpm ui:build && pnpm --filter @ermnvldmr/kits build && pnpm -r --filter='./services/*' build",
|
||||
"build:storybooks": "pnpm ui:build && pnpm --filter @ermnvldmr/kits build && pnpm --filter @ermnvldmr/ui build-storybook && pnpm --filter @ermnvldmr/kits build-storybook && pnpm --filter @ermnvldmr/www build-storybook",
|
||||
"lint": "pnpm -r lint",
|
||||
|
||||
@@ -40,6 +40,6 @@
|
||||
"@types/node": "^22.13.0",
|
||||
"sharp": "^0.35.2",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -297,4 +297,83 @@ describe('runS3Sync', () => {
|
||||
const commandInstance = mockSend.mock.calls[0][0];
|
||||
expect(commandInstance.input.Key).toBe('test/logo.webp');
|
||||
});
|
||||
|
||||
it('should support custom sourceFilesPattern and urlPrefixes for docs', async () => {
|
||||
const staticDir = path.join(tempDir, 'static');
|
||||
fs.mkdirSync(staticDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(staticDir, 'banner.png'), 'png-content');
|
||||
fs.writeFileSync(path.join(staticDir, 'banner.webp'), 'webp-content');
|
||||
|
||||
const contentDir = path.join(tempDir, 'content');
|
||||
fs.mkdirSync(contentDir, { recursive: true });
|
||||
const docPath = path.join(contentDir, 'index.md');
|
||||
fs.writeFileSync(docPath, '');
|
||||
|
||||
const cmd: S3SyncCommandConfig = {
|
||||
name: 'sync',
|
||||
description: 'desc',
|
||||
targetPrefix: 'docs',
|
||||
sourceDir: './static',
|
||||
sourceFilesPattern: 'content/**/*.md',
|
||||
urlPrefixes: ['/images/'],
|
||||
type: 's3-sync',
|
||||
};
|
||||
|
||||
await runS3Sync(cmd, {});
|
||||
|
||||
const content = fs.readFileSync(docPath, 'utf-8');
|
||||
expect(content).toContain('https://s3.example.com/my-bucket/docs/banner.webp');
|
||||
expect(content).not.toContain('/images/banner.png');
|
||||
});
|
||||
|
||||
it('should use custom domain directly if bucket contains a dot', async () => {
|
||||
const staticDir = path.join(tempDir, 'static');
|
||||
fs.mkdirSync(staticDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staticDir, 'img.webp'), 'webp-content');
|
||||
|
||||
const srcDir = path.join(tempDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
const srcPath = path.join(srcDir, 'index.ts');
|
||||
fs.writeFileSync(srcPath, 'const img = "/static/img.webp";');
|
||||
|
||||
const cmd: S3SyncCommandConfig = {
|
||||
name: 'sync',
|
||||
description: 'desc',
|
||||
bucket: 'static.ermnvldmr.com',
|
||||
targetPrefix: 'docs',
|
||||
sourceDir: './static',
|
||||
type: 's3-sync',
|
||||
};
|
||||
|
||||
await runS3Sync(cmd, {});
|
||||
|
||||
const content = fs.readFileSync(srcPath, 'utf-8');
|
||||
expect(content).toContain('https://static.ermnvldmr.com/docs/img.webp');
|
||||
});
|
||||
|
||||
it('should use explicit publicUrl if provided', async () => {
|
||||
const staticDir = path.join(tempDir, 'static');
|
||||
fs.mkdirSync(staticDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staticDir, 'img.webp'), 'webp-content');
|
||||
|
||||
const srcDir = path.join(tempDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
const srcPath = path.join(srcDir, 'index.ts');
|
||||
fs.writeFileSync(srcPath, 'const img = "/static/img.webp";');
|
||||
|
||||
const cmd: S3SyncCommandConfig = {
|
||||
name: 'sync',
|
||||
description: 'desc',
|
||||
targetPrefix: 'www',
|
||||
sourceDir: './static',
|
||||
publicUrl: 'https://cdn.example.com',
|
||||
type: 's3-sync',
|
||||
};
|
||||
|
||||
await runS3Sync(cmd, {});
|
||||
|
||||
const content = fs.readFileSync(srcPath, 'utf-8');
|
||||
expect(content).toContain('https://cdn.example.com/www/img.webp');
|
||||
});
|
||||
});
|
||||
@@ -149,6 +149,13 @@ export async function runS3Sync(
|
||||
*/
|
||||
const getS3Url = (finalName: string): string => {
|
||||
const normalizedKey = normalizeKey(cmd.targetPrefix, finalName);
|
||||
if (cmd.publicUrl) {
|
||||
const cleanPublicUrl = cmd.publicUrl.replace(/\/+$/, '');
|
||||
return `${cleanPublicUrl}/${normalizedKey}`;
|
||||
}
|
||||
if (bucket.includes('.')) {
|
||||
return `https://${bucket}/${normalizedKey}`;
|
||||
}
|
||||
if (endpoint) {
|
||||
const cleanEndpoint = endpoint.replace(/\/+$/, '');
|
||||
return `${cleanEndpoint}/${bucket}/${normalizedKey}`;
|
||||
@@ -156,12 +163,17 @@ export async function runS3Sync(
|
||||
return `https://${bucket}.s3.${region}.amazonaws.com/${normalizedKey}`;
|
||||
};
|
||||
|
||||
// Find workspace source files matching `src/**/*.{ts,tsx,css,md,mdx}`
|
||||
const sourceFiles = await fg('src/**/*.{ts,tsx,css,md,mdx}', {
|
||||
// Find workspace source files matching configured pattern or default `src/**/*.{ts,tsx,css,md,mdx}`
|
||||
const filePattern = cmd.sourceFilesPattern ?? 'src/**/*.{ts,tsx,css,md,mdx}';
|
||||
const sourceFiles = await fg(filePattern, {
|
||||
cwd: process.cwd(),
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
const urlPrefixes = cmd.urlPrefixes ?? ['/static/'];
|
||||
const hasMatchingPrefix = (text: string): boolean =>
|
||||
urlPrefixes.some((prefix) => text.includes(prefix));
|
||||
|
||||
// Sort assets by length descending to handle overlapping name replacement correctly
|
||||
const sortedAssets = [...processedFiles].sort(
|
||||
(a, b) => b.originalName.length - a.originalName.length
|
||||
@@ -170,28 +182,38 @@ export async function runS3Sync(
|
||||
// Replace static asset references in workspace source files
|
||||
for (const sourceFile of sourceFiles) {
|
||||
let content = await fs.promises.readFile(sourceFile, 'utf-8');
|
||||
if (!content.includes('/static/')) {
|
||||
if (!hasMatchingPrefix(content)) {
|
||||
continue;
|
||||
}
|
||||
let modified = false;
|
||||
const originalContent = content;
|
||||
|
||||
for (const asset of sortedAssets) {
|
||||
let searchName = asset.originalName;
|
||||
if (searchName.startsWith('static/')) {
|
||||
searchName = searchName.substring('static/'.length);
|
||||
} else if (searchName.startsWith('/static/')) {
|
||||
searchName = searchName.substring('/static/'.length);
|
||||
}
|
||||
const searchStr = `/static/${searchName}`;
|
||||
const assetUrl = getS3Url(asset.finalName);
|
||||
const namesToSearch =
|
||||
asset.finalName !== asset.originalName
|
||||
? [asset.originalName, asset.finalName]
|
||||
: [asset.originalName];
|
||||
|
||||
if (content.includes(searchStr)) {
|
||||
content = content.split(searchStr).join(assetUrl);
|
||||
modified = true;
|
||||
for (const prefix of urlPrefixes) {
|
||||
const cleanPrefix = prefix.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
for (const name of namesToSearch) {
|
||||
let searchName = name;
|
||||
if (searchName.startsWith(`${cleanPrefix}/`)) {
|
||||
searchName = searchName.substring(`${cleanPrefix}/`.length);
|
||||
} else if (searchName.startsWith(`/${cleanPrefix}/`)) {
|
||||
searchName = searchName.substring(`/${cleanPrefix}/`.length);
|
||||
}
|
||||
const searchStr = `/${cleanPrefix}/${searchName}`;
|
||||
const assetUrl = getS3Url(asset.finalName);
|
||||
const escaped = searchStr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp('(?<!https?:\\/\\/[^\\s"\'`()]*)' + escaped, 'g');
|
||||
|
||||
content = content.replace(regex, assetUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
if (content !== originalContent) {
|
||||
await fs.promises.writeFile(sourceFile, content, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ export interface S3SyncCommandConfig extends BaseCommandConfig {
|
||||
type: 's3-sync';
|
||||
/** The source directory containing files to sync */
|
||||
sourceDir?: string;
|
||||
/** Glob pattern(s) matching source files where asset references should be replaced */
|
||||
sourceFilesPattern?: string | string[];
|
||||
/** URL prefixes to detect and replace with S3 URLs, defaults to ['/static/'] */
|
||||
urlPrefixes?: string[];
|
||||
/** The prefix (directory) under which to upload the files in the S3 bucket */
|
||||
targetPrefix: string;
|
||||
/** Optional S3 bucket name */
|
||||
@@ -114,6 +118,8 @@ export interface S3SyncCommandConfig extends BaseCommandConfig {
|
||||
region?: string;
|
||||
/** Optional custom endpoint URL (e.g., for MinIO or Yandex Cloud) */
|
||||
endpoint?: string;
|
||||
/** Optional public base URL for assets (e.g., 'https://static.ermnvldmr.com') */
|
||||
publicUrl?: string;
|
||||
/** Optional AWS Access Key ID */
|
||||
accessKeyId?: string;
|
||||
/** Optional AWS Secret Access Key */
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test src/lib/rules/no-hanging-particles.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// @ts-check
|
||||
import { i18nPlugin } from '../plugins/i18n.js';
|
||||
import { markdownParser } from '../parsers/markdown.js';
|
||||
|
||||
/**
|
||||
* i18n typography and validation ESLint configuration.
|
||||
*/
|
||||
export const i18nConfig = [
|
||||
{
|
||||
files: ['**/*.i18n.{ts,tsx,js,jsx,mjs}'],
|
||||
plugins: {
|
||||
i18n: i18nPlugin,
|
||||
},
|
||||
rules: {
|
||||
'i18n/no-hanging-particles': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{md,mdx}'],
|
||||
languageOptions: {
|
||||
parser: markdownParser,
|
||||
},
|
||||
plugins: {
|
||||
i18n: i18nPlugin,
|
||||
},
|
||||
rules: {
|
||||
'no-irregular-whitespace': 'off',
|
||||
'i18n/no-hanging-particles': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -6,6 +6,7 @@ import { jsdocConfig } from './jsdoc.js';
|
||||
import { testingConfig } from './testing.js';
|
||||
import { toolingConfig } from './tooling.js';
|
||||
import { defineMonorepoRestrictedImports } from './monorepo.js';
|
||||
import { i18nConfig } from './i18n.js';
|
||||
|
||||
/**
|
||||
* Common ignores for the entire monorepo.
|
||||
@@ -38,6 +39,9 @@ export function defineBaseConfig(rootDir) {
|
||||
...jsdocConfig,
|
||||
...testingConfig,
|
||||
...toolingConfig,
|
||||
...i18nConfig,
|
||||
...defineMonorepoRestrictedImports(rootDir),
|
||||
];
|
||||
}
|
||||
|
||||
export { i18nConfig };
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-check
|
||||
import path from 'node:path';
|
||||
import { defineBaseConfig } from './config/index.js';
|
||||
import { defineBaseConfig, i18nConfig } from './config/index.js';
|
||||
import { i18nPlugin } from './plugins/i18n.js';
|
||||
|
||||
// Resolve the monorepo root relative to this library's location
|
||||
const rootDir = path.resolve(import.meta.dirname, '../../../../');
|
||||
@@ -10,4 +11,4 @@ const rootDir = path.resolve(import.meta.dirname, '../../../../');
|
||||
*/
|
||||
export const baseConfig = defineBaseConfig(rootDir);
|
||||
|
||||
export { defineBaseConfig };
|
||||
export { defineBaseConfig, i18nConfig, i18nPlugin };
|
||||
@@ -0,0 +1,61 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* Calculates start and end line/column locations for a string offset.
|
||||
*
|
||||
* @param {string} code - Entire source code.
|
||||
* @returns {import('eslint').AST.SourceLocation} Full document SourceLocation.
|
||||
*/
|
||||
function getDocumentLocation(code) {
|
||||
const lines = code.split('\n');
|
||||
const lastLine = lines.length;
|
||||
const lastColumn = lines[lines.length - 1].length;
|
||||
|
||||
return {
|
||||
start: { line: 1, column: 0 },
|
||||
end: { line: lastLine, column: lastColumn },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom lightweight parser for Markdown and MDX documents.
|
||||
* Produces an AST that enables typography linting and autofixing on prose text.
|
||||
*/
|
||||
export const markdownParser = {
|
||||
meta: {
|
||||
name: 'eslint-parser-markdown-typography',
|
||||
version: '1.0.0',
|
||||
},
|
||||
parseForESLint(code, options = {}) {
|
||||
const filePath = options.filePath || '';
|
||||
const isRu = /(?:^|[\\/])ru(?:[\\/]|\.ru\.)/i.test(filePath);
|
||||
const isEn = /(?:^|[\\/])en(?:[\\/]|\.en\.)/i.test(filePath);
|
||||
const locale = isRu ? 'ru' : isEn ? 'en' : undefined;
|
||||
|
||||
const loc = getDocumentLocation(code);
|
||||
|
||||
return {
|
||||
ast: {
|
||||
type: 'Program',
|
||||
range: [0, code.length],
|
||||
loc,
|
||||
body: [
|
||||
{
|
||||
type: 'MarkdownDocument',
|
||||
range: [0, code.length],
|
||||
loc,
|
||||
value: code,
|
||||
locale,
|
||||
filePath,
|
||||
},
|
||||
],
|
||||
tokens: [],
|
||||
comments: [],
|
||||
},
|
||||
visitorKeys: {
|
||||
Program: ['body'],
|
||||
MarkdownDocument: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// @ts-check
|
||||
import { noHangingParticlesRule } from '../rules/no-hanging-particles.js';
|
||||
|
||||
/**
|
||||
* ESLint plugin providing i18n translation validation and typography rules.
|
||||
*/
|
||||
export const i18nPlugin = {
|
||||
meta: {
|
||||
name: 'eslint-plugin-i18n',
|
||||
version: '1.0.0',
|
||||
},
|
||||
rules: {
|
||||
'no-hanging-particles': noHangingParticlesRule,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
// @ts-check
|
||||
|
||||
const RU_PARTICLES = [
|
||||
'[а-яё]',
|
||||
'во', 'со', 'ко', 'до', 'по', 'на', 'из', 'за', 'от', 'об', 'не', 'ни', 'но', 'да', 'то', 'уж',
|
||||
'обо', 'под', 'над', 'при', 'про', 'для', 'без', 'или', 'как', 'что', 'еще', 'ещё', 'уже',
|
||||
'через', 'сквозь', 'перед', 'из-за', 'из-под',
|
||||
].join('|');
|
||||
|
||||
const EN_PARTICLES = [
|
||||
'a', 'an', 'the',
|
||||
'in', 'on', 'at', 'to', 'of', 'by', 'for', 'up', 'as', 'if', 'is', 'it', 'no',
|
||||
'and', 'or', 'nor', 'but', 'yet', 'so', 'with', 'into', 'from',
|
||||
].join('|');
|
||||
|
||||
const RU_TRAILING = ['ли', 'ль', 'же', 'ж', 'бы', 'б'].join('|');
|
||||
|
||||
const KNOWN_LOCALES = new Set(['en', 'ru']);
|
||||
const PLURAL_KEYS = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
|
||||
|
||||
/**
|
||||
* Applies non-breaking space typography rules to a translation string value.
|
||||
*
|
||||
* @param {string} input - The raw translation string.
|
||||
* @param {string} [locale] - The locale code ('en' | 'ru').
|
||||
* @returns {string} The transformed string with non-breaking spaces.
|
||||
*/
|
||||
export function applyTypography(input, locale) {
|
||||
let text = input.replaceAll(' ', '\u00A0');
|
||||
|
||||
/** @type {string[]} */
|
||||
const protectedTokens = [];
|
||||
text = text.replace(/\{[^}]+\}|<[^>]+>|\]\([^)]+\)/g, (match) => {
|
||||
const idx = protectedTokens.length;
|
||||
protectedTokens.push(match);
|
||||
return `__TYPO_PROT_${idx}__`;
|
||||
});
|
||||
|
||||
// Em-dash and en-dash (prepend non-breaking space)
|
||||
text = text.replace(/(?<=\S)[\t \u00A0]+([—–])[\t ]+/g, '\u00A0$1 ');
|
||||
|
||||
// Numbers & units/symbols
|
||||
text = text.replace(/(?<=\d)[\t ]+(?=[a-zA-Zа-яА-ЯёЁ%№$€₽]|__TYPO_PROT_)/g, '\u00A0');
|
||||
text = text.replace(/(?<=[№§$€₽])[\t ]+(?=\d|__TYPO_PROT_)/g, '\u00A0');
|
||||
|
||||
// Trailing particles (Russian)
|
||||
if (!locale || locale === 'ru') {
|
||||
const trailingRegex = new RegExp(`(?<=[а-яА-ЯёЁ\\w])\\s+(${RU_TRAILING})(?=[\\s\\u00A0.,!?:;»"')\\]]|$)`, 'gi');
|
||||
text = text.replace(trailingRegex, '\u00A0$1');
|
||||
}
|
||||
|
||||
// Leading particles (RU / EN)
|
||||
const particles = locale === 'en' ? EN_PARTICLES : locale === 'ru' ? RU_PARTICLES : `${RU_PARTICLES}|${EN_PARTICLES}`;
|
||||
const leadingRegex = new RegExp(`(?<=^|[\\s\\u00A0.,!?:;«"'(])(${particles})[ \\t]+(?=\\S)`, 'gi');
|
||||
text = text.replace(leadingRegex, '$1\u00A0');
|
||||
|
||||
// Restore protected tokens
|
||||
text = text.replace(/__TYPO_PROT_(\d+)__/g, (_, idx) => protectedTokens[Number(idx)]);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks frontmatter non-title/description lines.
|
||||
*
|
||||
* @param {string} text - Raw document text.
|
||||
* @param {(str: string) => string} mask - Masking function.
|
||||
* @returns {string} Text with frontmatter metadata masked.
|
||||
*/
|
||||
function maskFrontmatter(text, mask) {
|
||||
if (!text.startsWith('---')) return text;
|
||||
const endIdx = text.indexOf('\n---', 3);
|
||||
if (endIdx === -1) return text;
|
||||
const fm = text.slice(0, endIdx + 4);
|
||||
const rest = text.slice(endIdx + 4);
|
||||
|
||||
const lines = fm.split('\n');
|
||||
const processedLines = lines.map((line, idx) => {
|
||||
if (idx === 0 || idx === lines.length - 1) return mask(line);
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('title:') || trimmed.startsWith('description:')) {
|
||||
return line;
|
||||
}
|
||||
return mask(line);
|
||||
});
|
||||
return processedLines.join('\n') + rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies typography rules to a Markdown/MDX document, protecting code blocks,
|
||||
* inline code, shortcodes, link URLs, HTML tags, and frontmatter metadata.
|
||||
*
|
||||
* @param {string} content - Markdown document content.
|
||||
* @param {string} [locale] - The document locale ('en' | 'ru').
|
||||
* @returns {string} Transformed markdown content.
|
||||
*/
|
||||
export function applyMarkdownTypography(content, locale) {
|
||||
/** @type {string[]} */
|
||||
const protectedTokens = [];
|
||||
/**
|
||||
* @param {string} str
|
||||
* @returns {string}
|
||||
*/
|
||||
function mask(str) {
|
||||
const idx = protectedTokens.length;
|
||||
protectedTokens.push(str);
|
||||
return `__MD_PROT_${idx}__`;
|
||||
}
|
||||
|
||||
let text = maskFrontmatter(content, mask);
|
||||
|
||||
// Mask fenced code blocks ```...``` and ~~~...~~~
|
||||
text = text.replace(/```[\s\S]*?```|~~~[\s\S]*?~~~/g, mask);
|
||||
// Mask Hugo shortcodes {{< ... >}} or {{% ... %}}
|
||||
text = text.replace(/\{\{[<%][\s\S]*?[>%]\}\}/g, mask);
|
||||
// Mask inline code `...`
|
||||
text = text.replace(/`[^`\n]+`/g, mask);
|
||||
// Mask Markdown link targets ](...)
|
||||
text = text.replace(/\]\([^)]+\)/g, mask);
|
||||
// Mask HTML tags
|
||||
text = text.replace(/<[^>]+>/g, mask);
|
||||
|
||||
// Apply typography rules
|
||||
text = applyTypography(text, locale);
|
||||
|
||||
// Restore protected tokens
|
||||
text = text.replace(/__MD_PROT_(\d+)__/g, (_, idx) => protectedTokens[Number(idx)]);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a string into a valid JS string literal with specified quote.
|
||||
*
|
||||
* @param {string} str - String to escape and wrap.
|
||||
* @param {string} quote - Quote character (' or ").
|
||||
* @returns {string} Serialized literal.
|
||||
*/
|
||||
function serializeStringLiteral(str, quote) {
|
||||
const escaped = str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\t/g, '\\t')
|
||||
.replace(new RegExp(quote, 'g'), `\\${quote}`);
|
||||
return `${quote}${escaped}${quote}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts property name from a property node.
|
||||
*
|
||||
* @param {any} prop - Property AST node.
|
||||
* @returns {string | null} Property name or null.
|
||||
*/
|
||||
function getPropertyName(prop) {
|
||||
if (!prop || !prop.key) return null;
|
||||
if (prop.key.type === 'Identifier') return prop.key.name;
|
||||
if (prop.key.type === 'Literal') return String(prop.key.value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* ESLint rule module for enforcing non-breaking spaces in i18n translation values
|
||||
* and Markdown/MDX documentation.
|
||||
*
|
||||
* @type {import('eslint').Rule.RuleModule}
|
||||
*/
|
||||
export const noHangingParticlesRule = {
|
||||
meta: {
|
||||
type: 'layout',
|
||||
docs: {
|
||||
description: 'Enforce non-breaking spaces for hanging particles and numbers in i18n translation values and markdown documentation.',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: 'whitespace',
|
||||
schema: [],
|
||||
messages: {
|
||||
hangingParticle: 'Translation value contains hanging particles or numbers that should be bound with a non-breaking space (\\u00A0).',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
/**
|
||||
* Checks and fixes a single string literal node.
|
||||
*
|
||||
* @param {any} node - String Literal AST node.
|
||||
* @param {string} [locale] - The locale code if known.
|
||||
*/
|
||||
function checkStringLiteral(node, locale) {
|
||||
if (!node || typeof node.value !== 'string') return;
|
||||
const original = node.value;
|
||||
const formatted = applyTypography(original, locale);
|
||||
|
||||
if (original !== formatted) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'hangingParticle',
|
||||
fix(fixer) {
|
||||
const raw = node.raw || '';
|
||||
const quote = raw.startsWith('"') ? '"' : "'";
|
||||
return fixer.replaceText(node, serializeStringLiteral(formatted, quote));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects a locale entry inside a translation item.
|
||||
*
|
||||
* @param {any} prop - Locale property node (e.g. en or ru).
|
||||
*/
|
||||
function processLocaleEntry(prop) {
|
||||
const locale = getPropertyName(prop);
|
||||
if (!locale || !KNOWN_LOCALES.has(locale)) return;
|
||||
|
||||
const valueNode = prop.value;
|
||||
if (!valueNode) return;
|
||||
|
||||
if (valueNode.type === 'Literal') {
|
||||
checkStringLiteral(valueNode, locale);
|
||||
} else if (valueNode.type === 'ObjectExpression' && Array.isArray(valueNode.properties)) {
|
||||
for (const pluralProp of valueNode.properties) {
|
||||
if (pluralProp.type === 'Property') {
|
||||
const pluralKey = getPropertyName(pluralProp);
|
||||
if (pluralKey && PLURAL_KEYS.has(pluralKey) && pluralProp.value?.type === 'Literal') {
|
||||
checkStringLiteral(pluralProp.value, locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes keyset object dictionary properties.
|
||||
*
|
||||
* @param {any} objectNode - Keyset ObjectExpression AST node.
|
||||
*/
|
||||
function processKeysetObject(objectNode) {
|
||||
if (!objectNode || !Array.isArray(objectNode.properties)) return;
|
||||
|
||||
for (const dictEntry of objectNode.properties) {
|
||||
if (
|
||||
dictEntry.type === 'Property' &&
|
||||
dictEntry.value &&
|
||||
dictEntry.value.type === 'ObjectExpression' &&
|
||||
Array.isArray(dictEntry.value.properties)
|
||||
) {
|
||||
for (const localeProp of dictEntry.value.properties) {
|
||||
if (localeProp.type === 'Property') {
|
||||
processLocaleEntry(localeProp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (
|
||||
node.callee &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
node.callee.name === 'createKeyset' &&
|
||||
node.arguments.length > 0 &&
|
||||
node.arguments[0].type === 'ObjectExpression'
|
||||
) {
|
||||
processKeysetObject(node.arguments[0]);
|
||||
}
|
||||
},
|
||||
|
||||
MarkdownDocument(node) {
|
||||
if (!node || typeof node.value !== 'string') return;
|
||||
const original = node.value;
|
||||
const formatted = applyMarkdownTypography(original, node.locale);
|
||||
|
||||
if (original === formatted) return;
|
||||
|
||||
const origLines = original.split('\n');
|
||||
const formLines = formatted.split('\n');
|
||||
|
||||
let reported = false;
|
||||
for (let i = 0; i < origLines.length; i++) {
|
||||
const origLine = origLines[i];
|
||||
const formLine = formLines[i];
|
||||
if (origLine !== formLine) {
|
||||
let diffCol = 0;
|
||||
while (
|
||||
diffCol < origLine.length &&
|
||||
diffCol < formLine.length &&
|
||||
origLine[diffCol] === formLine[diffCol]
|
||||
) {
|
||||
diffCol++;
|
||||
}
|
||||
|
||||
const isFirst = !reported;
|
||||
reported = true;
|
||||
|
||||
context.report({
|
||||
node,
|
||||
loc: {
|
||||
start: { line: i + 1, column: diffCol },
|
||||
end: { line: i + 1, column: diffCol + 1 },
|
||||
},
|
||||
messageId: 'hangingParticle',
|
||||
fix: isFirst ? (fixer) => fixer.replaceText(node, formatted) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
// @ts-check
|
||||
import { describe, it } from 'node:test';
|
||||
import { RuleTester } from 'eslint';
|
||||
import { noHangingParticlesRule } from './no-hanging-particles.js';
|
||||
import { markdownParser } from '../parsers/markdown.js';
|
||||
|
||||
// Configure RuleTester for node:test
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('no-hanging-particles', noHangingParticlesRule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
createKeyset({
|
||||
'Yet capturing context': {
|
||||
en: 'Yet\\u00A0capturing context and\\u00A0architectural decisions',
|
||||
ru: 'Мои мысли и\\u00A0проекты',
|
||||
},
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Keys should not trigger errors even if they contain particles
|
||||
code: `
|
||||
createKeyset({
|
||||
'и иные проблемы': {
|
||||
en: 'and\\u00A0other issues',
|
||||
ru: 'и\\u00A0иные проблемы',
|
||||
},
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Manual {nbsp} token is allowed
|
||||
code: `
|
||||
createKeyset({
|
||||
manual: {
|
||||
en: 'Yet{nbsp}capturing',
|
||||
ru: 'и{nbsp}иные',
|
||||
},
|
||||
});
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Not a createKeyset call
|
||||
code: `
|
||||
const regularObj = {
|
||||
en: 'Yet capturing without keyset',
|
||||
};
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
// English leading particle
|
||||
code: `
|
||||
createKeyset({
|
||||
key: {
|
||||
en: 'Yet capturing context',
|
||||
ru: 'Фиксация контекста',
|
||||
},
|
||||
});
|
||||
`,
|
||||
output: `
|
||||
createKeyset({
|
||||
key: {
|
||||
en: 'Yet\u00A0capturing context',
|
||||
ru: 'Фиксация контекста',
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'hangingParticle',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Russian leading particle
|
||||
code: `
|
||||
createKeyset({
|
||||
'и иные проблемы': {
|
||||
en: 'and other issues',
|
||||
ru: 'и иные проблемы',
|
||||
},
|
||||
});
|
||||
`,
|
||||
output: `
|
||||
createKeyset({
|
||||
'и иные проблемы': {
|
||||
en: 'and\u00A0other issues',
|
||||
ru: 'и\u00A0иные проблемы',
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Interpolation placeholder & trailing particles & dashes & numbers
|
||||
code: `
|
||||
createKeyset({
|
||||
welcome: {
|
||||
en: 'Inspect the {repositoryLink} for 10 items — all good',
|
||||
ru: 'Загляните в {repositoryLink}, как же так — это 10 вещей в 2026 году',
|
||||
},
|
||||
});
|
||||
`,
|
||||
output: `
|
||||
createKeyset({
|
||||
welcome: {
|
||||
en: 'Inspect the\u00A0{repositoryLink} for\u00A010\u00A0items\u00A0— all good',
|
||||
ru: 'Загляните в\u00A0{repositoryLink}, как\u00A0же так\u00A0— это 10\u00A0вещей в\u00A02026\u00A0году',
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// HTML entity replacement
|
||||
code: `
|
||||
createKeyset({
|
||||
test: {
|
||||
en: 'Text with entity',
|
||||
ru: 'Текст с сущностью',
|
||||
},
|
||||
});
|
||||
`,
|
||||
output: `
|
||||
createKeyset({
|
||||
test: {
|
||||
en: 'Text\u00A0with\u00A0entity',
|
||||
ru: 'Текст\u00A0с\u00A0сущностью',
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Plural forms
|
||||
code: `
|
||||
createKeyset({
|
||||
counter: {
|
||||
en: {
|
||||
one: '{count} item',
|
||||
other: '{count} items in stock',
|
||||
},
|
||||
ru: {
|
||||
one: '{count} штука в наличии',
|
||||
few: '{count} штуки в наличии',
|
||||
many: '{count} штук в наличии',
|
||||
other: '{count} штук в наличии',
|
||||
},
|
||||
},
|
||||
});
|
||||
`,
|
||||
output: `
|
||||
createKeyset({
|
||||
counter: {
|
||||
en: {
|
||||
one: '{count} item',
|
||||
other: '{count} items in\u00A0stock',
|
||||
},
|
||||
ru: {
|
||||
one: '{count} штука в\u00A0наличии',
|
||||
few: '{count} штуки в\u00A0наличии',
|
||||
many: '{count} штук в\u00A0наличии',
|
||||
other: '{count} штук в\u00A0наличии',
|
||||
},
|
||||
},
|
||||
});
|
||||
`,
|
||||
errors: [
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const markdownRuleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: markdownParser,
|
||||
},
|
||||
});
|
||||
|
||||
markdownRuleTester.run('no-hanging-particles (markdown)', noHangingParticlesRule, {
|
||||
valid: [
|
||||
{
|
||||
code: '# Title\n\nYet\\u00A0capturing context\\u00A0— for\\u00A010\\u00A0items',
|
||||
filename: 'content/en/overview.md',
|
||||
},
|
||||
{
|
||||
// Code blocks should be protected
|
||||
code: '```ts\nconst in = 10;\n```',
|
||||
filename: 'content/en/guide.md',
|
||||
},
|
||||
{
|
||||
// Inline code and link targets should be protected
|
||||
code: 'Use `in` keyword here [link](https://example.com/in/path).',
|
||||
filename: 'content/en/guide.md',
|
||||
},
|
||||
{
|
||||
// Hugo shortcodes should be protected
|
||||
code: '{{< card link="apex" title="apex" >}}',
|
||||
filename: 'content/en/guide.md',
|
||||
},
|
||||
{
|
||||
// Frontmatter tags/date should be protected
|
||||
code: '---\ntitle: "Title"\ntags: [a, in, on]\n---\nBody text',
|
||||
filename: 'content/en/guide.md',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: '# Title\n\nYet capturing context — for 10 items',
|
||||
output: '# Title\n\nYet\u00A0capturing context\u00A0— for\u00A010\u00A0items',
|
||||
filename: 'content/en/overview.md',
|
||||
errors: [{ messageId: 'hangingParticle' }],
|
||||
},
|
||||
{
|
||||
code: 'Это руководство для заметок с нуля',
|
||||
output: 'Это руководство для\u00A0заметок с\u00A0нуля',
|
||||
filename: 'content/ru/overview.md',
|
||||
errors: [{ messageId: 'hangingParticle' }],
|
||||
},
|
||||
{
|
||||
code: '---\ntitle: "Руководство по развертыванию"\ntags: [a, in]\n---\nС нуля',
|
||||
output: '---\ntitle: "Руководство по\u00A0развертыванию"\ntags: [a, in]\n---\nС\u00A0нуля',
|
||||
filename: 'content/ru/guide.md',
|
||||
errors: [
|
||||
{ messageId: 'hangingParticle' },
|
||||
{ messageId: 'hangingParticle' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
@@ -44,6 +45,6 @@
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"react": "^19.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
@@ -65,7 +68,7 @@
|
||||
"storybook": "^8.6.17",
|
||||
"storybook-react-rsbuild": "^1.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest": "^4.1.11",
|
||||
"wait-on": "^8.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -12,19 +12,19 @@ describe('PageHead', () => {
|
||||
expect(screen.getByText('Test Caption')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders breadcrumbs, addonLeft, and addonRight', () => {
|
||||
it('renders breadcrumbs and addons in both expanded and collapsed views', () => {
|
||||
render(
|
||||
<PageHead
|
||||
addonLeft={<span>Left Addon</span>}
|
||||
addonRight={<span>Right Addon</span>}
|
||||
addonLeft={<span>Logo Left</span>}
|
||||
addonRight={<span>Nav Menu</span>}
|
||||
breadcrumbs={<span>Breadcrumbs Trail</span>}
|
||||
heading="Test Heading"
|
||||
heading="Wanted"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Breadcrumbs Trail')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Left Addon').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Right Addon').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Breadcrumbs Trail').length).toBe(2);
|
||||
expect(screen.getAllByText('Nav Menu').length).toBe(2);
|
||||
expect(screen.getAllByText('Logo Left').length).toBe(2);
|
||||
});
|
||||
|
||||
it('renders correctly with always-collapsed-fixed strategy', () => {
|
||||
|
||||
@@ -76,22 +76,28 @@ export function PageHead({
|
||||
|
||||
useEffect(() => {
|
||||
if (isCollapsible) {
|
||||
setIsCollapsed(scrollY > 200);
|
||||
setIsCollapsed(scrollY > 150);
|
||||
}
|
||||
}, [scrollY, isCollapsible]);
|
||||
|
||||
const rootClasses = cn(pageHeadRootVariants({ strategy }), className);
|
||||
|
||||
const hasTopBar = Boolean(addonLeft ?? breadcrumbs ?? addonRight);
|
||||
|
||||
const expandedContent = (
|
||||
<PageContainer paddingY="none">
|
||||
<VStack align="stretch" className="pb-8 sm:pb-12" gap={6}>
|
||||
<HStack align="center" className="py-2.5" gap={4} justify="between" wrap="wrap">
|
||||
<HStack align="center" className="flex-1" gap={4}>
|
||||
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
|
||||
{breadcrumbs && <div className="hidden sm:block">{breadcrumbs}</div>}
|
||||
<div>
|
||||
{hasTopBar && (
|
||||
<PageContainer paddingY="none">
|
||||
<HStack align="center" className="min-h-[60px] py-2.5" gap={4} justify="between" wrap="wrap">
|
||||
<HStack align="center" className="flex-1" gap={4}>
|
||||
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
|
||||
{breadcrumbs && <div className="hidden sm:block text-label-l [&_ol]:text-label-l">{breadcrumbs}</div>}
|
||||
</HStack>
|
||||
{addonRight && <div className="flex-shrink-0">{addonRight}</div>}
|
||||
</HStack>
|
||||
{addonRight && <div className="flex-shrink-0">{addonRight}</div>}
|
||||
</HStack>
|
||||
</PageContainer>
|
||||
)}
|
||||
<PageContainer className={cn(hasTopBar ? 'pt-2.5 pb-8 sm:pt-3.5 sm:pb-12' : 'pt-5 pb-8 sm:pt-9 sm:pb-12')}>
|
||||
<VStack gap={4}>
|
||||
<Header level={1}>{heading}</Header>
|
||||
{typeof caption === 'string' ? (
|
||||
@@ -102,16 +108,21 @@ export function PageHead({
|
||||
caption
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</PageContainer>
|
||||
</PageContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
const collapsedContent = (
|
||||
<PageContainer paddingY="none">
|
||||
<HStack align="center" className="py-2.5" gap={4} justify="between" wrap="wrap">
|
||||
<HStack align="center" className="min-h-[60px] py-2.5" gap={4} justify="between" wrap="wrap">
|
||||
<HStack align="center" className="flex-1" gap={4}>
|
||||
{addonLeft && <div className="flex-shrink-0 whitespace-nowrap">{addonLeft}</div>}
|
||||
<Header className="min-w-0" level={4} overflow="ellipsis">
|
||||
{breadcrumbs && <div className="hidden sm:block text-label-l [&_ol]:text-label-l">{breadcrumbs}</div>}
|
||||
<Header
|
||||
className={cn('min-w-0', breadcrumbs && 'sm:hidden')}
|
||||
level={4}
|
||||
overflow="ellipsis"
|
||||
>
|
||||
{heading}
|
||||
</Header>
|
||||
</HStack>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"./testing": "./dist/testing.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
"build": "tsc",
|
||||
"test": "vitest run src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rsbuild/plugin-react": "^1.1.1",
|
||||
@@ -26,6 +27,6 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './rsbuild.js';
|
||||
export * from './server.js';
|
||||
export * from './vitest.js';
|
||||
@@ -26,9 +26,6 @@ export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
|
||||
distPath: {
|
||||
font: 'static/font',
|
||||
},
|
||||
filename: {
|
||||
font: '[name][ext]',
|
||||
},
|
||||
},
|
||||
html: {
|
||||
template: './src/static/index.html',
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ensureDevCertificates,
|
||||
ensureHostsEntry,
|
||||
findWorkspaceRoot,
|
||||
hasHostsEntry,
|
||||
loadDevRootCaKey,
|
||||
localDevServer,
|
||||
} from './server.js';
|
||||
|
||||
describe('server dev configuration', () => {
|
||||
it('locates the workspace root with pnpm-workspace.yaml', () => {
|
||||
const root = findWorkspaceRoot();
|
||||
expect(existsSync(`${root}/pnpm-workspace.yaml`)).toBe(true);
|
||||
});
|
||||
|
||||
it('ensures dev certificates exist and returns existing paths', () => {
|
||||
const certs = ensureDevCertificates();
|
||||
expect(existsSync(certs.certPath)).toBe(true);
|
||||
expect(existsSync(certs.keyPath)).toBe(true);
|
||||
expect(certs.caCertPath).toBeDefined();
|
||||
if (certs.caCertPath) {
|
||||
expect(existsSync(certs.caCertPath)).toBe(true);
|
||||
expect(certs.caCertPath).toContain('ErmnvldmrDevelopmentRootCA.pem');
|
||||
}
|
||||
expect(certs.certPath).toContain('.certs/localhost.ermnvldmr.com.pem');
|
||||
});
|
||||
|
||||
it('loads dev root CA key from environment variable when present', () => {
|
||||
const prevKey = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
try {
|
||||
process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY = 'mock-key-value\\nwith-newline';
|
||||
const key = loadDevRootCaKey();
|
||||
expect(key).toBe('mock-key-value\nwith-newline');
|
||||
} finally {
|
||||
if (prevKey !== undefined) {
|
||||
process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY = prevKey;
|
||||
} else {
|
||||
delete process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('loads dev root CA key from .env.dev when available', () => {
|
||||
const prevKey = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
delete process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
try {
|
||||
const key = loadDevRootCaKey();
|
||||
expect(key).toBeDefined();
|
||||
if (key) {
|
||||
expect(key).toContain('BEGIN PRIVATE KEY');
|
||||
}
|
||||
} finally {
|
||||
if (prevKey !== undefined) {
|
||||
process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY = prevKey;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('returns production configuration without https when NODE_ENV is production', () => {
|
||||
const prevEnv = process.env.NODE_ENV;
|
||||
try {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const config = localDevServer({ port: 3000 });
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.https).toBeUndefined();
|
||||
} finally {
|
||||
process.env.NODE_ENV = prevEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('configures dev server with https and formatted urls for localhost.ermnvldmr.com', () => {
|
||||
const prevEnv = process.env.NODE_ENV;
|
||||
try {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const config = localDevServer({ port: 3000 });
|
||||
expect(config.host).toBe('0.0.0.0');
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.cors).toBe(true);
|
||||
expect(config.https).toBeDefined();
|
||||
|
||||
if (typeof config.printUrls === 'function') {
|
||||
const urls = config.printUrls({
|
||||
protocol: 'https',
|
||||
port: 3000,
|
||||
urls: [],
|
||||
routes: [],
|
||||
});
|
||||
expect(urls).toEqual(['https://localhost.ermnvldmr.com:3000']);
|
||||
}
|
||||
} finally {
|
||||
process.env.NODE_ENV = prevEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('detects entries correctly in hosts file', () => {
|
||||
const mockHostsPath = join(tmpdir(), `hosts-test-${Date.now()}`);
|
||||
try {
|
||||
writeFileSync(mockHostsPath, '127.0.0.1 localhost\n# 127.0.0.1 localhost.ermnvldmr.com\n');
|
||||
expect(hasHostsEntry('localhost.ermnvldmr.com', mockHostsPath)).toBe(false);
|
||||
|
||||
writeFileSync(
|
||||
mockHostsPath,
|
||||
'127.0.0.1 localhost\n127.0.0.1 localhost.ermnvldmr.com other.domain\n'
|
||||
);
|
||||
expect(hasHostsEntry('localhost.ermnvldmr.com', mockHostsPath)).toBe(true);
|
||||
} finally {
|
||||
if (existsSync(mockHostsPath)) unlinkSync(mockHostsPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('appends missing entry to writable hosts file', () => {
|
||||
const mockHostsPath = join(tmpdir(), `hosts-test-${Date.now()}`);
|
||||
try {
|
||||
writeFileSync(mockHostsPath, '127.0.0.1 localhost\n');
|
||||
const added = ensureHostsEntry('localhost.ermnvldmr.com', '127.0.0.1', mockHostsPath);
|
||||
expect(added).toBe(true);
|
||||
|
||||
const content = readFileSync(mockHostsPath, 'utf8');
|
||||
expect(content).toContain('127.0.0.1 localhost.ermnvldmr.com');
|
||||
expect(hasHostsEntry('localhost.ermnvldmr.com', mockHostsPath)).toBe(true);
|
||||
} finally {
|
||||
if (existsSync(mockHostsPath)) unlinkSync(mockHostsPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid domain or ip in ensureHostsEntry', () => {
|
||||
expect(ensureHostsEntry('invalid domain with spaces', '127.0.0.1')).toBe(false);
|
||||
expect(ensureHostsEntry('localhost.ermnvldmr.com', 'invalid-ip-format')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,417 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import type { ServerConfig } from '@rsbuild/core';
|
||||
|
||||
/**
|
||||
* Configuration options for the local development server.
|
||||
*/
|
||||
export interface LocalDevServerOptions {
|
||||
/**
|
||||
* The port number on which the dev server should listen.
|
||||
*/
|
||||
port: number;
|
||||
/**
|
||||
* Optional custom domain name. Defaults to 'localhost.ermnvldmr.com'.
|
||||
*/
|
||||
host?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate file paths resolved for local development.
|
||||
*/
|
||||
export interface DevCertificates {
|
||||
/**
|
||||
* Absolute path to the PEM certificate file.
|
||||
*/
|
||||
certPath: string;
|
||||
/**
|
||||
* Absolute path to the PEM private key file.
|
||||
*/
|
||||
keyPath: string;
|
||||
/**
|
||||
* Absolute path to the PEM Root CA certificate file.
|
||||
*/
|
||||
caCertPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a hostname entry exists in the specified hosts file.
|
||||
*
|
||||
* @param domain - Domain to search for, defaults to 'localhost.ermnvldmr.com'
|
||||
* @param hostsPath - Path to hosts file, defaults to '/etc/hosts'
|
||||
* @returns Whether the domain is mapped in the hosts file
|
||||
*/
|
||||
export function hasHostsEntry(
|
||||
domain: string = 'localhost.ermnvldmr.com',
|
||||
hostsPath: string = '/etc/hosts'
|
||||
): boolean {
|
||||
if (!existsSync(hostsPath)) return false;
|
||||
try {
|
||||
const content = readFileSync(hostsPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
for (const line of lines) {
|
||||
const uncommented = line.split('#')[0]?.trim() ?? '';
|
||||
if (!uncommented) continue;
|
||||
const tokens = uncommented.split(/\s+/);
|
||||
const hostnames = tokens.slice(1);
|
||||
if (tokens.length >= 2 && hostnames.some((host) => host === domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const DOMAIN_PATTERN = /^[a-zA-Z0-9.-]+$/;
|
||||
const IP_PATTERN = /^[\da-fA-F.:]+$/;
|
||||
|
||||
/**
|
||||
* Ensures a mapping for the domain exists in the hosts file.
|
||||
*
|
||||
* If not present and the file is not directly writable, attempts to invoke sudo
|
||||
* when running in an interactive terminal.
|
||||
*
|
||||
* @param domain - Domain to map, defaults to 'localhost.ermnvldmr.com'
|
||||
* @param ip - Target IP address, defaults to '127.0.0.1'
|
||||
* @param hostsPath - Path to hosts file, defaults to '/etc/hosts'
|
||||
* @returns Whether the entry is now present in the hosts file
|
||||
*/
|
||||
export function ensureHostsEntry(
|
||||
domain: string = 'localhost.ermnvldmr.com',
|
||||
ip: string = '127.0.0.1',
|
||||
hostsPath: string = '/etc/hosts'
|
||||
): boolean {
|
||||
if (!DOMAIN_PATTERN.test(domain) || !IP_PATTERN.test(ip)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasHostsEntry(domain, hostsPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entry = `${ip} ${domain}`;
|
||||
|
||||
try {
|
||||
appendFileSync(hostsPath, `\n${entry}\n`);
|
||||
console.log(`[hosts] Added "${entry}" to ${hostsPath}`);
|
||||
return true;
|
||||
} catch {
|
||||
// Requires elevated permissions
|
||||
}
|
||||
|
||||
const isInteractive = Boolean(process.stdin?.isTTY && process.stdout?.isTTY);
|
||||
if (isInteractive) {
|
||||
try {
|
||||
console.log(`[hosts] Adding "${entry}" to ${hostsPath} (requires sudo)...`);
|
||||
execFileSync('sudo', ['tee', '-a', hostsPath], {
|
||||
input: `\n${entry}\n`,
|
||||
stdio: ['pipe', 'ignore', 'inherit'],
|
||||
});
|
||||
console.log(`[hosts] Successfully added "${entry}" to ${hostsPath}`);
|
||||
return true;
|
||||
} catch {
|
||||
console.warn(`[hosts] Could not automatically update ${hostsPath}.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[hosts] Notice: "${entry}" is missing from ${hostsPath}.\nPlease run:\n sudo sh -c 'echo "${entry}" >> ${hostsPath}'`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses directory hierarchy upwards to locate the workspace root containing pnpm-workspace.yaml.
|
||||
*
|
||||
* @param startDir - Starting directory for lookup, defaults to current process directory
|
||||
* @returns Absolute path to the workspace root directory
|
||||
*/
|
||||
export function findWorkspaceRoot(startDir: string = process.cwd()): string {
|
||||
let current = startDir;
|
||||
while (current !== dirname(current)) {
|
||||
if (existsSync(join(current, 'pnpm-workspace.yaml'))) {
|
||||
return current;
|
||||
}
|
||||
current = dirname(current);
|
||||
}
|
||||
return startDir;
|
||||
}
|
||||
|
||||
interface DotenvxModule {
|
||||
config: (options: { path?: string; envKeysFile?: string }) => void;
|
||||
}
|
||||
|
||||
function isDotenvxModule(val: unknown): val is DotenvxModule {
|
||||
return (
|
||||
typeof val === 'object' &&
|
||||
val !== null &&
|
||||
'config' in val &&
|
||||
typeof val.config === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the development Root CA private key from the environment or .env.dev.
|
||||
*
|
||||
* @param workspaceRoot - Absolute path to workspace root
|
||||
* @returns The PEM private key string, or undefined if unavailable
|
||||
*/
|
||||
export function loadDevRootCaKey(
|
||||
workspaceRoot: string = findWorkspaceRoot()
|
||||
): string | undefined {
|
||||
if (process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY) {
|
||||
const raw = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
return raw.includes('\\n') ? raw.replace(/\\n/g, '\n') : raw;
|
||||
}
|
||||
|
||||
const envDevPath = join(workspaceRoot, '.env.dev');
|
||||
const envKeysPath = join(workspaceRoot, '.env.keys');
|
||||
|
||||
if (!existsSync(envDevPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const candidatePackages = [
|
||||
join(workspaceRoot, 'packages/cli/package.json'),
|
||||
join(workspaceRoot, 'package.json'),
|
||||
];
|
||||
|
||||
for (const pkgPath of candidatePackages) {
|
||||
if (!existsSync(pkgPath)) continue;
|
||||
try {
|
||||
const req = createRequire(pkgPath);
|
||||
const dotenvx: unknown = req('@dotenvx/dotenvx');
|
||||
if (isDotenvxModule(dotenvx)) {
|
||||
dotenvx.config({ path: envDevPath, envKeysFile: envKeysPath });
|
||||
const key = process.env.COM_ERMNVLDMR_DEV_ROOT_CA_KEY;
|
||||
if (key) {
|
||||
return key.includes('\\n') ? key.replace(/\\n/g, '\n') : key;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue to next candidate package
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote public URL for the Ermnvldmr development root CA certificate.
|
||||
*/
|
||||
export const REMOTE_ROOT_CA_URL =
|
||||
'https://static.ermnvldmr.com/ssl/certs/ErmnvldmrDevelopmentRootCA.pem';
|
||||
|
||||
/**
|
||||
* Ensures that valid TLS development certificates exist for localhost.ermnvldmr.com.
|
||||
*
|
||||
* Checks for certificates in the workspace `.certs` directory. If absent, downloads
|
||||
* or uses ErmnvldmrDevelopmentRootCA.pem and generates certificates signed by it.
|
||||
*
|
||||
* @param workspaceRoot - Absolute path to workspace root
|
||||
* @returns Paths to the certificate and private key files
|
||||
*/
|
||||
export function ensureDevCertificates(
|
||||
workspaceRoot: string = findWorkspaceRoot()
|
||||
): DevCertificates {
|
||||
const certsDir = join(workspaceRoot, '.certs');
|
||||
const certPath = join(certsDir, 'localhost.ermnvldmr.com.pem');
|
||||
const keyPath = join(certsDir, 'localhost.ermnvldmr.com-key.pem');
|
||||
const caCertPath = join(certsDir, 'ErmnvldmrDevelopmentRootCA.pem');
|
||||
const caKeyPath = join(certsDir, 'ErmnvldmrDevelopmentRootCA-key.pem');
|
||||
|
||||
ensureHostsEntry('localhost.ermnvldmr.com', '127.0.0.1');
|
||||
|
||||
const home = homedir();
|
||||
const globalCaDir = join(home, '.local/share/mkcert');
|
||||
const globalCaCert = join(globalCaDir, 'ErmnvldmrDevelopmentRootCA.pem');
|
||||
const globalCaKey = join(globalCaDir, 'ErmnvldmrDevelopmentRootCA-key.pem');
|
||||
|
||||
mkdirSync(certsDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// 1. Sync CA cert from global cache if available, or download from remote static URL
|
||||
if (!existsSync(caCertPath)) {
|
||||
if (existsSync(globalCaCert)) {
|
||||
writeFileSync(caCertPath, readFileSync(globalCaCert));
|
||||
chmodSync(caCertPath, 0o644);
|
||||
} else {
|
||||
try {
|
||||
execFileSync('curl', ['-sSfL', REMOTE_ROOT_CA_URL, '-o', caCertPath], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
chmodSync(caCertPath, 0o644);
|
||||
try {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
writeFileSync(globalCaCert, readFileSync(caCertPath));
|
||||
chmodSync(globalCaCert, 0o644);
|
||||
} catch {
|
||||
// Best-effort global cache
|
||||
}
|
||||
} catch {
|
||||
// Failed to download, will fall back to generation if key also needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync CA key from global cache if available, or decrypt from .env.dev
|
||||
if (!existsSync(caKeyPath)) {
|
||||
if (existsSync(globalCaKey)) {
|
||||
writeFileSync(caKeyPath, readFileSync(globalCaKey));
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
} else {
|
||||
const decryptedKey = loadDevRootCaKey(workspaceRoot);
|
||||
if (decryptedKey) {
|
||||
writeFileSync(caKeyPath, decryptedKey);
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
try {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
writeFileSync(globalCaKey, decryptedKey);
|
||||
chmodSync(globalCaKey, 0o600);
|
||||
} catch {
|
||||
// Best-effort global cache
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If server cert and CA cert already exist, return them
|
||||
if (existsSync(certPath) && existsSync(keyPath) && existsSync(caCertPath)) {
|
||||
return { certPath, keyPath, caCertPath };
|
||||
}
|
||||
|
||||
// Ensure ErmnvldmrDevelopmentRootCA exists
|
||||
if (!existsSync(caCertPath) || !existsSync(caKeyPath)) {
|
||||
mkdirSync(globalCaDir, { recursive: true });
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-x509',
|
||||
'-nodes',
|
||||
'-newkey',
|
||||
'rsa:3072',
|
||||
'-keyout',
|
||||
caKeyPath,
|
||||
'-out',
|
||||
caCertPath,
|
||||
'-days',
|
||||
'3650',
|
||||
'-subj',
|
||||
'/CN=Ermnvldmr Development Root CA/O=ermnvldmr.com',
|
||||
'-batch',
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
chmodSync(caKeyPath, 0o600);
|
||||
chmodSync(caCertPath, 0o644);
|
||||
|
||||
writeFileSync(globalCaCert, readFileSync(caCertPath));
|
||||
writeFileSync(globalCaKey, readFileSync(caKeyPath));
|
||||
chmodSync(globalCaKey, 0o600);
|
||||
chmodSync(globalCaCert, 0o644);
|
||||
}
|
||||
|
||||
// Generate server certificate signed by ErmnvldmrDevelopmentRootCA
|
||||
const tempDir = tmpdir();
|
||||
const tempCsr = join(tempDir, `ermnvldmr-dev-${Date.now()}.csr`);
|
||||
const tempExt = join(tempDir, `ermnvldmr-dev-${Date.now()}.cnf`);
|
||||
|
||||
try {
|
||||
writeFileSync(
|
||||
tempExt,
|
||||
'subjectAltName=DNS:localhost.ermnvldmr.com,DNS:*.localhost.ermnvldmr.com,DNS:localhost,IP:127.0.0.1,IP:::1\n'
|
||||
);
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'req',
|
||||
'-new',
|
||||
'-nodes',
|
||||
'-newkey',
|
||||
'rsa:2048',
|
||||
'-keyout',
|
||||
keyPath,
|
||||
'-out',
|
||||
tempCsr,
|
||||
'-subj',
|
||||
'/CN=localhost.ermnvldmr.com',
|
||||
'-batch',
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
execFileSync(
|
||||
'openssl',
|
||||
[
|
||||
'x509',
|
||||
'-req',
|
||||
'-in',
|
||||
tempCsr,
|
||||
'-CA',
|
||||
caCertPath,
|
||||
'-CAkey',
|
||||
caKeyPath,
|
||||
'-CAcreateserial',
|
||||
'-out',
|
||||
certPath,
|
||||
'-days',
|
||||
'825',
|
||||
'-extfile',
|
||||
tempExt,
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
chmodSync(keyPath, 0o600);
|
||||
chmodSync(certPath, 0o644);
|
||||
} finally {
|
||||
rmSync(tempCsr, { force: true });
|
||||
rmSync(tempExt, { force: true });
|
||||
}
|
||||
|
||||
return { certPath, keyPath, caCertPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a dev server configuration preset with HTTPS and localhost.ermnvldmr.com domain formatting.
|
||||
*
|
||||
* @param options - Server configuration options (port, host)
|
||||
* @returns Rsbuild ServerConfig preset
|
||||
*/
|
||||
export function localDevServer(options: LocalDevServerOptions): ServerConfig {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return {
|
||||
port: options.port,
|
||||
};
|
||||
}
|
||||
|
||||
const { certPath, keyPath } = ensureDevCertificates();
|
||||
const cert = readFileSync(certPath);
|
||||
const key = readFileSync(keyPath);
|
||||
const host = options.host ?? 'localhost.ermnvldmr.com';
|
||||
|
||||
return {
|
||||
host: '0.0.0.0',
|
||||
port: options.port,
|
||||
cors: true,
|
||||
https: {
|
||||
key,
|
||||
cert,
|
||||
},
|
||||
printUrls: ({ protocol, port }: { protocol: string; port: number }) => {
|
||||
return [`${protocol}://${host}:${port}`];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export interface AnalyticsHtmlTag {
|
||||
head?: boolean;
|
||||
/** Whether the tag should be appended at the end. */
|
||||
append?: boolean;
|
||||
/** Whether to prepend publicPath / assetPrefix to the tag URL. Defaults to true in Rsbuild. */
|
||||
publicPath?: boolean | string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -92,6 +92,7 @@ export function umamiAnalytics(options: UmamiAnalyticsOptions): AnalyticsProvide
|
||||
tag: 'script',
|
||||
attrs,
|
||||
head: true,
|
||||
publicPath: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -134,10 +134,11 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
const Critters = _require('critters') as new (opts?: {
|
||||
path?: string;
|
||||
publicPath?: string;
|
||||
preload?: string;
|
||||
preload?: 'body' | 'media' | 'swap' | 'js' | 'js-lazy' | boolean;
|
||||
inlineFonts?: boolean;
|
||||
pruneSource?: boolean;
|
||||
reduceInlineStyles?: boolean;
|
||||
allowRules?: (string | RegExp)[];
|
||||
}) => { process(html: string): Promise<string> };
|
||||
|
||||
const configuredAssetPrefix =
|
||||
@@ -147,10 +148,11 @@ export function ssgPlugin(): RsbuildPlugin {
|
||||
const critters = new Critters({
|
||||
path: webDistPath,
|
||||
publicPath: configuredAssetPrefix,
|
||||
preload: 'swap',
|
||||
preload: false,
|
||||
inlineFonts: false,
|
||||
pruneSource: false,
|
||||
reduceInlineStyles: false,
|
||||
allowRules: [/\.dark/],
|
||||
});
|
||||
|
||||
for (const entryName of entryNames) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { createRoot, hydrateRoot } from 'react-dom/client';
|
||||
|
||||
/**
|
||||
* Options for page creation.
|
||||
@@ -15,9 +14,8 @@ export interface PageOptions {
|
||||
* Mounts or hydrates a React component into the `#root` element.
|
||||
*
|
||||
* Skips DOM mounting during the SSG server-side render pass
|
||||
* (`typeof window === 'undefined'`). On the client, hydrates if the
|
||||
* root already has children (SSG output), otherwise performs a fresh
|
||||
* `createRoot` render.
|
||||
* (`typeof window === 'undefined'`). On the client, hydrates asynchronously
|
||||
* during idle time to unblock First Contentful Paint.
|
||||
*
|
||||
* @param Component - The React component to render.
|
||||
* @param options - Optional page metadata (title, description).
|
||||
@@ -45,17 +43,31 @@ export function createPage(Component: React.ComponentType, options: PageOptions
|
||||
metaDescription.setAttribute('content', options.description);
|
||||
}
|
||||
|
||||
if (rootElement) {
|
||||
const node = (
|
||||
<React.StrictMode>
|
||||
<Component />
|
||||
</React.StrictMode>
|
||||
);
|
||||
if (!rootElement) return;
|
||||
|
||||
if (rootElement.hasChildNodes()) {
|
||||
hydrateRoot(rootElement, node);
|
||||
} else {
|
||||
createRoot(rootElement).render(node);
|
||||
}
|
||||
const mountOrHydrate = () => {
|
||||
void import('react-dom/client')
|
||||
.then(({ createRoot, hydrateRoot }) => {
|
||||
const node = (
|
||||
<React.StrictMode>
|
||||
<Component />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
if (rootElement.hasChildNodes()) {
|
||||
hydrateRoot(rootElement, node);
|
||||
} else {
|
||||
createRoot(rootElement).render(node);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[createPage] Client hydration failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
window.requestIdleCallback(mountOrHydrate);
|
||||
} else {
|
||||
setTimeout(mountOrHydrate, 1);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
@@ -52,7 +53,7 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from './functions/index.js';
|
||||
export * from './object/index.js';
|
||||
export * from './window/localStorage.js';
|
||||
export * from './react/index.js';
|
||||
export * from './security.js';
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import vm from 'node:vm';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
THEME_INIT_SCRIPT_HASH,
|
||||
TRUSTED_TYPES_INIT_SCRIPT,
|
||||
TRUSTED_TYPES_INIT_SCRIPT_HASH,
|
||||
} from './security';
|
||||
|
||||
interface MockPolicyOptions {
|
||||
createHTML?: (s: string) => string;
|
||||
createScript?: (s: string) => string;
|
||||
createScriptURL?: (u: string) => string;
|
||||
}
|
||||
|
||||
interface MockTrustedTypes {
|
||||
createPolicy: (name: string, options: MockPolicyOptions) => unknown;
|
||||
}
|
||||
|
||||
interface PolicyRecorder {
|
||||
name: string;
|
||||
options: MockPolicyOptions;
|
||||
}
|
||||
|
||||
describe('Security policy script hashes and execution', () => {
|
||||
it('computes exact SHA-256 base64 hash for TRUSTED_TYPES_INIT_SCRIPT', () => {
|
||||
const computedHash = `sha256-${createHash('sha256').update(TRUSTED_TYPES_INIT_SCRIPT).digest('base64')}`;
|
||||
expect(computedHash).toBe(TRUSTED_TYPES_INIT_SCRIPT_HASH);
|
||||
});
|
||||
|
||||
it('computes exact SHA-256 base64 hash for THEME_INIT_SCRIPT constant', () => {
|
||||
// Theme initializer literal from packages/ui
|
||||
const themeScript =
|
||||
'(function(){try{var k=\'ermnvldmr/ui/lib/theme\';var p=JSON.parse(localStorage.getItem(k)||\'"system"\');var d=p===\'dark\'||(p===\'system\'&&window.matchMedia(\'(prefers-color-scheme:dark)\').matches);document.documentElement.classList.toggle(\'dark\',d)}catch(e){}})();';
|
||||
const computedHash = `sha256-${createHash('sha256').update(themeScript).digest('base64')}`;
|
||||
expect(computedHash).toBe(THEME_INIT_SCRIPT_HASH);
|
||||
});
|
||||
|
||||
it('executes TRUSTED_TYPES_INIT_SCRIPT without errors when window.trustedTypes is absent', () => {
|
||||
const sandbox = {
|
||||
window: {},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
|
||||
expect(() => {
|
||||
vm.runInContext(TRUSTED_TYPES_INIT_SCRIPT, sandbox);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('evaluates TRUSTED_TYPES_INIT_SCRIPT and registers default policy when window.trustedTypes is present', () => {
|
||||
const recorder: PolicyRecorder = {
|
||||
name: '',
|
||||
options: {},
|
||||
};
|
||||
|
||||
const mockTrustedTypes: MockTrustedTypes = {
|
||||
createPolicy: (name: string, options: MockPolicyOptions) => {
|
||||
recorder.name = name;
|
||||
recorder.options = options;
|
||||
return { name };
|
||||
},
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
window: {
|
||||
trustedTypes: mockTrustedTypes,
|
||||
},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
|
||||
expect(() => {
|
||||
vm.runInContext(TRUSTED_TYPES_INIT_SCRIPT, sandbox);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(recorder.name).toBe('default');
|
||||
expect(recorder.options.createHTML?.('<p>safe</p>')).toBe('<p>safe</p>');
|
||||
expect(recorder.options.createScript?.('console.log(1)')).toBe('console.log(1)');
|
||||
expect(recorder.options.createScriptURL?.('https://example.com/lib.js')).toBe(
|
||||
'https://example.com/lib.js'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Inline bootstrapper script that registers a transparent default Trusted Types policy.
|
||||
* This prevents runtime DOM-based XSS injection crashes in React 19, Hextra, and third-party DOM sinks
|
||||
* when `require-trusted-types-for 'script'` is enforced via Content-Security-Policy.
|
||||
*/
|
||||
export const TRUSTED_TYPES_INIT_SCRIPT =
|
||||
'(function(){if(typeof window!=="undefined"&&window.trustedTypes&&window.trustedTypes.createPolicy){try{window.trustedTypes.createPolicy("default",{createHTML:function(s){return s},createScript:function(s){return s},createScriptURL:function(u){return u}});}catch(e){}}})();';
|
||||
|
||||
/**
|
||||
* SHA-256 base64 hash of `TRUSTED_TYPES_INIT_SCRIPT` for inclusion in CSP `script-src`.
|
||||
*/
|
||||
export const TRUSTED_TYPES_INIT_SCRIPT_HASH =
|
||||
'sha256-3VjVzYfJpuNMLKyrxQTIc8ACxv37paQ4Talwg1s/7N0=';
|
||||
|
||||
/**
|
||||
* SHA-256 base64 hash of `@ermnvldmr/ui` `THEME_INIT_SCRIPT` for inclusion in CSP `script-src`.
|
||||
*/
|
||||
export const THEME_INIT_SCRIPT_HASH =
|
||||
'sha256-gUpIj4OcSwECFlSp351SCYJAe1wTOCQlrKBlASuO3CY=';
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
@@ -30,6 +30,6 @@
|
||||
"babel-plugin-istanbul": "^7.0.1",
|
||||
"storybook-react-rsbuild": "^1.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^8.0.7"
|
||||
"vite": "^8.1.6"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
@@ -88,11 +91,11 @@
|
||||
"http-server": "^14.1.1",
|
||||
"jsdom": "^28.0.0",
|
||||
"playwright": "^1.57.0",
|
||||
"postcss": "^8.5.14",
|
||||
"postcss": "^8.5.23",
|
||||
"storybook": "^8.6.17",
|
||||
"storybook-react-rsbuild": "^1.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest": "^4.1.11",
|
||||
"wait-on": "^8.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -76,3 +76,15 @@ export const WithBullets: Story = {
|
||||
</Breadcrumbs>
|
||||
),
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
render: () => (
|
||||
<Breadcrumbs size="l">
|
||||
<Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="slash" />
|
||||
<Breadcrumbs.Item href="/articles">Articles</Breadcrumbs.Item>
|
||||
<Breadcrumbs.Separator variant="slash" />
|
||||
<Breadcrumbs.Item isCurrent>Deep Learning</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
),
|
||||
};
|
||||
@@ -36,6 +36,16 @@ describe('components/Breadcrumbs', () => {
|
||||
expect(ol.className).toContain('text-label-m');
|
||||
});
|
||||
|
||||
it('supports custom size variant', () => {
|
||||
render(
|
||||
<Breadcrumbs size="l">
|
||||
<Breadcrumbs.Item href="/test">Home</Breadcrumbs.Item>
|
||||
</Breadcrumbs>
|
||||
);
|
||||
const ol = screen.getByRole('list');
|
||||
expect(ol.className).toContain('text-label-l');
|
||||
});
|
||||
|
||||
it('supports isCurrent state', () => {
|
||||
render(
|
||||
<Breadcrumbs>
|
||||
|
||||
@@ -16,15 +16,44 @@ import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
export interface BreadcrumbsProps extends HStackProps {
|
||||
/** Breadcrumb items and separators */
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Typography size variant for the breadcrumbs trail.
|
||||
* @default 'm'
|
||||
*/
|
||||
size?: 's' | 'm' | 'l';
|
||||
}
|
||||
|
||||
const breadcrumbsVariants = cva('font-sans font-medium', {
|
||||
variants: {
|
||||
size: {
|
||||
s: 'text-label-s',
|
||||
m: 'text-label-m',
|
||||
l: 'text-label-l',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'm',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Root component for hierarchical navigation.
|
||||
*/
|
||||
const BreadcrumbsRoot = memo(function Breadcrumbs({ children, className, ...props }: BreadcrumbsProps) {
|
||||
const BreadcrumbsRoot = memo(function Breadcrumbs({
|
||||
children,
|
||||
className,
|
||||
size = 'm',
|
||||
...props
|
||||
}: BreadcrumbsProps) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={className}>
|
||||
<HStack align="center" as="ol" className="text-label-m font-sans font-medium" gap={2} {...props}>
|
||||
<nav aria-label="Breadcrumb">
|
||||
<HStack
|
||||
align="center"
|
||||
as="ol"
|
||||
className={cn(breadcrumbsVariants({ size }), className)}
|
||||
gap={2}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</HStack>
|
||||
</nav>
|
||||
@@ -126,7 +155,7 @@ const BreadcrumbsSeparator = memo(function BreadcrumbsSeparator({
|
||||
const content = {
|
||||
slash: '/',
|
||||
bull: '•',
|
||||
arrow: <ChevronRight className="size-4" />,
|
||||
arrow: <ChevronRight className="size-[1em]" />,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createHighlighter } from 'shiki';
|
||||
|
||||
import type { Highlighter } from 'shiki';
|
||||
|
||||
/**
|
||||
@@ -77,14 +75,16 @@ class CodeHighlighter {
|
||||
return this.initializingPromise;
|
||||
}
|
||||
|
||||
this.initializingPromise = createHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: SUPPORTED_LANGUAGES,
|
||||
}).then((highlighter) => {
|
||||
this.initializingPromise = (async () => {
|
||||
const { createHighlighter } = await import('shiki');
|
||||
const highlighter = await createHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: SUPPORTED_LANGUAGES,
|
||||
});
|
||||
this.highlighter = highlighter;
|
||||
this.initializingPromise = null;
|
||||
return highlighter;
|
||||
});
|
||||
})();
|
||||
|
||||
return this.initializingPromise;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { Link } from './Link';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
import { render, screen, fireEvent } from '../../lib/testing';
|
||||
|
||||
describe('components/Link', () => {
|
||||
beforeEach(() => {
|
||||
@@ -66,6 +66,83 @@ describe('components/Link', () => {
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveClass('opacity-0');
|
||||
expect(link).toHaveStyle({ animationDelay: '500ms', animationFillMode: 'both' });
|
||||
expect(link).toHaveClass('animate-in');
|
||||
});
|
||||
|
||||
it('obfuscates mailto link until user interaction', () => {
|
||||
render(
|
||||
<Link isObfuscated href="mailto:personal@ermnvldmr.com">
|
||||
personal@ermnvldmr.com
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'personal@ermnvldmr.com' });
|
||||
expect(link).toHaveAttribute('href', '#');
|
||||
expect(link).toHaveAttribute('data-user', 'personal');
|
||||
expect(link).toHaveAttribute('data-domain', 'ermnvldmr.com');
|
||||
|
||||
fireEvent.pointerEnter(link);
|
||||
expect(link).toHaveAttribute('href', 'mailto:personal@ermnvldmr.com');
|
||||
});
|
||||
|
||||
it('activates obfuscated mailto link on focus and pointer down', () => {
|
||||
const { rerender } = render(
|
||||
<Link isObfuscated href="mailto:focus@ermnvldmr.com">
|
||||
focus@ermnvldmr.com
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'focus@ermnvldmr.com' });
|
||||
fireEvent.focus(link);
|
||||
expect(link).toHaveAttribute('href', 'mailto:focus@ermnvldmr.com');
|
||||
|
||||
rerender(
|
||||
<Link key="pointer-down" isObfuscated href="mailto:pointer@ermnvldmr.com">
|
||||
pointer@ermnvldmr.com
|
||||
</Link>
|
||||
);
|
||||
const linkPointer = screen.getByRole('link', { name: 'pointer@ermnvldmr.com' });
|
||||
fireEvent.pointerDown(linkPointer);
|
||||
expect(linkPointer).toHaveAttribute('href', 'mailto:pointer@ermnvldmr.com');
|
||||
});
|
||||
|
||||
it('handles click on unactivated and activated obfuscated mailto link', () => {
|
||||
const mockLocation = { href: '' };
|
||||
vi.stubGlobal('location', mockLocation);
|
||||
|
||||
render(
|
||||
<Link isObfuscated href="mailto:click@ermnvldmr.com">
|
||||
click@ermnvldmr.com
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'click@ermnvldmr.com' });
|
||||
|
||||
fireEvent.click(link);
|
||||
expect(mockLocation.href).toBe('mailto:click@ermnvldmr.com');
|
||||
expect(link).toHaveAttribute('href', 'mailto:click@ermnvldmr.com');
|
||||
|
||||
// Click again when already activated
|
||||
fireEvent.click(link);
|
||||
expect(mockLocation.href).toBe('mailto:click@ermnvldmr.com');
|
||||
});
|
||||
|
||||
it('renders custom children directly when obfuscated instead of bidi override', () => {
|
||||
render(
|
||||
<Link isObfuscated href="mailto:custom@ermnvldmr.com">
|
||||
Send an Email
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'custom@ermnvldmr.com' });
|
||||
expect(link).toHaveTextContent('Send an Email');
|
||||
});
|
||||
|
||||
it('handles obfuscated mailto without at symbol gracefully', () => {
|
||||
render(
|
||||
<Link isObfuscated href="mailto:plainaddress">
|
||||
plainaddress
|
||||
</Link>
|
||||
);
|
||||
const link = screen.getByRole('link', { name: 'plainaddress' });
|
||||
expect(link).toHaveAttribute('data-user', '');
|
||||
expect(link).toHaveAttribute('data-domain', '');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { castRef, cn, filterDataAttributes } from '@ermnvldmr/stl';
|
||||
import React, { memo, useRef } from 'react';
|
||||
import React, { memo, useCallback, useRef, useState } from 'react';
|
||||
import { useLink } from 'react-aria';
|
||||
|
||||
import { Text } from '../Text/Text';
|
||||
@@ -27,6 +27,8 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps, DataAttribut
|
||||
color?: TextColor;
|
||||
/** Delay in milliseconds before the link appears after entering the viewport. */
|
||||
delay?: number;
|
||||
/** Whether to obfuscate the email/link from static HTML scrapers. */
|
||||
isObfuscated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +38,40 @@ export interface LinkProps extends AriaLinkOptions, ClassNameProps, DataAttribut
|
||||
* It uses standard design system colors (--rb-ring) and provides responsive hover/focus states.
|
||||
*/
|
||||
export const Link = memo(function Link(props: LinkProps) {
|
||||
const { children, className, href, isExternal, type, size, color, delay, ...otherProps } = props;
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
isExternal,
|
||||
type,
|
||||
size,
|
||||
color,
|
||||
delay,
|
||||
isObfuscated = false,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const [isActivated, setIsActivated] = useState(false);
|
||||
const isMailto = Boolean(isObfuscated && href?.startsWith('mailto:'));
|
||||
const rawEmail = isMailto && href ? href.slice(7) : '';
|
||||
const atIndex = rawEmail.indexOf('@');
|
||||
const user = atIndex !== -1 ? rawEmail.slice(0, atIndex) : '';
|
||||
const domain = atIndex !== -1 ? rawEmail.slice(atIndex + 1) : '';
|
||||
|
||||
const activate = useCallback(() => {
|
||||
setIsActivated(true);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
activate();
|
||||
if (!isActivated && href) {
|
||||
window.location.href = href;
|
||||
e.preventDefault();
|
||||
}
|
||||
},
|
||||
[activate, href, isActivated]
|
||||
);
|
||||
|
||||
// We use a specific ref type that useLink expects.
|
||||
// Since Link can be an 'a' or a 'span', we use HTMLElement as common denominator.
|
||||
@@ -53,15 +88,35 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
const dataAttributes = filterDataAttributes(props);
|
||||
|
||||
if (href) {
|
||||
const resolvedHref = localePath(href);
|
||||
const isAutoExternal = isExternal ?? href.startsWith('http');
|
||||
const externalProps = isAutoExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {};
|
||||
|
||||
const resolvedHref = isMailto ? (isActivated ? href : '#') : localePath(href);
|
||||
|
||||
const isChildrenEmail = typeof children === 'string' && children === rawEmail;
|
||||
const displayedContent =
|
||||
isMailto && !isActivated && isChildrenEmail ? (
|
||||
<span style={{ unicodeBidi: 'bidi-override', direction: 'rtl' }}>
|
||||
{domain.split('').reverse().join('')}@{user.split('').reverse().join('')}
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
|
||||
const obfuscationAttrs = isMailto
|
||||
? {
|
||||
'data-user': user,
|
||||
'data-domain': domain,
|
||||
'aria-label': rawEmail,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Text
|
||||
{...linkProps}
|
||||
{...externalProps}
|
||||
{...dataAttributes}
|
||||
{...obfuscationAttrs}
|
||||
ref={ref}
|
||||
as="a"
|
||||
className={sharedClasses}
|
||||
@@ -70,8 +125,12 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
href={resolvedHref}
|
||||
size={size}
|
||||
type={type}
|
||||
onClick={isMailto ? handleClick : undefined}
|
||||
onFocus={isMailto ? activate : undefined}
|
||||
onPointerDown={isMailto ? activate : undefined}
|
||||
onPointerEnter={isMailto ? activate : undefined}
|
||||
>
|
||||
{children}
|
||||
{displayedContent}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,14 @@ import React from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { Text } from './Text';
|
||||
import { render, screen, act } from '../../lib/testing';
|
||||
import { render, screen } from '../../lib/testing';
|
||||
|
||||
describe('components/Text', () => {
|
||||
let intersectionCallback: (entries: any[]) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
constructor(public callback: (entries: any[]) => void) {
|
||||
intersectionCallback = callback;
|
||||
}
|
||||
observe = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
@@ -31,58 +26,25 @@ describe('components/Text', () => {
|
||||
expect(screen.getByText('Test content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is immediately visible when no delay is provided', () => {
|
||||
it('is immediately visible without animation when no delay is provided', () => {
|
||||
render(<Text>No delay</Text>);
|
||||
const el = screen.getByText('No delay');
|
||||
expect(el).not.toHaveClass('opacity-0');
|
||||
expect(el).not.toHaveClass('animate-in');
|
||||
expect(el.style.animationDelay).toBe('');
|
||||
});
|
||||
|
||||
it('is initially hidden when delay is provided', () => {
|
||||
it('applies CSS animation classes and delay style when delay is provided', () => {
|
||||
render(<Text delay={500}>With delay</Text>);
|
||||
const el = screen.getByText('With delay');
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
expect(el).toHaveClass('animate-in', 'fade-in', 'slide-in-from-bottom-2');
|
||||
expect(el).toHaveStyle({ animationDelay: '500ms', animationFillMode: 'both' });
|
||||
});
|
||||
|
||||
it('becomes visible after intersection and specified delay', () => {
|
||||
render(<Text delay={500}>Delayed visible</Text>);
|
||||
const el = screen.getByText('Delayed visible');
|
||||
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
|
||||
// Simulate intersection
|
||||
act(() => {
|
||||
intersectionCallback([{ isIntersecting: true }]);
|
||||
});
|
||||
|
||||
// Should still be hidden before delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
|
||||
// Should be visible after delay
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(el).not.toHaveClass('opacity-0');
|
||||
expect(el).toHaveClass('animate-in');
|
||||
expect(el).toHaveClass('fade-in');
|
||||
});
|
||||
|
||||
it('becomes visible immediately after intersection if delay is 0', () => {
|
||||
it('applies CSS animation classes and zero delay when delay is 0', () => {
|
||||
render(<Text delay={0}>Zero delay</Text>);
|
||||
const el = screen.getByText('Zero delay');
|
||||
|
||||
expect(el).toHaveClass('opacity-0');
|
||||
|
||||
// Simulate intersection
|
||||
act(() => {
|
||||
intersectionCallback([{ isIntersecting: true }]);
|
||||
});
|
||||
|
||||
expect(el).not.toHaveClass('opacity-0');
|
||||
expect(el).toHaveClass('animate-in');
|
||||
expect(el).toHaveStyle({ animationDelay: '0ms', animationFillMode: 'both' });
|
||||
});
|
||||
|
||||
it('applies alignment classes', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn, genericMemo, useIntersectionObserver, castMutableRef } from '@ermnvldmr/stl';
|
||||
import React, { forwardRef, useState, useEffect, useRef } from 'react';
|
||||
import { cn, genericMemo, castMutableRef } from '@ermnvldmr/stl';
|
||||
import React, { forwardRef, useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
getTypographyClassNames,
|
||||
@@ -85,7 +85,6 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const [isVisible, setIsVisible] = useState(delay === undefined);
|
||||
const internalRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,22 +96,13 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
const isIntersecting = useIntersectionObserver(internalRef, {
|
||||
threshold: 0.1,
|
||||
once: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (delay !== undefined && isIntersecting) {
|
||||
if (delay > 0) {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [delay, isIntersecting]);
|
||||
const delayStyle: React.CSSProperties | undefined =
|
||||
delay !== undefined
|
||||
? {
|
||||
animationDelay: `${delay}ms`,
|
||||
animationFillMode: 'both',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Component
|
||||
@@ -132,13 +122,14 @@ const TextComponent = forwardRef<HTMLElement, TextProps>(function Text(
|
||||
overflow,
|
||||
maxLines,
|
||||
}),
|
||||
delay !== undefined &&
|
||||
(isVisible
|
||||
? 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out'
|
||||
: 'opacity-0'),
|
||||
delay !== undefined && 'animate-in fade-in slide-in-from-bottom-2 duration-1000 ease-out',
|
||||
className
|
||||
)}
|
||||
data-testid={testId}
|
||||
style={{
|
||||
...delayStyle,
|
||||
...props.style,
|
||||
}}
|
||||
type={htmlType ?? (Component === 'button' ? 'button' : undefined)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const THEME_INIT_SCRIPT = `(function(){try{var k='ermnvldmr/ui/lib/theme'
|
||||
* These align with the main palette in styles.css.
|
||||
*/
|
||||
export const THEME_INIT_STYLES = `
|
||||
:root { background-color: #fafafa; color: #262626; }
|
||||
.dark { background-color: #141414; color: #f6f6f6; }
|
||||
:root { --rb-background: #fafafa; --rb-base: #f6f6f6; --rb-text: #262626; background-color: #fafafa; color: #262626; }
|
||||
.dark { --rb-background: #141414; --rb-base: #262626; --rb-text: #f6f6f6; background-color: #141414; color: #f6f6f6; }
|
||||
body { margin: 0; transition: background-color 0.2s ease; }
|
||||
`;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Generated
+879
-1369
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
import { defineCli, env, s3SyncCommand } from '@ermnvldmr/cli';
|
||||
|
||||
export default defineCli({
|
||||
commands: [
|
||||
s3SyncCommand({
|
||||
name: 'sync:static',
|
||||
description: 'Sync static assets to S3',
|
||||
targetPrefix: 'docs',
|
||||
sourceDir: './static',
|
||||
sourceFilesPattern: 'content/**/*.{md,html}',
|
||||
urlPrefixes: ['/static/', '/images/'],
|
||||
bucket: env('COM_ERMNVLDMR_STATIC_S3_BUCKET'),
|
||||
region: env('COM_ERMNVLDMR_STATIC_S3_REGION'),
|
||||
endpoint: env('COM_ERMNVLDMR_STATIC_S3_ENDPOINT_URL'),
|
||||
accessKeyId: env('COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID'),
|
||||
secretAccessKey: env('COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY'),
|
||||
hooks: {
|
||||
beforeSync: 'bash ../../ci/scripts/optimize-images.sh ./static',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
(function () {
|
||||
if (typeof window !== "undefined" && window.trustedTypes && window.trustedTypes.createPolicy) {
|
||||
try {
|
||||
window.trustedTypes.createPolicy("default", {
|
||||
createHTML: function (s) { return s; },
|
||||
createScript: function (s) { return s; },
|
||||
createScriptURL: function (u) { return u; }
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
@@ -1,13 +1,53 @@
|
||||
---
|
||||
title: "Ermnvldmr's Documentation"
|
||||
title: "ermnvldmr.com Documentation"
|
||||
layout: hextra-home
|
||||
---
|
||||
|
||||
<br />
|
||||
<div class="hx:w-full hx:flex hx:flex-col hx:sm:flex-row hx:items-stretch hx:justify-between hx:my-6" style="gap: 2.5rem;">
|
||||
<div style="flex: 1 1 58%; min-width: 0;">
|
||||
|
||||
Welcome to the unified documentation for my own projects
|
||||
<div class="hx:mb-6">
|
||||
{{< hextra/hero-headline >}}
|
||||
Documenting Decisions<br class="hx:sm:block hx:hidden" />Without the Actual Decisions
|
||||
{{< /hextra/hero-headline >}}
|
||||
</div>
|
||||
|
||||
{{< cards >}}
|
||||
{{< card link="ermnvldmr" title="ermnvldmr.com Platform" icon="cloud" subtitle="Cloud-native infrastructure, Packer golden image, K3s cluster, and GitOps platform" >}}
|
||||
{{< card link="apex" title="apex.ermnvldmr.com" icon="server" subtitle="Fleet operations framework: a stdlib-python engine and a shared compose core" >}}
|
||||
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Modular Discord bot framework documentation" >}}
|
||||
<div class="hx:mb-6">
|
||||
{{< hextra/hero-subtitle >}}
|
||||
I have neither the time nor the desire to manually craft every line of this half-baked novel. Yet capturing context and architectural decisions is essential, otherwise the number of entities comprehending what goes on here drops to zero. Consider this a structured neural knowledge dump for AI agents and rare human visitors.
|
||||
<br /><br />
|
||||
If you ever feel like fixing anything (why though?) — any contribution here is guaranteed to be an improvement.
|
||||
{{< /hextra/hero-subtitle >}}
|
||||
</div>
|
||||
|
||||
<div class="hx:flex hx:flex-wrap hx:items-center hx:mb-8" style="gap: 1rem;">
|
||||
<a href="https://ermnvldmr.com" target="_blank" rel="noreferrer" class="not-prose hx:font-medium hx:cursor-pointer hx:px-6 hx:py-3 hx:rounded-full hx:text-center hx:text-gray-800 hx:dark:text-gray-200 hx:inline-flex hx:items-center hx:gap-2 hx:bg-gray-100 hx:hover:bg-gray-200 hx:dark:bg-neutral-800 hx:dark:hover:bg-neutral-700 hx:border hx:border-gray-200 hx:dark:border-neutral-700 hx:transition-all hx:ease-in hx:duration-200">
|
||||
<span>ermnvldmr.com</span>
|
||||
{{< icon name="external-link" attributes="height=16" >}}
|
||||
</a>
|
||||
<a href="https://github.com/deytenit/ermnvldmr.com" target="_blank" rel="noreferrer" class="not-prose hx:font-medium hx:cursor-pointer hx:px-6 hx:py-3 hx:rounded-full hx:text-center hx:text-gray-800 hx:dark:text-gray-200 hx:inline-flex hx:items-center hx:gap-2 hx:bg-gray-100 hx:hover:bg-gray-200 hx:dark:bg-neutral-800 hx:dark:hover:bg-neutral-700 hx:border hx:border-gray-200 hx:dark:border-neutral-700 hx:transition-all hx:ease-in hx:duration-200">
|
||||
{{< icon name="github" attributes="height=18" >}}
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="projects" class="hx:w-full hx:mb-4">
|
||||
<h2 class="hx:text-2xl hx:font-bold hx:tracking-tight">Projects</h2>
|
||||
</div>
|
||||
|
||||
{{< cards cols="2" >}}
|
||||
{{< card link="apex" title="apex.ermnvldmr.com" icon="server" subtitle="Fleet operations framework: a python3-stdlib engine and a shared compose core for self-hosted nodes." >}}
|
||||
{{< card link="quetza" title="Quetza Bot" icon="terminal" subtitle="Modular Discord bot framework: plugin architecture, event handling, and state management." >}}
|
||||
{{< /cards >}}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="hx:w-full" style="flex: 1 1 42%; max-width: 460px; display: flex; flex-direction: column;">
|
||||
<div class="hx:overflow-hidden hx:border hx:border-gray-200 hx:dark:border-neutral-800 hx:shadow-lg hx:bg-gray-50 hx:dark:bg-neutral-900/50" style="border-radius: 1.5rem; height: 100%; display: flex; flex-direction: column;">
|
||||
<img src="https://static.ermnvldmr.com/docs/images/home/dead-internet-debugging.webp" alt="Dead Internet Debugging" class="hx:w-full" style="flex: 1 1 auto; width: 100%; height: 100%; min-height: 340px; object-fit: cover; object-position: top; border-radius: 1.5rem 1.5rem 0 0;" />
|
||||
<div class="hx:p-3 hx:text-xs hx:text-gray-500 hx:dark:text-gray-400 hx:text-center hx:border-t hx:border-gray-200 hx:dark:border-neutral-800" style="flex-shrink: 0;">
|
||||
Image enhanced with Gemini 4 Argon · <a href="https://www.reddit.com/r/ProgrammerHumor/s/UVVd1CnFxH" target="_blank" rel="noreferrer" class="hx:underline hx:hover:text-primary-600">Original on Reddit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,24 +1,24 @@
|
||||
---
|
||||
title: Documentation of the apex fleet operations framework
|
||||
description: "Documentation for the apex fleet operations framework: a stdlib-python engine and a shared compose core for self-hosted nodes."
|
||||
title: Documentation of the apex fleet operations framework
|
||||
description: "Documentation for the apex fleet operations framework: a stdlib-python engine and a shared compose core for self-hosted nodes."
|
||||
weight: 1
|
||||
type: docs
|
||||
cascade:
|
||||
type: docs
|
||||
---
|
||||
|
||||
`apex` is the [commons](/apex/glossary#commons) of a fleet of self-hosted [nodes](/apex/glossary#node): a python3-stdlib [engine](/apex/glossary#engine) that runs grouped [actions](/apex/glossary#action) as `apex <group>/<action>`, paired with a shared, `${env}`-parameterized docker-compose core. Every node repository consumes the framework as the pinned `commons/` git submodule. apex succeeds the bash `root` commons framework.
|
||||
`apex` is the [commons](/apex/glossary#commons) of a fleet of self-hosted [nodes](/apex/glossary#node): a python3-stdlib [engine](/apex/glossary#engine) that runs grouped [actions](/apex/glossary#action) as `apex <group>/<action>`, paired with a shared, `${env}`-parameterized docker-compose core. Every node repository consumes the framework as the pinned `commons/` git submodule. apex succeeds the bash `root` commons framework.
|
||||
|
||||
The stack is deliberately small: `git` plus the Python 3 standard library. There is no `pip`, Node, Go, or CUE, and compose files are hand-authored — no code generation.
|
||||
The stack is deliberately small: `git` plus the Python 3 standard library. There is no `pip`, Node, Go, or CUE, and compose files are hand-authored — no code generation.
|
||||
|
||||
## Core features
|
||||
|
||||
- **Single entrypoint on the local checkout** — `apex` always acts on the checkout it runs from; there is no `<node>` argument to pass.
|
||||
- **Explicit identity from `node.env`** — [node identity](/apex/glossary#node-identity) is declared per node in `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`); the framework assumes no particular domain, and the short node `name` is just the first label of the host.
|
||||
- **Four-pillar node repositories** — every node repository follows the same shape: the pinned `commons/` submodule, node-local [proprietaries](/apex/glossary#proprietaries), the node's [compositions](/apex/glossary#compositions), and its `node.env`.
|
||||
- **Proprietary overlay** — node-local proprietaries are [overlaid](/apex/glossary#overlay) over the shared actions; commons holds only what every node shares.
|
||||
- **Tiered storage with privilege isolation** — standardized storage [tiers](/apex/glossary#tier) with per-project privilege isolation.
|
||||
- **Shared core composition** — the [core composition](/apex/glossary#core-composition) (`compositions/apex/docker-compose.yml`) is `${env}`-parameterized and included in every node's compositions.
|
||||
- **Single entrypoint on the local checkout** — `apex` always acts on the checkout it runs from; there is no `<node>` argument to pass.
|
||||
- **Explicit identity from `node.env`** — [node identity](/apex/glossary#node-identity) is declared per node in `node.env` (`APEX_NODE_FQDN`, `APEX_NODE_HOST`, `APEX_SUBNET`); the framework assumes no particular domain, and the short node `name` is just the first label of the host.
|
||||
- **Four-pillar node repositories** — every node repository follows the same shape: the pinned `commons/` submodule, node-local [proprietaries](/apex/glossary#proprietaries), the node's [compositions](/apex/glossary#compositions), and its `node.env`.
|
||||
- **Proprietary overlay** — node-local proprietaries are [overlaid](/apex/glossary#overlay) over the shared actions; commons holds only what every node shares.
|
||||
- **Tiered storage with privilege isolation** — standardized storage [tiers](/apex/glossary#tier) with per-project privilege isolation.
|
||||
- **Shared core composition** — the [core composition](/apex/glossary#core-composition) (`compositions/apex/docker-compose.yml`) is `${env}`-parameterized and included in every node's compositions.
|
||||
|
||||
## Sections
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: Concepts
|
||||
description: "Architecture, identity, actions, execution context, compositions, storage, addressing, and security."
|
||||
description: "Architecture, identity, actions, execution context, compositions, storage, addressing, and security."
|
||||
weight: 10
|
||||
---
|
||||
|
||||
Theoretical foundations of the apex fleet-operations framework: how a [node](/apex/glossary#node) declares its [identity](/apex/concepts/node-identity), how [actions](/apex/glossary#action) execute, and how a node organizes its services, storage, and addressing.
|
||||
Theoretical foundations of the apex fleet-operations framework: how a [node](/apex/glossary#node) declares its [identity](/apex/concepts/node-identity), how [actions](/apex/glossary#action) execute, and how a node organizes its services, storage, and addressing.
|
||||
|
||||
{{% cards %}}
|
||||
{{< card link="architecture" title="Architecture" icon="share" subtitle="The engine, commons, and configs layers and how they fit together." >}}
|
||||
@@ -21,5 +21,5 @@ Theoretical foundations of the apex fleet-operations framework: how a [node](/ap
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Guides: Operating an apex fleet](/apex/guides)
|
||||
- [Reference: Commands and interfaces](/apex/reference)
|
||||
- [Guides: Operating an apex fleet](/apex/guides)
|
||||
- [Reference: Commands and interfaces](/apex/reference)
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
title: "Actions and the overlay"
|
||||
description: "The action contract and how node-local proprietaries shadow commons actions."
|
||||
title: "Actions and the overlay"
|
||||
description: "The action contract and how node-local proprietaries shadow commons actions."
|
||||
weight: 13
|
||||
---
|
||||
|
||||
Every unit of automation in apex is an [action](/apex/glossary#action): a single Python module that the [engine](/apex/glossary#engine) dispatches by name. The [overlay](/apex/glossary#overlay) lets a node's [proprietaries](/apex/glossary#proprietaries) add, override, or disable [commons](/apex/glossary#commons) actions without forking them. This page defines the action contract, the declarative argument descriptors, and the resolution rules.
|
||||
Every unit of automation in apex is an [action](/apex/glossary#action): a single Python module that the [engine](/apex/glossary#engine) dispatches by name. The [overlay](/apex/glossary#overlay) lets a node's [proprietaries](/apex/glossary#proprietaries) add, override, or disable [commons](/apex/glossary#commons) actions without forking them. This page defines the action contract, the declarative argument descriptors, and the resolution rules.
|
||||
|
||||
## The action contract {#contract}
|
||||
## The action contract {#contract}
|
||||
|
||||
An action is one `.py` file under an `actions/` tree. The file path **is** the action name: `actions/<group>/<action>.py` is invoked as `apex <group>/<action> [args...]`. There is no registry to update — dropping a file into the tree publishes the action.
|
||||
An action is one `.py` file under an `actions/` tree. The file path **is** the action name: `actions/<group>/<action>.py` is invoked as `apex <group>/<action> [args...]`. There is no registry to update — dropping a file into the tree publishes the action.
|
||||
|
||||
The module must expose two things:
|
||||
The module must expose two things:
|
||||
|
||||
- **`METADATA = Meta(...)`** → the summary and argument descriptors that drive both the help text and parsing. The descriptor system succeeds the `action_metadata()` heredoc of the bash "root" commons framework, apex's predecessor.
|
||||
- **`run(ctx, args)`** → the entrypoint. `ctx` is the engine-built execution context; `args` is the parsed `argparse` namespace.
|
||||
- **`METADATA = Meta(...)`** → the summary and argument descriptors that drive both the help text and parsing. The descriptor system succeeds the `action_metadata()` heredoc of the bash "root" commons framework, apex's predecessor.
|
||||
- **`run(ctx, args)`** → the entrypoint. `ctx` is the engine-built execution context; `args` is the parsed `argparse` namespace.
|
||||
|
||||
```python {filename="actions/<group>/<action>.py"}
|
||||
from engine.descriptor import Meta, Arg, Flag
|
||||
@@ -31,36 +31,36 @@ def run(ctx, args):
|
||||
return 0
|
||||
```
|
||||
|
||||
The return value of `run` becomes the process exit code. A non-integer return (including `None`) is treated as success (`0`). An unhandled exception is logged with the context prefix and exits `1`.
|
||||
The return value of `run` becomes the process exit code. A non-integer return (including `None`) is treated as success (`0`). An unhandled exception is logged with the context prefix and exits `1`.
|
||||
|
||||
> [!NOTE]
|
||||
> `METADATA` is technically optional — a module without it is dispatched with an empty `Meta` and accepts no arguments — but every real action should declare it, since the listing and `--help` output are generated from it.
|
||||
> `METADATA` is technically optional — a module without it is dispatched with an empty `Meta` and accepts no arguments — but every real action should declare it, since the listing and `--help` output are generated from it.
|
||||
|
||||
## Declarative arguments {#arguments}
|
||||
|
||||
`Meta.args` is a list of descriptors. Each maps to exactly one `argparse` construct:
|
||||
`Meta.args` is a list of descriptors. Each maps to exactly one `argparse` construct:
|
||||
|
||||
| Descriptor | Maps to | Behavior |
|
||||
| Descriptor | Maps to | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `Arg("name")` | positional argument | Required by default; `required=False` makes it an optional positional (`nargs="?"`, default `None`). |
|
||||
| `Opt("--name", default=...)` | value option | An optional flag that takes a value, with a declared default. |
|
||||
| `Arg("name")` | positional argument | Required by default; `required=False` makes it an optional positional (`nargs="?"`, default `None`). |
|
||||
| `Opt("--name", default=...)` | value option | An optional flag that takes a value, with a declared default. |
|
||||
| `Flag("--name")` | boolean flag | `store_true`; present means `True`. |
|
||||
| `Rest("name")` | remainder | Greedy passthrough of all remaining argv (`argparse.REMAINDER`). |
|
||||
| `Rest("name")` | remainder | Greedy passthrough of all remaining argv (`argparse.REMAINDER`). |
|
||||
|
||||
The engine builds the parser as `apex <group>/<action>` and parses the remaining argv before calling `run`. `--help` is handled at parse time. A usage error — unknown flag, missing positional — prints usage and exits `64` (`EX_USAGE`) instead of argparse's default `2`.
|
||||
The engine builds the parser as `apex <group>/<action>` and parses the remaining argv before calling `run`. `--help` is handled at parse time. A usage error — unknown flag, missing positional — prints usage and exits `64` (`EX_USAGE`) instead of argparse's default `2`.
|
||||
|
||||
## Listing without importing {#listing}
|
||||
|
||||
`apex` with no arguments (or `--help`) lists every available action. Listing never imports action modules: the engine statically scans each file with the `ast` module and extracts only the `METADATA` summary and any `DISABLED` constant. Action code therefore cannot run — or crash — at list time; a file that fails to parse is listed as `(unparseable)` rather than breaking the listing.
|
||||
`apex` with no arguments (or `--help`) lists every available action. Listing never imports action modules: the engine statically scans each file with the `ast` module and extracts only the `METADATA` summary and any `DISABLED` constant. Action code therefore cannot run — or crash — at list time; a file that fails to parse is listed as `(unparseable)` rather than breaking the listing.
|
||||
|
||||
Each entry is tagged with its overlay source:
|
||||
Each entry is tagged with its overlay source:
|
||||
|
||||
- no tag → a commons action,
|
||||
- `(local)` → exists only in proprietaries,
|
||||
- `(local override)` → a proprietaries action shadowing a commons one,
|
||||
- `[DISABLED: reason]` → the action declares `DISABLED`.
|
||||
- no tag → a commons action,
|
||||
- `(local)` → exists only in proprietaries,
|
||||
- `(local override)` → a proprietaries action shadowing a commons one,
|
||||
- `[DISABLED: reason]` → the action declares `DISABLED`.
|
||||
|
||||
## The overlay {#overlay}
|
||||
## The overlay {#overlay}
|
||||
|
||||
Action names are resolved across two trees, proprietaries first:
|
||||
|
||||
@@ -81,31 +81,31 @@ Action names are resolved across two trees, proprietaries first:
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/container >}}
|
||||
|
||||
The engine detects this layout by its own location: when it runs from a `commons/` directory whose parent contains `node.env`, the parent is the [node](/apex/glossary#node) repository root and `proprietaries/actions/` is the overlay tree. A standalone commons checkout is its own root and has no overlay layer. Inside a node repository, [node identity](/apex/glossary#node-identity) is resolved from the host FQDN before dispatch.
|
||||
The engine detects this layout by its own location: when it runs from a `commons/` directory whose parent contains `node.env`, the parent is the [node](/apex/glossary#node) repository root and `proprietaries/actions/` is the overlay tree. A standalone commons checkout is its own root and has no overlay layer. Inside a node repository, [node identity](/apex/glossary#node-identity) is resolved from the host FQDN before dispatch.
|
||||
|
||||
Resolution for a given name checks `proprietaries/actions/<name>.py` first and `commons/actions/<name>.py` second; exactly one module is loaded by path. An unknown name is an error (exit `1`). The overlay supports four moves:
|
||||
Resolution for a given name checks `proprietaries/actions/<name>.py` first and `commons/actions/<name>.py` second; exactly one module is loaded by path. An unknown name is an error (exit `1`). The overlay supports four moves:
|
||||
|
||||
- **Add** → a file that exists only in proprietaries becomes a node-local action, listed as `(local)`.
|
||||
- **Override** → a proprietaries file at the same relative path shadows the commons implementation entirely; the commons path is recorded on the resolution as `shadowed`.
|
||||
- **Wrap** → an overriding module can delegate to the implementation it shadows via `ctx.commons.run(args)`, adding node-local behavior before or after the commons logic instead of replacing it.
|
||||
- **Disable** → a module that assigns `DISABLED = "reason"` is refused at dispatch: the engine logs the reason and exits `1`. The listing shows `[DISABLED: reason]`. A bare `DISABLED = True` also disables, with the generic reason `disabled`; an empty string does not.
|
||||
- **Add** → a file that exists only in proprietaries becomes a node-local action, listed as `(local)`.
|
||||
- **Override** → a proprietaries file at the same relative path shadows the commons implementation entirely; the commons path is recorded on the resolution as `shadowed`.
|
||||
- **Wrap** → an overriding module can delegate to the implementation it shadows via `ctx.commons.run(args)`, adding node-local behavior before or after the commons logic instead of replacing it.
|
||||
- **Disable** → a module that assigns `DISABLED = "reason"` is refused at dispatch: the engine logs the reason and exits `1`. The listing shows `[DISABLED: reason]`. A bare `DISABLED = True` also disables, with the generic reason `disabled`; an empty string does not.
|
||||
|
||||
## Exit codes {#exit-codes}
|
||||
|
||||
Actions and the engine follow a fixed exit-code convention:
|
||||
Actions and the engine follow a fixed exit-code convention:
|
||||
|
||||
| Code | Meaning | Typical cause |
|
||||
| --- | --- | --- |
|
||||
| `0` | success | `run` returned `0` or a non-integer. |
|
||||
| `0` | success | `run` returned `0` or a non-integer. |
|
||||
| `1` | general error | Unknown action, disabled action, unhandled exception, non-integer `SystemExit`. |
|
||||
| `64` | usage error | Arguments rejected by the parser (`EX_USAGE`). |
|
||||
| `65` | data error | Malformed input data (`EX_DATAERR`), raised by engine libraries. |
|
||||
| `66` | missing input | A required input is absent (`EX_NOINPUT`), raised by engine libraries. |
|
||||
| `64` | usage error | Arguments rejected by the parser (`EX_USAGE`). |
|
||||
| `65` | data error | Malformed input data (`EX_DATAERR`), raised by engine libraries. |
|
||||
| `66` | missing input | A required input is absent (`EX_NOINPUT`), raised by engine libraries. |
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Framework architecture](/apex/concepts/architecture)
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
- [Concept: The execution context](/apex/concepts/execution-context)
|
||||
- [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com)
|
||||
@@ -1,46 +1,46 @@
|
||||
---
|
||||
title: "Fleet addressing scheme"
|
||||
description: "Per-node /24 subnets and static anchor IPs inside the enclave space (default 198.18.0.0/16)."
|
||||
description: "Per-node /24 subnets and static anchor IPs inside the enclave space (default 198.18.0.0/16)."
|
||||
weight: 17
|
||||
---
|
||||
|
||||
Apex assigns every service a fixed, predictable IPv4 address inside a per-node subnet. Each node declares its own `/24`, service anchors are static addresses within it, and the whole scheme is enforced mechanically by the anchor-IP lint.
|
||||
Apex assigns every service a fixed, predictable IPv4 address inside a per-node subnet. Each node declares its own `/24`, service anchors are static addresses within it, and the whole scheme is enforced mechanically by the anchor-IP lint.
|
||||
|
||||
## Address space {#address-space}
|
||||
|
||||
The [enclave](/apex/glossary#enclave) space defaults to `198.18.0.0/16`, the benchmarking range set aside by [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544). This range is never routed on the public Internet and does not collide with the common private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) that cloud providers and home networks already consume. The operator may override it via `APEX_ENCLAVE_SUBNET`.
|
||||
The [enclave](/apex/glossary#enclave) space defaults to `198.18.0.0/16`, the benchmarking range set aside by [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544). This range is never routed on the public Internet and does not collide with the common private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) that cloud providers and home networks already consume. The operator may override it via `APEX_ENCLAVE_SUBNET`.
|
||||
|
||||
## Node subnets {#node-subnets}
|
||||
|
||||
Each [node](/apex/glossary#node) declares exactly one `/24` as `APEX_SUBNET` in its `node.env`, and that subnet is the `direct` network's IPAM range. The operator assigns it; conventionally it sits inside the enclave space (`198.18.0.0/16` by default, or whatever `APEX_ENCLAVE_SUBNET` is set to), but the framework imposes no arithmetic on the choice.
|
||||
Each [node](/apex/glossary#node) declares exactly one `/24` as `APEX_SUBNET` in its `node.env`, and that subnet is the `direct` network's IPAM range. The operator assigns it; conventionally it sits inside the enclave space (`198.18.0.0/16` by default, or whatever `APEX_ENCLAVE_SUBNET` is set to), but the framework imposes no arithmetic on the choice.
|
||||
|
||||
> [!NOTE]
|
||||
> As an optional operator convention, you may organize node `/24`s into contiguous blocks (for example, grouping related nodes) to keep allocations legible. This is a bookkeeping choice, not a framework rule.
|
||||
> As an optional operator convention, you may organize node `/24`s into contiguous blocks (for example, grouping related nodes) to keep allocations legible. This is a bookkeeping choice, not a framework rule.
|
||||
|
||||
## Allocation inside a node /24 {#node-allocation}
|
||||
## Allocation inside a node /24 {#node-allocation}
|
||||
|
||||
Within a node's `/24`, host addresses are partitioned by role:
|
||||
Within a node's `/24`, host addresses are partitioned by role:
|
||||
|
||||
| Range | Purpose |
|
||||
|---|---|
|
||||
| `.10`–`.19` | Reserved for [core composition](/apex/glossary#core-composition) anchors; `traefik` is always `.10` (via `APEX_TRAEFIK_DIRECT_IP`) |
|
||||
| `.20` and up | Service anchors, allocated per `<project>` |
|
||||
| `.10`–`.19` | Reserved for [core composition](/apex/glossary#core-composition) anchors; `traefik` is always `.10` (via `APEX_TRAEFIK_DIRECT_IP`) |
|
||||
| `.20` and up | Service anchors, allocated per `<project>` |
|
||||
|
||||
Every service anchor is a static `ipv4_address` on the direct network — a fixed [anchor IP](/apex/glossary#anchor-ip) rather than a dynamically assigned lease. Allocations are recorded in `compositions/ADDRESSING.md`, which serves as the human-readable register for the node's subnet.
|
||||
Every service anchor is a static `ipv4_address` on the direct network — a fixed [anchor IP](/apex/glossary#anchor-ip) rather than a dynamically assigned lease. Allocations are recorded in `compositions/ADDRESSING.md`, which serves as the human-readable register for the node's subnet.
|
||||
|
||||
## Enforcement {#enforcement}
|
||||
|
||||
The compose lint validates anchor IPs across all [compositions](/apex/glossary#compositions) on a node. For every `ipv4_address` found in `compositions/*/docker-compose.yml` it checks that the address is:
|
||||
The compose lint validates anchor IPs across all [compositions](/apex/glossary#compositions) on a node. For every `ipv4_address` found in `compositions/*/docker-compose.yml` it checks that the address is:
|
||||
|
||||
- **Well-formed** — a malformed literal is reported as a lint error, not a crash.
|
||||
- **Unique** — the same anchor IP may not appear in two projects; duplicates are reported with both offending projects.
|
||||
- **Inside the node subnet** — every anchor must fall within the node's declared `/24`.
|
||||
- **Well-formed** — a malformed literal is reported as a lint error, not a crash.
|
||||
- **Unique** — the same anchor IP may not appear in two projects; duplicates are reported with both offending projects.
|
||||
- **Inside the node subnet** — every anchor must fall within the node's declared `/24`.
|
||||
|
||||
Any violation fails the lint with exit code `1` (general error). Passing validation logs `Anchor IP validation passed.` The implementation lives in the `utils/lint-docker-compose` [action](/apex/glossary#action); see [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com).
|
||||
Any violation fails the lint with exit code `1` (general error). Passing validation logs `Anchor IP validation passed.` The implementation lives in the `utils/lint-docker-compose` [action](/apex/glossary#action); see [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com).
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [GitHub: apex.ermnvldmr.com](https://github.com/deytenit/apex.ermnvldmr.com)
|
||||
- [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544)
|
||||
- [RFC 2544: Benchmarking Methodology for Network Interconnect Devices](https://datatracker.ietf.org/doc/html/rfc2544)
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
title: Framework architecture
|
||||
description: Four-pillar node repositories, the commons submodule, and the dispatch flow.
|
||||
description: Four-pillar node repositories, the commons submodule, and the dispatch flow.
|
||||
weight: 11
|
||||
---
|
||||
|
||||
apex splits fleet automation into one shared repository — the [commons](/apex/glossary#commons) — and one repository per [node](/apex/glossary#node). The commons carries everything every node shares: the [engine](/apex/glossary#engine), the shared [actions](/apex/glossary#action), and the [core composition](/apex/glossary#core-composition). A node repository carries everything that makes that node itself: its identity, host [configs](/apex/glossary#configs), service [compositions](/apex/glossary#compositions), and node-local [proprietaries](/apex/glossary#proprietaries).
|
||||
apex splits fleet automation into one shared repository — the [commons](/apex/glossary#commons) — and one repository per [node](/apex/glossary#node). The commons carries everything every node shares: the [engine](/apex/glossary#engine), the shared [actions](/apex/glossary#action), and the [core composition](/apex/glossary#core-composition). A node repository carries everything that makes that node itself: its identity, host [configs](/apex/glossary#configs), service [compositions](/apex/glossary#compositions), and node-local [proprietaries](/apex/glossary#proprietaries).
|
||||
|
||||
## The four pillars {#pillars}
|
||||
## The four pillars {#pillars}
|
||||
|
||||
Every node repository has the same shape:
|
||||
Every node repository has the same shape:
|
||||
|
||||
{{< filetree/container >}}
|
||||
{{< filetree/folder name="<node>.<your-domain>" state="open" >}}
|
||||
@@ -23,16 +23,16 @@ Every node repository has the same shape:
|
||||
|
||||
| Pillar | Content | Ownership |
|
||||
|---|---|---|
|
||||
| `commons/` | Git submodule → `apex.ermnvldmr.com`, pinned to a release tag | Shared, versioned |
|
||||
| `proprietaries/` | Node-local actions, libraries, and tool sources ([overlaid](/apex/glossary#overlay) over commons) | Node |
|
||||
| `commons/` | Git submodule → `apex.ermnvldmr.com`, pinned to a release tag | Shared, versioned |
|
||||
| `proprietaries/` | Node-local actions, libraries, and tool sources ([overlaid](/apex/glossary#overlay) over commons) | Node |
|
||||
| `configs/` | Host configuration sources: `ufw/`, `cron/crontab`, `systemd/`, `crowdsec/` | Node |
|
||||
| `compositions/` | One directory per project plus the `apex/` core wrapper and the addressing ledger | Node |
|
||||
| `compositions/` | One directory per project plus the `apex/` core wrapper and the addressing ledger | Node |
|
||||
|
||||
The submodule pin is the framework's version contract: a node runs exactly the engine and core composition recorded in its gitlink. Upgrading a node is a deliberate act — check out a newer tag inside `commons/` and commit the moved pointer.
|
||||
The submodule pin is the framework's version contract: a node runs exactly the engine and core composition recorded in its gitlink. Upgrading a node is a deliberate act — check out a newer tag inside `commons/` and commit the moved pointer.
|
||||
|
||||
## Dispatch flow {#dispatch}
|
||||
|
||||
The launcher `commons/apex` acts on the local checkout; there is no node argument anywhere.
|
||||
The launcher `commons/apex` acts on the local checkout; there is no node argument anywhere.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -47,32 +47,32 @@ flowchart TD
|
||||
H --> I["run(ctx, args)"]
|
||||
```
|
||||
|
||||
The engine detects the layout from its own location: running from a directory named `commons/` whose parent contains `node.env` means "node checkout" — the parent becomes the repository root and `proprietaries/actions/` becomes the overlay tree. Any other location is a standalone commons checkout acting as its own root.
|
||||
The engine detects the layout from its own location: running from a directory named `commons/` whose parent contains `node.env` means "node checkout" — the parent becomes the repository root and `proprietaries/actions/` becomes the overlay tree. Any other location is a standalone commons checkout acting as its own root.
|
||||
|
||||
Listing (`apex --help`) never imports action modules; summaries and disabled markers are extracted by static `ast` scanning. Dispatch loads exactly one module by path. Details in [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay).
|
||||
Listing (`apex --help`) never imports action modules; summaries and disabled markers are extracted by static `ast` scanning. Dispatch loads exactly one module by path. Details in [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay).
|
||||
|
||||
## The capture-up model {#capture-up}
|
||||
## The capture-up model {#capture-up}
|
||||
|
||||
Node repositories are ledgers, not just sources: the host itself records what it runs.
|
||||
Node repositories are ledgers, not just sources: the host itself records what it runs.
|
||||
|
||||
1. A scheduled `sync/repository` run on the host commits the working tree to a `sync/<node>` branch (including the commons submodule pointer), rebases it onto `origin/main`, and pushes with `--force-with-lease`.
|
||||
2. A scheduled CI job in the node repository merges `sync/<node>` back into `main` under a bot identity.
|
||||
1. A scheduled `sync/repository` run on the host commits the working tree to a `sync/<node>` branch (including the commons submodule pointer), rebases it onto `origin/main`, and pushes with `--force-with-lease`.
|
||||
2. A scheduled CI job in the node repository merges `sync/<node>` back into `main` under a bot identity.
|
||||
|
||||
This [capture-up](/apex/glossary#capture-up) loop means drift on a host becomes a commit — visible, diffable, and revertable — instead of silent divergence. The sync aborts rather than committing when the commons submodule is dirty or the `compositions/` tree is missing, so a broken checkout never becomes the recorded state.
|
||||
This [capture-up](/apex/glossary#capture-up) loop means drift on a host becomes a commit — visible, diffable, and revertable — instead of silent divergence. The sync aborts rather than committing when the commons submodule is dirty or the `compositions/` tree is missing, so a broken checkout never becomes the recorded state.
|
||||
|
||||
> [!NOTE]
|
||||
> The CI merge job checks out the repository *without* submodules: the commons submodule uses an SSH URL that the default CI token cannot fetch, and the merge only moves the gitlink pointer anyway.
|
||||
> The CI merge job checks out the repository *without* submodules: the commons submodule uses an SSH URL that the default CI token cannot fetch, and the merge only moves the gitlink pointer anyway.
|
||||
|
||||
## Design constraints {#constraints}
|
||||
|
||||
- **Stdlib only.** The engine runs on a stock Debian Python 3 — no `pip`, no third-party imports. The optional `vendor/` directory exists for pinned pure-Python code but ships empty.
|
||||
- **Hand-authored composes.** There is no manifest layer or code generation; compose files are the source of truth.
|
||||
- **Evidence over convention.** Anything enforceable is enforced by an action (anchor-IP lint, crontab rollback, marker-block self-healing) rather than by documentation.
|
||||
- **Stdlib only.** The engine runs on a stock Debian Python 3 — no `pip`, no third-party imports. The optional `vendor/` directory exists for pinned pure-Python code but ships empty.
|
||||
- **Hand-authored composes.** There is no manifest layer or code generation; compose files are the source of truth.
|
||||
- **Evidence over convention.** Anything enforceable is enforced by an action (anchor-IP lint, crontab rollback, marker-block self-healing) rather than by documentation.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
- [Concept: Node identity resolution](/apex/concepts/node-identity)
|
||||
- [Guide: Build a node repository](/apex/guides/node-repository)
|
||||
- [Guide: Build a node repository](/apex/guides/node-repository)
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
title: Compositions and the shared core
|
||||
description: The include-based core composition, profiles, networks, and env layering.
|
||||
title: Compositions and the shared core
|
||||
description: The include-based core composition, profiles, networks, and env layering.
|
||||
weight: 15
|
||||
---
|
||||
|
||||
A node's runtime is declared as [compositions](/apex/glossary#compositions): one docker-compose project per service under `compositions/<project>/`, plus the [core composition](/apex/glossary#core-composition) — the edge and infrastructure services every node needs. The core lives in the [commons](/apex/glossary#commons) and is consumed per node through a thin wrapper, so its definition is written once and versioned with the framework.
|
||||
A node's runtime is declared as [compositions](/apex/glossary#compositions): one docker-compose project per service under `compositions/<project>/`, plus the [core composition](/apex/glossary#core-composition) — the edge and infrastructure services every node needs. The core lives in the [commons](/apex/glossary#commons) and is consumed per node through a thin wrapper, so its definition is written once and versioned with the framework.
|
||||
|
||||
## The include wrapper {#include}
|
||||
## The include wrapper {#include}
|
||||
|
||||
Each node's `compositions/apex/docker-compose.yml` is three lines:
|
||||
Each node's `compositions/apex/docker-compose.yml` is three lines:
|
||||
|
||||
```yaml {filename="compositions/apex/docker-compose.yml"}
|
||||
name: apex
|
||||
@@ -16,9 +16,9 @@ include:
|
||||
- ../../commons/compositions/apex/docker-compose.yml
|
||||
```
|
||||
|
||||
The shared file is fully `${APEX_*}`-parameterized and contains no relative host paths — every mount resolves through variables the [engine](/apex/glossary#engine) exports at invocation, so the same file is correct on every node.
|
||||
The shared file is fully `${APEX_*}`-parameterized and contains no relative host paths — every mount resolves through variables the [engine](/apex/glossary#engine) exports at invocation, so the same file is correct on every node.
|
||||
|
||||
A structural outlier — a node that genuinely needs to change a core service, such as publishing an extra host port — layers an override through the include path-list form instead of editing the shared file:
|
||||
A structural outlier — a node that genuinely needs to change a core service, such as publishing an extra host port — layers an override through the include path-list form instead of editing the shared file:
|
||||
|
||||
```yaml {filename="compositions/apex/docker-compose.yml"}
|
||||
name: apex
|
||||
@@ -29,59 +29,59 @@ include:
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `include:` directive requires Docker Compose v2.20 or newer.
|
||||
> The `include:` directive requires Docker Compose v2.20 or newer.
|
||||
|
||||
## Core services and profiles {#core-services}
|
||||
## Core services and profiles {#core-services}
|
||||
|
||||
The core composition defines the fleet's shared service set. Always-on services:
|
||||
The core composition defines the fleet's shared service set. Always-on services:
|
||||
|
||||
- **traefik** — the edge reverse proxy and the anchor of the `direct` network (always `.10` in the node subnet). It terminates TLS via a DNS challenge, discovers backends through docker labels, and exposes its dashboard on the node FQDN behind an [enclave](/apex/glossary#enclave) IP allowlist.
|
||||
- **socket-proxy** — a filtered docker-socket proxy; traefik reads container state through it and never touches the socket directly.
|
||||
- **traefik** — the edge reverse proxy and the anchor of the `direct` network (always `.10` in the node subnet). It terminates TLS via a DNS challenge, discovers backends through docker labels, and exposes its dashboard on the node FQDN behind an [enclave](/apex/glossary#enclave) IP allowlist.
|
||||
- **socket-proxy** — a filtered docker-socket proxy; traefik reads container state through it and never touches the socket directly.
|
||||
|
||||
Opt-in services, gated by compose profiles a node enables in `COMPOSE_PROFILES` (set in `apex.env`):
|
||||
Opt-in services, gated by compose profiles a node enables in `COMPOSE_PROFILES` (set in `apex.env`):
|
||||
|
||||
| Profile | Service | Role |
|
||||
|---|---|---|
|
||||
| `xray` | xray | Host-network proxy edge for the enclave. |
|
||||
| `adguard` | adguard | DNS filtering, sharing traefik's network namespace; routed at `/adguard` on the node FQDN. |
|
||||
| `alloy` | alloy | Per-node observability agent (Grafana Alloy). Collects host + container metrics and journal + container logs and **pushes** them outbound to a central Prometheus and Loki (see [Concept: Security model](/apex/concepts/security-model#observability)). Its container `hostname` is set to `APEX_NODE_HOST`, and that value stamps the `node` and `instance` labels on every metric and log stream. |
|
||||
| `manual` | resticontainer | One-shot label-driven backup runner (`ghcr.io/deytenit/resticontainer`) — never starts with `up`; invoked by the `backup/run` action, which discovers per-service backup intent from `restic.*` compose labels. |
|
||||
| `xray` | xray | Host-network proxy edge for the enclave. |
|
||||
| `adguard` | adguard | DNS filtering, sharing traefik's network namespace; routed at `/adguard` on the node FQDN. |
|
||||
| `alloy` | alloy | Per-node observability agent (Grafana Alloy). Collects host + container metrics and journal + container logs and **pushes** them outbound to a central Prometheus and Loki (see [Concept: Security model](/apex/concepts/security-model#observability)). Its container `hostname` is set to `APEX_NODE_HOST`, and that value stamps the `node` and `instance` labels on every metric and log stream. |
|
||||
| `manual` | resticontainer | One-shot label-driven backup runner (`ghcr.io/deytenit/resticontainer`) — never starts with `up`; invoked by the `backup/run` action, which discovers per-service backup intent from `restic.*` compose labels. |
|
||||
|
||||
## Networks {#networks}
|
||||
|
||||
The core composition owns the three node networks; service compositions reference them by key with `external: true`.
|
||||
The core composition owns the three node networks; service compositions reference them by key with `external: true`.
|
||||
|
||||
| Network | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `direct` | bridge, subnet = `APEX_SUBNET` | Static-[anchor-IP](/apex/glossary#anchor-ip) network; every project's anchor holds a fixed address here (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)). |
|
||||
| `direct` | bridge, subnet = `APEX_SUBNET` | Static-[anchor-IP](/apex/glossary#anchor-ip) network; every project's anchor holds a fixed address here (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)). |
|
||||
| `enclave` | bridge, `internal: true` | Reverse-proxy backend network; traefik reaches labeled services here. |
|
||||
| `socket` | bridge, `internal: true` | Carries only traefik ↔ socket-proxy traffic. |
|
||||
|
||||
Within a project, sibling containers typically join the anchor's network namespace (`network_mode: service:<anchor>`) instead of attaching to networks themselves.
|
||||
Within a project, sibling containers typically join the anchor's network namespace (`network_mode: service:<anchor>`) instead of attaching to networks themselves.
|
||||
|
||||
## Environment layering {#env-layering}
|
||||
|
||||
Values reach a composition from three layers, in increasing precedence:
|
||||
Values reach a composition from three layers, in increasing precedence:
|
||||
|
||||
1. **`.env`** — uncommitted secrets, a symlink into tier-1 storage (`APEX_RESTIC_*` credentials, `APEX_TRAEFIK_*` tokens, the UID/GID block).
|
||||
2. **`apex.env`** — committed non-secret scalars (`APEX_TRAEFIK_DIRECT_IP`, `APEX_RESTIC_REPOSITORY`, `COMPOSE_PROFILES`, optional `APEX_RESTIC_COMPRESSION`).
|
||||
3. **Engine exports** — the `APEX_*` identity and [tier](/apex/glossary#tier) paths from `ctx.vars()`, injected into the process environment by the orchestration actions and therefore winning over both files.
|
||||
1. **`.env`** — uncommitted secrets, a symlink into tier-1 storage (`APEX_RESTIC_*` credentials, `APEX_TRAEFIK_*` tokens, the UID/GID block).
|
||||
2. **`apex.env`** — committed non-secret scalars (`APEX_TRAEFIK_DIRECT_IP`, `APEX_RESTIC_REPOSITORY`, `COMPOSE_PROFILES`, optional `APEX_RESTIC_COMPRESSION`).
|
||||
3. **Engine exports** — the `APEX_*` identity and [tier](/apex/glossary#tier) paths from `ctx.vars()`, injected into the process environment by the orchestration actions and therefore winning over both files.
|
||||
|
||||
Critical variables carry `?error` markers in the shared file, so a missing secret fails composition config loudly instead of starting a misconfigured container.
|
||||
Critical variables carry `?error` markers in the shared file, so a missing secret fails composition config loudly instead of starting a misconfigured container.
|
||||
|
||||
## Orchestration {#orchestration}
|
||||
|
||||
`apex compose <action>` drives every composition on the node:
|
||||
`apex compose <action>` drives every composition on the node:
|
||||
|
||||
- **Order** — the core project first (it owns the networks), then every service composition in alphabetical order; `down` reverses the sequence.
|
||||
- **Env files** — for each project, `.env` and `apex.env` are passed as `--env-file` when present.
|
||||
- **`up`** implies `-d`; any extra arguments pass through to `docker compose`.
|
||||
- **`--dry-run`** prints each command with its working directory instead of executing — accepted anywhere on the command line.
|
||||
- **Order** — the core project first (it owns the networks), then every service composition in alphabetical order; `down` reverses the sequence.
|
||||
- **Env files** — for each project, `.env` and `apex.env` are passed as `--env-file` when present.
|
||||
- **`up`** implies `-d`; any extra arguments pass through to `docker compose`.
|
||||
- **`--dry-run`** prints each command with its working directory instead of executing — accepted anywhere on the command line.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Fleet addressing scheme](/apex/concepts/addressing)
|
||||
- [Guide: Add a service composition](/apex/guides/service-composition)
|
||||
- [Guide: Add a service composition](/apex/guides/service-composition)
|
||||
- [Reference: Environment variables](/apex/reference/environment)
|
||||
@@ -1,66 +1,66 @@
|
||||
---
|
||||
title: The execution context
|
||||
description: ctx — the single seam between an action and the host.
|
||||
title: The execution context
|
||||
description: ctx — the single seam between an action and the host.
|
||||
weight: 14
|
||||
---
|
||||
|
||||
Every [action](/apex/glossary#action) receives a `ctx` object as its first argument. It is the only sanctioned way to touch the host: logging, process execution, template rendering, file mutation, firewall and service management, and notifications all go through it. Actions therefore stay declarative, and behavior that must be consistent fleet-wide (log format, backup discipline, notification payloads) lives in one place — the [engine](/apex/glossary#engine).
|
||||
Every [action](/apex/glossary#action) receives a `ctx` object as its first argument. It is the only sanctioned way to touch the host: logging, process execution, template rendering, file mutation, firewall and service management, and notifications all go through it. Actions therefore stay declarative, and behavior that must be consistent fleet-wide (log format, backup discipline, notification payloads) lives in one place — the [engine](/apex/glossary#engine).
|
||||
|
||||
## Members {#members}
|
||||
|
||||
| Member | Purpose |
|
||||
|---|---|
|
||||
| `ctx.log` | Prefixed logging: `[<node>] [<action>] [LEVEL] message`; errors go to stderr, everything else to stdout. |
|
||||
| `ctx.sys` | Process execution (`run`, `sudo`, `ok`), systemd service helpers, and idempotent package installation. |
|
||||
| `ctx.tpl` | Template rendering over the `configs/` pillar with `string.Template.safe_substitute`. |
|
||||
| `ctx.host` | Idempotent host mutation: timestamped backups, atomic file writes, marker-block injection, crontab installation with rollback. |
|
||||
| `ctx.ufw` | Host and container firewall application (see [Concept: Security model](/apex/concepts/security-model)). |
|
||||
| `ctx.crowdsec` | CrowdSec installation, config deployment, and collection management. |
|
||||
| `ctx.systemd` | Rendering and enabling systemd units from `configs/systemd/`. |
|
||||
| `ctx.notify` | Telegram notifications; every method returns a boolean so actions can gate their exit code on delivery. |
|
||||
| `ctx.paths` | Resolved repository paths: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core`, and the tier properties. |
|
||||
| `ctx.node` | The resolved [node identity](/apex/glossary#node-identity) record (`name`, `subnet`, `fqdn`, `host`); `None` in a standalone commons checkout. |
|
||||
| `ctx.commons` | Available only in an overriding proprietary action: `ctx.commons.run(args)` delegates to the shadowed commons implementation. |
|
||||
| `ctx.log` | Prefixed logging: `[<node>] [<action>] [LEVEL] message`; errors go to stderr, everything else to stdout. |
|
||||
| `ctx.sys` | Process execution (`run`, `sudo`, `ok`), systemd service helpers, and idempotent package installation. |
|
||||
| `ctx.tpl` | Template rendering over the `configs/` pillar with `string.Template.safe_substitute`. |
|
||||
| `ctx.host` | Idempotent host mutation: timestamped backups, atomic file writes, marker-block injection, crontab installation with rollback. |
|
||||
| `ctx.ufw` | Host and container firewall application (see [Concept: Security model](/apex/concepts/security-model)). |
|
||||
| `ctx.crowdsec` | CrowdSec installation, config deployment, and collection management. |
|
||||
| `ctx.systemd` | Rendering and enabling systemd units from `configs/systemd/`. |
|
||||
| `ctx.notify` | Telegram notifications; every method returns a boolean so actions can gate their exit code on delivery. |
|
||||
| `ctx.paths` | Resolved repository paths: `repo_root`, `commons`, `proprietaries`, `configs`, `compositions`, `core`, and the tier properties. |
|
||||
| `ctx.node` | The resolved [node identity](/apex/glossary#node-identity) record (`name`, `subnet`, `fqdn`, `host`); `None` in a standalone commons checkout. |
|
||||
| `ctx.commons` | Available only in an overriding proprietary action: `ctx.commons.run(args)` delegates to the shadowed commons implementation. |
|
||||
|
||||
Method-level signatures are listed in [Reference: Engine API](/apex/reference/engine-api).
|
||||
Method-level signatures are listed in [Reference: Engine API](/apex/reference/engine-api).
|
||||
|
||||
## Template semantics {#templates}
|
||||
|
||||
`ctx.tpl` renders files from `configs/` with Python's `string.Template.safe_substitute`:
|
||||
`ctx.tpl` renders files from `configs/` with Python's `string.Template.safe_substitute`:
|
||||
|
||||
- Known `$VAR` and `${VAR}` references are replaced.
|
||||
- **Unknown references stay literal.** A typo in a template survives into the rendered output where it is visible, instead of silently collapsing to an empty string.
|
||||
- Shell command substitution (`$(...)`) passes through untouched, which keeps crontab entries with embedded `$(cat ...)` intact.
|
||||
- Known `$VAR` and `${VAR}` references are replaced.
|
||||
- **Unknown references stay literal.** A typo in a template survives into the rendered output where it is visible, instead of silently collapsing to an empty string.
|
||||
- Shell command substitution (`$(...)`) passes through untouched, which keeps crontab entries with embedded `$(cat ...)` intact.
|
||||
|
||||
The substitution set is `ctx.vars()` — the full process environment plus the engine-provided `APEX_*` values below. Loops and conditionals never live in templates; they belong in the action.
|
||||
The substitution set is `ctx.vars()` — the full process environment plus the engine-provided `APEX_*` values below. Loops and conditionals never live in templates; they belong in the action.
|
||||
|
||||
## The variable export {#vars}
|
||||
## The variable export {#vars}
|
||||
|
||||
`ctx.vars()` returns the environment used for every template render, and its `APEX_*` subset is exported to `docker compose` by the orchestration actions. On a node it contains:
|
||||
`ctx.vars()` returns the environment used for every template render, and its `APEX_*` subset is exported to `docker compose` by the orchestration actions. On a node it contains:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `APEX_COMMONS` | Absolute path of the commons directory (the launcher lives at `$APEX_COMMONS/apex`). |
|
||||
| `APEX_REPO_ROOT` | Absolute path of the node repository root. |
|
||||
| `APEX_NODE_HOST`, `APEX_NODE_FQDN`, `APEX_SUBNET` | The resolved identity fields. |
|
||||
| `APEX_TIER1`…`APEX_TIER3` | The core project's per-[tier](/apex/glossary#tier) data directories. |
|
||||
| `APEX_TIER1_SHARED`…`APEX_TIER3_SHARED` | The shared areas inside each tier. |
|
||||
| `APEX_TIER_ROOT1`…`APEX_TIER_ROOT3` | The tier storage roots (node-level links). |
|
||||
| `APEX_COMMONS` | Absolute path of the commons directory (the launcher lives at `$APEX_COMMONS/apex`). |
|
||||
| `APEX_REPO_ROOT` | Absolute path of the node repository root. |
|
||||
| `APEX_NODE_HOST`, `APEX_NODE_FQDN`, `APEX_SUBNET` | The resolved identity fields. |
|
||||
| `APEX_TIER1`…`APEX_TIER3` | The core project's per-[tier](/apex/glossary#tier) data directories. |
|
||||
| `APEX_TIER1_SHARED`…`APEX_TIER3_SHARED` | The shared areas inside each tier. |
|
||||
| `APEX_TIER_ROOT1`…`APEX_TIER_ROOT3` | The tier storage roots (node-level links). |
|
||||
|
||||
The full origin/consumer matrix is in [Reference: Environment variables](/apex/reference/environment).
|
||||
The full origin/consumer matrix is in [Reference: Environment variables](/apex/reference/environment).
|
||||
|
||||
## Host mutation discipline {#host}
|
||||
|
||||
`ctx.host` encodes the framework's write rules:
|
||||
`ctx.host` encodes the framework's write rules:
|
||||
|
||||
- **Atomic writes.** `write_file` renders to a temporary file (mode `0644`, matching what a root shell redirect would create) and installs it with `mv` or `sudo cp`; overwriting an existing file preserves that file's mode.
|
||||
- **Timestamped backups.** When requested, the previous file is kept next to the target as `<name>.<epoch>.bak` before the write.
|
||||
- **Marker-block injection.** Managed blocks inside system files (for example `/etc/ufw/before.rules`) are delimited by start/end marker comments. Injection removes every prior managed block — including blocks left by predecessor tooling — before inserting the fresh one after its anchor line, so re-runs never accumulate.
|
||||
- **Crontab rollback.** `install_crontab` snapshots the current crontab, refuses an empty render (exit `65`), previews the result, and restores the snapshot if installation fails.
|
||||
- **Atomic writes.** `write_file` renders to a temporary file (mode `0644`, matching what a root shell redirect would create) and installs it with `mv` or `sudo cp`; overwriting an existing file preserves that file's mode.
|
||||
- **Timestamped backups.** When requested, the previous file is kept next to the target as `<name>.<epoch>.bak` before the write.
|
||||
- **Marker-block injection.** Managed blocks inside system files (for example `/etc/ufw/before.rules`) are delimited by start/end marker comments. Injection removes every prior managed block — including blocks left by predecessor tooling — before inserting the fresh one after its anchor line, so re-runs never accumulate.
|
||||
- **Crontab rollback.** `install_crontab` snapshots the current crontab, refuses an empty render (exit `65`), previews the result, and restores the snapshot if installation fails.
|
||||
|
||||
## Notifications {#notifications}
|
||||
|
||||
`ctx.notify` posts Telegram messages through the standard library (`urllib`). Payloads carry the instance name — the node's public host `APEX_NODE_HOST` — a status (`firing`/`resolved`), an escalation level, and the message truncated to 200 characters. Every method returns `True` only when the send succeeded; unattended actions treat a failed send of their final status message as a failure (exit `1`), so a broken notification channel is noticed instead of silently tolerated.
|
||||
`ctx.notify` posts Telegram messages through the standard library (`urllib`). Payloads carry the instance name — the node's public host `APEX_NODE_HOST` — a status (`firing`/`resolved`), an escalation level, and the message truncated to 200 characters. Every method returns `True` only when the send succeeded; unattended actions treat a failed send of their final status message as a failure (exit `1`), so a broken notification channel is noticed instead of silently tolerated.
|
||||
|
||||
---
|
||||
|
||||
@@ -68,4 +68,4 @@ The full origin/consumer matrix is in [Reference: Environment variables](/apex/r
|
||||
|
||||
- [Reference: Engine API](/apex/reference/engine-api)
|
||||
- [Reference: Environment variables](/apex/reference/environment)
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
- [Concept: Actions and the overlay](/apex/concepts/actions-and-overlay)
|
||||
@@ -1,22 +1,22 @@
|
||||
---
|
||||
title: "Node identity resolution"
|
||||
description: "How a node learns who it is: node.env identity keys and the FQDN fallback."
|
||||
description: "How a node learns who it is: node.env identity keys and the FQDN fallback."
|
||||
weight: 12
|
||||
---
|
||||
|
||||
Every apex operation runs in the context of exactly one [node](/apex/glossary#node). The [engine](/apex/glossary#engine) derives that [node identity](/apex/glossary#node-identity) — node name, subnet, FQDN, and public host — from the `node.env` file at the repository root, with the OS hostname as a fallback for the FQDN only. The framework assumes no domain and no clustering: a node declares who it is explicitly. This resolution step succeeds the `root_require_node` mechanism of the bash "root" commons framework that apex replaces.
|
||||
Every apex operation runs in the context of exactly one [node](/apex/glossary#node). The [engine](/apex/glossary#engine) derives that [node identity](/apex/glossary#node-identity) — node name, subnet, FQDN, and public host — from the `node.env` file at the repository root, with the OS hostname as a fallback for the FQDN only. The framework assumes no domain and no clustering: a node declares who it is explicitly. This resolution step succeeds the `root_require_node` mechanism of the bash "root" commons framework that apex replaces.
|
||||
|
||||
## Identity sources {#sources}
|
||||
|
||||
### node.env
|
||||
|
||||
`node.env` is a plain `KEY=value` file (shell-sourceable) located at the repository root. When parsed, comments and blank lines are ignored, and surrounding single or double quotes around values are stripped.
|
||||
`node.env` is a plain `KEY=value` file (shell-sourceable) located at the repository root. When parsed, comments and blank lines are ignored, and surrounding single or double quotes around values are stripped.
|
||||
|
||||
It declares identity explicitly through these keys:
|
||||
It declares identity explicitly through these keys:
|
||||
|
||||
- `APEX_NODE_FQDN` — the node's real FQDN, used for traefik `Host()` routing. If unset, the engine falls back to the OS hostname (and warns).
|
||||
- `APEX_NODE_HOST` — the node's public host name, used for the backup `--host` and the observability/notify `instance`. Defaults to `APEX_NODE_FQDN`.
|
||||
- `APEX_SUBNET` — the node's `/24` for the `direct` network. **Required**; there is no other source for it (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
|
||||
- `APEX_NODE_FQDN` — the node's real FQDN, used for traefik `Host()` routing. If unset, the engine falls back to the OS hostname (and warns).
|
||||
- `APEX_NODE_HOST` — the node's public host name, used for the backup `--host` and the observability/notify `instance`. Defaults to `APEX_NODE_FQDN`.
|
||||
- `APEX_SUBNET` — the node's `/24` for the `direct` network. **Required**; there is no other source for it (see [Concept: Fleet addressing scheme](/apex/concepts/addressing)).
|
||||
|
||||
```ini {filename="node.env"}
|
||||
APEX_NODE_FQDN=node1.example.com
|
||||
@@ -24,51 +24,51 @@ APEX_NODE_HOST=node1.example.com
|
||||
APEX_SUBNET=198.18.16.0/24
|
||||
```
|
||||
|
||||
### The OS hostname
|
||||
### The OS hostname
|
||||
|
||||
The OS hostname is consulted only as a fallback for `APEX_NODE_FQDN`. When `node.env` does not set `APEX_NODE_FQDN`, the engine uses the hostname and emits a warning. It is never parsed for a node name or any other coordinate.
|
||||
The OS hostname is consulted only as a fallback for `APEX_NODE_FQDN`. When `node.env` does not set `APEX_NODE_FQDN`, the engine uses the hostname and emits a warning. It is never parsed for a node name or any other coordinate.
|
||||
|
||||
## Resolution order {#resolution}
|
||||
|
||||
{{% steps %}}
|
||||
|
||||
### Resolve the FQDN
|
||||
### Resolve the FQDN
|
||||
|
||||
`fqdn` is `APEX_NODE_FQDN` from `node.env`, or the OS hostname when that key is unset. If neither yields a value, resolution fails (see [Failure modes](#failures)).
|
||||
`fqdn` is `APEX_NODE_FQDN` from `node.env`, or the OS hostname when that key is unset. If neither yields a value, resolution fails (see [Failure modes](#failures)).
|
||||
|
||||
### Resolve the public host
|
||||
### Resolve the public host
|
||||
|
||||
`host` is `APEX_NODE_HOST` from `node.env`, or `fqdn` when that key is unset.
|
||||
`host` is `APEX_NODE_HOST` from `node.env`, or `fqdn` when that key is unset.
|
||||
|
||||
### Derive the short name
|
||||
### Derive the short name
|
||||
|
||||
`name` is the **first label** of `host` (`host.split(".")[0]`) — for example, `node1` for `node1.example.com`. It is used for logs, the `sync/<node>` branch, and messages.
|
||||
`name` is the **first label** of `host` (`host.split(".")[0]`) — for example, `node1` for `node1.example.com`. It is used for logs, the `sync/<node>` branch, and messages.
|
||||
|
||||
### Read the subnet
|
||||
### Read the subnet
|
||||
|
||||
`subnet` is always read from `APEX_SUBNET` in `node.env`. It is required.
|
||||
`subnet` is always read from `APEX_SUBNET` in `node.env`. It is required.
|
||||
|
||||
{{% /steps %}}
|
||||
|
||||
The result is a `Node` record with four fields: `name`, `subnet`, `fqdn`, and `host`.
|
||||
The result is a `Node` record with four fields: `name`, `subnet`, `fqdn`, and `host`.
|
||||
|
||||
## Warnings {#warnings}
|
||||
|
||||
Identity resolution emits exactly one warning; it signals a recoverable inconsistency that should be fixed.
|
||||
Identity resolution emits exactly one warning; it signals a recoverable inconsistency that should be fixed.
|
||||
|
||||
| Condition | Warning message |
|
||||
|---|---|
|
||||
| `APEX_NODE_FQDN` is unset in `node.env` (the hostname is used instead) | `APEX_NODE_FQDN not set in node.env; using hostname '<fqdn>'.` |
|
||||
| `APEX_NODE_FQDN` is unset in `node.env` (the hostname is used instead) | `APEX_NODE_FQDN not set in node.env; using hostname '<fqdn>'.` |
|
||||
|
||||
## Failure modes {#failures}
|
||||
|
||||
Resolution refuses to guess. Two conditions are fatal, and both exit with code `66` — the engine's exit-code convention for missing input:
|
||||
Resolution refuses to guess. Two conditions are fatal, and both exit with code `66` — the engine's exit-code convention for missing input:
|
||||
|
||||
- **No FQDN at all.** `APEX_NODE_FQDN` is unset in `node.env` *and* the OS hostname is empty. The engine writes `identity: APEX_NODE_FQDN missing from node.env and no hostname` to stderr and exits.
|
||||
- **No subnet.** `APEX_SUBNET` is absent from `node.env` (including when the file itself is missing). The engine writes `identity: APEX_SUBNET missing from node.env` to stderr and exits.
|
||||
- **No FQDN at all.** `APEX_NODE_FQDN` is unset in `node.env` *and* the OS hostname is empty. The engine writes `identity: APEX_NODE_FQDN missing from node.env and no hostname` to stderr and exits.
|
||||
- **No subnet.** `APEX_SUBNET` is absent from `node.env` (including when the file itself is missing). The engine writes `identity: APEX_SUBNET missing from node.env` to stderr and exits.
|
||||
|
||||
> [!WARNING]
|
||||
> A valid `node.env` carrying `APEX_SUBNET` is required on every node. `APEX_NODE_FQDN` may be omitted only when the OS hostname supplies a usable FQDN.
|
||||
> A valid `node.env` carrying `APEX_SUBNET` is required on every node. `APEX_NODE_FQDN` may be omitted only when the OS hostname supplies a usable FQDN.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
---
|
||||
title: Security model
|
||||
description: Privilege isolation, firewall management, intrusion detection, and secret handling.
|
||||
description: Privilege isolation, firewall management, intrusion detection, and secret handling.
|
||||
weight: 18
|
||||
---
|
||||
|
||||
apex hardens a [node](/apex/glossary#node) in independent layers: filesystem privilege isolation, a managed host firewall, intrusion detection, container-level containment, and a strict secrets boundary. Each layer is applied by an [action](/apex/glossary#action) and is therefore reproducible and re-runnable.
|
||||
apex hardens a [node](/apex/glossary#node) in independent layers: filesystem privilege isolation, a managed host firewall, intrusion detection, container-level containment, and a strict secrets boundary. Each layer is applied by an [action](/apex/glossary#action) and is therefore reproducible and re-runnable.
|
||||
|
||||
## Privilege isolation {#privilege}
|
||||
|
||||
Every project runs as its own locked `noroot-<project>` system user; shared storage is group-writable with the sticky bit so projects can exchange files without being able to delete each other's. The full mechanism — users, groups, and the ownership matrix — is described in [Concept: Tiered storage](/apex/concepts/tiered-storage).
|
||||
Every project runs as its own locked `noroot-<project>` system user; shared storage is group-writable with the sticky bit so projects can exchange files without being able to delete each other's. The full mechanism — users, groups, and the ownership matrix — is described in [Concept: Tiered storage](/apex/concepts/tiered-storage).
|
||||
|
||||
## Host firewall {#firewall}
|
||||
|
||||
`apex configure/ufw` applies the node's firewall from the `configs/ufw/` pillar in three parts:
|
||||
`apex configure/ufw` applies the node's firewall from the `configs/ufw/` pillar in three parts:
|
||||
|
||||
- **Host rules** (`configs/ufw/host/host.rules`) — plain `ufw` commands, one per line, applied in order.
|
||||
- **Kernel-level rule blocks** (`configs/ufw/host/before.rules`, `after.rules`) — injected into `/etc/ufw/before.rules` and `/etc/ufw/after.rules` as managed marker blocks. Injection is **self-healing**: every previous managed block — including blocks left by predecessor tooling — is removed before the fresh block is inserted after ufw's `# End required lines` anchor, and a timestamped `.bak` of the target is kept beside it.
|
||||
- **Container rules** (`configs/ufw/docker/*.rules`) — applied through `ufw-docker`, which translates them into rules that survive Docker's own iptables manipulation. `configure/base` installs the helper to `~/.local/bin/ufw-docker`.
|
||||
- **Host rules** (`configs/ufw/host/host.rules`) — plain `ufw` commands, one per line, applied in order.
|
||||
- **Kernel-level rule blocks** (`configs/ufw/host/before.rules`, `after.rules`) — injected into `/etc/ufw/before.rules` and `/etc/ufw/after.rules` as managed marker blocks. Injection is **self-healing**: every previous managed block — including blocks left by predecessor tooling — is removed before the fresh block is inserted after ufw's `# End required lines` anchor, and a timestamped `.bak` of the target is kept beside it.
|
||||
- **Container rules** (`configs/ufw/docker/*.rules`) — applied through `ufw-docker`, which translates them into rules that survive Docker's own iptables manipulation. `configure/base` installs the helper to `~/.local/bin/ufw-docker`.
|
||||
|
||||
Missing configuration is an error, not a silent skip: an absent `docker/` or `host/` directory exits `66` when its counterpart exists, and a missing `/etc/ufw` target file aborts the run. The action finishes with a reminder that `ufw` must be restarted to take effect — and that the SSH port must be allowed first.
|
||||
Missing configuration is an error, not a silent skip: an absent `docker/` or `host/` directory exits `66` when its counterpart exists, and a missing `/etc/ufw` target file aborts the run. The action finishes with a reminder that `ufw` must be restarted to take effect — and that the SSH port must be allowed first.
|
||||
|
||||
## Intrusion detection {#crowdsec}
|
||||
|
||||
`apex configure/crowdsec` installs CrowdSec through its official installer when `cscli` is absent, renders everything under `configs/crowdsec/` into `/etc/crowdsec/` (acquisition sources, parsers, bouncer configuration), and installs the `crowdsecurity/linux` and `crowdsecurity/traefik` collections. When the node's configs include a firewall-bouncer configuration, the matching bouncer package is installed as well, closing the loop from detection to blocking.
|
||||
`apex configure/crowdsec` installs CrowdSec through its official installer when `cscli` is absent, renders everything under `configs/crowdsec/` into `/etc/crowdsec/` (acquisition sources, parsers, bouncer configuration), and installs the `crowdsecurity/linux` and `crowdsecurity/traefik` collections. When the node's configs include a firewall-bouncer configuration, the matching bouncer package is installed as well, closing the loop from detection to blocking.
|
||||
|
||||
## Container containment {#containers}
|
||||
|
||||
The [core composition](/apex/glossary#core-composition) encodes container-level hardening:
|
||||
The [core composition](/apex/glossary#core-composition) encodes container-level hardening:
|
||||
|
||||
- The edge proxy never touches the Docker socket: it discovers backends through a **filtered socket proxy** that exposes read-only container/service state on an isolated network.
|
||||
- `enclave` and `socket` are `internal: true` networks — no egress, no host port exposure.
|
||||
- Core services set `no-new-privileges` and run under the project's noroot identity wherever the workload allows; capabilities are granted per service, not globally.
|
||||
- The edge proxy never touches the Docker socket: it discovers backends through a **filtered socket proxy** that exposes read-only container/service state on an isolated network.
|
||||
- `enclave` and `socket` are `internal: true` networks — no egress, no host port exposure.
|
||||
- Core services set `no-new-privileges` and run under the project's noroot identity wherever the workload allows; capabilities are granted per service, not globally.
|
||||
|
||||
## Observability {#observability}
|
||||
|
||||
Observability is an **outbound push**, not an exposed route. The optional `alloy` core service (Grafana Alloy, one agent per node) collects host metrics (a `node_exporter`-equivalent), container metrics (cadvisor), the systemd journal, and docker container logs, and pushes them to a **central** Prometheus and Loki that live on a designated node — the metrics via `prometheus.remote_write` to `APEX_PROM_PUSH_URL`, the logs via `loki.write` to `APEX_LOKI_PUSH_URL`. Both targets are authenticated with HTTP basic auth using `APEX_OBS_BASIC_AUTH_USER` / `APEX_OBS_BASIC_AUTH_PASS`. Because the push is outbound, Alloy attaches to the `direct` network (which has egress) rather than the `internal` `enclave`; there is no node-local log store and no `/loki` route to protect.
|
||||
Observability is an **outbound push**, not an exposed route. The optional `alloy` core service (Grafana Alloy, one agent per node) collects host metrics (a `node_exporter`-equivalent), container metrics (cadvisor), the systemd journal, and docker container logs, and pushes them to a **central** Prometheus and Loki that live on a designated node — the metrics via `prometheus.remote_write` to `APEX_PROM_PUSH_URL`, the logs via `loki.write` to `APEX_LOKI_PUSH_URL`. Both targets are authenticated with HTTP basic auth using `APEX_OBS_BASIC_AUTH_USER` / `APEX_OBS_BASIC_AUTH_PASS`. Because the push is outbound, Alloy attaches to the `direct` network (which has egress) rather than the `internal` `enclave`; there is no node-local log store and no `/loki` route to protect.
|
||||
|
||||
## Secrets boundary {#secrets}
|
||||
|
||||
- Secrets live only in per-project `.env` files, which are **symlinks into tier-1 storage** — outside the repository working tree and covered by `.gitignore` (`**/.env`).
|
||||
- Each node repository documents its expected keys in `SECRETS.md`; the file documents names, never values.
|
||||
- Committed configuration (`apex.env`) is restricted to non-secret scalars.
|
||||
- The shared composition marks critical secrets with `?error`, so a missing value fails fast at compose-config time.
|
||||
- [Capture-up](/apex/glossary#capture-up) commits are safe by construction: the ignored `.env` links and tier data never enter the git index.
|
||||
- Secrets live only in per-project `.env` files, which are **symlinks into tier-1 storage** — outside the repository working tree and covered by `.gitignore` (`**/.env`).
|
||||
- Each node repository documents its expected keys in `SECRETS.md`; the file documents names, never values.
|
||||
- Committed configuration (`apex.env`) is restricted to non-secret scalars.
|
||||
- The shared composition marks critical secrets with `?error`, so a missing value fails fast at compose-config time.
|
||||
- [Capture-up](/apex/glossary#capture-up) commits are safe by construction: the ignored `.env` links and tier data never enter the git index.
|
||||
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Concept: Tiered storage and privilege isolation](/apex/concepts/tiered-storage)
|
||||
- [Concept: Compositions and the shared core](/apex/concepts/compositions)
|
||||
- [Concept: Tiered storage and privilege isolation](/apex/concepts/tiered-storage)
|
||||
- [Concept: Compositions and the shared core](/apex/concepts/compositions)
|
||||
- [Reference: Actions](/apex/reference/actions)
|
||||
@@ -1,25 +1,25 @@
|
||||
---
|
||||
title: Tiered storage and privilege isolation
|
||||
description: The @tier link structure, shared storage, and per-project noroot users.
|
||||
title: Tiered storage and privilege isolation
|
||||
description: The @tier link structure, shared storage, and per-project noroot users.
|
||||
weight: 16
|
||||
---
|
||||
|
||||
Service data never lives inside a [node](/apex/glossary#node) repository. It lives in three storage [tiers](/apex/glossary#tier) — separate locations chosen per host — and reaches [compositions](/apex/glossary#compositions) through a standardized symlink structure. Ownership of that data is delegated to per-project system users, so no service container writes as root.
|
||||
Service data never lives inside a [node](/apex/glossary#node) repository. It lives in three storage [tiers](/apex/glossary#tier) — separate locations chosen per host — and reaches [compositions](/apex/glossary#compositions) through a standardized symlink structure. Ownership of that data is delegated to per-project system users, so no service container writes as root.
|
||||
|
||||
## The tier link structure {#links}
|
||||
## The tier link structure {#links}
|
||||
|
||||
`apex tiers/link <tier1> <tier2> <tier3>` builds two layers of links:
|
||||
`apex tiers/link <tier1> <tier2> <tier3>` builds two layers of links:
|
||||
|
||||
- **Node-level:** `compositions/@tierN` → the tier storage root. These are what the engine exposes as `APEX_TIER_ROOT1..3`.
|
||||
- **Per-project:** for every `compositions/<project>/`, the action creates `<tier-root>/<project>/` and links `compositions/<project>/@tierN` → `<tier-root>/<project>`. Inside each tier it also maintains `shared/` with a per-project subdirectory, and a relative `shared` link inside every project's tier directory pointing back at it.
|
||||
- **Node-level:** `compositions/@tierN` → the tier storage root. These are what the engine exposes as `APEX_TIER_ROOT1..3`.
|
||||
- **Per-project:** for every `compositions/<project>/`, the action creates `<tier-root>/<project>/` and links `compositions/<project>/@tierN` → `<tier-root>/<project>`. Inside each tier it also maintains `shared/` with a per-project subdirectory, and a relative `shared` link inside every project's tier directory pointing back at it.
|
||||
|
||||
Each project additionally gets `compositions/<project>/.env` → `<tier1-root>/<project>/.env` (created empty if missing) — the uncommitted secrets file described in [Concept: Compositions](/apex/concepts/compositions). The `@tierN` links and `.env` files are ignored by git; the repository stays pure source.
|
||||
Each project additionally gets `compositions/<project>/.env` → `<tier1-root>/<project>/.env` (created empty if missing) — the uncommitted secrets file described in [Concept: Compositions](/apex/concepts/compositions). The `@tierN` links and `.env` files are ignored by git; the repository stays pure source.
|
||||
|
||||
If a path that should be a symlink exists as a real file or directory, the action warns and asks for confirmation before removing it; declining (including non-interactive EOF) skips that link and continues.
|
||||
If a path that should be a symlink exists as a real file or directory, the action warns and asks for confirmation before removing it; declining (including non-interactive EOF) skips that link and continues.
|
||||
|
||||
## Users and groups {#users}
|
||||
## Users and groups {#users}
|
||||
|
||||
`apex tiers/useradd` provisions one locked system user per project — `noroot-<project>` (name sanitized, its own primary group) — plus the fleet-wide `noroot-shared` group every project user joins. It then injects the resolved IDs into the project's `.env` between marker comments, replacing any previous block on re-runs:
|
||||
`apex tiers/useradd` provisions one locked system user per project — `noroot-<project>` (name sanitized, its own primary group) — plus the fleet-wide `noroot-shared` group every project user joins. It then injects the resolved IDs into the project's `.env` between marker comments, replacing any previous block on re-runs:
|
||||
|
||||
```ini {filename="compositions/<project>/.env"}
|
||||
# START AUTO GENERATED APEX-NOROOT-USERS
|
||||
@@ -29,42 +29,42 @@ APEX_SHARED_GID=997
|
||||
# END AUTO GENERATED APEX-NOROOT-USERS
|
||||
```
|
||||
|
||||
Compositions consume these as `user: "${APEX_UID?error}:${APEX_GID?error}"` and `group_add: ["${APEX_SHARED_GID?error}"]`.
|
||||
Compositions consume these as `user: "${APEX_UID?error}:${APEX_GID?error}"` and `group_add: ["${APEX_SHARED_GID?error}"]`.
|
||||
|
||||
## The ownership matrix {#ownership}
|
||||
## The ownership matrix {#ownership}
|
||||
|
||||
`apex tiers/chown` enforces ownership across all three tiers. Without arguments it operates on the tier roots (`APEX_TIER_ROOT1..3`); explicit tier paths can override.
|
||||
`apex tiers/chown` enforces ownership across all three tiers. Without arguments it operates on the tier roots (`APEX_TIER_ROOT1..3`); explicit tier paths can override.
|
||||
|
||||
| Path | Ownership | Notes |
|
||||
|---|---|---|
|
||||
| `<tier-root>` | `root:root` | Directory inode only. |
|
||||
| `<tier-root>/shared` | `root:root` | Directory inode only — never recursive, so existing project data inside is untouched. |
|
||||
| `<tier-root>/shared` | `root:root` | Directory inode only — never recursive, so existing project data inside is untouched. |
|
||||
| `<tier-root>/node-infra` | `root:root` | Recursive. |
|
||||
| `<tier-root>/<project>` | `uid:gid` | Recursive; IDs read from the project `.env`. |
|
||||
| `<tier-root>/shared/<project>` | `uid:shared_gid` | Recursive, plus the sticky bit — collaborators can add files but only delete their own. |
|
||||
| `<tier-root>/<project>` | `uid:gid` | Recursive; IDs read from the project `.env`. |
|
||||
| `<tier-root>/shared/<project>` | `uid:shared_gid` | Recursive, plus the sticky bit — collaborators can add files but only delete their own. |
|
||||
|
||||
Projects with an incomplete `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` set are skipped with a warning pointing at `tiers/useradd`.
|
||||
Projects with an incomplete `APEX_UID`/`APEX_GID`/`APEX_SHARED_GID` set are skipped with a warning pointing at `tiers/useradd`.
|
||||
|
||||
## Engine tier variables {#variables}
|
||||
|
||||
| Variable | Resolves to |
|
||||
| Variable | Resolves to |
|
||||
|---|---|
|
||||
| `APEX_TIER_ROOT1..3` | The tier storage roots (`compositions/@tierN`, fully resolved). |
|
||||
| `APEX_TIER1..3` | The core project's data directories inside each tier (`compositions/apex/@tierN`). |
|
||||
| `APEX_TIER1..3_SHARED` | The shared area inside the core project's tiers. |
|
||||
| `APEX_TIER_ROOT1..3` | The tier storage roots (`compositions/@tierN`, fully resolved). |
|
||||
| `APEX_TIER1..3` | The core project's data directories inside each tier (`compositions/apex/@tierN`). |
|
||||
| `APEX_TIER1..3_SHARED` | The shared area inside the core project's tiers. |
|
||||
|
||||
The core composition mounts through these exclusively; the backup runner instead mounts the fleet data root `/srv` read-only and resolves each labeled service's container mounts back to their host paths.
|
||||
The core composition mounts through these exclusively; the backup runner instead mounts the fleet data root `/srv` read-only and resolves each labeled service's container mounts back to their host paths.
|
||||
|
||||
## Backups {#backups}
|
||||
|
||||
`apex backup/run <telegram-bot-url>` drives the core `resticontainer` service (profile `manual`, one-shot) end to end. Backup intent is **label-driven**: resticontainer discovers every service carrying `restic.enable=true`, reads its `restic.backup.paths` (and optional `restic.backup.stop` / `restic.hooks.*`), resolves those container paths to host paths, and snapshots their union in a single restic run.
|
||||
`apex backup/run <telegram-bot-url>` drives the core `resticontainer` service (profile `manual`, one-shot) end to end. Backup intent is **label-driven**: resticontainer discovers every service carrying `restic.enable=true`, reads its `restic.backup.paths` (and optional `restic.backup.stop` / `restic.hooks.*`), resolves those container paths to host paths, and snapshots their union in a single restic run.
|
||||
|
||||
1. Checks repository reachability with `snapshots`; initializes the repository if needed.
|
||||
2. Runs `backup` with `--host <APEX_NODE_HOST>` and `--tag biweekly` — resticontainer runs each labeled service's pre-hook, stops any container flagged `restic.backup.stop`, snapshots the resolved host paths, then restarts and runs post-hooks. The run refuses to report success if no new snapshot lands (a guard against zero labels being discovered).
|
||||
3. Applies retention with `forget --prune` (7 daily, 4 weekly, 12 monthly).
|
||||
4. Sends a Telegram status either way; a success whose notification cannot be delivered still exits `1` so schedulers notice.
|
||||
1. Checks repository reachability with `snapshots`; initializes the repository if needed.
|
||||
2. Runs `backup` with `--host <APEX_NODE_HOST>` and `--tag biweekly` — resticontainer runs each labeled service's pre-hook, stops any container flagged `restic.backup.stop`, snapshots the resolved host paths, then restarts and runs post-hooks. The run refuses to report success if no new snapshot lands (a guard against zero labels being discovered).
|
||||
3. Applies retention with `forget --prune` (7 daily, 4 weekly, 12 monthly).
|
||||
4. Sends a Telegram status either way; a success whose notification cannot be delivered still exits `1` so schedulers notice.
|
||||
|
||||
The repository location and credentials come entirely from `APEX_RESTIC_*` configuration (see [Reference: Environment variables](/apex/reference/environment)). Which data is captured is decided per service by its `restic.*` labels — for example the core services back up only their configuration (traefik dynamic rules, xray/adguard config), leaving reproducible tier-3 data such as caches and logs out of the set.
|
||||
The repository location and credentials come entirely from `APEX_RESTIC_*` configuration (see [Reference: Environment variables](/apex/reference/environment)). Which data is captured is decided per service by its `restic.*` labels — for example the core services back up only their configuration (traefik dynamic rules, xray/adguard config), leaving reproducible tier-3 data such as caches and logs out of the set.
|
||||
|
||||
---
|
||||
|
||||
@@ -72,4 +72,4 @@ The repository location and credentials come entirely from `APEX_RESTIC_*` confi
|
||||
|
||||
- [Concept: Security model](/apex/concepts/security-model)
|
||||
- [Reference: Actions](/apex/reference/actions)
|
||||
- [Guide: Add a service composition](/apex/guides/service-composition)
|
||||
- [Guide: Add a service composition](/apex/guides/service-composition)
|
||||
Loaded 100 of 194 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user