diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b86c415..a9d3870 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -7,22 +7,38 @@ on: branches: [main, master] jobs: + # --------------------------------------------------------------------------- + # Гранулярная детекция изменений по модулям. + # Каждый флаг соответствует группе файлов; downstream-джобы запускаются + # только когда их группа затронута. Изменение CI-конфигурации (.gitea/workflows/*) + # поднимает все флаги, чтобы гарантировать полный прогон. + # --------------------------------------------------------------------------- changes: runs-on: ubuntu-latest outputs: - openapi: ${{ steps.detect.outputs.openapi }} - code: ${{ steps.detect.outputs.code }} + openapi: ${{ steps.detect.outputs.openapi }} + go: ${{ steps.detect.outputs.go }} + web: ${{ steps.detect.outputs.web }} + bird_conf: ${{ steps.detect.outputs.bird_conf }} + docker_go: ${{ steps.detect.outputs.docker_go }} + docker_web: ${{ steps.detect.outputs.docker_web }} + docker_bird: ${{ steps.detect.outputs.docker_bird }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - id: detect - name: Detect changed paths (OpenAPI vs doc-only vs code) + name: Detect changed paths per module run: | set -euo pipefail openapi=false - code=true + go=false + web=false + bird_conf=false + docker_go=false + docker_web=false + docker_bird=false if [ "${{ github.event_name }}" = "pull_request" ]; then base="${{ github.event.pull_request.base.sha }}" @@ -36,52 +52,61 @@ jobs: elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then FILES="$(git diff --name-only HEAD~1 HEAD)" else - # Нет родителя (первый коммит / тонкий clone) — полный пайплайн + OpenAPI - openapi=true - code=true - echo "openapi=$openapi" >> "$GITHUB_OUTPUT" - echo "code=$code" >> "$GITHUB_OUTPUT" - echo "No parent commit: openapi=true code=true" + openapi=true; go=true; web=true; bird_conf=true + docker_go=true; docker_web=true; docker_bird=true + for v in openapi go web bird_conf docker_go docker_web docker_bird; do + echo "$v=true" >> "$GITHUB_OUTPUT" + done + echo "No parent commit — full pipeline" exit 0 fi fi - # Пустой diff (редко) — не отключаем сборку из осторожности if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then - openapi=false - code=true - echo "openapi=$openapi" >> "$GITHUB_OUTPUT" - echo "code=$code" >> "$GITHUB_OUTPUT" - echo "Empty diff: openapi=false code=true" + go=true; web=true + for v in openapi go web bird_conf docker_go docker_web docker_bird; do + eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT" + done + echo "Empty diff — safe fallback: go=true web=true" exit 0 fi - if printf '%s\n' "$FILES" | grep -qE '^docs/openapi\.yaml$|^redocly\.yaml$'; then - openapi=true - fi + ci_changed=false - doc_only_all=true while IFS= read -r f || [ -n "${f:-}" ]; do [ -z "${f:-}" ] && continue - if [[ "$f" == "README.md" || "$f" == ".gitea/README.md" || "$f" == "web/README.md" || "$f" == "redocly.yaml" || "$f" == docs/* ]]; then - continue - fi - doc_only_all=false - break + case "$f" in + .gitea/workflows/*) ci_changed=true ;; + docs/openapi.yaml|redocly.yaml) openapi=true ;; + web/README.md) ;; # doc-only + web/*) web=true ;; + deploy/bird/*) bird_conf=true ;; + deploy/docker/gobinary/*) docker_go=true ;; + deploy/docker/evobgp-agent/*) docker_go=true ;; + deploy/docker/evobgp-web/*) docker_web=true ;; + deploy/docker/bird2/*) docker_bird=true ;; + go.mod|go.sum|go.work) go=true ;; + *.go) go=true ;; + cmd/*|internal/*) go=true ;; + esac done <<< "$FILES" - if $doc_only_all; then - code=false - else - code=true + if $ci_changed; then + go=true; web=true; bird_conf=true + docker_go=true; docker_web=true; docker_bird=true fi - echo "openapi=$openapi" >> "$GITHUB_OUTPUT" - echo "code=$code" >> "$GITHUB_OUTPUT" - echo "Changed files (first 20):" - printf '%s\n' "$FILES" | head -n 20 - echo "openapi=$openapi code=$code" + for v in openapi go web bird_conf docker_go docker_web docker_bird; do + eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT" + done + echo "Changed files (first 30):" + printf '%s\n' "$FILES" | head -n 30 + echo "--- flags ---" + echo "openapi=$openapi go=$go web=$web bird_conf=$bird_conf" + echo "docker_go=$docker_go docker_web=$docker_web docker_bird=$docker_bird ci=$ci_changed" + + # --------------------------------------------------------------------------- openapi: needs: [changes] if: needs.changes.outputs.openapi == 'true' @@ -94,9 +119,10 @@ jobs: - name: Lint OpenAPI (Redocly) run: npx --yes @redocly/cli@1 lint docs/openapi.yaml + # --------------------------------------------------------------------------- go: needs: [changes] - if: needs.changes.outputs.code == 'true' + if: needs.changes.outputs.go == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -117,13 +143,12 @@ jobs: go build -o "$out/$name" "./$d" done + # --------------------------------------------------------------------------- bird2: runs-on: ubuntu-latest needs: [go] steps: - uses: actions/checkout@v4 - # Вложенный docker + bind-mount ломается, если демон Docker на хосте не видит путь - # workspace runner'а (/workspace/...). bird -p на самом job-раннере надёжнее. - name: Install bird2 run: | set -euxo pipefail @@ -148,10 +173,20 @@ jobs: bird -c "$WS/$conf" -p done - # Сборка образов и push в Container Registry Gitea (только push в main/master; см. .gitea/README.md). - docker-images: - needs: [go] - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + # --------------------------------------------------------------------------- + # Docker: Go-бинарники (api, all, scheduler, ingest, render, deploy, node, agent). + # Запускается если изменился Go-код или Go-Dockerfile. + # Если Go-код менялся — требуем успех go-тестов; если только Dockerfile — go skipped, ОК. + # --------------------------------------------------------------------------- + docker-go: + needs: [changes, go] + if: >- + always() && + needs.changes.result == 'success' && + needs.go.result != 'failure' && + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && + (needs.changes.outputs.go == 'true' || needs.changes.outputs.docker_go == 'true') runs-on: ubuntu-latest strategy: fail-fast: false @@ -192,24 +227,13 @@ jobs: bin: evobgp-node birdc: "0" evobgp_upstream: "" - - image: evobgp-web - dockerfile: deploy/docker/evobgp-web/Dockerfile - evobgp_upstream: "" - - image: evobgp-web-all - dockerfile: deploy/docker/evobgp-web/Dockerfile - evobgp_upstream: evobgp-all - image: evobgp-agent dockerfile: deploy/docker/evobgp-agent/Dockerfile evobgp_upstream: "" - - image: evobgp-bird2 - dockerfile: deploy/docker/bird2/Dockerfile - evobgp_upstream: "" steps: - uses: actions/checkout@v4 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Prepare image metadata id: meta run: | @@ -218,22 +242,16 @@ jobs: echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT" short_sha="$(echo '${{ github.sha }}' | cut -c1-7)" echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT" - echo "docker_push=true" >> "$GITHUB_OUTPUT" - - name: Log in to Gitea Registry - if: steps.meta.outputs.docker_push == 'true' uses: docker/login-action@v3 with: registry: git.shts.su username: ${{ gitea.actor }} - # PAT репозитория или встроенный токен job (нужны права на пакеты в настройках Actions). password: ${{ secrets.ACTIONS_PAT || gitea.token }} - - name: Build and push ${{ matrix.image }} env: OWNER_LC: ${{ steps.meta.outputs.owner_lc }} SHORT_SHA: ${{ steps.meta.outputs.short_sha }} - DO_PUSH: ${{ steps.meta.outputs.docker_push }} DF: ${{ matrix.dockerfile }} IMAGE: ${{ matrix.image }} BIN: ${{ matrix.bin }} @@ -244,7 +262,7 @@ jobs: WS="${{ github.workspace }}" cd "$WS" - TAG_LOCAL="evobgp:${IMAGE}-${SHORT_SHA}" + IMG="git.shts.su/${OWNER_LC}/${IMAGE}" BUILD_ARGS=() if [ -n "${BIN:-}" ]; then BUILD_ARGS+=(--build-arg "BIN=${BIN}") @@ -254,23 +272,126 @@ jobs: BUILD_ARGS+=(--build-arg "EVOBGP_UPSTREAM=${EVOBGP_UPSTREAM}") fi - if [ "$DO_PUSH" = "true" ]; then - IMG="git.shts.su/${OWNER_LC}/${IMAGE}" - docker buildx build \ - --platform linux/amd64 \ - --file "$DF" \ - "${BUILD_ARGS[@]}" \ - --tag "${IMG}:latest" \ - --tag "${IMG}:${SHORT_SHA}" \ - --tag "${IMG}:sha-${{ github.sha }}" \ - --push \ - "$WS" - else - docker buildx build \ - --platform linux/amd64 \ - --file "$DF" \ - "${BUILD_ARGS[@]}" \ - --tag "$TAG_LOCAL" \ - --load \ - "$WS" + docker buildx build \ + --platform linux/amd64 \ + --file "$DF" \ + "${BUILD_ARGS[@]}" \ + --tag "${IMG}:latest" \ + --tag "${IMG}:${SHORT_SHA}" \ + --tag "${IMG}:sha-${{ github.sha }}" \ + --push \ + "$WS" + + # --------------------------------------------------------------------------- + # Docker: Web-фронтенд (evobgp-web, evobgp-web-all). + # Не зависит от Go-тестов — SvelteKit собирается отдельно. + # --------------------------------------------------------------------------- + docker-web: + needs: [changes] + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && + (needs.changes.outputs.web == 'true' || needs.changes.outputs.docker_web == 'true') + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - image: evobgp-web + dockerfile: deploy/docker/evobgp-web/Dockerfile + evobgp_upstream: "" + - image: evobgp-web-all + dockerfile: deploy/docker/evobgp-web/Dockerfile + evobgp_upstream: evobgp-all + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Prepare image metadata + id: meta + run: | + set -euo pipefail + owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" + echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT" + short_sha="$(echo '${{ github.sha }}' | cut -c1-7)" + echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT" + - name: Log in to Gitea Registry + uses: docker/login-action@v3 + with: + registry: git.shts.su + username: ${{ gitea.actor }} + password: ${{ secrets.ACTIONS_PAT || gitea.token }} + - name: Build and push ${{ matrix.image }} + env: + OWNER_LC: ${{ steps.meta.outputs.owner_lc }} + SHORT_SHA: ${{ steps.meta.outputs.short_sha }} + DF: ${{ matrix.dockerfile }} + IMAGE: ${{ matrix.image }} + EVOBGP_UPSTREAM: ${{ matrix.evobgp_upstream }} + run: | + set -euxo pipefail + WS="${{ github.workspace }}" + cd "$WS" + + IMG="git.shts.su/${OWNER_LC}/${IMAGE}" + BUILD_ARGS=() + if [ -n "${EVOBGP_UPSTREAM:-}" ]; then + BUILD_ARGS+=(--build-arg "EVOBGP_UPSTREAM=${EVOBGP_UPSTREAM}") fi + + docker buildx build \ + --platform linux/amd64 \ + --file "$DF" \ + "${BUILD_ARGS[@]}" \ + --tag "${IMG}:latest" \ + --tag "${IMG}:${SHORT_SHA}" \ + --tag "${IMG}:sha-${{ github.sha }}" \ + --push \ + "$WS" + + # --------------------------------------------------------------------------- + # Docker: BIRD2 (evobgp-bird2). + # Собирается только при изменении deploy/bird/ или Dockerfile bird2. + # --------------------------------------------------------------------------- + docker-bird: + needs: [changes] + if: >- + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && + (needs.changes.outputs.bird_conf == 'true' || needs.changes.outputs.docker_bird == 'true') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Prepare image metadata + id: meta + run: | + set -euo pipefail + owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" + echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT" + short_sha="$(echo '${{ github.sha }}' | cut -c1-7)" + echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT" + - name: Log in to Gitea Registry + uses: docker/login-action@v3 + with: + registry: git.shts.su + username: ${{ gitea.actor }} + password: ${{ secrets.ACTIONS_PAT || gitea.token }} + - name: Build and push evobgp-bird2 + env: + OWNER_LC: ${{ steps.meta.outputs.owner_lc }} + SHORT_SHA: ${{ steps.meta.outputs.short_sha }} + run: | + set -euxo pipefail + WS="${{ github.workspace }}" + IMG="git.shts.su/${OWNER_LC}/evobgp-bird2" + + docker buildx build \ + --platform linux/amd64 \ + --file deploy/docker/bird2/Dockerfile \ + --tag "${IMG}:latest" \ + --tag "${IMG}:${SHORT_SHA}" \ + --tag "${IMG}:sha-${{ github.sha }}" \ + --push \ + "$WS" diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 961192d..06758af 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -27,6 +27,24 @@ function mergeHeaders(init?: RequestInit, extraHeaders?: Record) return h; } +/** + * Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost). + * Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback. + */ +function newIdempotencyKey(): string { + const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined; + if (c?.randomUUID) return c.randomUUID(); + if (c?.getRandomValues) { + const buf = new Uint8Array(16); + c.getRandomValues(buf); + buf[6] = (buf[6]! & 0x0f) | 0x40; + buf[8] = (buf[8]! & 0x3f) | 0x80; + const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`; +} + export class ApiError extends Error { constructor( public readonly status: number, @@ -58,7 +76,7 @@ export async function apiMutate( const headers: Record = {}; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (opts?.idempotent !== false) { - headers['Idempotency-Key'] = crypto.randomUUID(); + headers['Idempotency-Key'] = newIdempotencyKey(); } const res = await fetch(path, { method,