diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 000000000..1b3c6e201
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,7 @@
+# Default ownership
+* @morpho-org/platform-engineers @0x666c6f
+
+# Helm charts migrated from morpho-infra-helm (2026-06-17)
+/helm/ @morpho-org/platform-engineers @morpho-org/api-engineers @0x666c6f
+/validate-helm-charts.sh @morpho-org/platform-engineers @0x666c6f
+/setup-hooks.sh @morpho-org/platform-engineers @0x666c6f
diff --git a/.github/workflows/helm-charts-validation.yaml b/.github/workflows/helm-charts-validation.yaml
new file mode 100644
index 000000000..49a93b59e
--- /dev/null
+++ b/.github/workflows/helm-charts-validation.yaml
@@ -0,0 +1,154 @@
+name: Helm Chart Validation
+
+on:
+ push:
+ branches: [morpho-main]
+ paths:
+ - 'helm/**'
+ pull_request:
+ branches: [morpho-main]
+ paths:
+ - 'helm/**'
+
+jobs:
+ # =============================================================================
+ # Job 1: Discover all charts to validate
+ # =============================================================================
+ discover-charts:
+ name: Discover Charts
+ runs-on: ubuntu-latest
+ outputs:
+ charts: ${{ steps.find.outputs.charts }}
+ chart-count: ${{ steps.find.outputs.count }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ persist-credentials: false
+
+ - name: Find all charts
+ id: find
+ run: |
+ # Find all Chart.yaml files and get their directories
+ charts=$(find helm/charts helm/environments -name Chart.yaml -type f -exec dirname {} \; | sort | jq -R -s -c 'split("\n") | map(select(length > 0))')
+ count=$(echo "$charts" | jq 'length')
+ echo "Found $count charts to validate"
+ echo "charts=$charts" >> $GITHUB_OUTPUT
+ echo "count=$count" >> $GITHUB_OUTPUT
+
+ # =============================================================================
+ # Job 2: Validate each chart in parallel
+ # =============================================================================
+ validate:
+ name: Validate
+ needs: discover-charts
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false # Continue validating other charts if one fails
+ matrix:
+ chart: ${{ fromJson(needs.discover-charts.outputs.charts) }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ persist-credentials: false
+
+ - name: Set up Helm
+ uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4
+ with:
+ version: v3.16.4
+
+ - name: Install kubeconform
+ run: |
+ KUBECONFORM_VERSION=v0.6.1
+ KUBECONFORM_SHA256=7f84c1e4109fb72f151da41bdffea05e1e37591f179de51adc048c83533ad215
+ curl -fsSLo /tmp/kubeconform.tar.gz "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz"
+ echo "${KUBECONFORM_SHA256} /tmp/kubeconform.tar.gz" | sha256sum -c -
+ tar xzf /tmp/kubeconform.tar.gz
+ sudo mv kubeconform /usr/local/bin/
+
+ - name: Add Helm repositories
+ run: |
+ helm repo add bitnami https://charts.bitnami.com/bitnami 2>/dev/null || true
+ helm repo add cnpg https://cloudnative-pg.github.io/charts 2>/dev/null || true
+ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts 2>/dev/null || true
+ helm repo add haproxytech https://haproxytech.github.io/helm-charts 2>/dev/null || true
+ helm repo add dandydeveloper https://dandydeveloper.github.io/charts 2>/dev/null || true
+ helm repo update
+
+ - name: Get chart info
+ id: chart-info
+ run: |
+ chart_path="${{ matrix.chart }}"
+ chart_name=$(basename "$chart_path")
+ echo "name=$chart_name" >> $GITHUB_OUTPUT
+ echo "path=$chart_path" >> $GITHUB_OUTPUT
+
+ # Check if chart should skip kubeconform validation
+ skip_validation="false"
+ case "$chart_name" in
+ *-db|*database*|*postgres*)
+ skip_validation="true"
+ ;;
+ esac
+ echo "skip-kubeconform=$skip_validation" >> $GITHUB_OUTPUT
+
+ - name: Helm lint
+ run: |
+ echo "::group::Helm lint ${{ steps.chart-info.outputs.name }}"
+ helm lint "${{ matrix.chart }}"
+ echo "::endgroup::"
+
+ - name: Update dependencies
+ run: |
+ cd "${{ matrix.chart }}"
+ if grep -q "dependencies:" Chart.yaml 2>/dev/null; then
+ echo "::group::Updating dependencies for ${{ steps.chart-info.outputs.name }}"
+ helm dependency update
+ echo "::endgroup::"
+ fi
+
+ - name: Kubeconform validation
+ if: steps.chart-info.outputs.skip-kubeconform != 'true'
+ run: |
+ CUSTOM_RESOURCES="PodMonitor,ServiceMonitor,AlertManager,Prometheus,PrometheusRule,Cluster,ScheduledBackup,ImageCatalog,IngressRoute,Certificate,CronJob,EventSource,EventBus,Sensor"
+
+ echo "::group::Kubeconform validation for ${{ steps.chart-info.outputs.name }}"
+ cd "${{ matrix.chart }}"
+ helm template . | kubeconform --ignore-missing-schemas --summary --skip "$CUSTOM_RESOURCES"
+ echo "::endgroup::"
+
+ - name: Skip notice
+ if: steps.chart-info.outputs.skip-kubeconform == 'true'
+ run: |
+ echo "::notice::Skipping kubeconform validation for ${{ steps.chart-info.outputs.name }} (database chart)"
+
+ # =============================================================================
+ # Job 3: Summary (ensures all validations completed)
+ # =============================================================================
+ validation-summary:
+ name: Validation Summary
+ needs: [discover-charts, validate]
+ runs-on: ubuntu-latest
+ if: always()
+ steps:
+ - name: Check validation results
+ run: |
+ if [ "${{ needs.discover-charts.result }}" == "failure" ]; then
+ echo "::error::Chart discovery failed"
+ exit 1
+ elif [ "${{ needs.discover-charts.result }}" == "cancelled" ]; then
+ echo "::warning::Chart discovery was cancelled"
+ exit 1
+ elif [ "${{ needs.validate.result }}" == "failure" ]; then
+ echo "::error::Some chart validations failed"
+ exit 1
+ elif [ "${{ needs.validate.result }}" == "cancelled" ]; then
+ echo "::warning::Validation was cancelled"
+ exit 1
+ elif [ "${{ needs.validate.result }}" == "skipped" ]; then
+ echo "::warning::Validation was skipped unexpectedly"
+ exit 1
+ else
+ echo "::notice::All ${{ needs.discover-charts.outputs.chart-count }} charts validated successfully"
+ fi
diff --git a/.github/workflows/only-values-yaml-guard.yml b/.github/workflows/only-values-yaml-guard.yml
new file mode 100644
index 000000000..2251b5a45
--- /dev/null
+++ b/.github/workflows/only-values-yaml-guard.yml
@@ -0,0 +1,63 @@
+# Required status check that scopes the morpho-infra-deployer bot's review
+# bypass to values.yaml-only changes: it is skipped unless the deployer bot
+# authored the PR or triggered the run, and fails only when such a PR touches
+# anything other than values.yaml.
+name: only-values-yaml-guard
+
+on:
+ pull_request:
+ branches: [morpho-main]
+ merge_group:
+
+permissions:
+ contents: read
+ pull-requests: read
+
+jobs:
+ guard:
+ name: values-yaml-guard
+ runs-on: ubuntu-latest
+ # Skipped for non-bot PRs: a skipped job SATISFIES required status checks,
+ # so human PRs merge normally without spinning up a runner. Covers the bot
+ # both as PR author and as pusher on someone else's PR, matching on the
+ # immutable user id (not the login) so an app rename cannot silently turn
+ # the guard off while the bot keeps its review-bypass.
+ if: github.event.pull_request.user.id == 187494245 || github.actor_id == 187494245 # morpho-infra-deployer[bot]
+ steps:
+ - name: Enforce values.yaml-only changes for the deployer bot
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ MERGE_GROUP_HEAD_REF: ${{ github.event.merge_group.head_ref }}
+ REPO: ${{ github.repository }}
+ run: |
+ # merge_group events carry no pull_request.number; extract it from
+ # the head ref (refs/heads/gh-readonly-queue//pr--).
+ if [[ -z "$PR_NUMBER" && -n "$MERGE_GROUP_HEAD_REF" ]]; then
+ PR_NUMBER=$(echo "$MERGE_GROUP_HEAD_REF" | grep -oE '/pr-[0-9]+' | grep -oE '[0-9]+')
+ fi
+
+ # Include previous_filename so a rename cannot smuggle a non-values
+ # path out of sight (the files API only reports the new path).
+ if ! mapfile -t CHANGED < <(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files" --jq '.[] | .filename, (.previous_filename // empty)'); then
+ echo "❌ Failed to fetch changed files from the GitHub API."
+ exit 1
+ fi
+ echo "Files changed by the deployer bot:"
+ printf ' %s\n' "${CHANGED[@]}"
+
+ OFFENDING=()
+ for file in "${CHANGED[@]}"; do
+ if [[ "$file" != "values.yaml" && "$file" != *"/values.yaml" ]]; then
+ OFFENDING+=("$file")
+ fi
+ done
+
+ if (( ${#OFFENDING[@]} > 0 )); then
+ echo "❌ Deployer bot PR modifies files other than values.yaml:"
+ printf ' %s\n' "${OFFENDING[@]}"
+ echo "The bot may only bypass review for values.yaml-only changes."
+ exit 1
+ fi
+
+ echo "✅ Deployer bot PR only modifies values.yaml files."
diff --git a/.github/workflows/slack-pr-notification.yaml b/.github/workflows/slack-pr-notification.yaml
new file mode 100644
index 000000000..5a439d0fe
--- /dev/null
+++ b/.github/workflows/slack-pr-notification.yaml
@@ -0,0 +1,49 @@
+name: Slack PR Notification
+
+on:
+ pull_request:
+ types: [closed]
+ branches: [morpho-main]
+
+jobs:
+ notify-slack:
+ if: github.event.pull_request.merged == true && github.event.pull_request.user.login != 'github-actions[bot]' && github.event.pull_request.user.login != 'morpho-infra-deployer[bot]'
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Send Slack notification
+ uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
+ with:
+ webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
+ webhook-type: incoming-webhook
+ payload: |
+ {
+ "text": ":rocket: PR merged to Production",
+ "attachments": [
+ {
+ "color": "#ff6b6b",
+ "fallback": "PR #${{ github.event.pull_request.number }} merged: ${{ github.event.pull_request.title }}",
+ "title": ":white_check_mark: PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}",
+ "title_link": "${{ github.event.pull_request.html_url }}",
+ "fields": [
+ {
+ "title": ":busts_in_silhouette: Author",
+ "value": "${{ github.event.pull_request.user.login }}",
+ "short": true
+ },
+ {
+ "title": ":earth_americas: Environment",
+ "value": ":red_circle: Production (morpho-main)",
+ "short": true
+ },
+ {
+ "title": ":arrow_right: Branch",
+ "value": "`${{ github.event.pull_request.head.ref }}` → `${{ github.event.pull_request.base.ref }}`",
+ "short": false
+ }
+ ],
+ "footer": ":octopus: ${{ github.repository }}",
+ "footer_icon": "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
+ }
+ ]
+ }
diff --git a/.github/workflows/update-image-tag.yaml b/.github/workflows/update-image-tag.yaml
new file mode 100644
index 000000000..9b4a0fdc2
--- /dev/null
+++ b/.github/workflows/update-image-tag.yaml
@@ -0,0 +1,291 @@
+name: Update chart image tag
+
+permissions: {}
+
+on:
+ workflow_dispatch:
+ inputs:
+ version_tag:
+ description: 'Release version to deploy (e.g., 0.1.3)'
+ required: true
+ type: string
+ release_url:
+ description: 'Release URL'
+ required: false
+ type: string
+
+jobs:
+ update-image-tag:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ checks: read
+ statuses: read
+ actions: read
+ env:
+ INPUT_VERSION_TAG: ${{ inputs.version_tag }}
+ INPUT_RELEASE_URL: ${{ inputs.release_url }}
+ ANCHOR_NAMES: 'erpcImageTag erpcValidatorImageTag'
+ steps:
+ - name: Validate version
+ run: |
+ if [[ ! "$INPUT_VERSION_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "❌ Invalid version '$INPUT_VERSION_TAG'. Expected semver like 0.1.3"
+ exit 1
+ fi
+
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ ref: morpho-main
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Determine auto-merge
+ id: automerge
+ run: |
+ # erpc only deploys to prd; auto-merge is opt-in via vars.ERPC_PRD_AUTOMERGE.
+ if [[ "${{ vars.ERPC_PRD_AUTOMERGE }}" == "true" ]]; then
+ echo "enabled=true" >> $GITHUB_OUTPUT
+ echo "✅ Auto-merge enabled"
+ else
+ echo "enabled=false" >> $GITHUB_OUTPUT
+ echo "⏸️ Auto-merge disabled — PR will require manual approval"
+ fi
+
+ - name: Generate GitHub App Token
+ id: generate-token
+ uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
+ with:
+ app-id: ${{ secrets.INFRA_DEPLOYER_APP_ID }}
+ private-key: ${{ secrets.INFRA_DEPLOYER_SECRET_KEY }}
+
+ - name: Setup Git remote
+ run: |
+ git remote set-url origin https://x-access-token:${{ steps.generate-token.outputs.token }}@github.com/${{ github.repository }}.git
+
+ - name: Create feature branch
+ id: create-branch
+ run: |
+ PR_BRANCH="update-image-tag/erpc/prd"
+
+ if git ls-remote --heads origin "$PR_BRANCH" | grep -q "$PR_BRANCH"; then
+ echo "Branch $PR_BRANCH exists remotely, deleting it"
+ git push origin --delete "$PR_BRANCH" || true
+ fi
+
+ git checkout -b "$PR_BRANCH"
+ git push -f origin "$PR_BRANCH"
+ echo "pr_branch=$PR_BRANCH" >> $GITHUB_OUTPUT
+
+ - name: Update image tags across all values.yaml
+ id: update
+ run: |
+ NEW_VERSION="$INPUT_VERSION_TAG"
+
+ UPDATED=0
+ FILES_UPDATED=""
+
+ while IFS= read -r FILE; do
+ for ANCHOR in $ANCHOR_NAMES; do
+ if grep -qE "tag: &${ANCHOR} " "$FILE"; then
+ # Update the anchor value: 'tag: & ""' — the
+ # aliased (*) occurrences follow automatically.
+ sed -i.bak -E "s|(tag: &${ANCHOR} )\"[^\"]+\"|\1\"${NEW_VERSION}\"|" "$FILE"
+ rm -f "${FILE}.bak"
+ fi
+ done
+
+ if ! git diff --quiet "$FILE"; then
+ echo "✓ Updated: $FILE"
+ git --no-pager diff "$FILE" | head -10
+ UPDATED=$((UPDATED + 1))
+ FILES_UPDATED="${FILES_UPDATED}- \`${FILE}\`\n"
+ fi
+ done < <(find helm/environments/prd -name 'values.yaml' -type f | sort -u)
+
+ echo "Total files updated: $UPDATED"
+ echo "count=$UPDATED" >> $GITHUB_OUTPUT
+ {
+ echo "files_list<> $GITHUB_OUTPUT
+
+ - name: Create signed commit via API
+ id: commit
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ PR_BRANCH: ${{ steps.create-branch.outputs.pr_branch }}
+ with:
+ github-token: ${{ steps.generate-token.outputs.token }}
+ script: |
+ const { execSync } = require("node:child_process");
+ const fs = require("node:fs");
+
+ const changed = execSync("git diff --name-only -- helm/environments", { encoding: "utf8" })
+ .split("\n")
+ .filter(Boolean);
+
+ if (changed.length === 0) {
+ core.setOutput("has_changes", "false");
+ core.info("No changes to commit — image tags already up to date");
+ return;
+ }
+
+ const version = process.env.INPUT_VERSION_TAG;
+ const url = process.env.INPUT_RELEASE_URL || "n/a";
+
+ const additions = changed.map((path) => ({
+ path,
+ contents: fs.readFileSync(path).toString("base64"),
+ }));
+
+ const expectedHeadOid = execSync("git rev-parse HEAD", { encoding: "utf8" }).trim();
+
+ const { createCommitOnBranch } = await github.graphql(
+ `mutation ($input: CreateCommitOnBranchInput!) {
+ createCommitOnBranch(input: $input) {
+ commit { oid }
+ }
+ }`,
+ {
+ input: {
+ branch: {
+ repositoryNameWithOwner: `${context.repo.owner}/${context.repo.repo}`,
+ branchName: process.env.PR_BRANCH,
+ },
+ message: {
+ headline: `[erpc] 🤖 Update image tags to ${version} in production`,
+ body: `Updates the erpc chart deployments to use the latest image.\n\nVersion: ${version}\nRelease URL: ${url}`,
+ },
+ fileChanges: { additions },
+ expectedHeadOid,
+ },
+ },
+ );
+
+ core.info(`Created signed commit ${createCommitOnBranch.commit.oid}`);
+ core.setOutput("has_changes", "true");
+ core.setOutput("commit_oid", createCommitOnBranch.commit.oid);
+
+ - name: Create or update PR
+ id: create-or-update-pr
+ if: steps.commit.outputs.has_changes == 'true'
+ env:
+ FILES_LIST: ${{ steps.update.outputs.files_list }}
+ GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
+ run: |
+ PR_BRANCH="${{ steps.create-branch.outputs.pr_branch }}"
+
+ if [[ "${{ steps.automerge.outputs.enabled }}" == "true" ]]; then
+ AUTO_MERGE_STATUS="✅ **Auto-merged** - This PR is automatically merged once its required checks pass."
+ else
+ AUTO_MERGE_STATUS="⏸️ **Manual approval required** - This PR requires manual review and approval before merging."
+ fi
+
+ PR_TITLE="chore(helm): update erpc image tags in production"
+ PR_BODY="## 🤖 Automated Image Update
+
+ This PR updates all erpc chart deployments in **production** to the latest image.
+
+ ### Files Updated (${{ steps.update.outputs.count }})
+ ${FILES_LIST}
+
+ ### Source Information
+ - Version: ${INPUT_VERSION_TAG}
+ - Release URL: ${INPUT_RELEASE_URL}
+ - Anchors: \`${ANCHOR_NAMES}\`
+
+ ### Merge Status
+ ${AUTO_MERGE_STATUS}"
+
+ # The head branch was deleted and recreated earlier in this run, which
+ # auto-closes any previous PR. gh pr list relies on the eventually
+ # consistent search index and can still return that just-closed PR,
+ # so confirm the state with a strongly consistent direct lookup
+ # before editing instead of creating.
+ PR_NUMBER=$(gh pr list --head "$PR_BRANCH" --json number --jq '.[0].number // empty')
+ if [[ -n "$PR_NUMBER" ]] && [[ "$(gh pr view "$PR_NUMBER" --json state --jq '.state')" == "OPEN" ]]; then
+ echo "PR #$PR_NUMBER already exists for branch $PR_BRANCH, updating it"
+ gh pr edit "$PR_NUMBER" --base "morpho-main" --title "$PR_TITLE" --body "$PR_BODY"
+ echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
+ else
+ PR_CREATE_ARGS=(
+ --title "$PR_TITLE"
+ --body "$PR_BODY"
+ --base "morpho-main"
+ --head "$PR_BRANCH"
+ )
+
+ if [[ "${{ steps.automerge.outputs.enabled }}" != "true" ]]; then
+ PR_CREATE_ARGS+=(--draft)
+ fi
+
+ PR_URL=$(gh pr create "${PR_CREATE_ARGS[@]}")
+
+ PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
+ echo "Created new PR #${PR_NUMBER} for branch $PR_BRANCH"
+ echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Merge PR with admin bypass
+ if: steps.commit.outputs.has_changes == 'true' && steps.automerge.outputs.enabled == 'true'
+ run: |
+ PR_NUMBER="${{ steps.create-or-update-pr.outputs.pr_number }}"
+
+ if [[ -z "$PR_NUMBER" ]]; then
+ echo "❌ Could not find PR number to merge"
+ exit 1
+ fi
+
+ # The bot cannot go through a merge queue: the queue merges as
+ # GitHub, without the bot's ruleset bypass, so it would wait forever
+ # for a review that is not supposed to happen. Instead the bot
+ # merges directly — it bypasses the review rules but NOT the
+ # values-yaml-guard required check, so wait for it before merging.
+ # Check reads use the workflow token (GH_TOKEN wins over the
+ # step-level GITHUB_TOKEN env): the deployer app has no Checks:read
+ # permission, only the merge below needs the app identity.
+ # Poll until a required check run is registered before watching;
+ # gh pr checks --watch exits immediately with "no checks reported"
+ # when called right after gh pr create (github.com/cli/cli/issues/7401).
+ # stderr is left visible on purpose: discarding it previously
+ # disguised a token permission error as missing checks.
+ echo "Waiting for required checks to appear on PR #${PR_NUMBER}"
+ for attempt in $(seq 1 12); do
+ GH_TOKEN=${{ github.token }} gh pr checks "$PR_NUMBER" --required | grep -q . && break
+ echo "No required checks registered yet (attempt ${attempt}/12); waiting 5s..."
+ sleep 5
+ done
+
+ echo "Waiting for required checks to pass on PR #${PR_NUMBER}"
+ timeout 600 env GH_TOKEN=${{ github.token }} gh pr checks "$PR_NUMBER" --watch --required
+
+ # A direct merge is exposed to the race where the base branch moves
+ # between check and merge — retry. --match-head-commit guards
+ # against a concurrent run pushing a new unchecked commit to the
+ # same branch after --watch returns.
+ COMMIT_OID="${{ steps.commit.outputs.commit_oid }}"
+ for attempt in 1 2 3; do
+ merge_output="$(gh pr merge "$PR_NUMBER" --merge --admin --match-head-commit "$COMMIT_OID" 2>&1)" && {
+ echo "$merge_output"
+ echo "✅ PR #${PR_NUMBER} merged"
+ exit 0
+ }
+
+ echo "$merge_output" >&2
+
+ if [[ "$merge_output" != *"Base branch was modified"* ]]; then
+ echo "❌ Merge failed on attempt ${attempt}" >&2
+ exit 1
+ fi
+
+ echo "Base branch moved under PR #${PR_NUMBER}; retrying (attempt ${attempt}/3)"
+ sleep 10
+ done
+
+ echo "❌ Could not merge PR #${PR_NUMBER} after retries" >&2
+ exit 1
+ env:
+ GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }}
diff --git a/.github/workflows/wiz-iac-scan.yml b/.github/workflows/wiz-iac-scan.yml
new file mode 100644
index 000000000..6e2df7148
--- /dev/null
+++ b/.github/workflows/wiz-iac-scan.yml
@@ -0,0 +1,37 @@
+name: Wiz IaC Scan
+
+on:
+ pull_request:
+ paths:
+ - 'helm/**'
+
+permissions: {}
+
+jobs:
+ wiz-iac-scan:
+ name: Wiz IaC Scan
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: arc-morpho-infra-dev
+ permissions:
+ contents: read
+ env:
+ WIZCLI_VERSION: "1.45.0"
+ WIZCLI_SHA256: "3f25af9edd63dbd7ecd058b71912f8415467be3f0fcb2b28c6914ebc454d368e"
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ persist-credentials: false
+
+ - name: Download Wiz CLI
+ run: |
+ curl -fsSLo wizcli "https://downloads.wiz.io/v1/wizcli/${WIZCLI_VERSION}/wizcli-linux-amd64"
+ echo "${WIZCLI_SHA256} wizcli" | sha256sum -c -
+ chmod +x wizcli
+
+ - name: Run Wiz CLI IaC scan
+ env:
+ WIZ_CLIENT_ID: ${{ secrets.WIZ_CLIENT_ID }}
+ WIZ_CLIENT_SECRET: ${{ secrets.WIZ_CLIENT_SECRET }}
+ run: ./wizcli scan dir helm/
diff --git a/.gitignore b/.gitignore
index 26eb389af..6efcb8bae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,9 +11,6 @@ vendor/
*.cover
coverage.txt
.DS_Store
-erpc.yaml
-erpc-*.yaml
-erpc.ts
dist/
sqd-simplify/
.generated-go-semantic-release-changelog.md
diff --git a/helm/charts/.gitkeep b/helm/charts/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/helm/charts/erpc/.env.example b/helm/charts/erpc/.env.example
new file mode 100644
index 000000000..c6389d481
--- /dev/null
+++ b/helm/charts/erpc/.env.example
@@ -0,0 +1,23 @@
+# eRPC Environment Configuration
+# Copy this file to .env and fill in your actual API keys
+
+# Provider API Keys
+ALCHEMY_API_KEY=your-alchemy-api-key-here
+DRPC_API_KEY=your-drpc-api-key-here
+TENDERLY_API_KEY=your-tenderly-api-key-here
+TENDERLY_PROJECT=your-tenderly-project-slug
+ERPC_API_KEY=your-erpc-api-key-here
+
+# Authentication
+LABELS_SERVICE_API_KEY=labels-service-api-key-here
+SQD_INDEXERS_API_KEY=sqd-indexers-api-key-here
+BLUE_API_API_KEY=blue-api-key-here
+
+# Optional Configuration
+LOG_LEVEL=info
+
+# Instructions:
+# 1. Copy this file: cp .env.example .env
+# 2. Edit .env with your actual API keys
+# 3. Run: docker-compose up -d
+# 4. Test: ./test-local.sh
\ No newline at end of file
diff --git a/helm/charts/erpc/Chart.yaml b/helm/charts/erpc/Chart.yaml
new file mode 100644
index 000000000..ecc04b052
--- /dev/null
+++ b/helm/charts/erpc/Chart.yaml
@@ -0,0 +1,24 @@
+apiVersion: v2
+name: erpc
+description: eRPC fault-tolerant EVM RPC proxy
+
+# A chart can be either an 'application' or a 'library' chart.
+#
+# Application charts are a collection of templates that can be packaged into versioned archives
+# to be deployed.
+#
+# Library charts provide useful utilities or functions for the chart developer. They're included as
+# a dependency of application charts to inject those utilities and functions into the rendering
+# pipeline. Library charts do not define any templates and therefore cannot be deployed.
+type: application
+
+# This is the chart version. This version number should be incremented each time you make changes
+# to the chart and its templates, including the app version.
+# Versions are expected to follow Semantic Versioning (https://semver.org/)
+version: 0.1.35
+
+# This is the version number of the application being deployed. This version number should be
+# incremented each time you make changes to the application. Versions are not expected to
+# follow Semantic Versioning. They should reflect the version the application is using.
+# It is recommended to use it with quotes.
+appVersion: "latest"
diff --git a/helm/charts/erpc/README.md b/helm/charts/erpc/README.md
new file mode 100644
index 000000000..34f5dc5d6
--- /dev/null
+++ b/helm/charts/erpc/README.md
@@ -0,0 +1,60 @@
+# eRPC Helm Chart
+
+This Helm chart deploys eRPC as a fault-tolerant EVM RPC proxy service within the Morpho infrastructure.
+
+## Local Development with Docker Compose
+
+For local testing and development:
+
+```bash
+# Navigate to chart directory
+cd helm/charts/erpc/
+# Copy environment template and edit with your API keys
+cp .env.example .env
+# Edit .env with your actual API keys and AUTH_SECRET
+
+# Start services (Redis + eRPC)
+docker-compose up -d
+
+# Run tests
+./test-local.sh
+
+# Stop services
+docker-compose down
+```
+
+
+## Usage
+
+When authentication is enabled, requests must include the secret as a query parameter:
+
+```bash
+curl --location 'http://localhost:4000/cache/evm/1?secret=YOUR-AUTH-SECRET' \
+ --header 'Content-Type: application/json' \
+ --data '{"method":"eth_blockNumber","params":[],"id":1,"jsonrpc":"2.0"}'
+```
+
+## Vault Config
+
+The eRPC chart reads non-secret config from `vault.configPath` and secret values
+from `vault.secretsPath`. Keep `vault.configPath` as a config template. Static
+secret placeholders use `__SECRET___`; the API-key auth list can use the
+`__SECRET_API_KEY_STRATEGIES__` marker to generate one secret auth strategy for
+every `API_KEY_*=` entry in `vault.secretsPath`. Generated auth IDs are
+derived from the key name after `API_KEY_`, lowercased with `_` converted to `-`;
+trailing hash rotation suffixes like `_9E41B694` are ignored so rotating a value
+does not change the eRPC secret ID. Optional per-key metadata entries can tune
+generated strategies without creating new API keys: `API_KEY_FOO__ID` pins the
+eRPC secret ID, `API_KEY_FOO__ORDER` pins list order, `API_KEY_FOO__ALLOW_METHODS`
+is a comma-separated method allowlist, and `API_KEY_FOO__RATE_LIMIT_BUDGET` sets
+`rateLimitBudget`.
+
+Vault secret keys must match `[A-Za-z_][A-Za-z0-9_]*`. Secret values are parsed
+from a line-oriented `KEY=VALUE` file, so keep them single-line. Placeholders in
+quoted YAML scalars are YAML-escaped by the renderer; placeholders embedded in
+unquoted YAML scalars, such as URL path fragments, must have YAML-plain-safe
+values.
+
+The Vault config job renders placeholders into a temporary `erpc.yaml`, validates
+that rendered config with `erpc validate`, then stores only the rendered file in
+the runtime Kubernetes Secret.
diff --git a/helm/charts/erpc/docker-compose.yml b/helm/charts/erpc/docker-compose.yml
new file mode 100644
index 000000000..09decb3af
--- /dev/null
+++ b/helm/charts/erpc/docker-compose.yml
@@ -0,0 +1,14 @@
+services:
+ erpc:
+ image: ghcr.io/erpc/erpc:latest
+ ports:
+ - "4000:4000" # HTTP RPC port
+ - "4001:4001" # Metrics port
+ volumes:
+ - ./config/erpc.yaml:/erpc.yaml:ro
+ env_file:
+ - .env
+ environment:
+ # DynamoDB configuration uses AWS credentials from environment or IAM
+ AWS_REGION: "eu-west-3"
+ restart: unless-stopped
\ No newline at end of file
diff --git a/helm/charts/erpc/templates/_helpers.tpl b/helm/charts/erpc/templates/_helpers.tpl
new file mode 100644
index 000000000..2b72a303d
--- /dev/null
+++ b/helm/charts/erpc/templates/_helpers.tpl
@@ -0,0 +1,87 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "erpc.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
+If release name contains chart name it will be used as a full name.
+*/}}
+{{- define "erpc.fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default .Chart.Name .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
+{{/*
+Create chart name and version as used by the chart label.
+*/}}
+{{- define "erpc.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels
+*/}}
+{{- define "erpc.labels" -}}
+helm.sh/chart: {{ include "erpc.chart" . }}
+{{ include "erpc.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Selector labels
+*/}}
+{{- define "erpc.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "erpc.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Vault creator ServiceAccount name.
+*/}}
+{{- define "erpc.vaultCreatorServiceAccountName" -}}
+{{- $vaultCreator := .Values.serviceAccount.vaultCreator | default dict -}}
+{{- $vaultCreator.name | default .Values.serviceAccount.name | default "erpc" }}
+{{- end }}
+
+{{/*
+Runtime ServiceAccount name.
+*/}}
+{{- define "erpc.serviceAccountName" -}}
+{{- if .Values.serviceAccount.runtimeName -}}
+{{- .Values.serviceAccount.runtimeName -}}
+{{- else if eq (toString .Values.serviceAccount.create) "false" -}}
+{{- include "erpc.vaultCreatorServiceAccountName" . -}}
+{{- else -}}
+{{- printf "%s-runtime" (include "erpc.vaultCreatorServiceAccountName" .) -}}
+{{- end -}}
+{{- end }}
+
+{{/*
+Kubernetes Secrets managed by Vault creator jobs.
+*/}}
+{{- define "erpc.vaultSecretResourceNames" -}}
+- "erpc-db-secret"
+- {{ printf "%s-vault-config" (include "erpc.fullname" .) | quote }}
+{{- end }}
+
+{{/*
+Common environment variables for erpc
+*/}}
+{{- define "erpc.commonEnv" -}}
+# All env vars now come from vault-secrets via envFrom
+{{- end -}}
diff --git a/helm/charts/erpc/templates/deployment.yaml b/helm/charts/erpc/templates/deployment.yaml
new file mode 100644
index 000000000..0044065b3
--- /dev/null
+++ b/helm/charts/erpc/templates/deployment.yaml
@@ -0,0 +1,139 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "erpc.fullname" . }}
+ annotations:
+ reloader.stakater.com/auto: "true"
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+spec:
+ {{- if not .Values.autoscaling.enabled }}
+ replicas: {{ .Values.replicaCount }}
+ {{- end }}
+ strategy:
+ rollingUpdate:
+ # Let the new ReplicaSet surge relative to the current HPA target,
+ # not just minReplicas, so rollouts can warm a full replacement pool.
+ maxSurge: 100%
+ maxUnavailable: 0
+ type: RollingUpdate
+ selector:
+ matchLabels:
+ {{- include "erpc.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ labels:
+ {{- include "erpc.selectorLabels" . | nindent 8 }}
+ # Environment variables loaded from vault secrets via pre-install job
+ spec:
+ terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds | default 45 }}
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ serviceAccountName: {{ include "erpc.serviceAccountName" . }}
+ securityContext:
+ {{- if .Values.securityHardening.enabled }}
+ {{- toYaml .Values.securityHardening.podSecurityContext | nindent 8 }}
+ {{- else }}
+ {{- toYaml .Values.podSecurityContext | nindent 8 }}
+ {{- end }}
+ topologySpreadConstraints:
+ - maxSkew: 1
+ topologyKey: kubernetes.io/hostname
+ whenUnsatisfiable: ScheduleAnyway
+ labelSelector:
+ matchLabels:
+ {{- include "erpc.selectorLabels" . | nindent 14 }}
+ - maxSkew: 1
+ topologyKey: topology.kubernetes.io/zone
+ whenUnsatisfiable: ScheduleAnyway
+ labelSelector:
+ matchLabels:
+ {{- include "erpc.selectorLabels" . | nindent 14 }}
+ containers:
+ - name: {{ .Chart.Name }}
+ securityContext:
+ {{- if .Values.securityHardening.enabled }}
+ {{- toYaml .Values.securityHardening.containerSecurityContext | nindent 12 }}
+ {{- else }}
+ {{- toYaml .Values.securityContext | nindent 12 }}
+ {{- end }}
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
+ ports:
+ - name: http
+ containerPort: {{ .Values.config.server.httpPort }}
+ protocol: TCP
+ - name: metrics
+ containerPort: {{ .Values.config.server.metricsPort }}
+ protocol: TCP
+ env:
+ {{- with .Values.extraEnvVars }}
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ volumeMounts:
+ - name: config
+ mountPath: /erpc.yaml
+ subPath: erpc.yaml
+ readOnly: true
+ {{- if .Values.securityHardening.enabled }}
+ - name: tmp
+ mountPath: /tmp
+ {{- end }}
+ startupProbe:
+ httpGet:
+ path: {{ .Values.startupProbe.path | default "/healthcheck" }}
+ port: http
+ initialDelaySeconds: {{ .Values.startupProbe.initialDelaySeconds | default 10 }}
+ periodSeconds: {{ .Values.startupProbe.periodSeconds | default 5 }}
+ timeoutSeconds: {{ .Values.startupProbe.timeoutSeconds | default 5 }}
+ failureThreshold: {{ .Values.startupProbe.failureThreshold | default 6 }}
+ livenessProbe:
+ tcpSocket:
+ port: http
+ {{- if .Values.livenessProbe.initialDelaySeconds }}
+ initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
+ {{- end }}
+ periodSeconds: {{ .Values.livenessProbe.periodSeconds | default 10 }}
+ timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds | default 5 }}
+ successThreshold: 1
+ failureThreshold: {{ .Values.livenessProbe.failureThreshold | default 3 }}
+ readinessProbe:
+ httpGet:
+ path: {{ .Values.readinessProbe.path | default "/healthcheck" }}
+ port: http
+ {{- if .Values.readinessProbe.initialDelaySeconds }}
+ initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
+ {{- end }}
+ periodSeconds: {{ .Values.readinessProbe.periodSeconds | default 5 }}
+ timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds | default 30 }}
+ successThreshold: 1
+ failureThreshold: {{ .Values.readinessProbe.failureThreshold | default 2 }}
+ resources:
+ {{- toYaml .Values.resources | nindent 12 }}
+ {{- if .Values.lifecycle.enabled }}
+ lifecycle:
+ {{- omit .Values.lifecycle "enabled" | toYaml | nindent 12 }}
+ {{- end }}
+ volumes:
+ - name: config
+ secret:
+ secretName: {{ include "erpc.fullname" . }}-vault-config
+ optional: true
+ {{- if .Values.securityHardening.enabled }}
+ - name: tmp
+ emptyDir: {}
+ {{- end }}
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
diff --git a/helm/charts/erpc/templates/hpa.yaml b/helm/charts/erpc/templates/hpa.yaml
new file mode 100644
index 000000000..b65a1cfb8
--- /dev/null
+++ b/helm/charts/erpc/templates/hpa.yaml
@@ -0,0 +1,38 @@
+{{- if .Values.autoscaling.enabled }}
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: {{ include "erpc.fullname" . }}
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: {{ include "erpc.fullname" . }}
+ minReplicas: {{ .Values.autoscaling.minReplicas }}
+ maxReplicas: {{ .Values.autoscaling.maxReplicas }}
+ metrics:
+ {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
+ - type: Pods
+ pods:
+ metric:
+ name: cpu_utilization_percentage
+ target:
+ type: AverageValue
+ averageValue: "{{ .Values.autoscaling.targetCPUUtilizationPercentage }}"
+ {{- end }}
+ {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
+ - type: Pods
+ pods:
+ metric:
+ name: memory_utilization_percentage
+ target:
+ type: AverageValue
+ averageValue: "{{ .Values.autoscaling.targetMemoryUtilizationPercentage }}"
+ {{- end }}
+ {{- with .Values.autoscaling.behavior }}
+ behavior:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+{{- end }}
diff --git a/helm/charts/erpc/templates/image-catalog-pg18.yaml b/helm/charts/erpc/templates/image-catalog-pg18.yaml
new file mode 100644
index 000000000..d265b7149
--- /dev/null
+++ b/helm/charts/erpc/templates/image-catalog-pg18.yaml
@@ -0,0 +1,12 @@
+{{- if ne (toString .Values.postgresql.enabled) "false" }}
+apiVersion: postgresql.cnpg.io/v1
+kind: ImageCatalog
+metadata:
+ name: erpc-postgresql-native-18
+ annotations:
+ argocd.argoproj.io/sync-wave: "-3"
+spec:
+ images:
+ - major: 18
+ image: "ghcr.io/cloudnative-pg/postgresql:18.0-system-trixie"
+{{- end }}
diff --git a/helm/charts/erpc/templates/job-vault-config.yaml b/helm/charts/erpc/templates/job-vault-config.yaml
new file mode 100644
index 000000000..bf1d58ada
--- /dev/null
+++ b/helm/charts/erpc/templates/job-vault-config.yaml
@@ -0,0 +1,444 @@
+{{- $validationImage := .Values.vault.validationImage | default dict }}
+{{- if and $validationImage.enabled (or (not $validationImage.repository) (not $validationImage.tag)) }}
+{{- fail "vault.validationImage.repository and vault.validationImage.tag are required when vault.validationImage.enabled=true" }}
+{{- end }}
+{{- $useDedicatedValidator := $validationImage.enabled }}
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: {{ include "erpc.fullname" . }}-vault-config-creator
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+ annotations:
+ "helm.sh/hook": pre-install,pre-upgrade
+ "helm.sh/hook-weight": "1"
+ "helm.sh/hook-delete-policy": hook-succeeded
+ argocd.argoproj.io/sync-wave: "-3"
+ argocd.argoproj.io/sync-options: Force=true,Replace=true
+spec:
+ template:
+ metadata:
+ labels:
+ {{- include "erpc.selectorLabels" . | nindent 8 }}
+ job: vault-config-creator
+ annotations:
+ # Vault Agent annotations for config injection (on pod template)
+ vault.hashicorp.com/agent-inject: "true"
+ vault.hashicorp.com/role: {{ include "erpc.vaultCreatorServiceAccountName" . | quote }}
+ vault.hashicorp.com/agent-inject-secret-erpc-config: {{ .Values.vault.configPath | default "secret/data/erpc/config" | quote }}
+ vault.hashicorp.com/agent-inject-secret-erpc-secrets: {{ .Values.vault.secretsPath | default "secret/data/erpc/secrets" | quote }}
+ vault.hashicorp.com/agent-inject-template-erpc-config: |
+ {{`{{- with secret "`}}{{ .Values.vault.configPath | default "secret/data/erpc/config" }}{{`" -}}
+ {{- .Data.data.config -}}
+ {{- end }}`}}
+ vault.hashicorp.com/agent-inject-template-erpc-secrets: |
+ {{`{{- with secret "`}}{{ .Values.vault.secretsPath | default "secret/data/erpc/secrets" }}{{`" -}}
+ {{- range $key, $value := .Data.data -}}
+ {{ printf "%s=%s\n" $key $value -}}
+ {{- end -}}
+ {{- end }}`}}
+ vault.hashicorp.com/agent-init-first: "true"
+ vault.hashicorp.com/agent-pre-populate-only: "true"
+ spec:
+ serviceAccountName: {{ include "erpc.vaultCreatorServiceAccountName" . }}
+ automountServiceAccountToken: true
+ {{- if .Values.securityHardening.enabled }}
+ securityContext:
+ {{- toYaml .Values.securityHardening.podSecurityContext | nindent 8 }}
+ {{- end }}
+ restartPolicy: OnFailure
+ volumes:
+ - name: rendered-config
+ emptyDir: {}
+ {{- if .Values.securityHardening.enabled }}
+ - name: tmp
+ emptyDir: {}
+ {{- end }}
+ initContainers:
+ - name: config-renderer
+ {{- if .Values.securityHardening.enabled }}
+ securityContext:
+ {{- toYaml .Values.securityHardening.containerSecurityContext | nindent 12 }}
+ {{- end }}
+ image: "{{ .Values.vault.jobImage.repository }}:{{ .Values.vault.jobImage.tag }}"
+ imagePullPolicy: {{ .Values.vault.jobImage.pullPolicy }}
+ resources:
+ {{- toYaml .Values.vault.jobResources | nindent 12 }}
+ {{- if .Values.securityHardening.enabled }}
+ env:
+ - name: HOME
+ value: /tmp
+ {{- end }}
+ volumeMounts:
+ - name: rendered-config
+ mountPath: /work
+ {{- if .Values.securityHardening.enabled }}
+ - name: tmp
+ mountPath: /tmp
+ {{- end }}
+ command:
+ - sh
+ - -c
+ - |
+ set -eu
+ if (set -o pipefail) 2>/dev/null; then
+ set -o pipefail
+ fi
+
+ echo "Rendering ERPC configuration from vault config and secrets..."
+
+ timeout=60
+ count=0
+ while { [ ! -f /vault/secrets/erpc-config ] || [ ! -f /vault/secrets/erpc-secrets ]; } && [ $count -lt $timeout ]; do
+ echo "Waiting for vault config and secrets files... ($count/$timeout)"
+ sleep 2
+ count=$((count + 2))
+ done
+
+ if [ ! -f /vault/secrets/erpc-config ]; then
+ echo "ERROR: Vault config file not found after $timeout seconds"
+ exit 1
+ fi
+
+ if [ ! -f /vault/secrets/erpc-secrets ]; then
+ echo "ERROR: Vault secrets file not found after $timeout seconds"
+ exit 1
+ fi
+
+ tmp_rendered_config=/tmp/erpc-rendered-config
+ rendered_config=/work/erpc.yaml
+ trap 'rm -f "$tmp_rendered_config"' EXIT
+
+ awk -v secrets_file="/vault/secrets/erpc-secrets" '
+ function yaml_quote(value, quoted) {
+ quoted = value
+ gsub(/\\/, "\\\\", quoted)
+ gsub(/"/, "\\\"", quoted)
+ gsub(/\r/, "\\r", quoted)
+ gsub(/\n/, "\\n", quoted)
+ gsub(/\t/, "\\t", quoted)
+ return "\"" quoted "\""
+ }
+
+ function metadata_key(key, suffix) {
+ return key "__" suffix
+ }
+
+ function api_key_base(key, suffix) {
+ suffix = substr(key, length(key) - 8)
+ if (suffix ~ /^_[0-9A-Fa-f]{8}$/) {
+ return substr(key, 1, length(key) - 9)
+ }
+ return key
+ }
+
+ function metadata_lookup_key(key, suffix, exact_key, base_key) {
+ exact_key = metadata_key(key, suffix)
+ if (exact_key in secrets) {
+ return exact_key
+ }
+
+ base_key = metadata_key(api_key_base(key), suffix)
+ if (base_key in secrets) {
+ return base_key
+ }
+
+ return ""
+ }
+
+ function api_key_id(key, id, id_key) {
+ id_key = metadata_lookup_key(key, "ID")
+ if (id_key != "") {
+ return secrets[id_key]
+ }
+
+ id = substr(api_key_base(key), 9)
+ id = tolower(id)
+ gsub(/_+/, "-", id)
+ return id
+ }
+
+ function trim(value) {
+ sub(/^[[:space:]]+/, "", value)
+ sub(/[[:space:]]+$/, "", value)
+ return value
+ }
+
+ function emit_method_list(indent, methods, parts, count, i, method) {
+ count = split(methods, parts, ",")
+ for (i = 1; i <= count; i++) {
+ method = trim(parts[i])
+ if (method != "") {
+ print indent " - " yaml_quote(method)
+ }
+ }
+ }
+
+ function api_key_sort_value(key, order_key) {
+ order_key = metadata_lookup_key(key, "ORDER")
+ if (order_key != "" && secrets[order_key] ~ /^[0-9]+$/) {
+ return sprintf("%09d:%s", secrets[order_key] + 0, key)
+ }
+ return "999999999:" key
+ }
+
+ function sort_api_keys(i, j, tmp, left, right) {
+ if (api_key_order_sorted) {
+ return
+ }
+
+ for (i = 1; i <= api_key_count; i++) {
+ for (j = i + 1; j <= api_key_count; j++) {
+ left = api_key_sort_value(api_key_order[i])
+ right = api_key_sort_value(api_key_order[j])
+ if (right < left) {
+ tmp = api_key_order[i]
+ api_key_order[i] = api_key_order[j]
+ api_key_order[j] = tmp
+ }
+ }
+ }
+ api_key_order_sorted = 1
+ }
+
+ function emit_api_key_strategies(indent, i, key, allow_key, budget_key) {
+ if (api_key_count == 0) {
+ print "ERROR: __SECRET_API_KEY_STRATEGIES__ used but no API_KEY_* entries exist in Vault secrets" > "/dev/stderr"
+ exit 1
+ }
+
+ sort_api_keys()
+ for (i = 1; i <= api_key_count; i++) {
+ key = api_key_order[i]
+ allow_key = metadata_lookup_key(key, "ALLOW_METHODS")
+ budget_key = metadata_lookup_key(key, "RATE_LIMIT_BUDGET")
+
+ print indent "- type: secret"
+ if (budget_key != "") {
+ print indent " rateLimitBudget: " yaml_quote(secrets[budget_key])
+ }
+ if (allow_key != "") {
+ print indent " ignoreMethods:"
+ print indent " - " yaml_quote("*")
+ }
+ print indent " secret:"
+ print indent " id: " yaml_quote(api_key_id(key))
+ print indent " value: " yaml_quote(secrets[key])
+ if (allow_key != "") {
+ print indent " allowMethods:"
+ emit_method_list(indent, secrets[allow_key])
+ }
+ }
+ }
+
+ BEGIN {
+ missing_count = 0
+ invalid_count = 0
+ api_key_count = 0
+ api_key_order_sorted = 0
+ while ((getline line < secrets_file) > 0) {
+ if (line == "") {
+ continue
+ }
+
+ delimiter = index(line, "=")
+ if (delimiter == 0) {
+ print "ERROR: Invalid Vault secret entry without = delimiter" > "/dev/stderr"
+ invalid_count++
+ continue
+ }
+
+ key = substr(line, 1, delimiter - 1)
+ value = substr(line, delimiter + 1)
+
+ if (key !~ /^[A-Za-z_][A-Za-z0-9_]*$/) {
+ print "ERROR: Invalid Vault secret key " key ". Use [A-Za-z_][A-Za-z0-9_]*." > "/dev/stderr"
+ invalid_count++
+ continue
+ }
+
+ secrets[key] = value
+ if (key ~ /^API_KEY_/ && key !~ /__(ALLOW_METHODS|RATE_LIMIT_BUDGET|ID|ORDER)$/) {
+ api_key_order[++api_key_count] = key
+ }
+ }
+ close(secrets_file)
+
+ if (invalid_count > 0) {
+ exit 1
+ }
+ }
+ {
+ while (match($0, /"__SECRET_[A-Za-z_][A-Za-z0-9_]*__"/)) {
+ token = substr($0, RSTART + 1, RLENGTH - 2)
+ key = substr(token, 10, length(token) - 11)
+
+ if (key in secrets) {
+ repl = yaml_quote(secrets[key])
+ } else {
+ repl = yaml_quote("")
+ if (!(token in missing)) {
+ missing[token] = 1
+ missing_order[++missing_count] = token
+ }
+ }
+
+ $0 = substr($0, 1, RSTART - 1) repl substr($0, RSTART + RLENGTH)
+ }
+
+ while (match($0, /'\''__SECRET_[A-Za-z_][A-Za-z0-9_]*__'\''/)) {
+ token = substr($0, RSTART + 1, RLENGTH - 2)
+ key = substr(token, 10, length(token) - 11)
+
+ if (key in secrets) {
+ repl = yaml_quote(secrets[key])
+ } else {
+ repl = yaml_quote("")
+ if (!(token in missing)) {
+ missing[token] = 1
+ missing_order[++missing_count] = token
+ }
+ }
+
+ $0 = substr($0, 1, RSTART - 1) repl substr($0, RSTART + RLENGTH)
+ }
+
+ if (match($0, /^[[:space:]]*-[[:space:]]*__SECRET_API_KEY_STRATEGIES__[[:space:]]*$/)) {
+ indent = substr($0, 1, index($0, "-") - 1)
+ emit_api_key_strategies(indent)
+ next
+ }
+
+ if (match($0, /^[[:space:]]*__SECRET_API_KEY_STRATEGIES__[[:space:]]*$/)) {
+ indent = $0
+ sub(/__SECRET_API_KEY_STRATEGIES__.*/, "", indent)
+ emit_api_key_strategies(indent)
+ next
+ }
+
+ out = ""
+ rest = $0
+ while (match(rest, /__SECRET_[A-Za-z_][A-Za-z0-9_]*__/)) {
+ token = substr(rest, RSTART, RLENGTH)
+ key = substr(token, 10, length(token) - 11)
+
+ if (key in secrets) {
+ repl = secrets[key]
+ } else {
+ repl = token
+ if (!(token in missing)) {
+ missing[token] = 1
+ missing_order[++missing_count] = token
+ }
+ }
+
+ out = out substr(rest, 1, RSTART - 1) repl
+ rest = substr(rest, RSTART + RLENGTH)
+ }
+ print out rest
+ }
+ END {
+ if (missing_count > 0) {
+ print "ERROR: Unresolved ERPC secret placeholders remain in rendered config:" > "/dev/stderr"
+ for (i = 1; i <= missing_count; i++) {
+ print missing_order[i] > "/dev/stderr"
+ }
+ exit 1
+ }
+ }
+ ' /vault/secrets/erpc-config > "$tmp_rendered_config"
+ mv "$tmp_rendered_config" "$rendered_config"
+
+ echo "ERPC configuration rendered to $rendered_config"
+ {{- if $useDedicatedValidator }}
+ - name: config-validator
+ {{- if .Values.securityHardening.enabled }}
+ securityContext:
+ {{- toYaml .Values.securityHardening.containerSecurityContext | nindent 12 }}
+ {{- end }}
+ image: "{{ $validationImage.repository }}:{{ $validationImage.tag }}"
+ imagePullPolicy: {{ $validationImage.pullPolicy | default .Values.vault.jobImage.pullPolicy }}
+ {{- if .Values.securityHardening.enabled }}
+ env:
+ - name: HOME
+ value: /tmp
+ {{- end }}
+ volumeMounts:
+ - name: rendered-config
+ mountPath: /work
+ {{- if .Values.securityHardening.enabled }}
+ - name: tmp
+ mountPath: /tmp
+ {{- end }}
+ command:
+ {{- toYaml ($validationImage.command | default (list "erpc")) | nindent 12 }}
+ args:
+ - validate
+ - --config
+ - /work/erpc.yaml
+ resources:
+ {{- toYaml ($validationImage.resources | default .Values.vault.jobResources) | nindent 12 }}
+ {{- end }}
+ containers:
+ - name: config-creator
+ {{- if .Values.securityHardening.enabled }}
+ securityContext:
+ {{- toYaml .Values.securityHardening.containerSecurityContext | nindent 12 }}
+ {{- end }}
+ image: "{{ .Values.vault.jobImage.repository }}:{{ .Values.vault.jobImage.tag }}"
+ imagePullPolicy: {{ .Values.vault.jobImage.pullPolicy }}
+ resources:
+ {{- toYaml .Values.vault.jobResources | nindent 12 }}
+ {{- if .Values.securityHardening.enabled }}
+ env:
+ - name: HOME
+ value: /tmp
+ {{- end }}
+ volumeMounts:
+ - name: rendered-config
+ mountPath: /work
+ {{- if .Values.securityHardening.enabled }}
+ - name: tmp
+ mountPath: /tmp
+ {{- end }}
+ command:
+ - sh
+ - -c
+ - |
+ set -eu
+ if (set -o pipefail) 2>/dev/null; then
+ set -o pipefail
+ fi
+
+ rendered_config=/work/erpc.yaml
+ echo "Creating Kubernetes Secret from rendered vault config..."
+
+ if [ ! -s "$rendered_config" ]; then
+ echo "ERROR: Rendered ERPC config file not found or empty: $rendered_config"
+ exit 1
+ fi
+
+ {{- if not $useDedicatedValidator }}
+ echo "Validating ERPC configuration..."
+
+ # Validate ERPC configuration using erpc binary
+ if ! erpc validate --config "$rendered_config"; then
+ echo "ERROR: Invalid ERPC configuration"
+ exit 1
+ fi
+
+ echo "ERPC configuration validation passed. Creating Kubernetes Secret..."
+ {{- else }}
+ echo "ERPC configuration validation passed with dedicated runtime image. Creating Kubernetes Secret..."
+ {{- end }}
+
+ # Create secret from rendered vault config file
+ kubectl create secret generic {{ include "erpc.fullname" . }}-vault-config \
+ --namespace={{ .Release.Namespace }} \
+ --from-file=erpc.yaml="$rendered_config" \
+ --dry-run=client -o yaml | kubectl apply -f -
+
+ echo "Secret {{ include "erpc.fullname" . }}-vault-config created successfully"
+
+ # Verify secret was created
+ kubectl get secret {{ include "erpc.fullname" . }}-vault-config -n {{ .Release.Namespace }}
diff --git a/helm/charts/erpc/templates/job-vault-db.yaml b/helm/charts/erpc/templates/job-vault-db.yaml
new file mode 100644
index 000000000..83eb51a47
--- /dev/null
+++ b/helm/charts/erpc/templates/job-vault-db.yaml
@@ -0,0 +1,87 @@
+{{- if ne (toString .Values.postgresql.enabled) "false" }}
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: {{ include "erpc.fullname" . }}-db-secret-creator
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+ annotations:
+ "helm.sh/hook": pre-install,pre-upgrade
+ "helm.sh/hook-weight": "0"
+ "helm.sh/hook-delete-policy": hook-succeeded
+ argocd.argoproj.io/sync-wave: "-3"
+ argocd.argoproj.io/sync-options: Force=true,Replace=true
+spec:
+ template:
+ metadata:
+ labels:
+ {{- include "erpc.selectorLabels" . | nindent 8 }}
+ job: db-secret-creator
+ annotations:
+ vault.hashicorp.com/agent-inject: "true"
+ vault.hashicorp.com/role: {{ include "erpc.vaultCreatorServiceAccountName" . | quote }}
+ vault.hashicorp.com/agent-inject-secret-secrets: {{ .Values.postgresql.vault.secretsPath | default "secret/data/erpc/db" | quote }}
+ vault.hashicorp.com/agent-inject-template-secrets: |
+ {{`{{- with secret "`}}{{ .Values.postgresql.vault.secretsPath | default "secret/data/erpc/db" }}{{`" -}}
+ {{- .Data.data.password -}}
+ {{- end }}`}}
+ vault.hashicorp.com/agent-pre-populate-only: "true"
+ spec:
+ serviceAccountName: {{ include "erpc.vaultCreatorServiceAccountName" . }}
+ automountServiceAccountToken: true
+ {{- if .Values.securityHardening.enabled }}
+ securityContext:
+ {{- toYaml .Values.securityHardening.podSecurityContext | nindent 8 }}
+ {{- end }}
+ restartPolicy: OnFailure
+ {{- if .Values.securityHardening.enabled }}
+ volumes:
+ - name: tmp
+ emptyDir: {}
+ {{- end }}
+ containers:
+ - name: db-secret-creator
+ {{- if .Values.securityHardening.enabled }}
+ securityContext:
+ {{- toYaml .Values.securityHardening.containerSecurityContext | nindent 12 }}
+ {{- end }}
+ image: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/alpine/k8s:1.31.0
+ {{- if .Values.securityHardening.enabled }}
+ env:
+ - name: HOME
+ value: /tmp
+ volumeMounts:
+ - name: tmp
+ mountPath: /tmp
+ {{- end }}
+ command:
+ - sh
+ - -c
+ - |
+ echo "Creating ERPC database Kubernetes Secret from Vault..."
+
+ timeout=60
+ count=0
+ while [ ! -f /vault/secrets/secrets ] && [ $count -lt $timeout ]; do
+ echo "Waiting for vault secrets file... ($count/$timeout)"
+ sleep 2
+ count=$((count + 2))
+ done
+
+ if [ ! -f /vault/secrets/secrets ]; then
+ echo "ERROR: Vault secrets file not found after $timeout seconds"
+ exit 1
+ fi
+
+ echo "Vault secrets file found. Creating Kubernetes Secret..."
+
+ kubectl create secret generic erpc-db-secret \
+ --namespace={{ .Release.Namespace }} \
+ --from-literal=username=postgres \
+ --from-file=password=/vault/secrets/secrets \
+ --dry-run=client -o yaml | kubectl apply -f -
+
+ echo "Secret erpc-db-secret created successfully"
+
+ kubectl get secret erpc-db-secret -n {{ .Release.Namespace }}
+{{- end }}
diff --git a/helm/charts/erpc/templates/monitoring-configmap.yaml b/helm/charts/erpc/templates/monitoring-configmap.yaml
new file mode 100644
index 000000000..45356facc
--- /dev/null
+++ b/helm/charts/erpc/templates/monitoring-configmap.yaml
@@ -0,0 +1,59 @@
+{{- if ne (toString .Values.postgresql.enabled) "false" }}
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "erpc.fullname" . }}-cnpg-recovery-health-monitoring
+ labels:
+ cnpg.io/reload: ""
+ {{- include "erpc.labels" . | nindent 4 }}
+data:
+ custom-queries: |
+ # PostgreSQL Recovery Health Metrics
+ # Detects conditions that prevent hot standby or cause recovery issues
+
+ pg_recovery_health:
+ query: |
+ SELECT
+ CASE WHEN pg_is_in_recovery() THEN 1 ELSE 0 END AS is_replica,
+ CASE WHEN pg_is_in_recovery() AND NOT pg_is_wal_replay_paused() THEN 1 ELSE 0 END AS recovery_active,
+ CASE WHEN pg_is_in_recovery() AND pg_is_wal_replay_paused() THEN 1 ELSE 0 END AS recovery_paused,
+ COALESCE(EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::bigint, 0) AS replication_lag_seconds
+ metrics:
+ - is_replica:
+ usage: "GAUGE"
+ description: "1 if this instance is a replica, 0 if primary"
+ - recovery_active:
+ usage: "GAUGE"
+ description: "1 if recovery is actively replaying WAL, 0 otherwise"
+ - recovery_paused:
+ usage: "GAUGE"
+ description: "1 if recovery is paused (critical - server will shut down if unpaused)"
+ - replication_lag_seconds:
+ usage: "GAUGE"
+ description: "Seconds since last replayed transaction"
+
+ pg_hot_standby_params:
+ query: |
+ SELECT
+ current_setting('max_connections')::int AS max_connections,
+ current_setting('max_worker_processes')::int AS max_worker_processes,
+ current_setting('max_wal_senders')::int AS max_wal_senders,
+ current_setting('max_prepared_transactions')::int AS max_prepared_transactions,
+ current_setting('max_locks_per_transaction')::int AS max_locks_per_transaction
+ metrics:
+ - max_connections:
+ usage: "GAUGE"
+ description: "Current max_connections setting"
+ - max_worker_processes:
+ usage: "GAUGE"
+ description: "Current max_worker_processes setting"
+ - max_wal_senders:
+ usage: "GAUGE"
+ description: "Current max_wal_senders setting"
+ - max_prepared_transactions:
+ usage: "GAUGE"
+ description: "Current max_prepared_transactions setting"
+ - max_locks_per_transaction:
+ usage: "GAUGE"
+ description: "Current max_locks_per_transaction setting"
+{{- end }}
diff --git a/helm/charts/erpc/templates/pdb.yaml b/helm/charts/erpc/templates/pdb.yaml
new file mode 100644
index 000000000..3e9a5fe2c
--- /dev/null
+++ b/helm/charts/erpc/templates/pdb.yaml
@@ -0,0 +1,12 @@
+{{- $replicas := ternary .Values.autoscaling.minReplicas .Values.replicaCount .Values.autoscaling.enabled -}}
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: {{ include "erpc.fullname" . }}-pdb
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+spec:
+ minAvailable: {{ sub $replicas 1 }}
+ selector:
+ matchLabels:
+ {{- include "erpc.selectorLabels" . | nindent 6 }}
diff --git a/helm/charts/erpc/templates/pg-erpc.yaml b/helm/charts/erpc/templates/pg-erpc.yaml
new file mode 100644
index 000000000..e86f43667
--- /dev/null
+++ b/helm/charts/erpc/templates/pg-erpc.yaml
@@ -0,0 +1,186 @@
+{{- if ne (toString .Values.postgresql.enabled) "false" }}
+apiVersion: postgresql.cnpg.io/v1
+kind: Cluster
+metadata:
+ name: erpc-db
+ annotations:
+ argocd.argoproj.io/sync-wave: "-2"
+spec:
+ instances: 2
+ primaryUpdateStrategy: unsupervised
+ imageCatalogRef:
+ apiGroup: postgresql.cnpg.io
+ kind: ImageCatalog
+ name: erpc-postgresql-native-18
+ major: 18
+
+ serviceAccountTemplate:
+ metadata:
+ annotations:
+ eks.amazonaws.com/role-arn: {{ .Values.postgresql.s3SARoleArn }}
+ postgresql:
+ shared_preload_libraries:
+ - pg_stat_statements
+ parameters:
+ max_connections: "5000"
+
+ idle_in_transaction_session_timeout: "30000"
+
+ max_locks_per_transaction: "512"
+ max_pred_locks_per_transaction: "512"
+
+ shared_buffers: {{ .Values.postgresql.sharedBuffers | default "1GB" | quote }}
+ effective_cache_size: {{ .Values.postgresql.effectiveCacheSize | default "3GB" | quote }}
+ maintenance_work_mem: {{ .Values.postgresql.maintenanceWorkMem | default "256MB" | quote }}
+ work_mem: {{ .Values.postgresql.workMem | default "8MB" | quote }}
+
+ wal_buffers: "16MB"
+ min_wal_size: "1GB"
+ max_wal_size: "8GB"
+ wal_keep_size: "2GB"
+ checkpoint_completion_target: "0.9"
+ checkpoint_timeout: "15min"
+ wal_level: "replica"
+ synchronous_commit: "off"
+
+ default_statistics_target: "200"
+ random_page_cost: "1.0"
+ effective_io_concurrency: "200"
+ huge_pages: "off"
+
+ max_worker_processes: {{ .Values.postgresql.maxWorkerProcesses | default "2" | quote }}
+ max_parallel_workers: {{ .Values.postgresql.maxParallelWorkers | default "2" | quote }}
+ max_parallel_workers_per_gather: {{ .Values.postgresql.maxParallelWorkersPerGather | default "1" | quote }}
+ max_parallel_maintenance_workers: {{ .Values.postgresql.maxParallelMaintenanceWorkers | default "1" | quote }}
+
+ autovacuum_max_workers: {{ .Values.postgresql.autovacuumMaxWorkers | default "2" | quote }}
+ autovacuum_naptime: "10s"
+ autovacuum_vacuum_scale_factor: "0.01"
+ autovacuum_analyze_scale_factor: "0.005"
+ autovacuum_vacuum_cost_delay: "2ms"
+ autovacuum_vacuum_cost_limit: "2000"
+
+ track_io_timing: "on"
+ jit: "off"
+
+ bgwriter_delay: "200ms"
+ bgwriter_lru_maxpages: "1000"
+ bgwriter_lru_multiplier: "10.0"
+
+ track_activity_query_size: "8192"
+ pg_stat_statements.max: "5000"
+ pg_stat_statements.track: "all"
+
+ # Must exceed worst-case pg_backup_start() duration (checkpoint + WAL flush)
+ # on a write-heavy DB; 10s was killing every barman base backup.
+ statement_timeout: "600000"
+ lock_timeout: "5000"
+
+ vacuum_cost_delay: "2ms"
+
+ # Write-heavy workload optimizations
+ full_page_writes: "on"
+ wal_compression: "on"
+ wal_writer_delay: "10ms"
+ commit_delay: "10"
+ commit_siblings: "10"
+
+ # Reduce checkpoint frequency impact
+ checkpoint_warning: "300s"
+
+ # Connection pooling optimization
+ tcp_keepalives_idle: "60"
+ tcp_keepalives_interval: "10"
+ tcp_keepalives_count: "6"
+ pg_hba:
+ - host all all 0.0.0.0/0 md5
+
+ bootstrap:
+ initdb:
+ database: erpc
+ owner: postgres
+ secret:
+ name: erpc-db-secret
+ postInitApplicationSQL:
+ - CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
+
+ storage:
+ storageClass: {{ .Values.postgresql.storageClass | default "gp3" }}
+ size: {{ .Values.postgresql.storage | default "100Gi" }}
+ {{- with .Values.postgresql.volumeAttributesClassName }}
+ pvcTemplate:
+ volumeAttributesClassName: {{ . | quote }}
+ {{- end }}
+
+ resources:
+ {{- toYaml .Values.postgresql.resources | nindent 4 }}
+
+ {{- $aff := .Values.postgresql.affinity | default dict -}}
+ {{- if or $aff.nodeSelector $aff.tolerations $aff.additionalPodAntiAffinity }}
+ affinity:
+ {{- with $aff.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 6 }}
+ {{- end }}
+ {{- with $aff.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 6 }}
+ {{- end }}
+ {{- with $aff.additionalPodAntiAffinity }}
+ additionalPodAntiAffinity:
+ {{- toYaml . | nindent 6 }}
+ {{- end }}
+ {{- else }}
+ affinity: {}
+ {{- end }}
+
+ enableSuperuserAccess: true
+ superuserSecret:
+ name: erpc-db-secret
+
+ monitoring:
+ customQueriesConfigMap:
+ - key: queries
+ name: cnpg-default-monitoring
+ - key: custom-queries
+ name: {{ include "erpc.fullname" . }}-cnpg-recovery-health-monitoring
+
+ {{- $snapshotClass := .Values.postgresql.backup.volumeSnapshotClassName | default "" }}
+ {{- $backupMethod := .Values.postgresql.backup.method | default (ternary "volumeSnapshot" "barmanObjectStore" (ne $snapshotClass "")) }}
+ {{- if eq $backupMethod "volumeSnapshot" }}
+ {{- if not $snapshotClass }}{{ fail "postgresql.backup.volumeSnapshotClassName is required when postgresql.backup.method=volumeSnapshot" }}{{ end }}
+ backup:
+ retentionPolicy: {{ .Values.postgresql.backup.retentionPolicy | default "7d" | quote }}
+ {{- with .Values.postgresql.backup.destinationPath }}
+ barmanObjectStore:
+ s3Credentials:
+ inheritFromIAMRole: true
+ destinationPath: {{ . }}
+ endpointURL: "https://s3.eu-west-3.amazonaws.com"
+ wal:
+ compression: gzip
+ maxParallel: 4
+ {{- end }}
+ target: prefer-standby
+ volumeSnapshot:
+ className: {{ $snapshotClass }}
+ online: true
+ onlineConfiguration:
+ immediateCheckpoint: false
+ waitForArchive: true
+ snapshotOwnerReference: backup
+ {{- else if eq $backupMethod "barmanObjectStore" }}
+ backup:
+ retentionPolicy: {{ .Values.postgresql.backup.retentionPolicy | default "7d" | quote }}
+ barmanObjectStore:
+ s3Credentials:
+ inheritFromIAMRole: true
+ destinationPath: {{ .Values.postgresql.backup.destinationPath }}
+ endpointURL: "https://s3.eu-west-3.amazonaws.com"
+ wal:
+ compression: gzip
+ maxParallel: 4
+ {{- else }}
+ {{- fail (printf "postgresql.backup.method must be \"barmanObjectStore\" or \"volumeSnapshot\", got %q" $backupMethod) }}
+ {{- end }}
+{{- end }}
diff --git a/helm/charts/erpc/templates/podmonitor-erpc-db.yaml b/helm/charts/erpc/templates/podmonitor-erpc-db.yaml
new file mode 100644
index 000000000..fc0860b8a
--- /dev/null
+++ b/helm/charts/erpc/templates/podmonitor-erpc-db.yaml
@@ -0,0 +1,17 @@
+{{- if ne (toString .Values.postgresql.enabled) "false" }}
+apiVersion: monitoring.coreos.com/v1
+kind: PodMonitor
+metadata:
+ name: erpc-db-monitor
+ labels:
+ app: erpc-db-monitor
+ cnpg-cluster: erpc-db
+spec:
+ selector:
+ matchLabels:
+ cnpg.io/cluster: erpc-db
+ podMetricsEndpoints:
+ - port: metrics
+ interval: 10s
+ scrapeTimeout: 8s
+{{- end }}
diff --git a/helm/charts/erpc/templates/podmonitor.yaml b/helm/charts/erpc/templates/podmonitor.yaml
new file mode 100644
index 000000000..627f8372d
--- /dev/null
+++ b/helm/charts/erpc/templates/podmonitor.yaml
@@ -0,0 +1,61 @@
+{{- if .Values.monitoring.podMonitor.enabled }}
+apiVersion: monitoring.coreos.com/v1
+kind: PodMonitor
+metadata:
+ name: {{ include "erpc.fullname" . }}
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+ {{- with .Values.monitoring.podMonitor.additionalLabels }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ selector:
+ matchLabels:
+ {{- include "erpc.selectorLabels" . | nindent 6 }}
+ podMetricsEndpoints:
+ - port: metrics
+ {{- with .Values.monitoring.podMonitor.interval }}
+ interval: {{ . }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.scrapeTimeout }}
+ scrapeTimeout: {{ . }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.path }}
+ path: {{ . }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.scheme }}
+ scheme: {{ . }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.honorLabels }}
+ honorLabels: {{ . }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.metricRelabelings }}
+ metricRelabelings:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.relabelings }}
+ relabelings:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.namespaceSelector }}
+ namespaceSelector:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.jobLabel }}
+ jobLabel: {{ . }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.podTargetLabels }}
+ podTargetLabels:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.sampleLimit }}
+ sampleLimit: {{ . }}
+ {{- end }}
+ {{- with .Values.monitoring.podMonitor.targetLimit }}
+ targetLimit: {{ . }}
+ {{- end }}
+{{- end }}
\ No newline at end of file
diff --git a/helm/charts/erpc/templates/rbac-vault.yaml b/helm/charts/erpc/templates/rbac-vault.yaml
new file mode 100644
index 000000000..55391c698
--- /dev/null
+++ b/helm/charts/erpc/templates/rbac-vault.yaml
@@ -0,0 +1,35 @@
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: {{ include "erpc.fullname" . }}-vault-secret
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+ annotations:
+ argocd.argoproj.io/sync-wave: "-4"
+rules:
+ - apiGroups: [""]
+ resources: ["secrets"]
+ verbs: ["create"]
+ - apiGroups: [""]
+ resources: ["secrets"]
+ resourceNames:
+ {{- include "erpc.vaultSecretResourceNames" . | nindent 6 }}
+ verbs: ["get", "patch", "update"]
+
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: {{ include "erpc.fullname" . }}-vault-secret
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+ annotations:
+ argocd.argoproj.io/sync-wave: "-4"
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: Role
+ name: {{ include "erpc.fullname" . }}-vault-secret
+subjects:
+ - kind: ServiceAccount
+ name: {{ include "erpc.vaultCreatorServiceAccountName" . }}
+ namespace: {{ .Release.Namespace }}
diff --git a/helm/charts/erpc/templates/scheduled-backup.yaml b/helm/charts/erpc/templates/scheduled-backup.yaml
new file mode 100644
index 000000000..d50daf0fc
--- /dev/null
+++ b/helm/charts/erpc/templates/scheduled-backup.yaml
@@ -0,0 +1,20 @@
+{{- if ne (toString .Values.postgresql.enabled) "false" }}
+{{- $snapshotClass := .Values.postgresql.backup.volumeSnapshotClassName | default "" }}
+{{- $backupMethod := .Values.postgresql.backup.method | default (ternary "volumeSnapshot" "barmanObjectStore" (ne $snapshotClass "")) }}
+apiVersion: postgresql.cnpg.io/v1
+kind: ScheduledBackup
+metadata:
+ name: erpc-db-scheduled-backup
+spec:
+ {{- if eq $backupMethod "volumeSnapshot" }}
+ method: volumeSnapshot
+ {{- else if eq $backupMethod "barmanObjectStore" }}
+ method: barmanObjectStore
+ {{- else }}
+ {{- fail (printf "postgresql.backup.method must be \"barmanObjectStore\" or \"volumeSnapshot\", got %q" $backupMethod) }}
+ {{- end }}
+ schedule: "0 0 0 * * *"
+ backupOwnerReference: self
+ cluster:
+ name: erpc-db
+{{- end }}
diff --git a/helm/charts/erpc/templates/service-headless.yaml b/helm/charts/erpc/templates/service-headless.yaml
new file mode 100644
index 000000000..fbbef67ab
--- /dev/null
+++ b/helm/charts/erpc/templates/service-headless.yaml
@@ -0,0 +1,23 @@
+{{- if .Values.headlessService.enabled }}
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "erpc.fullname" . }}-headless
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+spec:
+ type: ClusterIP
+ clusterIP: None
+ publishNotReadyAddresses: false
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
+ - port: {{ .Values.service.metricsPort }}
+ targetPort: metrics
+ protocol: TCP
+ name: metrics
+ selector:
+ {{- include "erpc.selectorLabels" . | nindent 4 }}
+{{- end }}
diff --git a/helm/charts/erpc/templates/service-metrics.yaml b/helm/charts/erpc/templates/service-metrics.yaml
new file mode 100644
index 000000000..0f99693ea
--- /dev/null
+++ b/helm/charts/erpc/templates/service-metrics.yaml
@@ -0,0 +1,20 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "erpc.fullname" . }}-metrics
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+ app.kubernetes.io/component: metrics
+ {{- with .Values.metricsService.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ type: {{ .Values.metricsService.type }}
+ ports:
+ - port: {{ .Values.service.metricsPort }}
+ targetPort: metrics
+ protocol: TCP
+ name: metrics
+ selector:
+ {{- include "erpc.selectorLabels" . | nindent 4 }}
diff --git a/helm/charts/erpc/templates/service.yaml b/helm/charts/erpc/templates/service.yaml
new file mode 100644
index 000000000..466c77f1c
--- /dev/null
+++ b/helm/charts/erpc/templates/service.yaml
@@ -0,0 +1,19 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "erpc.fullname" . }}
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+ {{- with .Values.service.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
+ selector:
+ {{- include "erpc.selectorLabels" . | nindent 4 }}
\ No newline at end of file
diff --git a/helm/charts/erpc/templates/serviceaccount.yaml b/helm/charts/erpc/templates/serviceaccount.yaml
new file mode 100644
index 000000000..0b5cf03c9
--- /dev/null
+++ b/helm/charts/erpc/templates/serviceaccount.yaml
@@ -0,0 +1,44 @@
+{{- $serviceAccountCreate := ne (toString .Values.serviceAccount.create) "false" -}}
+{{- $vaultCreator := .Values.serviceAccount.vaultCreator | default dict -}}
+{{- $hasCreateOverride := hasKey $vaultCreator "create" -}}
+{{- $vaultCreatorName := $vaultCreator.name | default "" -}}
+{{- $hasExplicitCreatorName := ne (toString $vaultCreatorName) "" -}}
+{{- $createVaultCreator := and $serviceAccountCreate (not $hasExplicitCreatorName) -}}
+{{- if $hasCreateOverride -}}
+{{- $createVaultCreator = ne (toString $vaultCreator.create) "false" -}}
+{{- end -}}
+{{- $runtimeName := .Values.serviceAccount.runtimeName | default "" -}}
+{{- $createRuntime := or $serviceAccountCreate (ne (toString $runtimeName) "") -}}
+{{- $runtimeServiceAccountName := include "erpc.serviceAccountName" . -}}
+{{- $vaultCreatorServiceAccountName := include "erpc.vaultCreatorServiceAccountName" . -}}
+{{- if $createRuntime }}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ $runtimeServiceAccountName }}
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+ annotations:
+ argocd.argoproj.io/sync-wave: "-4"
+ {{- with .Values.serviceAccount.annotations }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+automountServiceAccountToken: false
+{{- end }}
+{{- if and $createVaultCreator (or (not $createRuntime) (ne $vaultCreatorServiceAccountName $runtimeServiceAccountName)) }}
+{{- if $createRuntime }}
+---
+{{- end }}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ $vaultCreatorServiceAccountName }}
+ labels:
+ {{- include "erpc.labels" . | nindent 4 }}
+ annotations:
+ argocd.argoproj.io/sync-wave: "-4"
+ {{- with .Values.serviceAccount.annotations }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+automountServiceAccountToken: false
+{{- end }}
diff --git a/helm/charts/erpc/test-local.sh b/helm/charts/erpc/test-local.sh
new file mode 100755
index 000000000..e9d3b1ecf
--- /dev/null
+++ b/helm/charts/erpc/test-local.sh
@@ -0,0 +1,275 @@
+#!/bin/bash
+# eRPC Local Docker Compose Test Script (DynamoDB Configuration)
+# Based on: https://docs.erpc.cloud/
+
+set -e
+
+cd "$(dirname "$0")"
+
+# Source the .env file to get environment variables
+if [ -f .env ]; then
+ source .env
+fi
+
+echo "🚀 Testing eRPC with Docker Compose (DynamoDB configuration)..."
+
+# Check if docker-compose is available
+if ! command -v docker-compose &> /dev/null || ! command -v docker &> /dev/null; then
+ echo "❌ Docker and docker-compose are required but not installed"
+ exit 1
+fi
+
+# Check if .env file exists
+if [ ! -f .env ]; then
+ echo "⚠️ No .env file found. Creating from example..."
+ cp .env.example .env
+ echo "📝 Please edit .env file with your actual API keys:"
+ echo " - ALCHEMY_API_KEY"
+ echo " - DRPC_API_KEY"
+ echo " - TENDERLY_API_KEY"
+ echo " - TENDERLY_PROJECT"
+ echo " - LABELS_SERVICE_API_KEY (for authentication)"
+ echo " - ERPC_API_KEY (for Goldsky edge access)"
+ echo ""
+ echo "⚠️ Note: This configuration uses DynamoDB for caching and shared state."
+ echo " You'll need AWS credentials or IAM role configured for DynamoDB access."
+ echo ""
+ echo "Then run this script again."
+ exit 1
+fi
+
+# Start services (only eRPC, no Redis needed)
+echo "🐳 Starting eRPC service..."
+docker-compose up -d erpc
+
+# Wait for services to be ready
+echo "⏳ Waiting for services to be ready..."
+for i in {1..30}; do
+ # Check if eRPC container is running
+ if docker-compose ps | grep "erpc-erpc" | grep -q "Up"; then
+ # Test if port 4000 is accessible
+ if nc -z localhost 4000 2>/dev/null; then
+ # Test if health endpoint responds with OK
+ health_response=$(curl -s http://localhost:4000/healthcheck 2>/dev/null || echo "failed")
+ if echo "$health_response" | grep -q '"status":"OK"'; then
+ echo "✅ eRPC is ready and responding!"
+ echo " Providers: $(echo "$health_response" | jq -r '.details.totalProviders // "unknown"' 2>/dev/null)"
+ break
+ else
+ echo " eRPC port open but health check not ready... ($i/30)"
+ echo " Response: $(echo "$health_response" | head -c 100)..."
+ fi
+ else
+ echo " eRPC container up but port 4000 not ready... ($i/30)"
+ fi
+ else
+ echo " eRPC container not up yet... ($i/30)"
+ fi
+
+ if [ $i -eq 30 ]; then
+ echo "❌ eRPC failed to start within timeout"
+ echo "🔍 Container status:"
+ docker-compose ps
+ echo "🔍 eRPC logs:"
+ docker-compose logs erpc
+ exit 1
+ fi
+ sleep 2
+done
+
+# Cleanup function
+cleanup() {
+ echo "🧹 Cleaning up..."
+ docker-compose down
+}
+trap cleanup EXIT
+
+# Test 1: Health check
+echo "🏥 Testing health endpoint..."
+health_response=$(curl -s http://localhost:4000/healthcheck 2>/dev/null || echo "failed")
+
+if echo "$health_response" | grep -q '"status":"OK"'; then
+ echo "✅ Health check passed"
+ echo " Status: $(echo "$health_response" | jq -r '.message' 2>/dev/null || echo "OK")"
+else
+ echo "❌ Health check failed"
+ echo " Response: $health_response"
+ exit 1
+fi
+
+# Test 2: Ethereum RPC call
+echo "🌐 Testing Ethereum RPC call..."
+response=$(curl -s --location "http://localhost:4000/cache/evm/1?secret=${LABELS_SERVICE_API_KEY}" \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "method": "eth_blockNumber",
+ "params": [],
+ "id": 1,
+ "jsonrpc": "2.0"
+ }')
+
+if echo "$response" | grep -q '"result"'; then
+ block_number=$(echo "$response" | jq -r '.result' 2>/dev/null || echo "unknown")
+ echo "✅ Ethereum RPC call successful"
+ echo " Latest block: $block_number"
+else
+ echo "❌ Ethereum RPC call failed"
+ echo " Response: $response"
+ exit 1
+fi
+
+# Test 3: Arbitrum RPC call (from eRPC quickstart docs)
+echo "🔗 Testing Arbitrum RPC call..."
+response=$(curl -s --location "http://localhost:4000/cache/evm/42161?secret=${LABELS_SERVICE_API_KEY}" \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "method": "eth_getBlockByNumber",
+ "params": ["latest", false],
+ "id": 9199,
+ "jsonrpc": "2.0"
+ }')
+
+if echo "$response" | grep -q '"result"'; then
+ echo "✅ Arbitrum RPC call successful"
+ block_hash=$(echo "$response" | jq -r '.result.hash // "unknown"' 2>/dev/null || echo "unknown")
+ echo " Block hash: $block_hash"
+else
+ echo "❌ Arbitrum RPC call failed"
+ echo " Response: $response"
+ exit 1
+fi
+
+# Test 4: Base network RPC call
+echo "🔵 Testing Base network RPC call..."
+response=$(curl -s --location "http://localhost:4000/cache/evm/8453?secret=${LABELS_SERVICE_API_KEY}" \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "method": "eth_chainId",
+ "params": [],
+ "id": 1,
+ "jsonrpc": "2.0"
+ }')
+
+if echo "$response" | grep -q '"result"'; then
+ chain_id=$(echo "$response" | jq -r '.result' 2>/dev/null || echo "unknown")
+ expected_chain_id="0x2105" # 8453 in hex
+ echo "✅ Base network RPC call successful"
+ echo " Chain ID: $chain_id (expected: $expected_chain_id)"
+else
+ echo "❌ Base network RPC call failed"
+ echo " Response: $response"
+ exit 1
+fi
+
+# Test 5: Authentication test (if LABELS_SERVICE_API_KEY is configured)
+echo "🔐 Testing authentication..."
+
+if [ -n "${LABELS_SERVICE_API_KEY:-}" ] && [ "$LABELS_SERVICE_API_KEY" != "your-secure-auth-secret-here" ]; then
+ # Test without auth secret (should fail if auth is enabled)
+ auth_response_no_secret=$(curl -s --location 'http://localhost:4000/cache/evm/1' \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "method": "eth_blockNumber",
+ "params": [],
+ "id": 1,
+ "jsonrpc": "2.0"
+ }')
+
+ if echo "$auth_response_no_secret" | grep -q "unauthorized\|auth"; then
+ echo "✅ Authentication is properly enforced (request without secret rejected)"
+
+ # Test with proper auth secret in URL
+ auth_response_with_secret=$(curl -s --location "http://localhost:4000/cache/evm/1?secret=$LABELS_SERVICE_API_KEY" \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "method": "eth_blockNumber",
+ "params": [],
+ "id": 1,
+ "jsonrpc": "2.0"
+ }')
+
+ if echo "$auth_response_with_secret" | grep -q '"result"'; then
+ echo "✅ Authentication with proper secret parameter works"
+ else
+ echo "⚠️ Authentication secret parameter didn't work as expected"
+ # Redact the API key in case the response (or a reflected error)
+ # echoes back the request URL containing the secret.
+ echo " Response: ${auth_response_with_secret//$LABELS_SERVICE_API_KEY/***REDACTED***}"
+ fi
+ else
+ echo "⚠️ Authentication might not be properly configured (no auth rejection detected)"
+ echo " Response: $auth_response_no_secret"
+ fi
+else
+ echo "ℹ️ Authentication not configured or using default value"
+fi
+
+# Test 6: Arbitrum eth_getLogs test (specific range and topics)
+echo "📋 Testing Arbitrum eth_getLogs with specific range and topics..."
+response=$(curl -s --location "http://localhost:4000/cache/evm/42161?secret=${LABELS_SERVICE_API_KEY}" \
+ --header 'Content-Type: application/json' \
+ --data '{
+ "method": "eth_getLogs",
+ "params": [{
+ "fromBlock": "0x1254048f",
+ "toBlock": "0x12558b2f",
+ "topics": [["0xe0c2db6b54586be6d7d49943139fccf0dd315ba63e55364a76c73cd8fdba724d","0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0"]],
+ "address": ["0x35a04d797164d54def24d6fc722c8f82f8ef0d45","0x1350994173f1cc83f8fc45a5af80a0acfc1b613c"]
+ }],
+ "id": 1,
+ "jsonrpc": "2.0"
+ }')
+
+if echo "$response" | grep -q '"result"'; then
+ log_count=$(echo "$response" | jq -r '.result | length' 2>/dev/null || echo "unknown")
+ echo "✅ Arbitrum eth_getLogs test successful"
+ echo " Logs found: $log_count"
+ echo " Block range: 0x1254048f (307,496,079) to 0x12558b2f (307,596,079)"
+ echo " Range size: 100,000 blocks"
+elif echo "$response" | grep -q '"error"'; then
+ error_message=$(echo "$response" | jq -r '.error.message // .error' 2>/dev/null || echo "unknown")
+ echo "⚠️ eth_getLogs returned error (expected for large ranges):"
+ echo " Error: $error_message"
+ # This is actually expected behavior for large block ranges - eRPC should auto-split
+else
+ echo "❌ Arbitrum eth_getLogs test failed"
+ echo " Response: $response"
+ exit 1
+fi
+
+# Test 7: DynamoDB cache verification
+echo "💾 DynamoDB cache configuration:"
+echo "✅ DynamoDB caching enabled"
+echo " Note: DynamoDB access requires AWS credentials or IAM role"
+echo " Table pattern: {environment}-erpc-cache"
+
+echo ""
+echo "🎉 All eRPC local tests passed!"
+echo ""
+echo "📋 Service URLs:"
+echo " 🌐 RPC Proxy: http://localhost:4000/cache/evm/{chainId}"
+echo " 📊 Metrics: http://localhost:4001/metrics"
+echo " 💾 Cache: DynamoDB (configured via environment)"
+echo ""
+echo "🔗 Supported networks (all EVM chains via networkDefaults):"
+echo " - Ethereum: http://localhost:4000/cache/evm/1"
+echo " - Base: http://localhost:4000/cache/evm/8453"
+echo " - Polygon: http://localhost:4000/cache/evm/137"
+echo " - Optimism: http://localhost:4000/cache/evm/10"
+echo " - Arbitrum: http://localhost:4000/cache/evm/42161"
+echo " - BSC: http://localhost:4000/cache/evm/56"
+echo " - + Any other EVM-compatible network"
+echo ""
+echo "🐛 Debug calls automatically route to Tenderly:"
+echo " curl --location 'http://localhost:4000/cache/evm/1' \\"
+echo " --header 'Content-Type: application/json' \\"
+echo " --data '{\"method\":\"debug_traceTransaction\",\"params\":[\"0x...\"],\"id\":1,\"jsonrpc\":\"2.0\"}'"
+echo ""
+echo "📚 DynamoDB Setup Requirements:"
+echo " 1. Create DynamoDB table: dev-erpc-cache (or your environment name)"
+echo " 2. Configure AWS credentials via:"
+echo " - AWS CLI: aws configure"
+echo " - Environment: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY"
+echo " - IAM Role: For EKS/container environments"
+echo ""
+echo "🛑 To stop services: docker-compose down"
\ No newline at end of file
diff --git a/helm/charts/erpc/values.yaml b/helm/charts/erpc/values.yaml
new file mode 100644
index 000000000..3f290978f
--- /dev/null
+++ b/helm/charts/erpc/values.yaml
@@ -0,0 +1,260 @@
+# Default values for erpc
+nameOverride: ""
+fullnameOverride: ""
+
+replicaCount: 3
+
+vault:
+ # Non-secret eRPC config template. Secret placeholders use __SECRET___;
+ # the API-key auth list can be generated from __SECRET_API_KEY_STRATEGIES__.
+ configPath: "secret/data/erpc/config"
+ # Dedicated secret-only entry shared by all eRPC config variants.
+ secretsPath: "secret/data/erpc/secrets"
+ jobImage:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/morphoorg/erpc-validator
+ tag: "0.1.3"
+ pullPolicy: IfNotPresent
+ jobResources:
+ requests:
+ cpu: "50m"
+ memory: "64Mi"
+ limits:
+ cpu: "200m"
+ memory: "256Mi"
+ validationImage:
+ enabled: false
+ repository: ""
+ tag: ""
+ pullPolicy: IfNotPresent
+ command:
+ - erpc
+ resources:
+ requests:
+ cpu: "100m"
+ memory: "128Mi"
+ limits:
+ cpu: "500m"
+ memory: "512Mi"
+
+image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/morphoorg/erpc
+ tag: "0.1.3"
+ pullPolicy: IfNotPresent
+
+imagePullSecrets: []
+
+serviceAccount:
+ # Deprecated/ignored: ServiceAccounts always disable token automount; pods opt in when needed.
+ automount: true
+ # The name of the service account to use.
+ # Legacy creator ServiceAccount name; runtime pods use runtimeName when set.
+ name: ""
+ runtimeName: ""
+ # Set create=false to reuse an existing Vault creator identity while still
+ # allowing the chart to create the runtime ServiceAccount.
+ vaultCreator:
+ # create: false
+ name: ""
+ annotations: {}
+
+# Legacy override hooks (kept for backward compatibility with environments
+# that already set podSecurityContext / securityContext directly on this
+# chart). Active only when securityHardening.enabled is false.
+podSecurityContext: {}
+
+securityContext: {}
+
+# Pod Security Standards "restricted" profile, gated behind an enabled
+# flag so it can be rolled out per-environment. Enable in dev to validate
+# before flipping in prd. When disabled the legacy podSecurityContext /
+# securityContext keys above are honored instead.
+securityHardening:
+ enabled: false
+ podSecurityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ fsGroup: 1000
+ seccompProfile:
+ type: RuntimeDefault
+ containerSecurityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop:
+ - ALL
+ readOnlyRootFilesystem: true
+ runAsNonRoot: true
+
+config:
+ server:
+ httpPort: 4000
+ metricsPort: 4001
+
+service:
+ type: ClusterIP
+ port: 4000
+ metricsPort: 4001
+ annotations: {}
+
+metricsService:
+ type: ClusterIP
+ annotations: {}
+
+# Headless service for HAProxy DNS-based pod discovery
+headlessService:
+ enabled: false
+
+autoscaling:
+ enabled: true
+ minReplicas: 3
+ maxReplicas: 8
+ targetCPUUtilizationPercentage: 100
+ targetMemoryUtilizationPercentage: 100
+ behavior:
+ scaleUp:
+ stabilizationWindowSeconds: 120
+ policies:
+ - type: Percent
+ value: 100
+ periodSeconds: 15
+ - type: Pods
+ value: 4
+ periodSeconds: 15
+ selectPolicy: Max
+ scaleDown:
+ stabilizationWindowSeconds: 300
+ policies:
+ - type: Percent
+ value: 50
+ periodSeconds: 30
+ - type: Pods
+ value: 2
+ periodSeconds: 30
+ selectPolicy: Max
+
+resources:
+ requests:
+ cpu: "600m"
+ memory: "1600Mi"
+ limits:
+ cpu: "1000m"
+ memory: "3500Mi"
+
+lifecycle:
+ enabled: true
+ preStop:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - sleep 15
+
+nodeSelector: {}
+
+tolerations: []
+
+affinity: {}
+
+extraEnvVars: []
+
+# Startup probe configuration (HTTP /healthcheck on httpPort)
+# Allows slow upstream initialization before liveness/readiness probes start
+# Pod has up to initialDelaySeconds + (periodSeconds * failureThreshold) to start
+startupProbe:
+ path: /healthcheck
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 6
+
+# Liveness probe configuration (TCP socket on httpPort)
+# Checks if HTTP server is running, otherwise eRPC itself is dead
+# Only runs after startup probe succeeds
+livenessProbe:
+ periodSeconds: 10
+ timeoutSeconds: 5
+ failureThreshold: 3
+
+# Readiness probe configuration (HTTP /healthcheck endpoint)
+# Pod stops receiving traffic if readiness fails
+# Detects when service should be removed from load balancers
+readinessProbe:
+ path: /healthcheck
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 2
+
+monitoring:
+ podMonitor:
+ enabled: true
+ # Additional labels for the PodMonitor
+ additionalLabels: {}
+ # Annotations for the PodMonitor
+ annotations: {}
+ # Scrape interval
+ interval: 30s
+ # Scrape timeout
+ scrapeTimeout: 10s
+ # Metrics path
+ path: /metrics
+ # Scheme (http or https)
+ scheme: http
+ # Honor labels from the scraped metrics
+ honorLabels: false
+ # Metric relabeling configurations
+ metricRelabelings: []
+ # Relabeling configurations
+ relabelings: []
+ # Namespace selector
+ namespaceSelector: {}
+ # Job label
+ jobLabel: ""
+ # Pod target labels
+ podTargetLabels: []
+ # Sample limit
+ sampleLimit: 0
+ # Target limit
+ targetLimit: 0
+
+postgresql:
+ storage: "500Gi"
+ storageClass: gp3
+ volumeAttributesClassName: ""
+ s3SARoleArn: "arn:aws:iam::537124939463:role/prd-erpc-backup"
+ vault:
+ secretsPath: "secret/data/erpc/db"
+ backup:
+ # Empty = infer volumeSnapshot when volumeSnapshotClassName is set.
+ method: ""
+ volumeSnapshotClassName: ""
+ retentionPolicy: "7d"
+ destinationPath: "s3://prd-morpho-database-backup/erpc-backups"
+ sharedBuffers: "2GB"
+ effectiveCacheSize: "6GB"
+ maintenanceWorkMem: "512MB"
+ workMem: "16MB"
+ maxWorkerProcesses: "2"
+ maxParallelWorkers: "2"
+ maxParallelWorkersPerGather: "1"
+ maxParallelMaintenanceWorkers: "1"
+ autovacuumMaxWorkers: "2"
+ resources:
+ requests:
+ cpu: "2"
+ memory: "8Gi"
+ limits:
+ cpu: "2"
+ memory: "8Gi"
+ # Node affinity for PostgreSQL cluster
+ # Set to null to disable affinity (e.g., in dev)
+ affinity:
+ nodeSelector:
+ purpose: disruptable-database
+ nodegroup: disruptable-database-nodes
+ tolerations:
+ - key: "dedicated"
+ operator: "Equal"
+ value: "disruptable-database"
+ effect: "NoSchedule"
+ # Raw block injected into spec.affinity.additionalPodAntiAffinity. Use to
+ # enforce 1-pod-per-node packing across all CNPG clusters in PRD.
+ additionalPodAntiAffinity: {}
diff --git a/helm/environments/prd/.gitkeep b/helm/environments/prd/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/helm/environments/prd/erpc/Chart.lock b/helm/environments/prd/erpc/Chart.lock
new file mode 100644
index 000000000..ed8c8bf25
--- /dev/null
+++ b/helm/environments/prd/erpc/Chart.lock
@@ -0,0 +1,33 @@
+dependencies:
+- name: erpc
+ repository: file://../../../charts/erpc
+ version: 0.1.35
+- name: erpc
+ repository: file://../../../charts/erpc
+ version: 0.1.35
+- name: erpc
+ repository: file://../../../charts/erpc
+ version: 0.1.35
+- name: erpc
+ repository: file://../../../charts/erpc
+ version: 0.1.35
+- name: erpc
+ repository: file://../../../charts/erpc
+ version: 0.1.35
+- name: redis-ha
+ repository: https://dandydeveloper.github.io/charts
+ version: 4.35.2
+- name: haproxy
+ repository: https://haproxytech.github.io/helm-charts
+ version: 1.26.1
+- name: haproxy
+ repository: https://haproxytech.github.io/helm-charts
+ version: 1.26.1
+- name: haproxy
+ repository: https://haproxytech.github.io/helm-charts
+ version: 1.26.1
+- name: prometheus-redis-exporter
+ repository: https://prometheus-community.github.io/helm-charts
+ version: 6.8.0
+digest: sha256:6f63ffe63edeae52172c50f6d1dd3ed8f12e964a7c8af088c49daeeb4f2a5cda
+generated: "2026-06-16T17:08:14.874547+02:00"
diff --git a/helm/environments/prd/erpc/Chart.yaml b/helm/environments/prd/erpc/Chart.yaml
new file mode 100644
index 000000000..054078259
--- /dev/null
+++ b/helm/environments/prd/erpc/Chart.yaml
@@ -0,0 +1,50 @@
+apiVersion: v2
+name: erpc-prd
+description: eRPC fault-tolerant EVM RPC proxy - Production Environment
+type: application
+version: 0.1.40
+appVersion: "latest"
+
+dependencies:
+ - name: erpc
+ repository: file://../../../charts/erpc
+ version: 0.1.35
+ - name: erpc
+ alias: erpc-dev
+ condition: erpc-dev.enabled
+ repository: file://../../../charts/erpc
+ version: 0.1.35
+ - name: erpc
+ alias: erpc-processing
+ condition: erpc-processing.enabled
+ repository: file://../../../charts/erpc
+ version: 0.1.35
+ - name: erpc
+ alias: erpc-router
+ condition: erpc-router.enabled
+ repository: file://../../../charts/erpc
+ version: 0.1.35
+ - name: erpc
+ alias: erpc-fallback
+ condition: erpc-fallback.enabled
+ repository: file://../../../charts/erpc
+ version: 0.1.35
+ - name: redis-ha
+ version: "4.35.2"
+ repository: "https://dandydeveloper.github.io/charts"
+ - name: haproxy
+ alias: erpc-haproxy
+ repository: https://haproxytech.github.io/helm-charts
+ version: "1.26.1"
+ - name: haproxy
+ alias: erpc-processing-haproxy
+ repository: https://haproxytech.github.io/helm-charts
+ version: "1.26.1"
+ - name: haproxy
+ alias: erpc-router-haproxy
+ repository: https://haproxytech.github.io/helm-charts
+ version: "1.26.1"
+ - name: prometheus-redis-exporter
+ alias: redis-exporter
+ version: "6.8.0"
+ repository: "https://prometheus-community.github.io/helm-charts"
diff --git a/helm/environments/prd/erpc/charts/erpc-0.1.35.tgz b/helm/environments/prd/erpc/charts/erpc-0.1.35.tgz
new file mode 100644
index 000000000..eb0368df3
Binary files /dev/null and b/helm/environments/prd/erpc/charts/erpc-0.1.35.tgz differ
diff --git a/helm/environments/prd/erpc/charts/haproxy-1.26.1.tgz b/helm/environments/prd/erpc/charts/haproxy-1.26.1.tgz
new file mode 100644
index 000000000..1fd3a048a
Binary files /dev/null and b/helm/environments/prd/erpc/charts/haproxy-1.26.1.tgz differ
diff --git a/helm/environments/prd/erpc/charts/prometheus-redis-exporter-6.8.0.tgz b/helm/environments/prd/erpc/charts/prometheus-redis-exporter-6.8.0.tgz
new file mode 100644
index 000000000..20fcaca22
Binary files /dev/null and b/helm/environments/prd/erpc/charts/prometheus-redis-exporter-6.8.0.tgz differ
diff --git a/helm/environments/prd/erpc/charts/redis-ha-4.35.2.tgz b/helm/environments/prd/erpc/charts/redis-ha-4.35.2.tgz
new file mode 100644
index 000000000..9fcf90434
Binary files /dev/null and b/helm/environments/prd/erpc/charts/redis-ha-4.35.2.tgz differ
diff --git a/helm/environments/prd/erpc/templates/_helpers.tpl b/helm/environments/prd/erpc/templates/_helpers.tpl
new file mode 100644
index 000000000..6ff9bbc30
--- /dev/null
+++ b/helm/environments/prd/erpc/templates/_helpers.tpl
@@ -0,0 +1,23 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "erpc-prd.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+*/}}
+{{- define "erpc-prd.fullname" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default .Chart.Name .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
diff --git a/helm/environments/prd/erpc/templates/erpc-haproxy-hpa.yaml b/helm/environments/prd/erpc/templates/erpc-haproxy-hpa.yaml
new file mode 100644
index 000000000..afceea903
--- /dev/null
+++ b/helm/environments/prd/erpc/templates/erpc-haproxy-hpa.yaml
@@ -0,0 +1,35 @@
+{{- if (index .Values "erpc-haproxy-hpa" "enabled") }}
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: erpc-erpc-haproxy
+ labels:
+ app.kubernetes.io/name: haproxy
+ app.kubernetes.io/instance: erpc-erpc-haproxy
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: erpc-erpc-haproxy
+ minReplicas: {{ index .Values "erpc-haproxy-hpa" "minReplicas" }}
+ maxReplicas: {{ index .Values "erpc-haproxy-hpa" "maxReplicas" }}
+ metrics:
+ {{- if index .Values "erpc-haproxy-hpa" "targetCPUUtilizationPercentage" }}
+ - type: Pods
+ pods:
+ metric:
+ name: cpu_utilization_percentage
+ target:
+ type: AverageValue
+ averageValue: {{ index .Values "erpc-haproxy-hpa" "targetCPUUtilizationPercentage" }}
+ {{- end }}
+ {{- if index .Values "erpc-haproxy-hpa" "targetMemoryUtilizationPercentage" }}
+ - type: Pods
+ pods:
+ metric:
+ name: memory_utilization_percentage
+ target:
+ type: AverageValue
+ averageValue: {{ index .Values "erpc-haproxy-hpa" "targetMemoryUtilizationPercentage" }}
+ {{- end }}
+{{- end }}
diff --git a/helm/environments/prd/erpc/templates/erpc-processing-haproxy-hpa.yaml b/helm/environments/prd/erpc/templates/erpc-processing-haproxy-hpa.yaml
new file mode 100644
index 000000000..0fa8b67e2
--- /dev/null
+++ b/helm/environments/prd/erpc/templates/erpc-processing-haproxy-hpa.yaml
@@ -0,0 +1,35 @@
+{{- if (index .Values "erpc-processing-haproxy-hpa" "enabled") }}
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: erpc-erpc-processing-haproxy
+ labels:
+ app.kubernetes.io/name: haproxy
+ app.kubernetes.io/instance: erpc-erpc-processing-haproxy
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: erpc-erpc-processing-haproxy
+ minReplicas: {{ index .Values "erpc-processing-haproxy-hpa" "minReplicas" }}
+ maxReplicas: {{ index .Values "erpc-processing-haproxy-hpa" "maxReplicas" }}
+ metrics:
+ {{- if index .Values "erpc-processing-haproxy-hpa" "targetCPUUtilizationPercentage" }}
+ - type: Pods
+ pods:
+ metric:
+ name: cpu_utilization_percentage
+ target:
+ type: AverageValue
+ averageValue: {{ index .Values "erpc-processing-haproxy-hpa" "targetCPUUtilizationPercentage" }}
+ {{- end }}
+ {{- if index .Values "erpc-processing-haproxy-hpa" "targetMemoryUtilizationPercentage" }}
+ - type: Pods
+ pods:
+ metric:
+ name: memory_utilization_percentage
+ target:
+ type: AverageValue
+ averageValue: {{ index .Values "erpc-processing-haproxy-hpa" "targetMemoryUtilizationPercentage" }}
+ {{- end }}
+{{- end }}
diff --git a/helm/environments/prd/erpc/templates/erpc-router-haproxy-hpa.yaml b/helm/environments/prd/erpc/templates/erpc-router-haproxy-hpa.yaml
new file mode 100644
index 000000000..8fa9e42fc
--- /dev/null
+++ b/helm/environments/prd/erpc/templates/erpc-router-haproxy-hpa.yaml
@@ -0,0 +1,35 @@
+{{- if (index .Values "erpc-router-haproxy-hpa" "enabled") }}
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: erpc-erpc-router-haproxy
+ labels:
+ app.kubernetes.io/name: haproxy
+ app.kubernetes.io/instance: erpc-erpc-router-haproxy
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: erpc-erpc-router-haproxy
+ minReplicas: {{ index .Values "erpc-router-haproxy-hpa" "minReplicas" }}
+ maxReplicas: {{ index .Values "erpc-router-haproxy-hpa" "maxReplicas" }}
+ metrics:
+ {{- if index .Values "erpc-router-haproxy-hpa" "targetCPUUtilizationPercentage" }}
+ - type: Pods
+ pods:
+ metric:
+ name: cpu_utilization_percentage
+ target:
+ type: AverageValue
+ averageValue: {{ index .Values "erpc-router-haproxy-hpa" "targetCPUUtilizationPercentage" }}
+ {{- end }}
+ {{- if index .Values "erpc-router-haproxy-hpa" "targetMemoryUtilizationPercentage" }}
+ - type: Pods
+ pods:
+ metric:
+ name: memory_utilization_percentage
+ target:
+ type: AverageValue
+ averageValue: {{ index .Values "erpc-router-haproxy-hpa" "targetMemoryUtilizationPercentage" }}
+ {{- end }}
+{{- end }}
diff --git a/helm/environments/prd/erpc/templates/job-vault-redis.yaml b/helm/environments/prd/erpc/templates/job-vault-redis.yaml
new file mode 100644
index 000000000..99a760a8f
--- /dev/null
+++ b/helm/environments/prd/erpc/templates/job-vault-redis.yaml
@@ -0,0 +1,100 @@
+{{- if index .Values "redis-ha" "enabled" }}
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: {{ .Release.Name }}-redis-vault-secret-creator
+ labels:
+ app.kubernetes.io/name: {{ .Release.Name }}-redis-vault
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ annotations:
+ "helm.sh/hook": pre-install,pre-upgrade
+ "helm.sh/hook-weight": "0"
+ "helm.sh/hook-delete-policy": hook-succeeded,hook-failed
+ argocd.argoproj.io/sync-wave: "-1"
+ argocd.argoproj.io/sync-options: Force=true,Replace=true
+spec:
+ backoffLimit: 3
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: {{ .Release.Name }}-redis-vault
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ job: redis-vault-secret-creator
+ annotations:
+ vault.hashicorp.com/agent-inject: "true"
+ vault.hashicorp.com/role: erpc
+ vault.hashicorp.com/agent-inject-secret-redis: {{ index .Values "redis-ha" "vault" "secretsPath" | quote }}
+ vault.hashicorp.com/agent-inject-template-redis: |
+ {{`{{- with secret "`}}{{ index .Values "redis-ha" "vault" "secretsPath" }}{{`" -}}
+ {{- .Data.data.password -}}
+ {{- end }}`}}
+ vault.hashicorp.com/agent-pre-populate-only: "true"
+ spec:
+ serviceAccountName: erpc
+ automountServiceAccountToken: true
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ runAsGroup: 1000
+ fsGroup: 1000
+ seccompProfile:
+ type: RuntimeDefault
+ restartPolicy: OnFailure
+ volumes:
+ - name: tmp
+ emptyDir: {}
+ containers:
+ - name: secret-creator
+ image: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/alpine/k8s:1.31.0
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop:
+ - ALL
+ env:
+ - name: HOME
+ value: /tmp
+ volumeMounts:
+ - name: tmp
+ mountPath: /tmp
+ resources:
+ requests:
+ cpu: 100m
+ memory: 128Mi
+ limits:
+ cpu: 200m
+ memory: 256Mi
+ command:
+ - sh
+ - -c
+ - |
+ echo "Creating Redis auth secret from Vault..."
+
+ # Wait for vault agent to complete
+ timeout=60
+ count=0
+ while [ ! -f /vault/secrets/redis ] && [ $count -lt $timeout ]; do
+ echo "Waiting for vault secrets file... (${count}s/${timeout}s)"
+ sleep 1
+ count=$((count + 1))
+ done
+
+ if [ ! -f /vault/secrets/redis ]; then
+ echo "ERROR: Vault secrets file not found after $timeout seconds"
+ exit 1
+ fi
+
+ echo "Vault secrets file found. Creating Kubernetes Secret..."
+
+ # Create secret for redis-ha chart (pipe password directly to avoid shell variable exposure)
+ kubectl create secret generic {{ index .Values "redis-ha" "existingSecret" }} \
+ --namespace={{ .Release.Namespace }} \
+ --from-literal={{ index .Values "redis-ha" "authKey" }}="$(cat /vault/secrets/redis)" \
+ --dry-run=client -o yaml | kubectl apply -f -
+
+ echo "Secret {{ index .Values "redis-ha" "existingSecret" }} created successfully"
+
+ # Verify secret was created
+ kubectl get secret {{ index .Values "redis-ha" "existingSecret" }} -n {{ .Release.Namespace }}
+{{- end }}
diff --git a/helm/environments/prd/erpc/templates/prometheus-rules.yaml b/helm/environments/prd/erpc/templates/prometheus-rules.yaml
new file mode 100644
index 000000000..9772e9fce
--- /dev/null
+++ b/helm/environments/prd/erpc/templates/prometheus-rules.yaml
@@ -0,0 +1,335 @@
+{{- $prodErpcSelector := printf "job=~\"%s/erpc(-dev|-processing|-router|-fallback)?\"" .Release.Namespace }}
+{{- $prodErpcMatcher := printf "{%s}" $prodErpcSelector }}
+{{- $prodErpcActionableFailureMatcher := printf "{%s,error!=\"ErrUpstreamsExhausted\"}" $prodErpcSelector }}
+{{- $prodErpcFallbackMatcher := printf "{job=\"%s/erpc-fallback\"}" .Release.Namespace }}
+{{- $prodErpcFallbackActionableFailureMatcher := printf "{job=\"%s/erpc-fallback\",error!=\"ErrUpstreamsExhausted\"}" .Release.Namespace }}
+{{- $prodErpcPrimaryMatcher := printf "{job=~\"%s/erpc(-processing|-router)?\"}" .Release.Namespace }}
+{{- $prodErpcPodRegex := "(erpc|erpc-dev|erpc-processing|erpc-router|erpc-fallback)-[^-]+-[0-9a-z]{5}" }}
+{{- $prodErpcRuntimeContainerRegex := "erpc(-dev|-processing|-router|-fallback)?" }}
+{{- $fullName := include "erpc-prd.fullname" . }}
+{{- $dropLabels := list "agent_name" "container" "container_id" "endpoint" "host_arch" "host_name" "instance" "k8s_pod_name" "namespace" "net_host_name" "net_host_port" "net_peer_name" "net_peer_port" "otel_scope_version" "pod" "process_command" "process_command_args" "process_executable_name" "process_executable_path" "process_owner" "process_pid" "process_runtime_description" "process_runtime_name" "process_runtime_version" "server_address" "server_port" "service_instance_id" | join ", " }}
+{{- $histogramFamilies := list "erpc_upstream_request_duration_seconds" "erpc_network_request_duration_seconds" "erpc_cache_set_success_duration_seconds" "erpc_cache_set_error_duration_seconds" "erpc_network_hedge_delay_seconds" "erpc_network_timeout_duration_seconds" "erpc_network_upstream_calls_per_request" "erpc_cache_get_error_duration_seconds" "erpc_cache_get_success_miss_duration_seconds" "erpc_cache_get_success_hit_duration_seconds" "erpc_network_evm_get_logs_range_requested" "erpc_consensus_agreement_count" "erpc_consensus_responses_collected" "erpc_x402_facilitator_request_duration_seconds" "erpc_multicall3_batch_wait_ms" "erpc_multicall3_batch_size" "erpc_shared_state_poll_lock_wait_duration_seconds" "erpc_shared_state_poll_lock_fallback_duration_seconds" "erpc_http_ingress_pre_forward_duration_seconds" "erpc_rate_limiter_permit_evaluation_duration_seconds" "erpc_rate_limiter_permit_wait_duration_seconds" "erpc_shared_state_poll_lock_hold_duration_seconds" }}
+{{- $counterMetrics := list "erpc_upstream_request_total" "erpc_network_successful_request_total" "erpc_network_request_received_total" "erpc_cache_set_original_bytes_total" "erpc_cache_set_success_total" "erpc_cache_set_error_total" "erpc_cache_set_compressed_bytes_total" "erpc_cache_get_error_total" "erpc_network_evm_block_range_requested_total" "erpc_network_evm_get_logs_forced_splits_total" "erpc_network_evm_get_logs_split_failure_total" "erpc_network_evm_get_logs_split_success_total" "erpc_network_request_self_rate_limited_total" "erpc_upstream_request_remote_rate_limited_total" "erpc_upstream_request_errors_total" "erpc_upstream_request_missing_data_error_total" "erpc_cache_get_success_miss_total" "erpc_cache_get_success_hit_total" "erpc_upstream_finalized_block_polled_total" "erpc_upstream_latest_block_polled_total" "erpc_upstream_stale_upper_bound_total" "erpc_upstream_stale_lower_bound_total" "erpc_upstream_stale_latest_block_total" "erpc_upstream_stale_finalized_block_total" "erpc_upstream_request_empty_response_total" "erpc_network_hedged_request_total" "erpc_network_hedge_discards_total" "erpc_network_multiplexed_request_total" "erpc_multicall3_user_percall_cache_miss_total" "erpc_network_hedge_lost_total" "erpc_network_failed_request_total" "erpc_network_attempt_reason_total" "erpc_cache_get_age_guard_reject_total" "erpc_multicall3_user_percall_cache_hit_total" "erpc_cache_set_skipped_total" "erpc_upstream_sorted_method_cache_pruned_total" "erpc_multicall3_aggregation_total" "erpc_upstream_wrong_empty_response_total" "erpc_multicall3_batch_percall_cache_miss_total" "erpc_cache_envelope_unwrap_failure_total" "erpc_multicall3_batch_percall_cache_set_total" "erpc_shared_state_poll_lock_total" "erpc_multicall3_cache_hits_total" "erpc_multicall3_cache_misses_total" "erpc_cors_disallowed_origin_total" "erpc_cors_preflight_requests_total" "erpc_cors_requests_total" "erpc_consensus_misbehavior_detected_total" "erpc_consensus_total" "erpc_consensus_short_circuit_total" "erpc_consensus_errors_total" "erpc_consensus_cancellations_total" "erpc_rate_limits_total" "erpc_rate_limiter_failopen_total" "erpc_x402_payment_total" "erpc_x402_facilitator_request_total" "erpc_unexpected_panic_total" "erpc_shadow_response_identical_total" "erpc_shadow_response_mismatch_total" "erpc_shadow_response_error_total" }}
+{{- $gaugeMetrics := list "erpc_upstream_latest_block_number" "erpc_upstream_finalized_block_number" "erpc_upstream_block_head_lag" "erpc_upstream_block_head_large_rollback" "erpc_upstream_finalization_lag" "erpc_upstream_routing_priority" "erpc_upstream_score_overall" "erpc_network_latest_block_timestamp_distance_seconds" "erpc_network_dynamic_block_time_milliseconds" "erpc_network_cache_write_queue_depth" "erpc_multicall3_queue_len" "erpc_http_ingress_inflight" "erpc_rate_limiter_budget_max_count" }}
+apiVersion: monitoring.coreos.com/v1
+kind: PrometheusRule
+metadata:
+ name: {{ printf "%s-recording-rules" $fullName | trunc 63 | trimSuffix "-" }}
+ labels:
+ helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+ app.kubernetes.io/name: {{ .Chart.Name }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ release: prometheus-stack
+spec:
+ groups:
+ - name: erpc.metric-recording.rules
+ interval: 30s
+ limit: 100000
+ rules:
+ {{- range $family := $histogramFamilies }}
+ {{- range $suffix := list "bucket" "sum" "count" }}
+ - record: "erpc:{{ trimPrefix "erpc_" $family }}_{{ $suffix }}:rate5m"
+ expr: |
+ sum without ({{ $dropLabels }}) (
+ rate({{ $family }}_{{ $suffix }}{{ $prodErpcMatcher }}[5m])
+ )
+ {{- end }}
+ {{- end }}
+ {{- range $metric := $counterMetrics }}
+ - record: "erpc:{{ trimPrefix "erpc_" $metric }}:rate5m"
+ expr: |
+ sum without ({{ $dropLabels }}) (
+ rate({{ $metric }}{{ $prodErpcMatcher }}[5m])
+ )
+ {{- end }}
+ {{- range $metric := $gaugeMetrics }}
+ - record: "erpc:{{ trimPrefix "erpc_" $metric }}:max"
+ expr: |
+ max without ({{ $dropLabels }}) (
+ {{ $metric }}{{ $prodErpcMatcher }}
+ )
+ {{- end }}
+
+ - record: erpc:container_memory_working_set:limit_ratio_by_pod
+ expr: |
+ sum by (pod) (
+ container_memory_working_set_bytes{namespace="{{ .Release.Namespace }}",container=~"{{ $prodErpcRuntimeContainerRegex }}",pod=~"{{ $prodErpcPodRegex }}"}
+ )
+ /
+ clamp_min(
+ sum by (pod) (
+ kube_pod_container_resource_limits{namespace="{{ .Release.Namespace }}",container=~"{{ $prodErpcRuntimeContainerRegex }}",resource="memory",pod=~"{{ $prodErpcPodRegex }}"}
+ ),
+ 1
+ )
+
+ - name: erpc.controller.rules
+ interval: 5s
+ rules:
+ - record: erpc:network_request_duration_seconds_bucket:rate1m_by_pod
+ expr: |
+ sum by (pod, le) (
+ rate(erpc_network_request_duration_seconds_bucket{{ $prodErpcMatcher }}[1m])
+ )
+
+ - record: erpc:network_request_received_total:rate1m_by_pod
+ expr: |
+ sum by (pod) (
+ rate(erpc_network_request_received_total{{ $prodErpcMatcher }}[1m])
+ )
+
+ - record: erpc:network_failed_request_total:rate1m_by_pod
+ expr: |
+ sum by (pod) (
+ rate(erpc_network_failed_request_total{{ $prodErpcMatcher }}[1m])
+ )
+
+ - record: erpc:network_failed_actionable_request_total:rate1m_by_pod
+ expr: |
+ sum by (pod) (
+ rate(erpc_network_failed_request_total{{ $prodErpcActionableFailureMatcher }}[1m])
+ )
+
+ - record: erpc:go_goroutines:by_pod
+ expr: |
+ max by (pod, instance) (
+ go_goroutines{namespace="{{ .Release.Namespace }}",pod=~"{{ $prodErpcPodRegex }}"}
+ )
+
+ - record: erpc:container_cpu_usage:limit_ratio1m_by_pod
+ expr: |
+ sum by (pod) (
+ rate(container_cpu_usage_seconds_total{namespace="{{ .Release.Namespace }}",container=~"{{ $prodErpcRuntimeContainerRegex }}",pod=~"{{ $prodErpcPodRegex }}"}[1m])
+ )
+ /
+ clamp_min(
+ sum by (pod) (
+ kube_pod_container_resource_limits{namespace="{{ .Release.Namespace }}",container=~"{{ $prodErpcRuntimeContainerRegex }}",resource="cpu",pod=~"{{ $prodErpcPodRegex }}"}
+ ),
+ 0.001
+ )
+
+ - name: erpc.fallback-hot-standby.rules
+ interval: 30s
+ rules:
+ - record: erpc:fallback_request_total:rate5m
+ expr: |
+ sum(
+ rate(erpc_network_request_received_total{{ $prodErpcFallbackMatcher }}[5m])
+ )
+
+ - record: erpc:primary_request_total:rate5m
+ expr: |
+ sum(
+ rate(erpc_network_request_received_total{{ $prodErpcPrimaryMatcher }}[5m])
+ )
+
+ - record: erpc:fallback_request_share:ratio5m
+ expr: |
+ (erpc:fallback_request_total:rate5m or on() vector(0))
+ /
+ clamp_min(
+ (erpc:primary_request_total:rate5m or on() vector(0))
+ +
+ (erpc:fallback_request_total:rate5m or on() vector(0)),
+ 1
+ )
+
+ - record: erpc:fallback_failed_request_total:rate5m_by_error_network_method
+ expr: |
+ sum by (error, network, method) (
+ rate(erpc_network_failed_request_total{{ $prodErpcFallbackMatcher }}[5m])
+ )
+
+ - record: erpc:fallback_actionable_failed_request_total:rate5m
+ expr: |
+ sum(
+ rate(erpc_network_failed_request_total{{ $prodErpcFallbackActionableFailureMatcher }}[5m])
+ )
+
+ - record: erpc:fallback_actionable_failure_ratio:ratio5m
+ expr: |
+ (erpc:fallback_actionable_failed_request_total:rate5m or on() vector(0))
+ /
+ clamp_min((erpc:fallback_request_total:rate5m or on() vector(0)), 1)
+
+ - record: erpc:haproxy_request_total:rate5m_by_proxy
+ expr: |
+ sum by (proxy) (
+ rate(haproxy_server_http_responses_total{namespace="{{ .Release.Namespace }}",service=~"erpc-erpc.*haproxy",proxy=~"erpc(-processing|-router)?-(backend|fallback-backend)",server=~"erpc(-fallback-direct|-fallback|-processing|-router)?[0-9]+",code=~"2xx|3xx|4xx|5xx"}[5m])
+ )
+
+ - record: erpc:haproxy_request_total:rate5m_by_proxy_target
+ expr: |
+ sum by (proxy, target) (
+ label_replace(
+ rate(haproxy_server_http_responses_total{namespace="{{ .Release.Namespace }}",service=~"erpc-erpc.*haproxy",proxy=~"erpc(-processing|-router)?-(backend|fallback-backend)",server=~"erpc(-fallback-direct|-fallback|-processing|-router)?[0-9]+",code=~"2xx|3xx|4xx|5xx"}[5m]),
+ "target", "$1", "server", "^(erpc-fallback-direct|erpc-fallback|erpc-processing|erpc-router|erpc)[0-9]+$"
+ )
+ )
+
+ - record: erpc:haproxy_fallback_request_total:rate5m_by_proxy
+ expr: |
+ sum by (proxy) (
+ rate(haproxy_server_http_responses_total{namespace="{{ .Release.Namespace }}",service=~"erpc-erpc.*haproxy",proxy=~"erpc(-processing|-router)?-(backend|fallback-backend)",server=~"erpc-fallback.*",code=~"2xx|3xx|4xx|5xx"}[5m])
+ )
+
+ - record: erpc:haproxy_direct_fallback_request_total:rate5m_by_proxy
+ expr: |
+ sum by (proxy) (
+ rate(haproxy_server_http_responses_total{namespace="{{ .Release.Namespace }}",service=~"erpc-erpc.*haproxy",proxy=~"erpc(-processing|-router)?-fallback-backend",server=~"erpc-fallback-direct[0-9]+",code=~"2xx|3xx|4xx|5xx"}[5m])
+ )
+
+ - record: erpc:haproxy_weighted_fallback_request_total:rate5m_by_proxy
+ expr: |
+ sum by (proxy) (
+ rate(haproxy_server_http_responses_total{namespace="{{ .Release.Namespace }}",service=~"erpc-erpc.*haproxy",proxy=~"erpc(-processing|-router)?-backend",server=~"erpc-fallback[0-9]+",code=~"2xx|3xx|4xx|5xx"}[5m])
+ )
+
+ - record: erpc:haproxy_fallback_request_share:ratio5m_by_proxy
+ expr: |
+ (
+ erpc:haproxy_fallback_request_total:rate5m_by_proxy
+ or on(proxy) (0 * erpc:haproxy_request_total:rate5m_by_proxy)
+ )
+ /
+ clamp_min(erpc:haproxy_request_total:rate5m_by_proxy, 1)
+
+ - alert: ErpcFallbackTrafficHigh
+ expr: |
+ erpc:fallback_request_share:ratio5m > 0.2
+ and
+ erpc:fallback_request_total:rate5m > 10
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "eRPC fallback traffic high"
+ description: "Fallback serves more than 20% of eRPC traffic and more than 10 RPS for 5m. Check main/router/processing health and fallback upstream quota."
+
+ - alert: ErpcFallbackTrafficHighByProxy
+ expr: |
+ erpc:haproxy_fallback_request_share:ratio5m_by_proxy > 0.8
+ and
+ erpc:haproxy_fallback_request_total:rate5m_by_proxy > 25
+ for: 15m
+ labels:
+ severity: warning
+ annotations:
+ summary: "eRPC fallback traffic high for {{`{{ $labels.proxy }}`}}"
+ description: "{{`{{ $labels.proxy }}`}} sends more than 80% of its eRPC traffic and more than 25 RPS to fallback for 15m. Check the matching main/router/processing pool and fallback upstream quota."
+
+ - alert: ErpcFallbackActionableFailuresHigh
+ expr: |
+ erpc:fallback_actionable_failure_ratio:ratio5m > 0.05
+ and
+ erpc:fallback_request_total:rate5m > 10
+ for: 10m
+ labels:
+ severity: warning
+ annotations:
+ summary: "eRPC fallback actionable failures high"
+ description: "Fallback actionable failure ratio is above 5% while fallback serves more than 10 RPS for 10m, excluding ErrUpstreamsExhausted. Check fallback upstream health and quotas."
+
+ - name: erpc.latency.rules
+ interval: 1m
+ rules:
+ - record: erpc:upstream_request_duration_seconds:p50:by_network
+ expr: |
+ histogram_quantile(0.5,
+ sum by (network, le) (erpc:upstream_request_duration_seconds_bucket:rate5m)
+ )
+
+ - record: erpc:upstream_request_duration_seconds:p90:by_network
+ expr: |
+ histogram_quantile(0.9,
+ sum by (network, le) (erpc:upstream_request_duration_seconds_bucket:rate5m)
+ )
+
+ - record: erpc:upstream_request_duration_seconds:p50:by_vendor
+ expr: |
+ histogram_quantile(0.5,
+ sum by (vendor, le) (
+ label_replace(
+ erpc:upstream_request_duration_seconds_bucket:rate5m,
+ "vendor", "$1", "upstream", "([^-]+)-.*"
+ )
+ )
+ )
+
+ - record: erpc:upstream_request_duration_seconds:p90:by_vendor
+ expr: |
+ histogram_quantile(0.9,
+ sum by (vendor, le) (
+ label_replace(
+ erpc:upstream_request_duration_seconds_bucket:rate5m,
+ "vendor", "$1", "upstream", "([^-]+)-.*"
+ )
+ )
+ )
+
+ - record: erpc:upstream_request_duration_seconds:p50
+ expr: |
+ histogram_quantile(0.5,
+ sum by (le) (erpc:upstream_request_duration_seconds_bucket:rate5m)
+ )
+
+ - record: erpc:upstream_request_duration_seconds:p90
+ expr: |
+ histogram_quantile(0.9,
+ sum by (le) (erpc:upstream_request_duration_seconds_bucket:rate5m)
+ )
+
+ - record: erpc:network_request_duration_seconds:p50:by_network
+ expr: |
+ histogram_quantile(0.5,
+ sum by (network, le) (erpc:network_request_duration_seconds_bucket:rate5m)
+ )
+
+ - record: erpc:network_request_duration_seconds:p90:by_network
+ expr: |
+ histogram_quantile(0.9,
+ sum by (network, le) (erpc:network_request_duration_seconds_bucket:rate5m)
+ )
+
+ - record: erpc:cache_get_miss_duration_seconds:p50
+ expr: |
+ histogram_quantile(0.5,
+ sum by (le) (erpc:cache_get_success_miss_duration_seconds_bucket:rate5m)
+ )
+
+ - record: erpc:cache_get_miss_duration_seconds:p90
+ expr: |
+ histogram_quantile(0.9,
+ sum by (le) (erpc:cache_get_success_miss_duration_seconds_bucket:rate5m)
+ )
+
+ - name: erpc.daily.rules
+ interval: 5m
+ rules:
+ - record: erpc:requests:rate5m
+ expr: sum(erpc:network_request_received_total:rate5m)
+
+ - record: erpc:upstream_ratio:rate5m
+ expr: |
+ sum(erpc:upstream_request_total:rate5m)
+ /
+ sum(erpc:network_request_received_total:rate5m)
+
+ - record: erpc:cache_hit_rate:rate5m
+ expr: |
+ sum(erpc:cache_get_success_hit_total:rate5m)
+ /
+ (
+ sum(erpc:cache_get_success_hit_total:rate5m)
+ +
+ sum(erpc:cache_get_success_miss_total:rate5m)
+ )
+
+ - record: erpc:error_rate:rate5m
+ expr: |
+ sum(erpc:upstream_request_errors_total:rate5m)
+ /
+ sum(erpc:upstream_request_total:rate5m)
diff --git a/helm/environments/prd/erpc/templates/rbac-vault-redis.yaml b/helm/environments/prd/erpc/templates/rbac-vault-redis.yaml
new file mode 100644
index 000000000..e55bcebc4
--- /dev/null
+++ b/helm/environments/prd/erpc/templates/rbac-vault-redis.yaml
@@ -0,0 +1,44 @@
+{{- if index .Values "redis-ha" "enabled" }}
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: {{ .Release.Name }}-redis-vault-secret
+ labels:
+ app.kubernetes.io/name: {{ .Release.Name }}-redis-vault
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ annotations:
+ "helm.sh/hook": pre-install,pre-upgrade
+ "helm.sh/hook-weight": "-5"
+ "helm.sh/hook-delete-policy": before-hook-creation
+ argocd.argoproj.io/sync-wave: "-2"
+rules:
+ - apiGroups: [""]
+ resources: ["secrets"]
+ verbs: ["create"]
+ - apiGroups: [""]
+ resources: ["secrets"]
+ resourceNames:
+ - {{ index .Values "redis-ha" "existingSecret" | quote }}
+ verbs: ["get", "patch", "update"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: {{ .Release.Name }}-redis-vault-secret
+ labels:
+ app.kubernetes.io/name: {{ .Release.Name }}-redis-vault
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ annotations:
+ "helm.sh/hook": pre-install,pre-upgrade
+ "helm.sh/hook-weight": "-5"
+ "helm.sh/hook-delete-policy": before-hook-creation
+ argocd.argoproj.io/sync-wave: "-2"
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: Role
+ name: {{ .Release.Name }}-redis-vault-secret
+subjects:
+ - kind: ServiceAccount
+ name: erpc
+ namespace: {{ .Release.Namespace }}
+{{- end }}
diff --git a/helm/environments/prd/erpc/templates/serviceaccount-vault.yaml b/helm/environments/prd/erpc/templates/serviceaccount-vault.yaml
new file mode 100644
index 000000000..93158905c
--- /dev/null
+++ b/helm/environments/prd/erpc/templates/serviceaccount-vault.yaml
@@ -0,0 +1,13 @@
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: erpc
+ labels:
+ app.kubernetes.io/name: erpc-vault
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ annotations:
+ argocd.argoproj.io/sync-wave: "-4"
+ {{- with .Values.erpc.serviceAccount.annotations }}
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+automountServiceAccountToken: false
diff --git a/helm/environments/prd/erpc/values.yaml b/helm/environments/prd/erpc/values.yaml
new file mode 100644
index 000000000..3c3b0d59c
--- /dev/null
+++ b/helm/environments/prd/erpc/values.yaml
@@ -0,0 +1,1972 @@
+# =============================================================================
+# HAProxy Smart Load Balancer for eRPC
+# =============================================================================
+# Provides intelligent traffic distribution based on real-time pod metrics:
+# - Goroutines: Direct measure of concurrent work (weight: 30%)
+# - P99 Latency: Response time degradation detection (weight: 35%)
+# - Request Rate: Actual load distribution (weight: 15%)
+# - CPU utilization: Resource pressure indicator (weight: 20%)
+#
+# Traffic flow: Client -> erpc-haproxy:4000 -> erpc pods (via headless service)
+# Auto fallback: erpc-fallback servers stay at weight 0 until Prometheus shows
+# sustained degradation on any public eRPC pool, then the matching HAProxy ramps
+# limited traffic to the upstream-image hot standby pool.
+# =============================================================================
+# Custom HPA for erpc-haproxy (overrides upstream chart HPA which uses metrics-server)
+erpc-haproxy-hpa:
+ enabled: true
+ minReplicas: 3
+ maxReplicas: 100
+ targetCPUUtilizationPercentage: 70
+ targetMemoryUtilizationPercentage: 80
+
+erpc-processing-haproxy-hpa:
+ enabled: true
+ minReplicas: 3
+ maxReplicas: 100
+ targetCPUUtilizationPercentage: 70
+ targetMemoryUtilizationPercentage: 80
+
+erpc-router-haproxy-hpa:
+ enabled: true
+ minReplicas: 3
+ maxReplicas: 100
+ targetCPUUtilizationPercentage: 70
+ targetMemoryUtilizationPercentage: 80
+
+erpc-haproxy:
+ image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/haproxytech/haproxy-alpine
+
+ rbac:
+ create: true
+
+ serviceMonitor:
+ enabled: true
+ extraLabels:
+ release: prometheus
+ endpoints:
+ - port: prometheus
+ path: /metrics
+ scheme: http
+ interval: 15s
+
+ # HAProxy configuration for HTTP RPC load balancing
+ config: |
+ global
+ stats socket /var/run/haproxy/admin.sock mode 660 level admin expose-fd listeners
+ stats timeout 30s
+ maxconn 500000
+ spread-checks 5
+ log stdout format raw local0 info
+ # Raise file descriptor limit to hard limit (was soft=65536, hard=1048576)
+ ulimit-n 1048576
+
+ # Performance tuning - maximized for throughput
+ # tune.bufsize: larger buffer for big JSON-RPC payloads (eth_call responses can be huge)
+ tune.bufsize 131072
+ # tune.maxrewrite: space for header rewriting
+ tune.maxrewrite 8192
+ # tune.http.maxhdr: max headers
+ tune.http.maxhdr 128
+ # tune.recv_enough: receive enough data before processing
+ tune.recv_enough 16384
+ # tune.runqueue-depth: scheduler queue depth for better throughput
+ tune.runqueue-depth 512
+
+ defaults
+ mode http
+ log global
+ option httplog
+ option dontlognull
+ option http-server-close
+ option forwardfor except 127.0.0.0/8
+
+ # Timeouts tuned for blockchain RPC (some calls can be slow)
+ timeout connect 5s
+ timeout client 60s
+ timeout server 60s
+ timeout http-request 30s
+ timeout http-keep-alive 30s
+ # Queue timeout: how long to wait for a server slot
+ timeout queue 120s
+ timeout check 15s
+
+ retries 3
+ option redispatch
+ retry-on conn-failure empty-response response-timeout 503
+
+ # Main frontend - accepts client connections
+ frontend erpc-frontend
+ bind *:4000
+ # Frontend maxconn: match global maxconn (no restriction)
+ maxconn 500000
+
+ # Add request ID for tracing
+ unique-id-format %{+X}o\ %ci:%cp_%fi:%fp_%Ts_%rt:%pid
+ unique-id-header X-Request-ID
+
+ # If every main eRPC server is down, bypass the metric debounce and
+ # route straight to fallback. The controller still handles gradual
+ # degraded-performance activation through erpc-backend weights.
+ use_backend erpc-fallback-backend if { nbsrv(erpc-main-health-backend) eq 0 }
+
+ # Route health through the serving backend so fallback only counts
+ # after activation or total main outage.
+ default_backend erpc-backend
+
+ # Main-only health backend for instant total-outage detection.
+ backend erpc-main-health-backend
+ balance roundrobin
+ option httpchk GET /healthcheck
+ http-check expect status 200
+ default-server inter 2s fastinter 1s downinter 2s rise 2 fall 3 check
+ server-template erpc-main-health 100 erpc-headless.morpho-prd.svc.cluster.local:4000 check resolvers k8s init-addr none
+
+ # Main backend - smart load balancing with dynamic weights
+ backend erpc-backend
+ # Weighted round-robin caps fallback traffic by configured weight.
+ # Controller adjusts weights based on Goroutines/Latency/RequestRate/CPU
+ balance roundrobin
+
+ # HTTP health check on application endpoint
+ option httpchk GET /healthcheck
+ http-check expect status 200
+
+ # Connection reuse for efficiency
+ http-reuse safe
+
+ # Aggressive health monitoring
+ # inter=2s: check every 2 seconds
+ # fastinter=1s: check every 1s when transitioning
+ # rise=2: 2 successes to mark healthy
+ # fall=3: 3 failures to mark unhealthy
+ # maxconn=5000: max connections per backend server (100 pods × 5000 = 500K total)
+ default-server inter 2s fastinter 1s downinter 2s rise 2 fall 3 maxconn 5000 check
+
+ # DNS-based service discovery via headless service
+ server-template erpc 100 erpc-headless.morpho-prd.svc.cluster.local:4000 check resolvers k8s init-addr none
+ # Weighted fallback is intentionally capped below fallback HPA max so
+ # partial fallback traffic cannot grow with scaled-out fallback pods.
+ server-template erpc-fallback 3 erpc-fallback-headless.morpho-prd.svc.cluster.local:4000 weight 0 check resolvers k8s init-addr none
+
+ # Direct fallback backend for sudden total main outage.
+ backend erpc-fallback-backend
+ balance leastconn
+ option httpchk GET /healthcheck
+ http-check expect status 200
+ http-reuse safe
+ default-server inter 2s fastinter 1s downinter 2s rise 2 fall 3 maxconn 5000 check
+ server-template erpc-fallback-direct 40 erpc-fallback-headless.morpho-prd.svc.cluster.local:4000 check resolvers k8s init-addr none
+
+ # Kubernetes DNS resolver
+ resolvers k8s
+ nameserver dns kube-dns.kube-system.svc.cluster.local:53
+ accepted_payload_size 8192
+ hold valid 5s
+ hold nx 5s
+ resolve_retries 3
+ timeout resolve 1s
+ timeout retry 1s
+
+ # Stats/metrics frontend
+ frontend stats
+ mode http
+ bind *:8404
+ http-request use-service prometheus-exporter if { path /metrics }
+ stats enable
+ stats uri /stats
+ stats refresh 10s
+ stats show-legends
+ stats show-node
+
+ # Weight controller sidecar - dynamically adjusts backend weights
+ # Uses 4 metrics: Goroutines, P99 Latency, Request Rate, CPU
+ sidecarContainers:
+ - name: weight-controller
+ image: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/morphoorg/haproxy-controller-erpc:v1.2.2
+ imagePullPolicy: IfNotPresent
+ env:
+ - name: PROMETHEUS_URL
+ value: "http://prometheus-stack-kube-prom-prometheus.monitoring.svc.cluster.local:9090"
+ # Goroutine query - direct count per pod
+ - name: GOROUTINE_QUERY
+ value: 'erpc:go_goroutines:by_pod{pod=~"erpc-[0-9a-f]{9,10}-[0-9a-z]{5}"}'
+ # P99 Latency query - in milliseconds
+ - name: LATENCY_QUERY
+ value: 'histogram_quantile(0.99, erpc:network_request_duration_seconds_bucket:rate1m_by_pod{pod=~"erpc-[0-9a-f]{9,10}-[0-9a-z]{5}"}) * 1000'
+ # Request rate query - requests per second
+ - name: REQUEST_RATE_QUERY
+ value: 'erpc:network_request_received_total:rate1m_by_pod{pod=~"erpc-[0-9a-f]{9,10}-[0-9a-z]{5}"}'
+ # CPU query - percentage of limit
+ - name: CPU_QUERY
+ value: '100 * erpc:container_cpu_usage:limit_ratio1m_by_pod{pod=~"erpc-[0-9a-f]{9,10}-[0-9a-z]{5}"}'
+ # Target thresholds - traffic shifts when exceeded
+ - name: TARGET_GOROUTINES
+ value: "400"
+ - name: TARGET_LATENCY_MS
+ value: "500"
+ - name: TARGET_REQUEST_RATE
+ value: "50"
+ - name: TARGET_CPU
+ value: "60"
+ # Metric weights (sum to 1.0)
+ # Latency has highest weight as it directly measures user impact
+ - name: WEIGHT_GOROUTINES
+ value: "0.30"
+ - name: WEIGHT_LATENCY
+ value: "0.35"
+ - name: WEIGHT_REQUEST_RATE
+ value: "0.15"
+ - name: WEIGHT_CPU
+ value: "0.20"
+ # Backend name must match HAProxy config
+ - name: BACKEND_NAME
+ value: "erpc-backend"
+ # Pod namespace for Kubernetes API lookups
+ - name: POD_NAMESPACE
+ value: "morpho-prd"
+ # Fast updates for responsive load balancing (matches pod scrape)
+ - name: UPDATE_INTERVAL
+ value: "5s"
+ # Weight range
+ - name: MIN_WEIGHT
+ value: "10"
+ - name: MAX_WEIGHT
+ value: "50"
+ - name: HAPROXY_SOCKET
+ value: "/var/run/haproxy/admin.sock"
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: [ALL]
+ readOnlyRootFilesystem: true
+ resources:
+ requests:
+ cpu: "100m"
+ memory: "128Mi"
+ limits:
+ cpu: "500m"
+ memory: "256Mi"
+ livenessProbe:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - pgrep -f "controller.sh" > /dev/null
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ failureThreshold: 3
+ readinessProbe:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - test -S "$HAPROXY_SOCKET"
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ failureThreshold: 3
+ volumeMounts:
+ - name: haproxy-socket
+ mountPath: /var/run/haproxy
+ - name: controller-tmp
+ mountPath: /tmp
+ - &erpcFallbackController
+ name: fallback-controller
+ image: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/morphoorg/haproxy-controller-erpc:v1.2.2
+ imagePullPolicy: IfNotPresent
+ env:
+ - name: PROMETHEUS_URL
+ value: "http://prometheus-stack-kube-prom-prometheus.monitoring.svc.cluster.local:9090"
+ - name: BACKEND_NAME
+ value: "erpc-backend"
+ - name: MAIN_HEALTH_BACKEND_NAME
+ value: "erpc-main-health-backend"
+ - name: MAIN_HEALTH_SERVER_PREFIX
+ value: "erpc-main-health"
+ - name: FALLBACK_SERVER_PREFIX
+ value: "erpc-fallback"
+ - name: HAPROXY_SOCKET
+ value: "/var/run/haproxy/admin.sock"
+ - name: UPDATE_INTERVAL_SECONDS
+ value: "30"
+ - name: ENABLE_AFTER_FAILURES
+ value: "6"
+ - name: RECOVER_AFTER_SUCCESSES
+ value: "20"
+ - name: DEGRADED_P99_MS
+ value: "25000"
+ - name: RECOVERED_P99_MS
+ value: "10000"
+ - name: DEGRADED_ERROR_RATE_PERCENT
+ value: "1"
+ - name: RECOVERED_ERROR_RATE_PERCENT
+ value: "0.5"
+ - name: FALLBACK_ACTIVE_WEIGHT
+ value: "1"
+ - name: FALLBACK_IDLE_WEIGHT
+ value: "0"
+ - name: MIN_TRAFFIC_RPS
+ value: "10"
+ - name: REQUEST_RATE_QUERY
+ value: 'sum(erpc:network_request_received_total:rate1m_by_pod{pod=~"erpc-[0-9a-f]{9,10}-[0-9a-z]{5}"})'
+ - name: LATENCY_QUERY
+ value: 'histogram_quantile(0.99, sum by (le) (erpc:network_request_duration_seconds_bucket:rate1m_by_pod{pod=~"erpc-[0-9a-f]{9,10}-[0-9a-z]{5}"})) * 1000'
+ - name: ERROR_RATE_QUERY
+ value: '100 * (sum(erpc:network_failed_actionable_request_total:rate1m_by_pod{pod=~"erpc-[0-9a-f]{9,10}-[0-9a-z]{5}"}) or on() vector(0)) / clamp_min(sum(erpc:network_request_received_total:rate1m_by_pod{pod=~"erpc-[0-9a-f]{9,10}-[0-9a-z]{5}"}), 1)'
+ command:
+ - /bin/sh
+ - -ec
+ args:
+ - |
+ log() {
+ echo "$(date '+%Y-%m-%d %H:%M:%S') [$(hostname)] fallback-controller - $*"
+ }
+
+ url_encode() {
+ echo "$1" | sed \
+ -e 's/%/%25/g' \
+ -e 's/ /%20/g' \
+ -e 's/{/%7B/g' \
+ -e 's/}/%7D/g' \
+ -e 's/\[/%5B/g' \
+ -e 's/\]/%5D/g' \
+ -e 's/=/%3D/g' \
+ -e 's/,/%2C/g' \
+ -e 's/>/%3E/g' \
+ -e 's/%3C/g' \
+ -e 's/"/%22/g' \
+ -e 's/+/%2B/g' \
+ -e 's#/#%2F#g' \
+ -e 's/(/%28/g' \
+ -e 's/)/%29/g'
+ }
+
+ query_prometheus_value() {
+ query="$1"
+ encoded_query="$(url_encode "$query")"
+ response="$(wget -qO- --timeout=10 "${PROMETHEUS_URL}/api/v1/query?query=${encoded_query}" 2>/dev/null || true)"
+
+ if [ -z "$response" ] || echo "$response" | grep -q '"status":"error"'; then
+ log "Prometheus query failed: $query"
+ return 1
+ fi
+
+ value="$(echo "$response" | sed -n 's/.*"value":\[[^,]*,"\([^"]*\)".*/\1/p' | head -1)"
+ case "$value" in
+ ""|NaN|nan|Inf|+Inf|-Inf)
+ log "Prometheus query returned no usable value: $query"
+ return 1
+ ;;
+ esac
+ echo "$value"
+ }
+
+ fallback_servers() {
+ echo "show servers state ${BACKEND_NAME}" | socat stdio "unix-connect:${HAPROXY_SOCKET}" 2>/dev/null | awk -v backend="$BACKEND_NAME" -v prefix="$FALLBACK_SERVER_PREFIX" '
+ $2 == backend && index($4, prefix) == 1 && $5 != "-" { print $4 }
+ '
+ }
+
+ set_fallback_weight() {
+ weight="$1"
+ servers="$(fallback_servers || true)"
+ if [ -z "$servers" ]; then
+ log "No fallback servers currently resolved"
+ return 1
+ fi
+
+ for server in $servers; do
+ command="set server ${BACKEND_NAME}/${server} weight ${weight}"
+ if echo "$command" | socat stdio "unix-connect:${HAPROXY_SOCKET}" >/dev/null 2>&1; then
+ log "Set ${BACKEND_NAME}/${server} weight=${weight}"
+ else
+ log "Failed to set ${BACKEND_NAME}/${server} weight=${weight}"
+ fi
+ done
+ }
+
+ is_gt() {
+ awk -v a="$1" -v b="$2" 'BEGIN { exit !(a > b) }'
+ }
+
+ is_lt() {
+ awk -v a="$1" -v b="$2" 'BEGIN { exit !(a < b) }'
+ }
+
+ fallback_has_active_weight() {
+ echo "show stat" | socat stdio "unix-connect:${HAPROXY_SOCKET}" 2>/dev/null | awk -F, -v backend="$BACKEND_NAME" -v prefix="$FALLBACK_SERVER_PREFIX" -v idle="$FALLBACK_IDLE_WEIGHT" '
+ $1 == backend && index($2, prefix) == 1 && ($19 + 0) > (idle + 0) { found=1 }
+ END { exit !found }
+ '
+ }
+
+ main_health_counts() {
+ stats="$(echo "show stat" | socat stdio "unix-connect:${HAPROXY_SOCKET}" 2>/dev/null)" || return 1
+ echo "$stats" | awk -F, -v backend="$MAIN_HEALTH_BACKEND_NAME" -v prefix="$MAIN_HEALTH_SERVER_PREFIX" '
+ $1 == backend && index($2, prefix) == 1 {
+ if ($18 !~ /^MAINT/) resolved++
+ if ($18 == "UP") up++
+ }
+ END { print (up + 0) ":" (resolved + 0) }
+ '
+ }
+
+ keep_fallback_active_for_direct_outage() {
+ main_health_counts="$(main_health_counts || echo "")"
+ main_healthy_count="${main_health_counts%%:*}"
+ main_resolved_count="${main_health_counts##*:}"
+ if [ -z "$main_health_counts" ] || [ "$main_resolved_count" = "0" ]; then
+ direct_outage_count=0
+ if [ "$main_seen_healthy" -eq 1 ]; then
+ direct_outage_seen=1
+ log "No resolved main eRPC health servers; direct fallback handles outage until main DNS/health recovers"
+ if [ "$active" -eq 1 ]; then
+ set_fallback_weight "${FALLBACK_ACTIVE_WEIGHT}" || true
+ fi
+ return 0
+ fi
+ direct_outage_seen=0
+ log "No resolved main eRPC health servers yet; skipping direct-outage fallback activation"
+ return 1
+ fi
+ if [ "$main_healthy_count" = "0" ]; then
+ direct_outage_count=$((direct_outage_count + 1))
+ if [ "$main_seen_healthy" -eq 1 ]; then
+ direct_outage_seen=1
+ fi
+ if [ "$active" -eq 1 ]; then
+ log "No healthy main eRPC servers; preserving weighted fallback recovery handoff"
+ set_fallback_weight "${FALLBACK_ACTIVE_WEIGHT}" || true
+ return 0
+ fi
+ if [ "$direct_outage_count" -lt "$ENABLE_AFTER_FAILURES" ]; then
+ log "No healthy main eRPC servers; direct fallback handles outage while weighted recovery handoff waits direct_outage_count=${direct_outage_count}/${ENABLE_AFTER_FAILURES}"
+ return 0
+ fi
+ missing_metrics_count=0
+ degraded_count=0
+ recovered_count=0
+ log "No healthy main eRPC servers for ${direct_outage_count} intervals; keeping direct-outage fallback recovery handoff active"
+ set_fallback_weight "${FALLBACK_ACTIVE_WEIGHT}" || true
+ active=1
+ return 0
+ fi
+ main_seen_healthy=1
+ if [ "$direct_outage_seen" -eq 1 ]; then
+ direct_outage_count=0
+ direct_outage_seen=0
+ missing_metrics_count=0
+ degraded_count=0
+ recovered_count=0
+ log "Main eRPC servers recovered after total outage; enabling weighted fallback recovery handoff"
+ set_fallback_weight "${FALLBACK_ACTIVE_WEIGHT}" && active=1
+ return 0
+ fi
+ direct_outage_count=0
+ return 1
+ }
+
+ sync_startup_state() {
+ if keep_fallback_active_for_direct_outage; then
+ return
+ fi
+
+ startup_request_rate="$(query_prometheus_value "$REQUEST_RATE_QUERY" || echo "")"
+
+ if [ -n "$startup_request_rate" ] && ! is_gt "$startup_request_rate" "$MIN_TRAFFIC_RPS"; then
+ if fallback_has_active_weight; then
+ active=1
+ log "Main eRPC traffic below gate at startup; preserving active fallback weights"
+ else
+ log "Main eRPC traffic below gate at startup; preserving idle fallback weights"
+ fi
+ return
+ fi
+
+ startup_p99_ms="$(query_prometheus_value "$LATENCY_QUERY" || echo "")"
+ startup_error_rate="$(query_prometheus_value "$ERROR_RATE_QUERY" || echo "")"
+
+ if [ -z "$startup_request_rate" ] || [ -z "$startup_p99_ms" ] || [ -z "$startup_error_rate" ]; then
+ if fallback_has_active_weight; then
+ active=1
+ log "Metrics unavailable at startup; preserving active fallback weights"
+ else
+ log "Metrics unavailable at startup; preserving idle fallback weights"
+ fi
+ return
+ fi
+
+ if is_gt "$startup_p99_ms" "$DEGRADED_P99_MS" || is_gt "$startup_error_rate" "$DEGRADED_ERROR_RATE_PERCENT"; then
+ log "Main eRPC already degraded at startup; enabling fallback weight=${FALLBACK_ACTIVE_WEIGHT}"
+ set_fallback_weight "${FALLBACK_ACTIVE_WEIGHT}" && active=1
+ elif is_lt "$startup_p99_ms" "$RECOVERED_P99_MS" && is_lt "$startup_error_rate" "$RECOVERED_ERROR_RATE_PERCENT"; then
+ log "Main eRPC healthy at startup; disabling fallback weight=${FALLBACK_IDLE_WEIGHT}"
+ set_fallback_weight "${FALLBACK_IDLE_WEIGHT}" && active=0
+ elif fallback_has_active_weight; then
+ active=1
+ log "Startup metrics between thresholds; preserving active fallback weights"
+ else
+ log "Startup metrics between thresholds; preserving idle fallback weights"
+ fi
+ }
+
+ active=0
+ degraded_count=0
+ recovered_count=0
+ missing_metrics_count=0
+ direct_outage_count=0
+ direct_outage_seen=0
+ main_seen_healthy=0
+
+ log "Starting auto fallback controller: traffic gate>${MIN_TRAFFIC_RPS}rps; degrade p99>${DEGRADED_P99_MS}ms or failures>${DEGRADED_ERROR_RATE_PERCENT}% for ${ENABLE_AFTER_FAILURES} intervals; recover p99<${RECOVERED_P99_MS}ms and failures<${RECOVERED_ERROR_RATE_PERCENT}% for ${RECOVER_AFTER_SUCCESSES} intervals"
+ date +%s > /tmp/fallback-controller.heartbeat
+ sync_startup_state || true
+
+ while true; do
+ date +%s > /tmp/fallback-controller.heartbeat
+ if keep_fallback_active_for_direct_outage; then
+ sleep "${UPDATE_INTERVAL_SECONDS}"
+ continue
+ fi
+
+ request_rate="$(query_prometheus_value "$REQUEST_RATE_QUERY" || echo "")"
+
+ if [ -n "$request_rate" ] && ! is_gt "$request_rate" "$MIN_TRAFFIC_RPS"; then
+ missing_metrics_count=0
+ degraded_count=0
+ recovered_count=0
+ log "Traffic below gate; holding fallback state active=${active} request_rate=${request_rate}"
+ if [ "$active" -eq 1 ]; then
+ set_fallback_weight "${FALLBACK_ACTIVE_WEIGHT}" || true
+ else
+ set_fallback_weight "${FALLBACK_IDLE_WEIGHT}" || true
+ fi
+ sleep "${UPDATE_INTERVAL_SECONDS}"
+ continue
+ fi
+
+ p99_ms="$(query_prometheus_value "$LATENCY_QUERY" || echo "")"
+ error_rate="$(query_prometheus_value "$ERROR_RATE_QUERY" || echo "")"
+
+ if [ -z "$request_rate" ] || [ -z "$p99_ms" ] || [ -z "$error_rate" ]; then
+ missing_metrics_count=$((missing_metrics_count + 1))
+ degraded_count=0
+ recovered_count=0
+ log "Metrics unavailable; active=${active} missing_metrics_count=${missing_metrics_count}"
+ if [ "$active" -eq 0 ] && [ "$missing_metrics_count" -ge "$ENABLE_AFTER_FAILURES" ]; then
+ log "Main eRPC metrics unavailable; enabling fallback weight=${FALLBACK_ACTIVE_WEIGHT}"
+ set_fallback_weight "${FALLBACK_ACTIVE_WEIGHT}" && active=1
+ elif [ "$active" -eq 1 ]; then
+ set_fallback_weight "${FALLBACK_ACTIVE_WEIGHT}" || true
+ fi
+ sleep "${UPDATE_INTERVAL_SECONDS}"
+ continue
+ fi
+
+ missing_metrics_count=0
+ degraded=0
+ recovered=0
+ if is_gt "$p99_ms" "$DEGRADED_P99_MS" || is_gt "$error_rate" "$DEGRADED_ERROR_RATE_PERCENT"; then
+ degraded=1
+ fi
+ if is_lt "$p99_ms" "$RECOVERED_P99_MS" && is_lt "$error_rate" "$RECOVERED_ERROR_RATE_PERCENT"; then
+ recovered=1
+ fi
+
+ if [ "$degraded" -eq 1 ]; then
+ degraded_count=$((degraded_count + 1))
+ recovered_count=0
+ elif [ "$recovered" -eq 1 ]; then
+ recovered_count=$((recovered_count + 1))
+ degraded_count=0
+ else
+ degraded_count=0
+ recovered_count=0
+ fi
+
+ log "request_rate=${request_rate} p99_ms=${p99_ms} failure_rate_percent=${error_rate} active=${active} degraded_count=${degraded_count} recovered_count=${recovered_count}"
+
+ if [ "$active" -eq 0 ] && [ "$degraded_count" -ge "$ENABLE_AFTER_FAILURES" ]; then
+ log "Main eRPC degraded; enabling fallback weight=${FALLBACK_ACTIVE_WEIGHT}"
+ set_fallback_weight "${FALLBACK_ACTIVE_WEIGHT}" && active=1
+ elif [ "$active" -eq 1 ] && [ "$recovered_count" -ge "$RECOVER_AFTER_SUCCESSES" ]; then
+ log "Main eRPC recovered; disabling fallback weight=${FALLBACK_IDLE_WEIGHT}"
+ set_fallback_weight "${FALLBACK_IDLE_WEIGHT}" && active=0
+ elif [ "$active" -eq 1 ]; then
+ set_fallback_weight "${FALLBACK_ACTIVE_WEIGHT}" || true
+ else
+ set_fallback_weight "${FALLBACK_IDLE_WEIGHT}" || true
+ fi
+
+ sleep "${UPDATE_INTERVAL_SECONDS}"
+ done
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: [ALL]
+ readOnlyRootFilesystem: true
+ resources:
+ requests:
+ cpu: "50m"
+ memory: "64Mi"
+ limits:
+ cpu: "200m"
+ memory: "128Mi"
+ readinessProbe:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - test -S "$HAPROXY_SOCKET"
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ failureThreshold: 3
+ livenessProbe:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - test -f /tmp/fallback-controller.heartbeat && [ $(( $(date +%s) - $(cat /tmp/fallback-controller.heartbeat) )) -lt 180 ]
+ initialDelaySeconds: 60
+ periodSeconds: 30
+ failureThreshold: 3
+ volumeMounts:
+ - name: haproxy-socket
+ mountPath: /var/run/haproxy
+ - name: controller-tmp
+ mountPath: /tmp
+
+ extraVolumes:
+ - name: haproxy-socket
+ emptyDir: {}
+ - name: controller-tmp
+ emptyDir: {}
+
+ extraVolumeMounts:
+ - name: haproxy-socket
+ mountPath: /var/run/haproxy
+
+ containerPorts:
+ http: 4000
+ prometheus: 8404
+
+ service:
+ type: ClusterIP
+
+ replicaCount: 3
+
+ resources:
+ requests:
+ cpu: "1"
+ memory: "512Mi"
+ limits:
+ cpu: "4"
+ memory: "2Gi"
+
+ # Disable upstream chart's HPA (uses metrics-server which isn't installed)
+ # Custom HPA is created via erpc-haproxy-hpa values above
+ autoscaling:
+ enabled: false
+
+ # Enable the HAProxy chart's actual PDB key. The previous lower-case
+ # podDisruptionBudget block was ignored by upstream chart 1.26.1.
+ PodDisruptionBudget:
+ enable: true
+ minAvailable: 2
+
+ # Pin the public CloudFront edge proxy to the on-demand node pool. Spot
+ # interruption of a single node previously removed all public HAProxy
+ # replicas at once and caused rpc.morpho.dev CloudFront 504s.
+ nodeSelector:
+ purpose: on-demand
+ tolerations:
+ - key: dedicated
+ operator: Equal
+ value: on-demand
+ effect: NoSchedule
+
+ # Keep the public CloudFront edge proxy off voluntary Karpenter disruption
+ # paths; this protects rpc.morpho.dev while still allowing HPA rollouts.
+ podAnnotations:
+ karpenter.sh/do-not-disrupt: "true"
+
+ podSecurityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ fsGroup: 1000
+ seccompProfile:
+ type: RuntimeDefault
+
+ affinity:
+ podAntiAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ - labelSelector:
+ matchLabels:
+ app.kubernetes.io/instance: erpc
+ app.kubernetes.io/name: erpc-haproxy
+ topologyKey: kubernetes.io/hostname
+
+ topologySpreadConstraints:
+ - maxSkew: 1
+ topologyKey: topology.kubernetes.io/zone
+ whenUnsatisfiable: DoNotSchedule
+ labelSelector:
+ matchLabels:
+ app.kubernetes.io/instance: erpc
+ app.kubernetes.io/name: erpc-haproxy
+
+erpc-processing-haproxy:
+ image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/haproxytech/haproxy-alpine
+
+ rbac:
+ create: true
+
+ serviceMonitor:
+ enabled: true
+ extraLabels:
+ release: prometheus
+ endpoints:
+ - port: prometheus
+ path: /metrics
+ scheme: http
+
+ config: |
+ global
+ stats socket /var/run/haproxy/admin.sock mode 660 level admin expose-fd listeners
+ stats timeout 30s
+ maxconn 500000
+ spread-checks 5
+ log stdout format raw local0 info
+ ulimit-n 1048576
+ tune.bufsize 131072
+ tune.maxrewrite 8192
+ tune.http.maxhdr 128
+ tune.recv_enough 16384
+ tune.runqueue-depth 512
+
+ defaults
+ mode http
+ log global
+ option httplog
+ option dontlognull
+ option http-server-close
+ option forwardfor except 127.0.0.0/8
+ timeout connect 5s
+ timeout client 60s
+ timeout server 60s
+ timeout http-request 30s
+ timeout http-keep-alive 30s
+ timeout queue 120s
+ timeout check 15s
+ retries 3
+ option redispatch
+ retry-on conn-failure empty-response response-timeout 503
+
+ frontend erpc-processing-frontend
+ bind *:4000
+ maxconn 500000
+ unique-id-format %{+X}o\ %ci:%cp_%fi:%fp_%Ts_%rt:%pid
+ unique-id-header X-Request-ID
+
+ # Direct fallback handles a total processing outage. The default backend
+ # also has metrics-driven weighted fallback for processing-side eRPC degradation.
+ use_backend erpc-processing-fallback-backend if { nbsrv(erpc-processing-main-health-backend) eq 0 }
+
+ default_backend erpc-processing-backend
+
+ backend erpc-processing-main-health-backend
+ balance roundrobin
+ option httpchk GET /healthcheck
+ http-check expect status 200
+ default-server inter 2s fastinter 1s downinter 2s rise 2 fall 3 check
+ server-template erpc-processing-main-health 100 erpc-processing-headless.morpho-prd.svc.cluster.local:4000 check resolvers k8s init-addr none
+
+ backend erpc-processing-backend
+ # Weighted round-robin keeps processing pods balanced by the existing
+ # weight-controller and lets fallback stay idle at weight 0 until the
+ # processing-specific fallback-controller activates it.
+ balance roundrobin
+ option httpchk GET /healthcheck
+ http-check expect status 200
+ http-reuse safe
+ default-server inter 2s fastinter 1s downinter 2s rise 2 fall 3 maxconn 5000 check
+ server-template erpc-processing 100 erpc-processing-headless.morpho-prd.svc.cluster.local:4000 check resolvers k8s init-addr none
+ # Weighted fallback is intentionally capped below fallback HPA max so
+ # partial fallback traffic cannot grow with scaled-out fallback pods.
+ server-template erpc-fallback 3 erpc-fallback-headless.morpho-prd.svc.cluster.local:4000 weight 0 check resolvers k8s init-addr none
+
+ backend erpc-processing-fallback-backend
+ balance leastconn
+ option httpchk GET /healthcheck
+ http-check expect status 200
+ http-reuse safe
+ default-server inter 2s fastinter 1s downinter 2s rise 2 fall 3 maxconn 5000 check
+ server-template erpc-fallback-direct 40 erpc-fallback-headless.morpho-prd.svc.cluster.local:4000 check resolvers k8s init-addr none
+
+ resolvers k8s
+ nameserver dns kube-dns.kube-system.svc.cluster.local:53
+ accepted_payload_size 8192
+ hold valid 5s
+ hold nx 5s
+ resolve_retries 3
+ timeout resolve 1s
+ timeout retry 1s
+
+ frontend stats
+ mode http
+ bind *:8404
+ http-request use-service prometheus-exporter if { path /metrics }
+ stats enable
+ stats uri /stats
+ stats refresh 10s
+ stats show-legends
+ stats show-node
+
+ sidecarContainers:
+ - name: weight-controller
+ image: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/morphoorg/haproxy-controller-erpc:v1.2.2
+ imagePullPolicy: IfNotPresent
+ env:
+ - name: PROMETHEUS_URL
+ value: "http://prometheus-stack-kube-prom-prometheus.monitoring.svc.cluster.local:9090"
+ - name: GOROUTINE_QUERY
+ value: 'erpc:go_goroutines:by_pod{pod=~"erpc-processing-[0-9a-f]{9,10}-[0-9a-z]{5}"}'
+ - name: LATENCY_QUERY
+ value: 'histogram_quantile(0.99, erpc:network_request_duration_seconds_bucket:rate1m_by_pod{pod=~"erpc-processing-[0-9a-f]{9,10}-[0-9a-z]{5}"}) * 1000'
+ - name: REQUEST_RATE_QUERY
+ value: 'erpc:network_request_received_total:rate1m_by_pod{pod=~"erpc-processing-[0-9a-f]{9,10}-[0-9a-z]{5}"}'
+ - name: CPU_QUERY
+ value: '100 * erpc:container_cpu_usage:limit_ratio1m_by_pod{pod=~"erpc-processing-[0-9a-f]{9,10}-[0-9a-z]{5}"}'
+ - name: TARGET_GOROUTINES
+ value: "400"
+ - name: TARGET_LATENCY_MS
+ value: "500"
+ - name: TARGET_REQUEST_RATE
+ value: "50"
+ - name: TARGET_CPU
+ value: "60"
+ - name: WEIGHT_GOROUTINES
+ value: "0.30"
+ - name: WEIGHT_LATENCY
+ value: "0.35"
+ - name: WEIGHT_REQUEST_RATE
+ value: "0.15"
+ - name: WEIGHT_CPU
+ value: "0.20"
+ - name: BACKEND_NAME
+ value: "erpc-processing-backend"
+ - name: POD_NAMESPACE
+ value: "morpho-prd"
+ - name: UPDATE_INTERVAL
+ value: "5s"
+ - name: MIN_WEIGHT
+ value: "10"
+ - name: MAX_WEIGHT
+ value: "50"
+ - name: HAPROXY_SOCKET
+ value: "/var/run/haproxy/admin.sock"
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: [ALL]
+ readOnlyRootFilesystem: true
+ resources:
+ requests:
+ cpu: "100m"
+ memory: "128Mi"
+ limits:
+ cpu: "500m"
+ memory: "256Mi"
+ livenessProbe:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - pgrep -f "controller.sh" > /dev/null
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ failureThreshold: 3
+ readinessProbe:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - test -S "$HAPROXY_SOCKET"
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ failureThreshold: 3
+ volumeMounts:
+ - name: haproxy-socket
+ mountPath: /var/run/haproxy
+ - name: controller-tmp
+ mountPath: /tmp
+ - <<: *erpcFallbackController
+ env:
+ - name: PROMETHEUS_URL
+ value: "http://prometheus-stack-kube-prom-prometheus.monitoring.svc.cluster.local:9090"
+ - name: BACKEND_NAME
+ value: "erpc-processing-backend"
+ - name: MAIN_HEALTH_BACKEND_NAME
+ value: "erpc-processing-main-health-backend"
+ - name: MAIN_HEALTH_SERVER_PREFIX
+ value: "erpc-processing-main-health"
+ - name: FALLBACK_SERVER_PREFIX
+ value: "erpc-fallback"
+ - name: HAPROXY_SOCKET
+ value: "/var/run/haproxy/admin.sock"
+ - name: UPDATE_INTERVAL_SECONDS
+ value: "30"
+ - name: ENABLE_AFTER_FAILURES
+ value: "6"
+ - name: RECOVER_AFTER_SUCCESSES
+ value: "20"
+ - name: DEGRADED_P99_MS
+ value: "25000"
+ - name: RECOVERED_P99_MS
+ value: "5000"
+ - name: DEGRADED_ERROR_RATE_PERCENT
+ value: "2"
+ - name: RECOVERED_ERROR_RATE_PERCENT
+ value: "0.5"
+ - name: FALLBACK_ACTIVE_WEIGHT
+ value: "1"
+ - name: FALLBACK_IDLE_WEIGHT
+ value: "0"
+ - name: MIN_TRAFFIC_RPS
+ value: "50"
+ - name: REQUEST_RATE_QUERY
+ value: 'sum(erpc:network_request_received_total:rate1m_by_pod{pod=~"erpc-processing-[0-9a-f]{9,10}-[0-9a-z]{5}"})'
+ - name: LATENCY_QUERY
+ value: 'histogram_quantile(0.99, sum by (le) (erpc:network_request_duration_seconds_bucket:rate1m_by_pod{pod=~"erpc-processing-[0-9a-f]{9,10}-[0-9a-z]{5}"})) * 1000'
+ - name: ERROR_RATE_QUERY
+ value: '100 * (sum(erpc:network_failed_actionable_request_total:rate1m_by_pod{pod=~"erpc-processing-[0-9a-f]{9,10}-[0-9a-z]{5}"}) or on() vector(0)) / clamp_min(sum(erpc:network_request_received_total:rate1m_by_pod{pod=~"erpc-processing-[0-9a-f]{9,10}-[0-9a-z]{5}"}), 1)'
+ extraVolumes:
+ - name: haproxy-socket
+ emptyDir: {}
+ - name: controller-tmp
+ emptyDir: {}
+
+ extraVolumeMounts:
+ - name: haproxy-socket
+ mountPath: /var/run/haproxy
+
+ containerPorts:
+ http: 4000
+ prometheus: 8404
+
+ service:
+ type: ClusterIP
+
+ replicaCount: 3
+
+ resources:
+ requests:
+ cpu: "1"
+ memory: "512Mi"
+ limits:
+ cpu: "4"
+ memory: "2Gi"
+
+ autoscaling:
+ enabled: false
+
+ # Enable the HAProxy chart's actual PDB key. The previous lower-case
+ # podDisruptionBudget block was ignored by upstream chart 1.26.1.
+ PodDisruptionBudget:
+ enable: true
+ minAvailable: 2
+
+ # Pin HAProxy pods to the on-demand node pool so edge/proxy capacity is not
+ # removed by abrupt spot interruptions.
+ nodeSelector:
+ purpose: on-demand
+ tolerations:
+ - key: dedicated
+ operator: Equal
+ value: on-demand
+ effect: NoSchedule
+
+ podSecurityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ fsGroup: 1000
+ seccompProfile:
+ type: RuntimeDefault
+
+ affinity:
+ podAntiAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ - labelSelector:
+ matchLabels:
+ app.kubernetes.io/instance: erpc
+ app.kubernetes.io/name: erpc-processing-haproxy
+ topologyKey: kubernetes.io/hostname
+
+ topologySpreadConstraints:
+ - maxSkew: 1
+ topologyKey: topology.kubernetes.io/zone
+ whenUnsatisfiable: DoNotSchedule
+ labelSelector:
+ matchLabels:
+ app.kubernetes.io/instance: erpc
+ app.kubernetes.io/name: erpc-processing-haproxy
+
+erpc-router-haproxy:
+ image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/haproxytech/haproxy-alpine
+
+ rbac:
+ create: true
+
+ serviceMonitor:
+ enabled: true
+ extraLabels:
+ release: prometheus
+ endpoints:
+ - port: prometheus
+ path: /metrics
+ scheme: http
+
+ config: |
+ global
+ stats socket /var/run/haproxy/admin.sock mode 660 level admin expose-fd listeners
+ stats timeout 30s
+ maxconn 500000
+ spread-checks 5
+ log stdout format raw local0 info
+ ulimit-n 1048576
+ tune.bufsize 131072
+ tune.maxrewrite 8192
+ tune.http.maxhdr 128
+ tune.recv_enough 16384
+ tune.runqueue-depth 512
+
+ defaults
+ mode http
+ log global
+ option httplog
+ option dontlognull
+ option http-server-close
+ option forwardfor except 127.0.0.0/8
+ timeout connect 5s
+ timeout client 60s
+ timeout server 60s
+ timeout http-request 30s
+ timeout http-keep-alive 30s
+ timeout queue 120s
+ timeout check 15s
+ retries 3
+ option redispatch
+ retry-on conn-failure empty-response response-timeout 503
+
+ frontend erpc-router-frontend
+ bind *:4000
+ maxconn 500000
+ unique-id-format %{+X}o\ %ci:%cp_%fi:%fp_%Ts_%rt:%pid
+ unique-id-header X-Request-ID
+
+ # If every router eRPC server is down, bypass the metric debounce and
+ # route straight to fallback. The controller still handles gradual
+ # degraded-performance activation through erpc-router-backend weights.
+ use_backend erpc-router-fallback-backend if { nbsrv(erpc-router-main-health-backend) eq 0 }
+
+ default_backend erpc-router-backend
+
+ backend erpc-router-main-health-backend
+ balance roundrobin
+ option httpchk GET /healthcheck
+ http-check expect status 200
+ default-server inter 2s fastinter 1s downinter 2s rise 2 fall 3 check
+ server-template erpc-router-main-health 100 erpc-router-headless.morpho-prd.svc.cluster.local:4000 check resolvers k8s init-addr none
+
+ backend erpc-router-backend
+ # Weighted round-robin caps fallback traffic by configured weight.
+ balance roundrobin
+ option httpchk GET /healthcheck
+ http-check expect status 200
+ http-reuse safe
+ default-server inter 2s fastinter 1s downinter 2s rise 2 fall 3 maxconn 5000 check
+ server-template erpc-router 100 erpc-router-headless.morpho-prd.svc.cluster.local:4000 check resolvers k8s init-addr none
+ # Weighted fallback is intentionally capped below fallback HPA max so
+ # partial fallback traffic cannot grow with scaled-out fallback pods.
+ server-template erpc-fallback 3 erpc-fallback-headless.morpho-prd.svc.cluster.local:4000 weight 0 check resolvers k8s init-addr none
+
+ backend erpc-router-fallback-backend
+ balance leastconn
+ option httpchk GET /healthcheck
+ http-check expect status 200
+ http-reuse safe
+ default-server inter 2s fastinter 1s downinter 2s rise 2 fall 3 maxconn 5000 check
+ server-template erpc-fallback-direct 40 erpc-fallback-headless.morpho-prd.svc.cluster.local:4000 check resolvers k8s init-addr none
+
+ resolvers k8s
+ nameserver dns kube-dns.kube-system.svc.cluster.local:53
+ accepted_payload_size 8192
+ hold valid 5s
+ hold nx 5s
+ resolve_retries 3
+ timeout resolve 1s
+ timeout retry 1s
+
+ frontend stats
+ mode http
+ bind *:8404
+ http-request use-service prometheus-exporter if { path /metrics }
+ stats enable
+ stats uri /stats
+ stats refresh 10s
+ stats show-legends
+ stats show-node
+
+ sidecarContainers:
+ - name: weight-controller
+ image: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/morphoorg/haproxy-controller-erpc:v1.2.2
+ imagePullPolicy: IfNotPresent
+ env:
+ - name: PROMETHEUS_URL
+ value: "http://prometheus-stack-kube-prom-prometheus.monitoring.svc.cluster.local:9090"
+ - name: GOROUTINE_QUERY
+ value: 'erpc:go_goroutines:by_pod{pod=~"erpc-router-[0-9a-f]{9,10}-[0-9a-z]{5}"}'
+ - name: LATENCY_QUERY
+ value: 'histogram_quantile(0.99, erpc:network_request_duration_seconds_bucket:rate1m_by_pod{pod=~"erpc-router-[0-9a-f]{9,10}-[0-9a-z]{5}"}) * 1000'
+ - name: REQUEST_RATE_QUERY
+ value: 'erpc:network_request_received_total:rate1m_by_pod{pod=~"erpc-router-[0-9a-f]{9,10}-[0-9a-z]{5}"}'
+ - name: CPU_QUERY
+ value: '100 * erpc:container_cpu_usage:limit_ratio1m_by_pod{pod=~"erpc-router-[0-9a-f]{9,10}-[0-9a-z]{5}"}'
+ - name: TARGET_GOROUTINES
+ value: "400"
+ - name: TARGET_LATENCY_MS
+ value: "500"
+ - name: TARGET_REQUEST_RATE
+ value: "50"
+ - name: TARGET_CPU
+ value: "60"
+ - name: WEIGHT_GOROUTINES
+ value: "0.30"
+ - name: WEIGHT_LATENCY
+ value: "0.35"
+ - name: WEIGHT_REQUEST_RATE
+ value: "0.15"
+ - name: WEIGHT_CPU
+ value: "0.20"
+ - name: BACKEND_NAME
+ value: "erpc-router-backend"
+ - name: POD_NAMESPACE
+ value: "morpho-prd"
+ - name: UPDATE_INTERVAL
+ value: "5s"
+ - name: MIN_WEIGHT
+ value: "10"
+ - name: MAX_WEIGHT
+ value: "50"
+ - name: HAPROXY_SOCKET
+ value: "/var/run/haproxy/admin.sock"
+ securityContext:
+ allowPrivilegeEscalation: false
+ capabilities:
+ drop: [ALL]
+ readOnlyRootFilesystem: true
+ resources:
+ requests:
+ cpu: "100m"
+ memory: "128Mi"
+ limits:
+ cpu: "500m"
+ memory: "256Mi"
+ livenessProbe:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - pgrep -f "controller.sh" > /dev/null
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ failureThreshold: 3
+ readinessProbe:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - test -S "$HAPROXY_SOCKET"
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ failureThreshold: 3
+ volumeMounts:
+ - name: haproxy-socket
+ mountPath: /var/run/haproxy
+ - name: controller-tmp
+ mountPath: /tmp
+ - <<: *erpcFallbackController
+ env:
+ - name: PROMETHEUS_URL
+ value: "http://prometheus-stack-kube-prom-prometheus.monitoring.svc.cluster.local:9090"
+ - name: BACKEND_NAME
+ value: "erpc-router-backend"
+ - name: MAIN_HEALTH_BACKEND_NAME
+ value: "erpc-router-main-health-backend"
+ - name: MAIN_HEALTH_SERVER_PREFIX
+ value: "erpc-router-main-health"
+ - name: FALLBACK_SERVER_PREFIX
+ value: "erpc-fallback"
+ - name: HAPROXY_SOCKET
+ value: "/var/run/haproxy/admin.sock"
+ - name: UPDATE_INTERVAL_SECONDS
+ value: "30"
+ - name: ENABLE_AFTER_FAILURES
+ value: "6"
+ - name: RECOVER_AFTER_SUCCESSES
+ value: "20"
+ - name: DEGRADED_P99_MS
+ value: "25000"
+ - name: RECOVERED_P99_MS
+ value: "10000"
+ - name: DEGRADED_ERROR_RATE_PERCENT
+ value: "1"
+ - name: RECOVERED_ERROR_RATE_PERCENT
+ value: "0.5"
+ - name: FALLBACK_ACTIVE_WEIGHT
+ value: "1"
+ - name: FALLBACK_IDLE_WEIGHT
+ value: "0"
+ - name: MIN_TRAFFIC_RPS
+ value: "10"
+ - name: REQUEST_RATE_QUERY
+ value: 'sum(erpc:network_request_received_total:rate1m_by_pod{pod=~"erpc-router-[0-9a-f]{9,10}-[0-9a-z]{5}"})'
+ - name: LATENCY_QUERY
+ value: 'histogram_quantile(0.99, sum by (le) (erpc:network_request_duration_seconds_bucket:rate1m_by_pod{pod=~"erpc-router-[0-9a-f]{9,10}-[0-9a-z]{5}"})) * 1000'
+ - name: ERROR_RATE_QUERY
+ value: '100 * (sum(erpc:network_failed_actionable_request_total:rate1m_by_pod{pod=~"erpc-router-[0-9a-f]{9,10}-[0-9a-z]{5}"}) or on() vector(0)) / clamp_min(sum(erpc:network_request_received_total:rate1m_by_pod{pod=~"erpc-router-[0-9a-f]{9,10}-[0-9a-z]{5}"}), 1)'
+
+ extraVolumes:
+ - name: haproxy-socket
+ emptyDir: {}
+ - name: controller-tmp
+ emptyDir: {}
+
+ extraVolumeMounts:
+ - name: haproxy-socket
+ mountPath: /var/run/haproxy
+
+ containerPorts:
+ http: 4000
+ prometheus: 8404
+
+ service:
+ type: ClusterIP
+
+ replicaCount: 3
+
+ resources:
+ requests:
+ cpu: "1"
+ memory: "512Mi"
+ limits:
+ cpu: "4"
+ memory: "2Gi"
+
+ autoscaling:
+ enabled: false
+
+ # Enable the HAProxy chart's actual PDB key. The previous lower-case
+ # podDisruptionBudget block was ignored by upstream chart 1.26.1.
+ PodDisruptionBudget:
+ enable: true
+ minAvailable: 2
+
+ # Pin HAProxy pods to the on-demand node pool so edge/proxy capacity is not
+ # removed by abrupt spot interruptions.
+ nodeSelector:
+ purpose: on-demand
+ tolerations:
+ - key: dedicated
+ operator: Equal
+ value: on-demand
+ effect: NoSchedule
+
+ podSecurityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ fsGroup: 1000
+ seccompProfile:
+ type: RuntimeDefault
+
+ affinity:
+ podAntiAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ - labelSelector:
+ matchLabels:
+ app.kubernetes.io/instance: erpc
+ app.kubernetes.io/name: erpc-router-haproxy
+ topologyKey: kubernetes.io/hostname
+
+ topologySpreadConstraints:
+ - maxSkew: 1
+ topologyKey: topology.kubernetes.io/zone
+ whenUnsatisfiable: DoNotSchedule
+ labelSelector:
+ matchLabels:
+ app.kubernetes.io/instance: erpc
+ app.kubernetes.io/name: erpc-router-haproxy
+
+# =============================================================================
+# eRPC Application Configuration
+# =============================================================================
+erpc:
+ fullnameOverride: "erpc"
+
+ serviceAccount:
+ runtimeName: "erpc-runtime"
+ vaultCreator:
+ create: false
+ name: "erpc"
+
+ # PLA-1360: enable Pod Security Standards "restricted" hardening on the
+ # main erpc prd instance after dev validation. The erpc-dev alias is
+ # already opted in via PR 7536.
+ securityHardening:
+ enabled: true
+
+ image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/erpc
+ tag: &erpcImageTag "0.1.3"
+
+ postgresql:
+ backup:
+ method: volumeSnapshot
+ volumeSnapshotClassName: "csi-aws-vsc"
+ retentionPolicy: "7d"
+ destinationPath: "s3://prd-morpho-database-backup/erpc-backups"
+ # PLA-1112: right-sized from gp3-db-burst (16000/1000) — CloudWatch peak was
+ # only ~339 IOPS / ~8 MB/s, ~50x under burst. gp3-db-standard (3000/125)
+ # leaves ample headroom.
+ storageClass: gp3-db-standard
+ volumeAttributesClassName: gp3-db-standard
+ # Primary currently uses ~693Gi; 500Gi chart default would block replica re-bootstrap
+ storage: "1Ti"
+ # PLA-1112: 8 CPU / 32Gi forced m8g.4xlarge (capped at 8/32 — paying for a
+ # 64Gi/16-vCPU node). 7d peak is only ~0.6 CPU cores and ~0.48Gi RSS.
+ # 7400m/28Gi still rounds up to m8g.4xlarge (both sit at ~98-99% of m8g.2xlarge
+ # allocatable once DaemonSets are counted), so trim to 6800m / 26Gi to land
+ # cleanly on m8g.2xlarge with headroom — half the node, ~26Gi cache for the
+ # ~700GB latency-sensitive DB.
+ resources:
+ requests:
+ cpu: "6800m"
+ memory: "26Gi"
+ limits:
+ cpu: "6800m"
+ memory: "26Gi"
+ # Enforce 1-pod-per-node across all CNPG instances (cross-cluster).
+ affinity:
+ additionalPodAntiAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ - labelSelector:
+ matchExpressions:
+ - key: cnpg.io/podRole
+ operator: In
+ values: [instance]
+ topologyKey: kubernetes.io/hostname
+ sharedBuffers: "8GB"
+ effectiveCacheSize: "24GB"
+ maintenanceWorkMem: "1GB"
+ workMem: "8MB"
+ maxWorkerProcesses: "4"
+ maxParallelWorkers: "4"
+ maxParallelWorkersPerGather: "2"
+ maxParallelMaintenanceWorkers: "2"
+ autovacuumMaxWorkers: "4"
+
+ vault:
+ jobImage:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/erpc-validator
+ tag: &erpcValidatorImageTag "0.1.3"
+
+ headlessService:
+ enabled: true
+
+ replicaCount: 12
+
+ autoscaling:
+ enabled: true
+ minReplicas: 12
+ maxReplicas: 20
+ targetCPUUtilizationPercentage: 80
+ targetMemoryUtilizationPercentage: 80
+
+ extraEnvVars:
+ - name: GOMEMLIMIT
+ value: "6GiB"
+ - name: GOGC
+ value: "25"
+
+ resources:
+ requests:
+ cpu: "750m"
+ memory: "4Gi"
+ limits:
+ cpu: "1500m"
+ memory: "8Gi"
+
+ startupProbe:
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 6
+
+ livenessProbe:
+ periodSeconds: 10
+ timeoutSeconds: 5
+ failureThreshold: 3
+
+ readinessProbe:
+ path: /healthcheck
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 2
+
+ monitoring:
+ podMonitor:
+ enabled: true
+ additionalLabels:
+ release: prometheus
+ interval: 5s
+ scrapeTimeout: 4s
+
+# =============================================================================
+# eRPC Dev Instance - Single pod, no HPA, no database
+# =============================================================================
+erpc-dev:
+ enabled: true
+ fullnameOverride: "erpc-dev"
+
+ # PLA-1360: validate Pod Security Standards "restricted" hardening on the
+ # erpc dev instance (distroless image) before flipping in the other prd
+ # erpc instances.
+ securityHardening:
+ enabled: true
+
+ image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/erpc
+ tag: *erpcImageTag
+
+ replicaCount: 1
+
+ autoscaling:
+ enabled: false
+ minReplicas: 1
+
+ # Disable PostgreSQL (shares the production database via Vault config)
+ postgresql:
+ enabled: false
+
+ # Reuse the existing erpc Vault creator identity only for secret materialization;
+ # runtime pods use erpc-dev-runtime and have no Secret RBAC.
+ serviceAccount:
+ runtimeName: "erpc-dev-runtime"
+ vaultCreator:
+ create: false
+ name: "erpc"
+
+ # Dev config path in Vault. Keep this source cache/state-isolated from prod:
+ # Redis DB/cluster key are dev-only and PostgreSQL cache connector is disabled.
+ vault:
+ configPath: "secret/data/erpc/config-dev"
+ jobImage:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/erpc-validator
+ tag: *erpcValidatorImageTag
+ pullPolicy: IfNotPresent
+
+ # Public ingress for erpc-dev is managed in morpho-infra via CloudFront and
+ # a Terraform-owned internal NLB.
+ service:
+ type: ClusterIP
+
+ extraEnvVars:
+ - name: GOMEMLIMIT
+ value: "640MiB"
+ - name: GOGC
+ value: "50"
+
+ resources:
+ requests:
+ cpu: "250m"
+ memory: "384Mi"
+ limits:
+ cpu: "1200m"
+ memory: "768Mi"
+
+ startupProbe:
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ timeoutSeconds: 5
+ failureThreshold: 6
+
+ livenessProbe:
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ timeoutSeconds: 1
+ failureThreshold: 3
+
+ readinessProbe:
+ path: /healthcheck
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 2
+
+ monitoring:
+ podMonitor:
+ enabled: true
+ additionalLabels:
+ release: prometheus
+ interval: 5s
+ scrapeTimeout: 4s
+
+# =============================================================================
+# eRPC Fallback Instance - Hot standby upstream-image pool for incident traffic
+# =============================================================================
+erpc-fallback:
+ enabled: true
+ fullnameOverride: "erpc-fallback"
+
+ securityHardening:
+ enabled: true
+
+ image:
+ # Keep the upstream eRPC image for incident fallback, but pin the digest so
+ # pod restarts cannot drift to a new upstream build without review.
+ repository: ghcr.io/erpc/erpc
+ tag: "0.1.0@sha256:f8a292d5e867cf878e5fad1c3b0702531bbf86f3f1d7db1af18e34cbcd5b5a6e"
+ pullPolicy: IfNotPresent
+
+ autoscaling:
+ enabled: true
+ # Keep fallback cheap while idle, but let it burst to full failover capacity
+ # as soon as real traffic or resource pressure appears.
+ minReplicas: 3
+ maxReplicas: 40
+ targetCPUUtilizationPercentage: 50
+ targetMemoryUtilizationPercentage: 70
+ behavior:
+ scaleUp:
+ stabilizationWindowSeconds: 0
+ policies:
+ - type: Percent
+ value: 200
+ periodSeconds: 15
+ - type: Pods
+ value: 16
+ periodSeconds: 15
+ selectPolicy: Max
+ scaleDown:
+ stabilizationWindowSeconds: 300
+ policies:
+ - type: Percent
+ value: 50
+ periodSeconds: 60
+ - type: Pods
+ value: 8
+ periodSeconds: 60
+ selectPolicy: Max
+
+ postgresql:
+ enabled: false
+
+ serviceAccount:
+ runtimeName: "erpc-fallback-runtime"
+ vaultCreator:
+ create: false
+ name: "erpc"
+
+ vault:
+ configPath: "secret/data/erpc/config-fallback"
+ secretsPath: "secret/data/erpc/secrets"
+ jobImage:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/erpc-validator
+ tag: *erpcValidatorImageTag
+ pullPolicy: IfNotPresent
+ validationImage:
+ enabled: true
+ repository: ghcr.io/erpc/erpc
+ tag: "0.1.0@sha256:f8a292d5e867cf878e5fad1c3b0702531bbf86f3f1d7db1af18e34cbcd5b5a6e"
+ pullPolicy: IfNotPresent
+ command:
+ - /erpc-server
+
+ headlessService:
+ enabled: true
+
+ extraEnvVars:
+ - name: GOMEMLIMIT
+ value: "6GiB"
+ - name: GOGC
+ value: "25"
+
+ resources:
+ requests:
+ cpu: "250m"
+ memory: "1Gi"
+ limits:
+ cpu: "1500m"
+ memory: "8Gi"
+
+ lifecycle:
+ enabled: false
+
+ startupProbe:
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ timeoutSeconds: 5
+ failureThreshold: 6
+
+ livenessProbe:
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ timeoutSeconds: 1
+ failureThreshold: 3
+
+ readinessProbe:
+ path: /healthcheck
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 2
+
+ monitoring:
+ podMonitor:
+ enabled: true
+ additionalLabels:
+ release: prometheus
+ interval: 5s
+ scrapeTimeout: 4s
+
+# =============================================================================
+# eRPC Processing Instance - Dedicated pool for heavier workloads
+# =============================================================================
+erpc-processing:
+ enabled: true
+ fullnameOverride: "erpc-processing"
+
+ # PLA-1360: enable Pod Security Standards "restricted" hardening after
+ # dev validation.
+ securityHardening:
+ enabled: true
+
+ image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/erpc
+ tag: *erpcImageTag
+
+ replicaCount: 3
+
+ autoscaling:
+ enabled: true
+ minReplicas: 3
+ maxReplicas: 8
+ targetCPUUtilizationPercentage: 80
+ targetMemoryUtilizationPercentage: 80
+
+ # Disable PostgreSQL (shares the production database via Vault config)
+ postgresql:
+ enabled: false
+
+ # Reuse the existing erpc Vault creator identity only for secret materialization;
+ # runtime pods use erpc-processing-runtime and have no Secret RBAC.
+ serviceAccount:
+ runtimeName: "erpc-processing-runtime"
+ vaultCreator:
+ create: false
+ name: "erpc"
+
+ # Dedicated processing config path in Vault
+ vault:
+ configPath: "secret/data/erpc/config-processing"
+ jobImage:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/erpc-validator
+ tag: *erpcValidatorImageTag
+ pullPolicy: IfNotPresent
+
+ extraEnvVars:
+ - name: GOMEMLIMIT
+ value: "768MiB"
+ - name: GOGC
+ value: "25"
+
+ resources:
+ requests:
+ cpu: "750m"
+ memory: "512Mi"
+ limits:
+ cpu: "1500m"
+ memory: "1Gi"
+
+ headlessService:
+ enabled: true
+
+ startupProbe:
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 6
+
+ livenessProbe:
+ periodSeconds: 10
+ timeoutSeconds: 5
+ failureThreshold: 3
+
+ readinessProbe:
+ path: /healthcheck
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 2
+
+ monitoring:
+ podMonitor:
+ enabled: true
+ additionalLabels:
+ release: prometheus
+ interval: 5s
+ scrapeTimeout: 4s
+
+# =============================================================================
+# eRPC Router Instance - Dedicated pool for router workloads
+# =============================================================================
+erpc-router:
+ enabled: true
+ fullnameOverride: "erpc-router"
+
+ # PLA-1360: enable Pod Security Standards "restricted" hardening after
+ # dev validation.
+ securityHardening:
+ enabled: true
+
+ image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/erpc
+ tag: *erpcImageTag
+
+ replicaCount: 3
+
+ autoscaling:
+ enabled: true
+ minReplicas: 3
+ maxReplicas: 8
+ targetCPUUtilizationPercentage: 80
+ targetMemoryUtilizationPercentage: 80
+
+ # Disable PostgreSQL (shares the production database via Vault config)
+ postgresql:
+ enabled: false
+
+ # Reuse the existing erpc Vault creator identity only for secret materialization;
+ # runtime pods use erpc-router-runtime and have no Secret RBAC.
+ serviceAccount:
+ runtimeName: "erpc-router-runtime"
+ vaultCreator:
+ create: false
+ name: "erpc"
+
+ # Dedicated router config path in Vault
+ vault:
+ configPath: "secret/data/erpc/config-router"
+ jobImage:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/erpc-validator
+ tag: *erpcValidatorImageTag
+ pullPolicy: IfNotPresent
+
+ extraEnvVars:
+ - name: GOMEMLIMIT
+ value: "768MiB"
+ - name: GOGC
+ value: "25"
+
+ resources:
+ requests:
+ cpu: "750m"
+ memory: "512Mi"
+ limits:
+ cpu: "1500m"
+ memory: "1Gi"
+
+ headlessService:
+ enabled: true
+
+ startupProbe:
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 6
+
+ livenessProbe:
+ periodSeconds: 10
+ timeoutSeconds: 5
+ failureThreshold: 3
+
+ readinessProbe:
+ path: /healthcheck
+ periodSeconds: 5
+ timeoutSeconds: 5
+ failureThreshold: 2
+
+ monitoring:
+ podMonitor:
+ enabled: true
+ additionalLabels:
+ release: prometheus
+ interval: 5s
+ scrapeTimeout: 4s
+
+redis-ha:
+ enabled: true
+ fullnameOverride: "erpc-redis"
+ replicas: 3
+
+ image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/library/redis
+ tag: 8.4.0-alpine
+
+ configmapTest:
+ image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/koalaman/shellcheck
+
+ # Authentication - uses existing secret created from Vault
+ auth: true
+ existingSecret: "erpc-redis-auth"
+ authKey: "password"
+
+ # Vault configuration for Redis password
+ vault:
+ secretsPath: "secret/data/erpc/redis"
+
+ # PodDisruptionBudget - Prevent simultaneous eviction during node scaling
+ podDisruptionBudget:
+ minAvailable: 2 # Always keep at least 2 of 3 Redis pods running
+
+ # Hard anti-affinity ensures pods spread across nodes
+ hardAntiAffinity: true
+
+ # Graceful shutdown - ensure Redis has time to sync before termination
+ terminationGracePeriodSeconds: 60
+
+ # Increase replication backlog for faster resync after restarts
+ redis:
+ # Custom Redis configuration
+ # Larger replication backlog prevents full resync after short disconnections
+ # Default is 1MB, we increase to 256MB to handle longer network partitions
+ config:
+ repl-backlog-size: "256mb"
+ repl-backlog-ttl: "3600"
+ # Reduce replication timeout for faster failover detection
+ repl-timeout: "60"
+ # Ensure RDB persistence for faster recovery
+ save: "900 1 300 10 60 10000"
+ resources:
+ requests:
+ cpu: "100m"
+ memory: "512Mi"
+ limits:
+ cpu: "1"
+ memory: "1Gi"
+ # PreStopHook for graceful failover
+ # Note: The redis-ha chart has a default trigger-failover-if-master.sh script
+ # We add BGSAVE before the failover to ensure data is persisted
+ lifecycle:
+ preStop:
+ exec:
+ command:
+ - /bin/sh
+ - -c
+ - |
+ # Use REDISCLI_AUTH to avoid password in process args
+ timeout 10 sh -c 'REDISCLI_AUTH="$AUTH" redis-cli BGSAVE' 2>/dev/null || true
+ sleep 2
+ if [ -f /readonly-config/trigger-failover-if-master.sh ]; then
+ /bin/sh /readonly-config/trigger-failover-if-master.sh
+ else
+ echo "Warning: trigger-failover-if-master.sh not found, skipping failover"
+ fi
+
+ haproxy:
+ enabled: true
+ image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/library/haproxy
+ # Enable Prometheus Redis monitoring via annotation-based discovery
+ service:
+ annotations:
+ prometheus.io/redis-scrape: "true"
+ prometheus.io/redis-port: "6379"
+ resources:
+ requests:
+ cpu: "50m"
+ memory: "128Mi"
+ limits:
+ cpu: "500m"
+ memory: "512Mi"
+
+ sentinel:
+ resources:
+ requests:
+ cpu: "10m"
+ memory: "32Mi"
+ limits:
+ cpu: "100m"
+ memory: "64Mi"
+
+ persistentVolume:
+ enabled: true
+ size: 1Gi
+ storageClass: "gp3"
+
+ securityContext:
+ runAsUser: 1000
+ fsGroup: 1000
+ runAsNonRoot: true
+
+ # Explicit on-demand node placement
+ nodeSelector:
+ nodegroup: on-demand-nodes
+
+ tolerations:
+ - key: dedicated
+ operator: Equal
+ value: on-demand
+ effect: NoSchedule
+
+# =============================================================================
+# Redis Exporter for Prometheus monitoring
+# =============================================================================
+redis-exporter:
+ image:
+ repository: 537124939463.dkr.ecr.eu-west-3.amazonaws.com/docker-hub/oliver006/redis_exporter
+
+ # Connect to redis-ha via HAProxy
+ redisAddress: redis://erpc-redis-haproxy:6379
+
+ # Use existing secret created by Vault job
+ auth:
+ enabled: true
+ secret:
+ name: erpc-redis-auth
+ key: password
+
+ # Enable ServiceMonitor for Prometheus
+ serviceMonitor:
+ enabled: true
+ labels:
+ release: prometheus-stack
+ # Add redis_instance label for Grafana dashboard compatibility
+ metricRelabelings:
+ - action: replace
+ sourceLabels: [service]
+ targetLabel: redis_instance
+ regex: (.*)
+ replacement: erpc-redis
+
+ # Resources
+ resources:
+ limits:
+ cpu: 100m
+ memory: 128Mi
+ requests:
+ cpu: 25m
+ memory: 32Mi
diff --git a/review/repo-map.md b/review/repo-map.md
index ab7203c4d..98963b78f 100644
--- a/review/repo-map.md
+++ b/review/repo-map.md
@@ -15,28 +15,25 @@ Top-level
- .semrelrc
- .vscode
- AGENTS.md
-- CLA.md
-- CLAUDE.md
-- CODE_OF_CONDUCT.md
-- CONTRIBUTING.md
-- Dockerfile
-- Dockerfile.erpc-validator
-- Dockerfile.fake
-- Dockerfile.validator
-- LICENSE
-- Makefile
-- README.md
- architecture
- artifacts
- assets
- auth
- biome.json
+- CLA.md
+- CLAUDE.md
- clients
- cmd
+- CODE_OF_CONDUCT.md
- common
- consensus
+- CONTRIBUTING.md
- data
- docker-compose.yml
+- Dockerfile
+- Dockerfile.erpc-validator
+- Dockerfile.fake
+- Dockerfile.validator
- docs
- erpc
- erpc.dist.ts
@@ -46,13 +43,17 @@ Top-level
- go.mod
- go.sum
- health
+- helm
- internal
- kube
+- LICENSE
+- Makefile
- monitoring
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- prometheus.yaml
+- README.md
- review
- scripts
- scylla
@@ -65,6 +66,7 @@ Top-level
- typescript
- upstream
- util
+- validate-helm-charts.sh
Go entrypoints
- cmd/erpc
diff --git a/scripts/review-repo-map.sh b/scripts/review-repo-map.sh
index 13a7b21b5..ad7cfc1a2 100755
--- a/scripts/review-repo-map.sh
+++ b/scripts/review-repo-map.sh
@@ -19,7 +19,7 @@ echo
echo "Top-level"
if [[ $in_git -eq 1 ]]; then
- git ls-files | awk -F/ '{print $1}' | sort -u | sed 's/^/- /'
+ git ls-files | awk -F/ '{print $1}' | LC_ALL=C sort -f -u | sed 's/^/- /'
else
ls -1 | sed 's/^/- /'
fi
diff --git a/validate-helm-charts.sh b/validate-helm-charts.sh
new file mode 100755
index 000000000..a533802d1
--- /dev/null
+++ b/validate-helm-charts.sh
@@ -0,0 +1,228 @@
+#!/bin/bash
+set -eo pipefail
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[0;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Parse command line arguments (PARALLEL_JOBS set after chart discovery)
+PARALLEL_JOBS=""
+while getopts "j:sh" opt; do
+ case $opt in
+ j) PARALLEL_JOBS="$OPTARG" ;;
+ s) SEQUENTIAL=true ;;
+ h)
+ echo "Usage: $0 [-j N] [-s] [-h]"
+ echo " -j N Run N parallel jobs (default: number of charts)"
+ echo " -s Run sequentially (disable parallelism)"
+ echo " -h Show this help"
+ exit 0
+ ;;
+ \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;;
+ esac
+done
+
+# Check if kubeconform is installed
+if ! command -v kubeconform &> /dev/null; then
+ echo -e "${RED}kubeconform is not installed. Please install it first.${NC}"
+ echo "You can install it via: https://github.com/yannh/kubeconform#installation"
+ exit 1
+fi
+
+# Check if helm is installed
+if ! command -v helm &> /dev/null; then
+ echo -e "${RED}helm is not installed. Please install it first.${NC}"
+ echo "You can install it via: https://helm.sh/docs/intro/install/"
+ exit 1
+fi
+
+# Set base directories to scan (erpc: base chart + prd env wrapper only)
+DIRS_TO_SCAN=("helm/charts/erpc" "helm/environments/prd/erpc")
+
+# List of custom resources to skip
+CUSTOM_RESOURCES="PodMonitor,ServiceMonitor,AlertManager,Prometheus,PrometheusRule,Cluster,ScheduledBackup,ImageCatalog,IngressRoute,Certificate,CronJob,EventSource,EventBus,Sensor"
+export CUSTOM_RESOURCES
+
+# List of charts to explicitly skip validation
+SKIP_VALIDATION_CHARTS=()
+
+# Function to check if a chart should skip validation
+should_skip_validation() {
+ local chart_name=$1
+
+ # Check explicit list
+ for skip_chart in "${SKIP_VALIDATION_CHARTS[@]}"; do
+ if [[ "$chart_name" == "$skip_chart" ]]; then
+ return 0 # true, should skip
+ fi
+ done
+
+ # Check database pattern
+ if [[ "$chart_name" == *"-db" || "$chart_name" == *"database"* || "$chart_name" == *"postgres"* ]]; then
+ return 0 # true, should skip
+ fi
+
+ return 1 # false, should not skip
+}
+
+# Export for parallel subshells
+export -f should_skip_validation
+export SKIP_VALIDATION_CHARTS
+
+# Function to process a single chart (for parallel execution)
+process_chart() {
+ local chart_path=$1
+ local chart_name=$(basename "$chart_path")
+ local result_dir=$(mktemp -d)
+ local lint_file="$result_dir/lint"
+ local template_file="$result_dir/template"
+ local has_error=0
+
+ # Store the absolute path to the chart
+ local chart_absolute_path=$(realpath "$chart_path")
+
+ echo "Processing: ${chart_path}" >&2
+
+ # Run helm lint
+ if ! helm lint "$chart_absolute_path" > "$lint_file" 2>&1; then
+ echo -e "${RED}✗ Helm lint failed for ${chart_name}${NC}" >&2
+ cat "$lint_file" >&2
+ has_error=1
+ else
+ echo -e "${GREEN}✓ Helm lint passed for ${chart_name}${NC}" >&2
+ fi
+
+ # Check if this chart should skip validation
+ if should_skip_validation "$chart_name"; then
+ echo -e "${YELLOW}⊘ Skipping kubeconform for ${chart_name} (database chart)${NC}" >&2
+ rm -rf "$result_dir"
+ return $has_error
+ fi
+
+ # Build dependencies if Chart.yaml exists and has dependencies
+ if [ -f "${chart_absolute_path}/Chart.yaml" ]; then
+ if grep -q "dependencies:" "${chart_absolute_path}/Chart.yaml"; then
+ if ! helm dependency update "$chart_absolute_path" > /dev/null 2>&1; then
+ echo -e "${RED}✗ Dependency update failed for ${chart_name}${NC}" >&2
+ rm -rf "$result_dir"
+ return 1
+ fi
+ fi
+
+ # Run template validation: render the chart and validate the
+ # resulting manifests with kubeconform. The pipeline's exit status
+ # reflects kubeconform (the last command), which is what we check.
+ if ! helm template "$chart_absolute_path" 2>/dev/null \
+ | kubeconform --ignore-missing-schemas --summary --skip "$CUSTOM_RESOURCES" \
+ > "$template_file" 2>&1; then
+ echo -e "${RED}✗ Kubeconform failed for ${chart_name}${NC}" >&2
+ cat "$template_file" >&2
+ has_error=1
+ else
+ echo -e "${GREEN}✓ Kubeconform passed for ${chart_name}${NC}" >&2
+ fi
+ else
+ echo -e "${RED}No Chart.yaml found in ${chart_path}${NC}" >&2
+ has_error=1
+ fi
+
+ rm -rf "$result_dir"
+ return $has_error
+}
+
+# Export for parallel
+export -f process_chart
+
+# Main script execution
+echo -e "${YELLOW}Starting Helm chart validation...${NC}"
+echo -e "${YELLOW}Skipping validation for these custom resources: ${CUSTOM_RESOURCES}${NC}"
+
+# Add common repositories that might be needed
+echo -e "${YELLOW}Adding common Helm repositories...${NC}"
+helm repo add bitnami https://charts.bitnami.com/bitnami 2>/dev/null || true
+helm repo add cnpg https://cloudnative-pg.github.io/charts 2>/dev/null || true
+helm repo add prometheus-community https://prometheus-community.github.io/helm-charts 2>/dev/null || true
+helm repo add haproxytech https://haproxytech.github.io/helm-charts 2>/dev/null || true
+helm repo add dandydeveloper https://dandydeveloper.github.io/charts 2>/dev/null || true
+
+# Update Helm repositories
+echo -e "${YELLOW}Updating Helm repositories...${NC}"
+helm repo update
+
+# Collect all charts to validate
+CHARTS=()
+for dir in "${DIRS_TO_SCAN[@]}"; do
+ if [ ! -d "$dir" ]; then
+ echo -e "${YELLOW}Directory $dir does not exist, skipping...${NC}"
+ continue
+ fi
+
+ while IFS= read -r chart_yaml; do
+ chart_dir=$(dirname "$chart_yaml")
+ CHARTS+=("$chart_dir")
+ done < <(find "$dir" -name Chart.yaml -type f)
+done
+
+TOTAL_CHARTS=${#CHARTS[@]}
+
+# Set PARALLEL_JOBS to number of charts if not specified
+if [ -z "$PARALLEL_JOBS" ]; then
+ PARALLEL_JOBS=$TOTAL_CHARTS
+fi
+
+echo -e "\n${YELLOW}Found ${TOTAL_CHARTS} charts to validate${NC}\n"
+
+# Run validation
+FAILED_CHARTS=()
+
+if [ "$SEQUENTIAL" = true ] || ! command -v parallel &> /dev/null; then
+ # Sequential execution (fallback or explicit)
+ if [ "$SEQUENTIAL" != true ]; then
+ echo -e "${YELLOW}GNU parallel not found, falling back to sequential execution${NC}"
+ echo -e "${YELLOW}Install with: brew install parallel (macOS) or apt install parallel (Linux)${NC}\n"
+ fi
+
+ for chart in "${CHARTS[@]}"; do
+ if ! process_chart "$chart"; then
+ FAILED_CHARTS+=("$chart")
+ fi
+ done
+else
+ # Parallel execution
+ echo -e "${BLUE}Running ${PARALLEL_JOBS} parallel validation jobs...${NC}\n"
+
+ # Create a temporary file to track failures
+ FAIL_FILE=$(mktemp)
+
+ # Run in parallel, collecting failures
+ printf '%s\n' "${CHARTS[@]}" | \
+ parallel -j "$PARALLEL_JOBS" --halt never,fail=1 --line-buffer \
+ "process_chart {} || echo {} >> $FAIL_FILE"
+
+ # Collect failed charts
+ if [ -f "$FAIL_FILE" ]; then
+ while IFS= read -r failed; do
+ FAILED_CHARTS+=("$failed")
+ done < "$FAIL_FILE"
+ rm -f "$FAIL_FILE"
+ fi
+fi
+
+# Summary report
+echo -e "\n${YELLOW}==== Validation Summary ====${NC}"
+echo -e "Total charts processed: ${TOTAL_CHARTS}"
+echo -e "Failed charts: ${#FAILED_CHARTS[@]}"
+
+if [ ${#FAILED_CHARTS[@]} -eq 0 ]; then
+ echo -e "${GREEN}All validations passed successfully!${NC}"
+ exit 0
+else
+ echo -e "${RED}The following charts failed validation:${NC}"
+ for chart in "${FAILED_CHARTS[@]}"; do
+ echo -e " - ${chart}"
+ done
+ exit 1
+fi