diff --git a/.github/workflows/gha-cache-smoke.yml b/.github/workflows/gha-cache-smoke.yml new file mode 100644 index 000000000..b91578637 --- /dev/null +++ b/.github/workflows/gha-cache-smoke.yml @@ -0,0 +1,133 @@ +# Temporary smoke test for docker buildx type=gha + actions: write. +# Delete after: this file + tmp/gha-cache-smoke/ +# +# Requires both: +# 1) permissions.actions: write +# 2) crazy-max/ghaction-github-runtime (exposes ACTIONS_RUNTIME_TOKEN / +# ACTIONS_RESULTS_URL to raw `docker buildx` in run: steps) +name: GHA cache smoke + +on: + push: + branches: + - main + paths: + - ".github/workflows/gha-cache-smoke.yml" + - "tmp/gha-cache-smoke/**" + +permissions: + contents: read + actions: write + +concurrency: + group: gha-cache-smoke + cancel-in-progress: true + +env: + CACHE_SCOPE: gha-cache-smoke + +jobs: + export-cache: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Raw `docker buildx --cache-to type=gha` needs these env vars; they are + # NOT visible to run: steps unless exposed (build-push-action does this + # internally). Without them, cache-to silently no-ops. + - name: Expose GitHub Actions runtime env + uses: crazy-max/ghaction-github-runtime@v3 + + - name: Build and export GHA cache + run: | + set -euo pipefail + echo "ACTIONS_RUNTIME_TOKEN set: ${ACTIONS_RUNTIME_TOKEN:+yes}" + echo "ACTIONS_CACHE_URL set: ${ACTIONS_CACHE_URL:+yes}" + echo "ACTIONS_RESULTS_URL set: ${ACTIONS_RESULTS_URL:+yes}" + if [ -z "${ACTIONS_RUNTIME_TOKEN:-}" ]; then + echo "::error::ACTIONS_RUNTIME_TOKEN still empty after ghaction-github-runtime" + exit 1 + fi + + docker buildx build \ + --progress=plain \ + --provenance=false \ + --sbom=false \ + --cache-from type=gha,scope=$CACHE_SCOPE \ + --cache-to type=gha,mode=max,scope=$CACHE_SCOPE \ + --output type=docker,dest=/tmp/gha-cache-smoke.tar \ + -f tmp/gha-cache-smoke/Dockerfile \ + tmp/gha-cache-smoke \ + 2>&1 | tee /tmp/export.log + + if ! grep -Eiq 'exporting (to GitHub Actions Cache|cache)|writing cache' /tmp/export.log; then + echo "::error::Expected GHA cache export markers missing from build log" + echo "---- log ----" + cat /tmp/export.log + exit 1 + fi + echo "OK: saw GHA cache export activity" + grep -Ein 'exporting (to GitHub Actions Cache|cache)|writing cache' /tmp/export.log || true + + - name: Assert cache entries exist via API + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + keys=$(gh api "repos/${{ github.repository }}/actions/caches?per_page=100" \ + --jq '[.actions_caches[].key] | map(select(test("gha-cache-smoke|buildx";"i")))') + echo "matching cache keys: $keys" + count=$(echo "$keys" | jq 'length') + if [ "$count" -lt 1 ]; then + echo "::error::No Actions cache entries after export" + gh api "repos/${{ github.repository }}/actions/caches?per_page=20" \ + --jq '.actions_caches[] | {key, size_in_bytes, created_at}' + exit 1 + fi + echo "OK: $count cache key(s) present" + + import-cache: + needs: export-cache + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Expose GitHub Actions runtime env + uses: crazy-max/ghaction-github-runtime@v3 + + - name: Build and require GHA cache import / layer CACHED + run: | + set -euo pipefail + docker buildx build \ + --progress=plain \ + --provenance=false \ + --sbom=false \ + --cache-from type=gha,scope=$CACHE_SCOPE \ + --cache-to type=gha,mode=max,scope=$CACHE_SCOPE \ + --output type=docker,dest=/tmp/gha-cache-smoke.tar \ + -f tmp/gha-cache-smoke/Dockerfile \ + tmp/gha-cache-smoke \ + 2>&1 | tee /tmp/import.log + + if ! grep -Eiq 'importing cache|CACHED' /tmp/import.log; then + echo "::error::No importing cache / CACHED markers — type=gha import failed" + echo "---- log ----" + cat /tmp/import.log + exit 1 + fi + echo "OK: GHA cache import/CACHED confirmed" + grep -Ein 'importing cache|CACHED' /tmp/import.log || true diff --git a/.github/workflows/mono-engine-deploy.yml b/.github/workflows/mono-engine-deploy.yml index 923ab5c9a..57654df0a 100644 --- a/.github/workflows/mono-engine-deploy.yml +++ b/.github/workflows/mono-engine-deploy.yml @@ -20,11 +20,14 @@ env: REGISTRY_ALIAS: m8q5m4u3 REPOSITORY: mega/mono-engine IMAGE_TAG_BASE: latest + HARBOR_REGISTRY: registry.xuanwu.openatom.cn -# Using AWS access key for authentication +# Using AWS access key for authentication. +# actions: write is required for docker buildx --cache-{from,to} type=gha. permissions: id-token: write contents: read + actions: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -41,6 +44,11 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for raw + # `docker buildx --cache-{from,to} type=gha` in the build step. + - name: Expose GitHub Actions runtime env + uses: crazy-max/ghaction-github-runtime@v3 + # ----------------------------- # AWS / ECR Public # ----------------------------- @@ -57,10 +65,17 @@ jobs: with: registry-type: public + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: ${{ env.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + # ----------------------------- - # Build & push to ECR + # Build & push to ECR + Harbor # ----------------------------- - - name: Build & push image to ECR (amd64) + - name: Build & push image to ECR and Harbor (amd64) env: ECR_REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} IMAGE_TAG_BASE: ${{ env.IMAGE_TAG_BASE }} @@ -71,11 +86,11 @@ jobs: IMAGE_TAG="${IMAGE_TAG_BASE}-${ARCH_SUFFIX}" ECR_IMAGE="$ECR_REGISTRY/${{ env.REGISTRY_ALIAS }}/${{ env.REPOSITORY }}:$IMAGE_TAG" - ECR_CACHE_IMAGE="$ECR_REGISTRY/${{ env.REGISTRY_ALIAS }}/${{ env.REPOSITORY }}:buildcache-${ARCH_SUFFIX}" + HARBOR_IMAGE="${{ env.HARBOR_REGISTRY }}/${{ env.REPOSITORY }}:$IMAGE_TAG" CACHE_SCOPE="mono-engine-${ARCH_SUFFIX}" echo "ECR_IMAGE=$ECR_IMAGE" - echo "ECR_CACHE_IMAGE=$ECR_CACHE_IMAGE" + echo "HARBOR_IMAGE=$HARBOR_IMAGE" echo "CACHE_SCOPE=$CACHE_SCOPE" docker buildx build \ @@ -85,6 +100,7 @@ jobs: --sbom=false \ -f ./mono/Dockerfile \ -t "$ECR_IMAGE" \ + -t "$HARBOR_IMAGE" \ --push . manifest: @@ -106,25 +122,41 @@ jobs: with: registry-type: public + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: ${{ env.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + - name: Create & push manifest env: REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} run: | set -euo pipefail IMAGE_BASE="$REGISTRY/${{ env.REGISTRY_ALIAS }}/${{ env.REPOSITORY }}" + HARBOR_BASE="${{ env.HARBOR_REGISTRY }}/${{ env.REPOSITORY }}" SHORT_SHA="${GITHUB_SHA:0:7}" - - # latest manifest - docker manifest create "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}" \ - "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}-amd64" \ - "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}-arm64" - docker manifest push "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}" - - # commit-hash manifest (same digests as latest for this commit) - docker manifest create "$IMAGE_BASE:$SHORT_SHA" \ - "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}-amd64" \ - "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}-arm64" - docker manifest push "$IMAGE_BASE:$SHORT_SHA" + TAG_BASE="${{ env.IMAGE_TAG_BASE }}" + + push_manifests() { + local base="$1" + local refs=("$base:${TAG_BASE}-amd64") + if docker manifest inspect "$base:${TAG_BASE}-arm64" >/dev/null 2>&1; then + refs+=("$base:${TAG_BASE}-arm64") + else + echo "WARN: $base:${TAG_BASE}-arm64 not found; publishing amd64-only manifests" + fi + + docker manifest create "$base:${TAG_BASE}" "${refs[@]}" + docker manifest push "$base:${TAG_BASE}" + + docker manifest create "$base:$SHORT_SHA" "${refs[@]}" + docker manifest push "$base:$SHORT_SHA" + } + + push_manifests "$IMAGE_BASE" + push_manifests "$HARBOR_BASE" deploy-aws: needs: manifest diff --git a/.github/workflows/orion-server-deploy.yml b/.github/workflows/orion-server-deploy.yml index c9f2bbb2e..9aa4003e7 100644 --- a/.github/workflows/orion-server-deploy.yml +++ b/.github/workflows/orion-server-deploy.yml @@ -11,10 +11,13 @@ env: REGISTRY_ALIAS: m8q5m4u3 REPOSITORY: mega/orion-server IMAGE_TAG_BASE: latest + HARBOR_REGISTRY: registry.xuanwu.openatom.cn +# actions: write is required for docker buildx --cache-{from,to} type=gha. permissions: id-token: write contents: read + actions: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -27,6 +30,7 @@ jobs: permissions: id-token: write contents: read + actions: write steps: - name: Checkout @@ -34,6 +38,11 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for raw + # `docker buildx --cache-{from,to} type=gha` in the build step. + - name: Expose GitHub Actions runtime env + uses: crazy-max/ghaction-github-runtime@v3 + # ----------------------------- # AWS auth # ----------------------------- @@ -50,10 +59,17 @@ jobs: with: registry-type: public + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: ${{ env.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + # ----------------------------- - # Build & push to AWS + # Build & push to AWS + Harbor # ----------------------------- - - name: Build & push amd64 image to AWS + - name: Build & push amd64 image to AWS and Harbor env: AWS_REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} run: | @@ -63,6 +79,7 @@ jobs: ARCH_SUFFIX="amd64" AWS_IMAGE_BASE="$AWS_REGISTRY/${{ env.REGISTRY_ALIAS }}/${{ env.REPOSITORY }}" + HARBOR_IMAGE_BASE="${{ env.HARBOR_REGISTRY }}/${{ env.REPOSITORY }}" TAG="${{ env.IMAGE_TAG_BASE }}-$ARCH_SUFFIX" CACHE_SCOPE="orion-server-$ARCH_SUFFIX" @@ -75,6 +92,7 @@ jobs: --sbom=false \ -f orion-server/Dockerfile \ -t "$AWS_IMAGE_BASE:$TAG" \ + -t "$HARBOR_IMAGE_BASE:$TAG" \ --push . manifest: @@ -96,25 +114,41 @@ jobs: with: registry-type: public + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: ${{ env.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + - name: Create & push manifest env: REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} run: | set -euo pipefail IMAGE_BASE="$REGISTRY/${{ env.REGISTRY_ALIAS }}/${{ env.REPOSITORY }}" + HARBOR_BASE="${{ env.HARBOR_REGISTRY }}/${{ env.REPOSITORY }}" SHORT_SHA="${GITHUB_SHA:0:7}" - - # latest manifest - docker manifest create "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}" \ - "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}-amd64" \ - "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}-arm64" - docker manifest push "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}" - - # commit-hash manifest (same digests as latest for this commit) - docker manifest create "$IMAGE_BASE:$SHORT_SHA" \ - "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}-amd64" \ - "$IMAGE_BASE:${{ env.IMAGE_TAG_BASE }}-arm64" - docker manifest push "$IMAGE_BASE:$SHORT_SHA" + TAG_BASE="${{ env.IMAGE_TAG_BASE }}" + + push_manifests() { + local base="$1" + local refs=("$base:${TAG_BASE}-amd64") + if docker manifest inspect "$base:${TAG_BASE}-arm64" >/dev/null 2>&1; then + refs+=("$base:${TAG_BASE}-arm64") + else + echo "WARN: $base:${TAG_BASE}-arm64 not found; publishing amd64-only manifests" + fi + + docker manifest create "$base:${TAG_BASE}" "${refs[@]}" + docker manifest push "$base:${TAG_BASE}" + + docker manifest create "$base:$SHORT_SHA" "${refs[@]}" + docker manifest push "$base:$SHORT_SHA" + } + + push_manifests "$IMAGE_BASE" + push_manifests "$HARBOR_BASE" deploy-aws: needs: manifest diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml index 52c398864..62f18a63c 100644 --- a/.github/workflows/web-deploy.yml +++ b/.github/workflows/web-deploy.yml @@ -13,10 +13,13 @@ env: REGISTRY_ALIAS: m8q5m4u3 REPOSITORY: mega/mega-ui IMAGE_VERSION: latest + HARBOR_REGISTRY: registry.xuanwu.openatom.cn +# actions: write is required for docker buildx --cache-{from,to} type=gha. permissions: id-token: write contents: read + actions: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -38,16 +41,18 @@ jobs: permissions: id-token: write contents: read + actions: write steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for raw + # `docker buildx --cache-{from,to} type=gha` in the build step. + - name: Expose GitHub Actions runtime env + uses: crazy-max/ghaction-github-runtime@v3 + - name: Configure AWS Credentials (ECR Public, us-east-1) uses: aws-actions/configure-aws-credentials@v4 with: @@ -61,6 +66,13 @@ jobs: with: registry-type: public + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: ${{ env.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + - name: Build & push unified image working-directory: moon env: @@ -75,16 +87,21 @@ jobs: PUBLIC_IMAGE="$ECR_PUBLIC_REGISTRY/${{ env.REGISTRY_ALIAS }}/${{ env.REPOSITORY }}:$IMAGE_TAG" PUBLIC_IMAGE_SHA="$ECR_PUBLIC_REGISTRY/${{ env.REGISTRY_ALIAS }}/${{ env.REPOSITORY }}:$SHA_TAG" + HARBOR_IMAGE="${{ env.HARBOR_REGISTRY }}/${{ env.REPOSITORY }}:$IMAGE_TAG" + HARBOR_IMAGE_SHA="${{ env.HARBOR_REGISTRY }}/${{ env.REPOSITORY }}:$SHA_TAG" docker buildx build \ --platform "$PLATFORM" \ + --build-arg TIPTAP_PRIVATE_REGISTRY_KEY="$TIPTAP_PRIVATE_REGISTRY_KEY" \ --cache-from type=gha,scope=mega-ui \ --cache-to type=gha,mode=max,scope=mega-ui \ --provenance=false \ --sbom=false \ -f apps/web/Dockerfile \ -t "$PUBLIC_IMAGE" \ - -t "$PUBLIC_IMAGE_SHA" . \ + -t "$PUBLIC_IMAGE_SHA" \ + -t "$HARBOR_IMAGE" \ + -t "$HARBOR_IMAGE_SHA" . \ --push manifest: @@ -106,6 +123,13 @@ jobs: with: registry-type: public + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: ${{ env.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + - name: Create & push unified manifest env: REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} @@ -113,18 +137,23 @@ jobs: set -euo pipefail IMAGE_BASE="$REGISTRY/${{ env.REGISTRY_ALIAS }}/${{ env.REPOSITORY }}" + HARBOR_BASE="${{ env.HARBOR_REGISTRY }}/${{ env.REPOSITORY }}" TAG="${IMAGE_VERSION}" SHORT_SHA="${GITHUB_SHA:0:7}" - # latest manifest - docker manifest create "$IMAGE_BASE:$TAG" \ - "$IMAGE_BASE:${TAG}-amd64" - docker manifest push "$IMAGE_BASE:$TAG" + push_manifests() { + local base="$1" + docker manifest create "$base:$TAG" \ + "$base:${TAG}-amd64" + docker manifest push "$base:$TAG" + + docker manifest create "$base:$SHORT_SHA" \ + "$base:${SHORT_SHA}-amd64" + docker manifest push "$base:$SHORT_SHA" + } - # commit-hash manifest - docker manifest create "$IMAGE_BASE:$SHORT_SHA" \ - "$IMAGE_BASE:${SHORT_SHA}-amd64" - docker manifest push "$IMAGE_BASE:$SHORT_SHA" + push_manifests "$IMAGE_BASE" + push_manifests "$HARBOR_BASE" deploy-aws: needs: manifest diff --git a/.github/workflows/web-sync-server-deploy.yml b/.github/workflows/web-sync-server-deploy.yml index c4c93dad9..02ee94bab 100644 --- a/.github/workflows/web-sync-server-deploy.yml +++ b/.github/workflows/web-sync-server-deploy.yml @@ -19,6 +19,7 @@ env: REGISTRY_ALIAS: m8q5m4u3 REPOSITORY: mega/web-sync-server IMAGE_TAG: latest + HARBOR_REGISTRY: registry.xuanwu.openatom.cn jobs: build-and-push: @@ -45,7 +46,14 @@ jobs: with: registry-type: public - - name: Build, tag, and push docker image to Amazon ECR Public + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: ${{ env.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + + - name: Build, tag, and push docker image to ECR Public and Harbor working-directory: moon env: REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} @@ -53,6 +61,7 @@ jobs: set -euo pipefail AWS_IMAGE_BASE="$REGISTRY/${{ env.REGISTRY_ALIAS }}/${{ env.REPOSITORY }}" + HARBOR_IMAGE_BASE="${{ env.HARBOR_REGISTRY }}/${{ env.REPOSITORY }}" IMAGE_TAG="${{ env.IMAGE_TAG }}" SHORT_SHA="${GITHUB_SHA:0:7}" @@ -61,11 +70,17 @@ jobs: -t "$AWS_IMAGE_BASE:$IMAGE_TAG" . docker tag "$AWS_IMAGE_BASE:$IMAGE_TAG" "$AWS_IMAGE_BASE:$SHORT_SHA" + docker tag "$AWS_IMAGE_BASE:$IMAGE_TAG" "$HARBOR_IMAGE_BASE:$IMAGE_TAG" + docker tag "$AWS_IMAGE_BASE:$IMAGE_TAG" "$HARBOR_IMAGE_BASE:$SHORT_SHA" # Push to AWS ECR Public (latest + commit hash) docker push "$AWS_IMAGE_BASE:$IMAGE_TAG" docker push "$AWS_IMAGE_BASE:$SHORT_SHA" + # Push to Harbor (latest + commit hash) + docker push "$HARBOR_IMAGE_BASE:$IMAGE_TAG" + docker push "$HARBOR_IMAGE_BASE:$SHORT_SHA" + deploy-aws: needs: build-and-push if: false # disabled diff --git a/Cargo.lock b/Cargo.lock index 58007b01e..26df3c3b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,7 +401,7 @@ dependencies = [ "arrow-schema", "arrow-select", "atoi", - "base64", + "base64 0.22.1", "chrono", "half", "lexical-core", @@ -688,7 +688,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -796,6 +796,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "base64ct" version = "1.8.3" @@ -808,7 +814,7 @@ version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" dependencies = [ - "base64", + "base64 0.22.1", "blowfish 0.9.1", "getrandom 0.2.17", "subtle", @@ -2755,7 +2761,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "base64", + "base64 0.22.1", "memchr", ] @@ -3649,7 +3655,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", "http", @@ -3902,7 +3908,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -4425,7 +4431,7 @@ version = "0.1.0" dependencies = [ "api-model", "async-trait", - "base64", + "base64 0.23.0", "bytes", "callisto", "chrono", @@ -4470,6 +4476,7 @@ dependencies = [ "sea-orm-migration", "serde_json", "tempfile", + "tokio", "tracing", ] @@ -4595,7 +4602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "email-encoding", "email_address", "fastrand", @@ -4839,7 +4846,7 @@ dependencies = [ "arc-swap", "as-any", "async-trait", - "base64", + "base64 0.22.1", "bcrypt", "better_default", "blake2b_simd", @@ -5242,7 +5249,7 @@ dependencies = [ "async-trait", "axum", "axum-extra", - "base64", + "base64 0.23.0", "bytes", "cedar-policy", "ceres", @@ -5266,7 +5273,7 @@ dependencies = [ "rand 0.10.2", "regex", "reqwest 0.13.4", - "russh 0.62.3", + "russh 0.62.4", "saturn", "serde", "serde_json", @@ -5618,7 +5625,7 @@ checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" dependencies = [ "async-trait", "aws-lc-rs", - "base64", + "base64 0.22.1", "bytes", "chrono", "crc-fast", @@ -6186,7 +6193,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -6277,7 +6284,7 @@ dependencies = [ "aes-gcm 0.10.3", "aes-kw", "argon2 0.5.3", - "base64", + "base64 0.22.1", "bitfields", "block-padding 0.3.3", "blowfish 0.9.1", @@ -6862,7 +6869,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49e6bdbbe5d13015b21a49a778a29ae3cee9c450c3154e1648aed670d57fe5ba" dependencies = [ - "base64", + "base64 0.22.1", "serde", "serde_json", ] @@ -7302,7 +7309,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-channel", @@ -7348,7 +7355,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-channel", @@ -7630,9 +7637,9 @@ dependencies = [ [[package]] name = "russh" -version = "0.62.3" +version = "0.62.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "059dd24c0fe20721639f7acad7b82cd51ec3dd3254ed8cf7a0b7df6c20eaff1c" +checksum = "b8b67b5a0d8068c89dcbe9d95df986af7a851d1f3c604525274c37468e60464f" dependencies = [ "aes 0.9.1", "aws-lc-rs", @@ -8563,7 +8570,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", @@ -9019,7 +9026,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bigdecimal", "bytes", "chrono", @@ -9056,7 +9063,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if", "chrono", @@ -9172,7 +9179,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.13.1", "byteorder", @@ -9249,7 +9256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", "bitflags 2.13.1", "byteorder", @@ -9292,7 +9299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.13.1", "byteorder", "chrono", @@ -10215,7 +10222,7 @@ checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -10364,7 +10371,7 @@ checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" dependencies = [ "async-trait", "axum-core", - "base64", + "base64 0.22.1", "futures", "http", "parking_lot 0.12.5", @@ -10711,7 +10718,7 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" dependencies = [ - "base64", + "base64 0.22.1", "flate2", "log", "once_cell", @@ -10791,7 +10798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" dependencies = [ "axum", - "base64", + "base64 0.22.1", "mime_guess", "regex", "rust-embed", diff --git a/Cargo.toml b/Cargo.toml index 87c8b8301..91e75b320 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ futures = "0.3.32" futures-util = "0.3.32" axum = { version = "0.8.9", features = ["macros", "json"] } axum-extra = "0.12.6" -russh = "0.62.3" +russh = "0.62.4" tower-http = "0.7.0" tower = "0.5.3" tower-sessions = { version = "0.15", features = ["memory-store"] } @@ -100,7 +100,7 @@ ctrlc = "3.5.2" cedar-policy = "4.11.2" secp256k1 = "0.31.1" pgp = "0.20.0" -base64 = "0.22.1" +base64 = "0.23.0" utoipa = { version = "5.5.0", features = ["chrono"] } utoipa-axum = "0.2.0" diff --git a/ceres/src/application/api_service/mono/admin/permissions.rs b/ceres/src/application/api_service/mono/admin/permissions.rs index c4e207586..1c7cee8de 100644 --- a/ceres/src/application/api_service/mono/admin/permissions.rs +++ b/ceres/src/application/api_service/mono/admin/permissions.rs @@ -40,12 +40,26 @@ impl AdminApplicationService { /// This method first attempts to read from Redis cache. On cache miss, /// it loads the admin list from the `.mega_cedar.json` file and caches /// the result. + /// + /// If `.mega_cedar.json` (or root refs) are missing, returns an empty list + /// so callers can fall through to other authz paths (e.g. user approval) + /// instead of hard-failing the request. async fn get_effective_admins(&self) -> Result, MegaError> { if let Ok(admins) = self.get_admins_from_cache().await { return Ok(admins); } - let store = self.load_admin_entity_store().await?; + let store = match self.load_admin_entity_store().await { + Ok(store) => store, + Err(e) if is_admin_config_unavailable(&e) => { + tracing::warn!( + error = %e, + "Admin config unavailable; treating as empty admin list" + ); + return Ok(Vec::new()); + } + Err(e) => return Err(e), + }; let resolver = saturn::admin_resolver::AdminResolver::from_entity_store(&store); let admins = resolver.admin_list(); @@ -151,3 +165,10 @@ impl AdminApplicationService { Ok(()) } } + +fn is_admin_config_unavailable(err: &MegaError) -> bool { + let msg = err.to_string(); + msg.contains(".mega_cedar.json not found") + || msg.contains("Root ref not found") + || msg.contains("Root tree not found") +} diff --git a/ceres/src/application/api_service/mono/buck/upload.rs b/ceres/src/application/api_service/mono/buck/upload.rs index 80580e33d..17e802b0b 100644 --- a/ceres/src/application/api_service/mono/buck/upload.rs +++ b/ceres/src/application/api_service/mono/buck/upload.rs @@ -44,6 +44,7 @@ impl MonoApiService { response.commit_id.clone(), response.cl_link.clone(), Some(response.cl_id), + Some(response.repo_path.clone()), Some(username.to_string()), ); context.ref_name = Some("main".to_string()); diff --git a/ceres/src/application/api_service/mono/cl/branch.rs b/ceres/src/application/api_service/mono/cl/branch.rs index 2799443f5..71117171b 100644 --- a/ceres/src/application/api_service/mono/cl/branch.rs +++ b/ceres/src/application/api_service/mono/cl/branch.rs @@ -14,7 +14,10 @@ use common::{errors::MegaError, utils::ZERO_ID}; use git_internal::{ errors::GitError, hash::ObjectHash, - internal::object::tree::{Tree, TreeItem, TreeItemMode}, + internal::object::{ + tree::{Tree, TreeItem, TreeItemMode}, + types::ObjectType, + }, }; use jupiter::utils::converter::FromMegaModel; use tracing::debug; @@ -199,6 +202,42 @@ impl ClApplicationService { } } + // Git does not track empty directories: deleting the last entry must remove + // this directory from its parent (or yield the empty root tree). + if items.is_empty() { + debug!( + cl_link = %cl.link, + parent_dir = %parent_dir_abs.to_string_lossy(), + "apply_changes: directory emptied by delete; removing from parent" + ); + if chain_trees.is_empty() { + let empty = Self::empty_tree(); + Self::record_tree(parent_dir_abs, &empty, &mut tree_cache, &mut new_trees); + root_tree = empty; + } else { + let dir_name = parent_dir_abs + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| { + GitError::CustomError(format!( + "Invalid emptied directory path: {}", + parent_dir_abs.to_string_lossy() + )) + })?; + // Drop the emptied dir from the cache so later diffs don't revive it. + tree_cache.remove(&parent_dir_abs); + root_tree = Self::propagate_removal_up( + &cl.link, + dir_name, + &chain_paths, + &chain_trees, + &mut tree_cache, + &mut new_trees, + )?; + } + continue; + } + let updated_tree = Tree::from_tree_items(items) .map_err(|e| GitError::CustomError(e.to_string()))?; // If parent tree id did not change (no-op), skip propagation for this diff. @@ -330,11 +369,15 @@ impl ClApplicationService { Self::record_tree(PathBuf::new(), &updated_tree, ctx.tree_cache, ctx.new_trees); } + // Wrap leaf upward for every missing segment except the shallowest + // (attach_name). Must use take(n-1), not skip(1): skip(1) after rev + // drops the deepest name and reuses a shallower one → config/config. + let wrap_count = missing.len().saturating_sub(1); for (child_name, path) in missing .iter() .rev() - .skip(1) - .zip(missing_paths.iter().rev().skip(1)) + .take(wrap_count) + .zip(missing_paths.iter().rev().take(wrap_count)) { let wrapper = Tree::from_tree_items(vec![TreeItem::new( TreeItemMode::Tree, @@ -452,6 +495,108 @@ impl ClApplicationService { Ok(updated_tree) } + + /// Remove `name_to_remove` from the deepest remaining parent and walk upward. + /// + /// If a parent becomes empty, keep removing that directory from its parent (Git does not + /// store empty directories). Only the repository root may become an empty tree. + fn propagate_removal_up( + cl_link: &str, + mut name_to_remove: &str, + chain_paths: &[PathBuf], + chain_trees: &[Tree], + tree_cache: &mut HashMap, + new_trees: &mut HashMap, + ) -> Result { + if chain_trees.is_empty() { + return Ok(Self::empty_tree()); + } + + // Own the next directory name when cascading empties upward. + let mut owned_name: Option = None; + + for parent_index in (0..chain_trees.len()).rev() { + let remove_name = owned_name.as_deref().unwrap_or(name_to_remove); + let parent_tree = &chain_trees[parent_index]; + let parent_path = chain_paths.get(parent_index).cloned().ok_or_else(|| { + GitError::CustomError("Tree path chain underflow during removal".to_string()) + })?; + + let mut items = parent_tree.tree_items.clone(); + let before = items.len(); + items.retain(|it| it.name != remove_name); + if items.len() == before { + debug!( + cl_link, + parent_path = %parent_path.to_string_lossy(), + missing_entry = %remove_name, + "apply_changes: removal target already absent" + ); + } + + if items.is_empty() { + tree_cache.remove(&parent_path); + if parent_index == 0 { + let empty = Self::empty_tree(); + Self::record_tree(parent_path, &empty, tree_cache, new_trees); + return Ok(empty); + } + owned_name = parent_path + .file_name() + .and_then(|n| n.to_str()) + .map(str::to_string); + name_to_remove = ""; + continue; + } + + let updated = + Tree::from_tree_items(items).map_err(|e| GitError::CustomError(e.to_string()))?; + Self::record_tree(parent_path, &updated, tree_cache, new_trees); + + // Remaining ancestors just need hash updates for this renamed child tree. + if parent_index == 0 { + return Ok(updated); + } + + let mut current = updated; + for ancestor_index in (0..parent_index).rev() { + let child_name = chain_paths + .get(ancestor_index + 1) + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .ok_or_else(|| { + GitError::CustomError( + "Missing child directory name while propagating removal".to_string(), + ) + })?; + let ancestor = Self::update_parent_tree( + cl_link, + &chain_trees[ancestor_index], + child_name, + TreeItemMode::Tree, + current.id, + chain_paths.get(ancestor_index), + )?; + let ancestor_path = chain_paths.get(ancestor_index).cloned().ok_or_else(|| { + GitError::CustomError("Tree path chain underflow".to_string()) + })?; + Self::record_tree(ancestor_path, &ancestor, tree_cache, new_trees); + current = ancestor; + } + return Ok(current); + } + + Ok(Self::empty_tree()) + } + + /// Canonical Git empty tree (SHA-1 `4b825dc642cb6eb9a060e54bf8d69288fbee4904`). + fn empty_tree() -> Tree { + Tree { + id: ObjectHash::from_type_and_data(ObjectType::Tree, &[]), + tree_items: vec![], + } + } + /// Return Update Branch status for a CL: only checks whether main/trunk moved past the CL base. pub async fn update_branch_status( &self, @@ -643,3 +788,331 @@ impl ClApplicationService { Ok(cl_paths.intersection(&target_paths).cloned().collect()) } } + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, path::PathBuf}; + + use git_internal::{ + hash::ObjectHash, + internal::object::tree::{Tree, TreeItem, TreeItemMode}, + }; + + use super::ClApplicationService; + + fn blob_item(name: &str, seed: u8) -> TreeItem { + TreeItem::new( + TreeItemMode::Blob, + ObjectHash::from_bytes(&[seed; 20]).expect("hash"), + name.to_string(), + ) + } + + fn tree_with(items: Vec) -> Tree { + Tree::from_tree_items(items).expect("non-empty tree") + } + + #[test] + fn empty_tree_has_canonical_git_sha1() { + let empty = ClApplicationService::empty_tree(); + assert!(empty.tree_items.is_empty()); + assert_eq!( + empty.id.to_string(), + "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + ); + } + + #[test] + fn propagate_removal_up_removes_emptied_directory_from_parent() { + let file = blob_item("only.txt", 1); + let child = tree_with(vec![file]); + let other = blob_item("keep.txt", 2); + let root = tree_with(vec![ + TreeItem::new(TreeItemMode::Tree, child.id, "subdir".to_string()), + other, + ]); + + let chain_paths = vec![PathBuf::from("/")]; + let chain_trees = vec![root.clone()]; + let mut tree_cache = HashMap::new(); + let mut new_trees = HashMap::new(); + + let updated_root = ClApplicationService::propagate_removal_up( + "TESTLINK1", + "subdir", + &chain_paths, + &chain_trees, + &mut tree_cache, + &mut new_trees, + ) + .expect("removal"); + + assert_eq!(updated_root.tree_items.len(), 1); + assert_eq!(updated_root.tree_items[0].name, "keep.txt"); + assert!(!new_trees.contains_key(&child.id)); + } + + #[test] + fn propagate_removal_up_cascades_when_parent_becomes_empty() { + let file = blob_item("only.txt", 3); + let leaf = tree_with(vec![file]); + let mid = tree_with(vec![TreeItem::new( + TreeItemMode::Tree, + leaf.id, + "leaf".to_string(), + )]); + let root = tree_with(vec![TreeItem::new( + TreeItemMode::Tree, + mid.id, + "mid".to_string(), + )]); + + // Emptied `mid/leaf` → remove `leaf` from `mid` → `mid` empty → remove from root. + let chain_paths = vec![PathBuf::from("/"), PathBuf::from("/mid")]; + let chain_trees = vec![root, mid]; + let mut tree_cache = HashMap::new(); + let mut new_trees = HashMap::new(); + + let updated_root = ClApplicationService::propagate_removal_up( + "TESTLINK2", + "leaf", + &chain_paths, + &chain_trees, + &mut tree_cache, + &mut new_trees, + ) + .expect("cascade removal"); + + assert!(updated_root.tree_items.is_empty()); + assert_eq!( + updated_root.id.to_string(), + "4b825dc642cb6eb9a060e54bf8d69288fbee4904" + ); + } + + fn blob_hash(seed: u8) -> ObjectHash { + ObjectHash::from_bytes(&[seed; 20]).expect("hash") + } + + fn child_tree<'a>( + parent: &Tree, + name: &str, + new_trees: &'a HashMap, + ) -> &'a Tree { + let item = parent + .tree_items + .iter() + .find(|it| it.name == name) + .unwrap_or_else(|| panic!("missing tree entry '{name}'")); + assert_eq!(item.mode, TreeItemMode::Tree, "'{name}' should be a tree"); + new_trees + .get(&item.id) + .unwrap_or_else(|| panic!("tree object for '{name}' not recorded")) + } + + fn assert_blob_at(parent: &Tree, name: &str, expected: ObjectHash) { + let item = parent + .tree_items + .iter() + .find(|it| it.name == name) + .unwrap_or_else(|| panic!("missing blob '{name}'")); + assert_eq!(item.mode, TreeItemMode::Blob); + assert_eq!(item.id, expected); + } + + #[test] + fn apply_missing_path_single_segment_creates_tool_buck() { + let root = ClApplicationService::empty_tree(); + let chain_paths = vec![PathBuf::from("/")]; + let chain_trees = vec![root]; + let components = vec!["tool".to_string()]; + let mut tree_cache = HashMap::new(); + let mut new_trees = HashMap::new(); + let mut ctx = crate::application::api_service::mono::types::ApplyChangeContext { + components: &components, + chain_paths: &chain_paths, + chain_trees: &chain_trees, + tree_cache: &mut tree_cache, + new_trees: &mut new_trees, + }; + + let buck = blob_hash(0x11); + let updated_root = ClApplicationService::apply_missing_path_update( + "TESTWRAP1", + vec!["tool".to_string()], + Some(buck), + "BUCK", + &mut ctx, + ) + .expect("apply") + .expect("root"); + + let tool = child_tree(&updated_root, "tool", &new_trees); + assert_blob_at(tool, "BUCK", buck); + assert_eq!(tool.tree_items.len(), 1); + } + + #[test] + fn apply_missing_path_two_segments_creates_config_mode_not_doubled() { + let root = ClApplicationService::empty_tree(); + let chain_paths = vec![PathBuf::from("/")]; + let chain_trees = vec![root]; + let components = vec!["config".to_string(), "mode".to_string()]; + let mut tree_cache = HashMap::new(); + let mut new_trees = HashMap::new(); + let mut ctx = crate::application::api_service::mono::types::ApplyChangeContext { + components: &components, + chain_paths: &chain_paths, + chain_trees: &chain_trees, + tree_cache: &mut tree_cache, + new_trees: &mut new_trees, + }; + + let buck = blob_hash(0x22); + let updated_root = ClApplicationService::apply_missing_path_update( + "TESTWRAP2", + vec!["config".to_string(), "mode".to_string()], + Some(buck), + "BUCK", + &mut ctx, + ) + .expect("apply") + .expect("root"); + + let config = child_tree(&updated_root, "config", &new_trees); + assert!( + config.tree_items.iter().all(|it| it.name != "config"), + "must not create config/config" + ); + let mode = child_tree(config, "mode", &new_trees); + assert_blob_at(mode, "BUCK", buck); + } + + #[test] + fn apply_missing_path_three_segments_nests_under_buckal_bundles() { + let root = ClApplicationService::empty_tree(); + let chain_paths = vec![PathBuf::from("/")]; + let chain_trees = vec![root]; + let components = vec![ + "buckal-bundles".to_string(), + "config".to_string(), + "mode".to_string(), + ]; + let mut tree_cache = HashMap::new(); + let mut new_trees = HashMap::new(); + let mut ctx = crate::application::api_service::mono::types::ApplyChangeContext { + components: &components, + chain_paths: &chain_paths, + chain_trees: &chain_trees, + tree_cache: &mut tree_cache, + new_trees: &mut new_trees, + }; + + let buck = blob_hash(0x33); + let updated_root = ClApplicationService::apply_missing_path_update( + "TESTWRAP3", + vec![ + "buckal-bundles".to_string(), + "config".to_string(), + "mode".to_string(), + ], + Some(buck), + "BUCK", + &mut ctx, + ) + .expect("apply") + .expect("root"); + + let bundles = child_tree(&updated_root, "buckal-bundles", &new_trees); + assert!( + bundles + .tree_items + .iter() + .all(|it| it.name != "buckal-bundles"), + "must not create buckal-bundles/buckal-bundles" + ); + let config = child_tree(bundles, "config", &new_trees); + let mode = child_tree(config, "mode", &new_trees); + assert_blob_at(mode, "BUCK", buck); + } + + #[test] + fn apply_missing_path_sequential_buckal_bundles_keeps_siblings() { + let root = ClApplicationService::empty_tree(); + let mut tree_cache = HashMap::new(); + tree_cache.insert(PathBuf::from("/"), root.clone()); + let mut new_trees = HashMap::new(); + + // 1) buckal-bundles/LICENSE + { + let chain_paths = vec![PathBuf::from("/")]; + let chain_trees = vec![tree_cache.get(&PathBuf::from("/")).unwrap().clone()]; + let components = vec!["buckal-bundles".to_string()]; + let mut ctx = crate::application::api_service::mono::types::ApplyChangeContext { + components: &components, + chain_paths: &chain_paths, + chain_trees: &chain_trees, + tree_cache: &mut tree_cache, + new_trees: &mut new_trees, + }; + let license = blob_hash(0x44); + let updated = ClApplicationService::apply_missing_path_update( + "TESTWRAP4", + vec!["buckal-bundles".to_string()], + Some(license), + "LICENSE", + &mut ctx, + ) + .expect("apply license") + .expect("root"); + tree_cache.insert(PathBuf::from("/"), updated); + } + + // 2) buckal-bundles/config/mode/BUCK onto existing buckal-bundles/ + let root_after_license = tree_cache.get(&PathBuf::from("/")).unwrap().clone(); + let bundles_after_license = child_tree(&root_after_license, "buckal-bundles", &new_trees); + { + let chain_paths = vec![PathBuf::from("/"), PathBuf::from("/buckal-bundles")]; + let chain_trees = vec![root_after_license.clone(), bundles_after_license.clone()]; + let components = vec![ + "buckal-bundles".to_string(), + "config".to_string(), + "mode".to_string(), + ]; + let mut ctx = crate::application::api_service::mono::types::ApplyChangeContext { + components: &components, + chain_paths: &chain_paths, + chain_trees: &chain_trees, + tree_cache: &mut tree_cache, + new_trees: &mut new_trees, + }; + let buck = blob_hash(0x55); + let updated = ClApplicationService::apply_missing_path_update( + "TESTWRAP4", + vec!["config".to_string(), "mode".to_string()], + Some(buck), + "BUCK", + &mut ctx, + ) + .expect("apply buck") + .expect("root"); + + let bundles = child_tree(&updated, "buckal-bundles", &new_trees); + assert_blob_at(bundles, "LICENSE", blob_hash(0x44)); + assert!( + bundles + .tree_items + .iter() + .all(|it| it.name != "buckal-bundles"), + "must not double buckal-bundles" + ); + let config = child_tree(bundles, "config", &new_trees); + assert!( + config.tree_items.iter().all(|it| it.name != "config"), + "must not create config/config" + ); + let mode = child_tree(config, "mode", &new_trees); + assert_blob_at(mode, "BUCK", buck); + } + } +} diff --git a/ceres/src/application/api_service/mono/cl/merge.rs b/ceres/src/application/api_service/mono/cl/merge.rs index a23bcd6e6..531a56cb3 100644 --- a/ceres/src/application/api_service/mono/cl/merge.rs +++ b/ceres/src/application/api_service/mono/cl/merge.rs @@ -7,11 +7,14 @@ use common::{errors::MegaError, utils::MEGA_BRANCH_NAME}; use git_internal::{ errors::GitError, hash::ObjectHash, - internal::{metadata::EntryMeta, object::commit::Commit}, + internal::{ + metadata::EntryMeta, + object::{commit::Commit, tree::Tree}, + }, }; use jupiter::{ storage::{base_storage::StorageConnector, mono_storage::RefUpdateData}, - utils::converter::IntoMegaModel, + utils::converter::{FromMegaModel, IntoMegaModel}, }; use tracing::debug; @@ -222,6 +225,7 @@ impl ClApplicationService { cl_link: Option<&str>, ) -> Result { let storage = self.storage().mono_storage(); + self.ensure_root_tree_keeps_admin_file(result).await?; let mut new_commit_id = String::new(); let mut commits: Vec = Vec::new(); @@ -292,6 +296,101 @@ impl ClApplicationService { Ok(new_commit_id) } + /// Reject updates that would replace monorepo `/` with a tree missing `.mega_cedar.json` + /// when the current root tip still has it. + async fn ensure_root_tree_keeps_admin_file( + &self, + result: &TreeUpdateResult, + ) -> Result<(), GitError> { + let Some(root_update) = result.ref_updates.iter().find(|u| u.path == "/") else { + return Ok(()); + }; + + let storage = self.storage().mono_storage(); + let Some(current) = storage + .get_main_ref("/") + .await + .map_err(|e| GitError::CustomError(e.to_string()))? + else { + return Ok(()); + }; + + let current_tree = storage + .get_tree_by_hash(¤t.ref_tree_hash) + .await + .map_err(|e| GitError::CustomError(e.to_string()))? + .ok_or_else(|| GitError::CustomError("Root tree not found".into()))?; + let current_tree = Tree::from_mega_model(current_tree); + let current_has_admin = current_tree + .tree_items + .iter() + .any(|item| item.name == crate::application::api_service::mono::ADMIN_FILE); + if !current_has_admin { + return Ok(()); + } + + let new_tree = if let Some(t) = result + .updated_trees + .iter() + .find(|t| t.id == root_update.tree_id) + { + t.clone() + } else { + let model = storage + .get_tree_by_hash(&root_update.tree_id.to_string()) + .await + .map_err(|e| GitError::CustomError(e.to_string()))? + .ok_or_else(|| { + GitError::CustomError(format!( + "Proposed root tree not found: {}", + root_update.tree_id + )) + })?; + Tree::from_mega_model(model) + }; + + let new_has_admin = new_tree + .tree_items + .iter() + .any(|item| item.name == crate::application::api_service::mono::ADMIN_FILE); + if !new_has_admin { + return Err(GitError::CustomError(format!( + "Refusing to update monorepo root: new tree is missing {}", + crate::application::api_service::mono::ADMIN_FILE + ))); + } + Ok(()) + } + + /// Ensure `refs/cl/{cl_link}` exists, creating it from the CL tip when missing. + /// + /// Push / web-edit paths can leave the mega_cl row and CL ref out of sync (or skip the + /// ref entirely on empty packs). Merge and update-branch need the ref to exist. + pub(crate) async fn ensure_cl_ref_exists( + &self, + cl_link: &str, + ) -> Result { + let storage = self.storage().mono_storage(); + let cl = self + .storage() + .cl_storage() + .get_cl(cl_link) + .await + .map_err(|e| GitError::CustomError(e.to_string()))? + .ok_or_else(|| GitError::CustomError(format!("CL not found: {cl_link}")))?; + + let commit = storage + .get_commit_by_hash(&cl.to_hash) + .await + .map_err(|e| GitError::CustomError(e.to_string()))? + .ok_or_else(|| GitError::CustomError(format!("Commit not found: {}", cl.to_hash)))?; + + storage + .ensure_cl_ref(&cl.path, cl_link, &cl.to_hash, &commit.tree) + .await + .map_err(|e| GitError::CustomError(e.to_string())) + } + /// Apply update result but only update the CL ref (never main). /// Optionally override the parent commit for the first created commit (used by rebase). pub(crate) async fn apply_update_result_cl_only( @@ -302,15 +401,11 @@ impl ClApplicationService { parent_override: Option, ) -> Result { let storage = self.storage().mono_storage(); + self.ensure_root_tree_keeps_admin_file(result).await?; let mut new_commit_id = String::new(); let mut commits: Vec = Vec::new(); - let cl_ref_name = format!("refs/cl/{}", cl_link); - let cl_ref = storage - .get_ref_by_name(&cl_ref_name) - .await - .map_err(|e| GitError::CustomError(e.to_string()))? - .ok_or_else(|| GitError::CustomError("CL ref not found".to_string()))?; + let cl_ref = self.ensure_cl_ref_exists(cl_link).await?; let mut updates: Vec = Vec::new(); diff --git a/ceres/src/application/api_service/mono/edit/entry.rs b/ceres/src/application/api_service/mono/edit/entry.rs index 556ef0a05..f30b2750f 100644 --- a/ceres/src/application/api_service/mono/edit/entry.rs +++ b/ceres/src/application/api_service/mono/edit/entry.rs @@ -326,7 +326,7 @@ impl MonoApiService { result: &TreeUpdateResult, repo_path: &str, ) -> Option { - let normalized = MonoServiceLogic::clean_path_str(repo_path); + let normalized = MonoServiceLogic::normalize_repo_path(repo_path).ok()?; result .ref_updates .iter() @@ -341,30 +341,19 @@ impl MonoApiService { let parent_path = file_path .parent() .ok_or_else(|| GitError::CustomError("Invalid file path".to_string()))?; - let cl_root_path = MonoServiceLogic::subtree_ref_path(parent_path) + // Bind the CL to the deepest path that already has main (e.g. /toolchains), + // not the Buck build root `/`. Root-bound CLs can wipe /.mega_cedar.json on merge. + let parent_repo_path = MonoServiceLogic::subtree_ref_path(parent_path) + .map_err(|e| GitError::CustomError(e.to_string()))?; + let cl_path = edit_utils::resolve_cl_path_for_edit(self.storage(), &parent_repo_path) + .await .map_err(|e| GitError::CustomError(e.to_string()))?; - let build_repo_path = match edit_utils::resolve_build_repo_root( - self.storage(), - &cl_root_path, - ) - .await - { - Ok(path) => path, - Err(e) => { - tracing::warn!( - repo_path = %cl_root_path, - "Failed to resolve build repo root for edit, fallback to CL subtree root: {}", - e - ); - cl_root_path.clone() - } - }; let parent_tree = tree_ops::search_tree_by_path(self, parent_path, None) .await? .ok_or(GitError::CustomError(format!( "invalid repo_path {}, Parent tree not found", - cl_root_path + cl_path )))?; let file_name = file_path @@ -372,6 +361,27 @@ impl MonoApiService { .and_then(|n| n.to_str()) .ok_or_else(|| GitError::CustomError("Invalid file name".to_string()))?; + let dest_name = + if let Some(new_path) = payload.new_path.as_deref().filter(|p| !p.is_empty()) { + let dest_path = PathBuf::from("/").join(PathBuf::from(new_path)); + let dest_parent = dest_path + .parent() + .ok_or_else(|| GitError::CustomError("Invalid new file path".to_string()))?; + if dest_parent != parent_path { + return Err(GitError::CustomError( + "Rename across directories is not supported; keep the file in the same folder" + .to_string(), + )); + } + dest_path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| GitError::CustomError("Invalid new file name".to_string()))? + .to_string() + } else { + file_name.to_string() + }; + let _current_item = parent_tree .tree_items .iter() @@ -380,12 +390,10 @@ impl MonoApiService { // Create new blob and build update result up to root let new_blob = Blob::from_content(&payload.content); - let new_tree = MonoServiceLogic::update_tree_hash( + let new_tree = MonoServiceLogic::update_or_rename_tree_blob( parent_tree.into(), - file_path - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| GitError::CustomError("Invalid path".into()))?, + file_name, + &dest_name, new_blob.id, )?; @@ -398,15 +406,12 @@ impl MonoApiService { update_chain, new_tree.id, )?; - let target_tree_id = Self::ref_update_tree_id_for_path(&update_result, &build_repo_path) + let target_tree_id = Self::ref_update_tree_id_for_path(&update_result, &cl_path) .ok_or_else(|| { - GitError::CustomError(format!( - "Missing updated tree for build repo root {build_repo_path}" - )) + GitError::CustomError(format!("Missing updated tree for CL path {cl_path}")) })?; - let src_commit = - edit_utils::get_repo_main_latest_commit(self.storage(), &build_repo_path).await?; + let src_commit = edit_utils::get_repo_main_latest_commit(self.storage(), &cl_path).await?; let dst_commit = Commit::from_tree_id( target_tree_id, vec![ @@ -448,7 +453,7 @@ impl MonoApiService { .map_err(|e| GitError::CustomError(e.to_string()))?; let editor = OneditCodeEdit::from( - &build_repo_path, + &cl_path, MEGA_BRANCH_NAME .strip_prefix("refs/heads/") .unwrap_or(MEGA_BRANCH_NAME), @@ -478,7 +483,7 @@ impl MonoApiService { Ok(EditFileResult { commit_id: new_commit_id, new_oid: new_blob.id.to_string(), - path: build_repo_path, + path: cl_path, cl_link: Some(cl.link), }) } @@ -507,33 +512,18 @@ impl MonoApiService { let repo_path_str = MonoServiceLogic::subtree_ref_path(&repo_path) .map_err(|e| GitError::CustomError(e.to_string()))?; - let build_repo_path = match edit_utils::resolve_build_repo_root( - self.storage(), - &repo_path_str, - ) - .await - { - Ok(path) => path, - Err(e) => { - tracing::warn!( - repo_path = %repo_path_str, - "Failed to resolve build repo root for create entry, fallback to CL subtree root: {}", - e - ); - repo_path_str.clone() - } - }; + // Bind CL to deepest path with main (e.g. /toolchains), not Buck root `/`. + let cl_path = edit_utils::resolve_cl_path_for_edit(self.storage(), &repo_path_str) + .await + .map_err(|e| GitError::CustomError(e.to_string()))?; - let src_commit = - edit_utils::get_repo_main_latest_commit(self.storage(), &build_repo_path).await?; + let src_commit = edit_utils::get_repo_main_latest_commit(self.storage(), &cl_path).await?; let base_commit = ObjectHash::from_str(&src_commit.id.to_string()).map_err(|e| { GitError::CustomError(format!("Invalid commit hash {}: {e}", src_commit.id)) })?; - let target_tree_id = Self::ref_update_tree_id_for_path(&update_result, &build_repo_path) + let target_tree_id = Self::ref_update_tree_id_for_path(&update_result, &cl_path) .ok_or_else(|| { - GitError::CustomError(format!( - "Missing updated tree for build repo root {build_repo_path}" - )) + GitError::CustomError(format!("Missing updated tree for CL path {cl_path}")) })?; let dst_commit = Commit::from_tree_id(target_tree_id, vec![base_commit], &entry_info.commit_msg()); @@ -573,7 +563,7 @@ impl MonoApiService { .await?; let editor = OneditCodeEdit::from( - &build_repo_path, + &cl_path, MEGA_BRANCH_NAME .strip_prefix("refs/heads/") .unwrap_or(MEGA_BRANCH_NAME), diff --git a/ceres/src/application/api_service/mono/logic/tree.rs b/ceres/src/application/api_service/mono/logic/tree.rs index 78f247d16..fd8e2f8b5 100644 --- a/ceres/src/application/api_service/mono/logic/tree.rs +++ b/ceres/src/application/api_service/mono/logic/tree.rs @@ -31,6 +31,36 @@ impl MonoServiceLogic { Tree::from_tree_items(items).map_err(|_| GitError::CustomError("Invalid tree".to_string())) } + /// Update a blob's content hash and optionally rename it within the same tree. + pub fn update_or_rename_tree_blob( + tree: Arc, + old_name: &str, + new_name: &str, + target_hash: ObjectHash, + ) -> Result { + if old_name == new_name { + return Self::update_tree_hash(tree, old_name, target_hash); + } + + let index = tree + .tree_items + .iter() + .position(|item| item.name == old_name) + .ok_or_else(|| GitError::CustomError(format!("Tree item '{old_name}' not found")))?; + + if tree.tree_items.iter().any(|item| item.name == new_name) { + return Err(GitError::CustomError(format!( + "Duplicate name '{new_name}'" + ))); + } + + let mut items = tree.tree_items.clone(); + items[index].name = new_name.to_string(); + items[index].id = target_hash; + items.sort_by(|a, b| a.name.cmp(&b.name)); + Tree::from_tree_items(items).map_err(|_| GitError::CustomError("Invalid tree".to_string())) + } + /// Walk an update chain from leaf to root, returning rebuilt trees and the new root tree id. pub fn propagate_tree_chain( mut path: PathBuf, @@ -280,6 +310,42 @@ mod tests { assert_eq!(new_tree.tree_items[0].id, new_hash); } + #[test] + fn test_update_or_rename_tree_blob() { + let old_hash = ObjectHash::from_str("1234567890123456789012345678901234567890").unwrap(); + let new_hash = ObjectHash::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); + let other_hash = ObjectHash::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(); + + let tree = Arc::new( + Tree::from_tree_items(vec![ + TreeItem::new(TreeItemMode::Blob, old_hash, "old.txt".to_string()), + TreeItem::new(TreeItemMode::Blob, other_hash, "sibling.txt".to_string()), + ]) + .expect("tree should build"), + ); + + let renamed = MonoServiceLogic::update_or_rename_tree_blob( + Arc::clone(&tree), + "old.txt", + "new.txt", + new_hash, + ) + .expect("rename should succeed"); + + assert_eq!(renamed.tree_items.len(), 2); + assert!( + renamed + .tree_items + .iter() + .any(|item| item.name == "new.txt" && item.id == new_hash) + ); + assert!(!renamed.tree_items.iter().any(|item| item.name == "old.txt")); + + let duplicate = + MonoServiceLogic::update_or_rename_tree_blob(tree, "old.txt", "sibling.txt", new_hash); + assert!(duplicate.is_err()); + } + #[test] fn test_build_result_by_chain_logic() { let item = TreeItem::new( diff --git a/ceres/src/application/build_trigger/changes_calculator.rs b/ceres/src/application/build_trigger/changes_calculator.rs index d8aa97a28..19f4d4c6e 100644 --- a/ceres/src/application/build_trigger/changes_calculator.rs +++ b/ceres/src/application/build_trigger/changes_calculator.rs @@ -120,20 +120,63 @@ fn push_change_if_valid( } } +/// Rebase a CL-relative file path onto the Buck repo root when the CL directory +/// is a strict subdirectory of `repo_path`. +/// +/// Example: repo=`/`, cl=`/project/dagrs-derive`, file=`src/lib.rs` +/// → `project/dagrs-derive/src/lib.rs` +fn rebase_cl_relative_path(repo_path: &str, cl_path: &str, file: &Path) -> PathBuf { + let repo = repo_path.trim_matches('/'); + let cl = cl_path.trim_matches('/'); + let file_str = file + .to_string_lossy() + .replace('\\', "/") + .trim_start_matches('/') + .to_string(); + + let cl_rel = if repo.is_empty() { + if cl.is_empty() { + return PathBuf::from(&file_str); + } + cl.to_string() + } else if cl == repo { + return PathBuf::from(&file_str); + } else if let Some(stripped) = cl.strip_prefix(&format!("{repo}/")) { + stripped.to_string() + } else { + return PathBuf::from(&file_str); + }; + + if cl_rel.is_empty() { + return PathBuf::from(&file_str); + } + + if file_str == cl_rel || file_str.starts_with(&format!("{cl_rel}/")) { + return PathBuf::from(&file_str); + } + + PathBuf::from(format!("{cl_rel}/{file_str}")) +} + fn build_changes_for_repo( repo_path: &str, + cl_path: Option<&str>, cl_diff_files: Vec, ) -> Result>, MegaError> { let repo_prefix = repo_path.trim_matches('/'); let repo_prefix_with_slash = (!repo_prefix.is_empty()).then(|| format!("{repo_prefix}/")); let to_project_relative = |path: &Path| { + let rebased = match cl_path { + Some(cl) => rebase_cl_relative_path(repo_path, cl, path), + None => path.to_path_buf(), + }; let normalized = normalize_change_path_for_repo_with_prefix( repo_prefix, repo_prefix_with_slash.as_deref(), - path, + &rebased, ); if let Some(normalized_path) = &normalized { - monitor_possible_repo_prefix_mismatch(repo_path, path, normalized_path.as_str()); + monitor_possible_repo_prefix_mismatch(repo_path, &rebased, normalized_path.as_str()); } normalized }; @@ -201,7 +244,8 @@ impl ChangesCalculator

{ let new_files = self.get_commit_blobs(&context.commit_hash).await?; let diff_files = self.cl_files_list(old_files, new_files).await?; - let changes = build_changes_for_repo(&context.repo_path, diff_files)?; + let changes = + build_changes_for_repo(&context.repo_path, context.cl_path.as_deref(), diff_files)?; Ok(changes) } @@ -280,6 +324,7 @@ mod tests { fn test_build_changes_normalizes_repo_local_paths_and_keeps_external_paths() { let changes = build_changes_for_repo( "/project/buck2_test", + Some("/project/buck2_test"), vec![ ClDiffFile::Modified( PathBuf::from("src/main.rs"), @@ -318,6 +363,7 @@ mod tests { fn test_build_changes_filters_unsafe_paths() { let changes = build_changes_for_repo( "/project/buck2_test", + Some("/project/buck2_test"), vec![ ClDiffFile::Modified( PathBuf::from("src/main.rs"), @@ -342,6 +388,7 @@ mod tests { fn test_build_changes_keeps_added_paths_for_create_entry_variants() { let changes = build_changes_for_repo( "/project/buck2_test", + Some("/project/buck2_test"), vec![ ClDiffFile::New( PathBuf::from("/project/buck2_test/src/new_file_abs.rs"), @@ -373,6 +420,7 @@ mod tests { fn test_build_changes_keeps_added_gitkeep_for_new_directory() { let changes = build_changes_for_repo( "/project/buck2_test", + Some("/project/buck2_test"), vec![ClDiffFile::New( PathBuf::from("project/buck2_test/src/new_dir/.gitkeep"), ObjectHash::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(), @@ -388,6 +436,98 @@ mod tests { ); } + #[test] + fn test_rebase_cl_relative_path_prefixes_monorepo_subdir_cl() { + assert_eq!( + rebase_cl_relative_path("/", "/project/dagrs-derive", Path::new("src/lib.rs")), + PathBuf::from("project/dagrs-derive/src/lib.rs") + ); + assert_eq!( + rebase_cl_relative_path( + "/", + "/project/dagrs-derive", + Path::new("project/dagrs-derive/BUCK") + ), + PathBuf::from("project/dagrs-derive/BUCK") + ); + } + + #[test] + fn test_rebase_cl_relative_path_noop_when_cl_equals_repo() { + assert_eq!( + rebase_cl_relative_path( + "/project/buck2_test", + "/project/buck2_test", + Path::new("src/main.rs") + ), + PathBuf::from("src/main.rs") + ); + } + + #[test] + fn test_rebase_cl_relative_path_nested_cl_under_registered_repo() { + assert_eq!( + rebase_cl_relative_path( + "/project/buck2_test", + "/project/buck2_test/src", + Path::new("main.rs") + ), + PathBuf::from("src/main.rs") + ); + } + + #[test] + fn test_build_changes_rebases_dagrs_derive_style_paths_onto_monorepo_root() { + let changes = build_changes_for_repo( + "/", + Some("/project/dagrs-derive"), + vec![ + ClDiffFile::Modified( + PathBuf::from("src/lib.rs"), + ObjectHash::from_str("1111111111111111111111111111111111111111").unwrap(), + ObjectHash::from_str("2222222222222222222222222222222222222222").unwrap(), + ), + ClDiffFile::New( + PathBuf::from("BUCK"), + ObjectHash::from_str("3333333333333333333333333333333333333333").unwrap(), + ), + ClDiffFile::New( + PathBuf::from("project/dagrs-derive/Cargo.toml"), + ObjectHash::from_str("4444444444444444444444444444444444444444").unwrap(), + ), + ], + ) + .unwrap(); + + assert_eq!( + changes, + vec![ + Status::Modified(ProjectRelativePath::new("project/dagrs-derive/src/lib.rs")), + Status::Added(ProjectRelativePath::new("project/dagrs-derive/BUCK")), + Status::Added(ProjectRelativePath::new("project/dagrs-derive/Cargo.toml")), + ] + ); + } + + #[test] + fn test_build_changes_rebases_nested_cl_under_buck2_test() { + let changes = build_changes_for_repo( + "/project/buck2_test", + Some("/project/buck2_test/src"), + vec![ClDiffFile::Modified( + PathBuf::from("main.rs"), + ObjectHash::from_str("1111111111111111111111111111111111111111").unwrap(), + ObjectHash::from_str("2222222222222222222222222222222222222222").unwrap(), + )], + ) + .unwrap(); + + assert_eq!( + changes, + vec![Status::Modified(ProjectRelativePath::new("src/main.rs"))] + ); + } + #[test] fn test_detect_single_level_prefixed_candidate_returns_unique_match() { let tempdir = TempDir::new().expect("create tempdir"); diff --git a/ceres/src/application/build_trigger/model.rs b/ceres/src/application/build_trigger/model.rs index c25de80d5..4e982ebb1 100644 --- a/ceres/src/application/build_trigger/model.rs +++ b/ceres/src/application/build_trigger/model.rs @@ -318,6 +318,9 @@ pub struct TriggerContext { pub trigger_source: TriggerSource, pub triggered_by: Option, pub repo_path: String, + /// Original CL directory path (may be a subdirectory of [`Self::repo_path`]). + /// Used to rebase CL-relative change paths onto the Buck repo root. + pub cl_path: Option, pub from_hash: String, pub commit_hash: String, pub cl_link: Option, @@ -328,6 +331,14 @@ pub struct TriggerContext { pub ref_type: Option, } +/// CL fields needed when constructing a retry [`TriggerContext`]. +#[derive(Debug, Clone)] +pub struct RetryClContext { + pub link: Option, + pub id: Option, + pub path: Option, +} + impl TriggerContext { pub fn from_git_push( repo_path: String, @@ -335,6 +346,7 @@ impl TriggerContext { commit_hash: String, cl_link: String, cl_id: Option, + cl_path: Option, triggered_by: Option, ) -> Self { Self { @@ -346,6 +358,7 @@ impl TriggerContext { }, triggered_by, repo_path, + cl_path, from_hash, commit_hash, cl_link: Some(cl_link), @@ -368,6 +381,7 @@ impl TriggerContext { trigger_source: TriggerSource::User, triggered_by: Some(triggered_by), repo_path, + cl_path: None, from_hash: commit_hash.clone(), commit_hash, cl_link: None, @@ -383,8 +397,7 @@ impl TriggerContext { repo_path: String, from_hash: String, commit_hash: String, - cl_link: Option, - cl_id: Option, + cl: RetryClContext, triggered_by: Option, original_trigger_id: i64, ) -> Self { @@ -393,10 +406,11 @@ impl TriggerContext { trigger_source: TriggerSource::User, triggered_by, repo_path, + cl_path: cl.path, from_hash, commit_hash, - cl_link, - cl_id, + cl_link: cl.link, + cl_id: cl.id, params: None, original_trigger_id: Some(original_trigger_id), ref_name: None, @@ -412,6 +426,7 @@ impl TriggerContext { commit_hash: String, cl_link: String, cl_id: Option, + cl_path: Option, triggered_by: Option, ) -> Self { Self { @@ -423,6 +438,7 @@ impl TriggerContext { }, triggered_by, repo_path, + cl_path, from_hash, commit_hash, cl_link: Some(cl_link), @@ -441,7 +457,8 @@ impl From for TriggerContext { trigger_type: BuildTriggerType::WebEdit, trigger_source: TriggerSource::User, triggered_by: Some(cl.username), - repo_path: cl.path, + repo_path: cl.path.clone(), + cl_path: Some(cl.path), from_hash: cl.from_hash, commit_hash: cl.to_hash, cl_link: Some(cl.link), diff --git a/ceres/src/application/build_trigger/service.rs b/ceres/src/application/build_trigger/service.rs index 35855e88f..99d1c24c7 100644 --- a/ceres/src/application/build_trigger/service.rs +++ b/ceres/src/application/build_trigger/service.rs @@ -32,7 +32,8 @@ use common::errors::MegaError; use jupiter::storage::Storage; use super::model::{ - BuildParams, GitPushEvent, ListTriggersParams, TriggerContext, TriggerRecord, TriggerResponse, + BuildParams, GitPushEvent, ListTriggersParams, RetryClContext, TriggerContext, TriggerRecord, + TriggerResponse, }; use crate::application::{ api_service::cache::GitObjectCache, @@ -91,12 +92,14 @@ impl BuildTriggerService { return Ok(None); } + let cl_path = Some(event.repo_path.clone()); let context = TriggerContext::from_git_push( event.repo_path, event.from_hash, event.commit_hash, event.cl_link, event.cl_id, + cl_path, event.triggered_by, ); @@ -211,12 +214,22 @@ impl BuildTriggerService { .parse_payload() .map_err(|e| MegaError::Other(format!("Failed to parse payload: {}", e)))?; + // Historical payloads only store Buck repo_path; reload CL path so retry + // can rebase CL-relative change paths onto the monorepo root. + let cl_path = match self.storage.cl_storage().get_cl(payload.cl_link()).await? { + Some(cl) => Some(cl.path), + None => None, + }; + let context = TriggerContext::from_retry( payload.repo_path().to_string(), payload.from_hash().to_string(), payload.commit_hash().to_string(), - Some(payload.cl_link().to_string()), - payload.cl_id(), + RetryClContext { + link: Some(payload.cl_link().to_string()), + id: payload.cl_id(), + path: cl_path, + }, Some(triggered_by), original_trigger_id, ); @@ -322,6 +335,7 @@ mod tests { .expect("resolve cl context"); assert_eq!(context.repo_path, "/project/buck2_test"); + assert_eq!(context.cl_path.as_deref(), Some("/project/buck2_test/src")); assert_eq!(context.cl_link.as_deref(), Some("HVKM7CXI")); assert_eq!(context.trigger_type, BuildTriggerType::WebEdit); } diff --git a/ceres/src/application/build_trigger/web_edit_handler.rs b/ceres/src/application/build_trigger/web_edit_handler.rs index d8e2cfcde..007781de3 100644 --- a/ceres/src/application/build_trigger/web_edit_handler.rs +++ b/ceres/src/application/build_trigger/web_edit_handler.rs @@ -90,6 +90,7 @@ mod tests { trigger_source: TriggerSource::User, triggered_by: Some("jackie".to_string()), repo_path: "/project/buck2_test".to_string(), + cl_path: Some("/project/buck2_test".to_string()), from_hash: "1".repeat(40), commit_hash: "2".repeat(40), cl_link: Some("HVKM7CXI".to_string()), @@ -110,6 +111,7 @@ mod tests { trigger_source: TriggerSource::User, triggered_by: Some("jackie".to_string()), repo_path: "/project/buck2_test".to_string(), + cl_path: Some("/project/buck2_test".to_string()), from_hash: "1".repeat(40), commit_hash: "abcdef1234567890abcdef1234567890abcdef12".to_string(), cl_link: None, diff --git a/ceres/src/application/code_edit/model.rs b/ceres/src/application/code_edit/model.rs index 6d0f317d6..db3ad65c8 100644 --- a/ceres/src/application/code_edit/model.rs +++ b/ceres/src/application/code_edit/model.rs @@ -285,8 +285,14 @@ impl< from_hash: &str, to_hash: &str, username: &str, + preferred_cl_link: Option<&str>, ) -> Result { - let cl_link = generate_link(); + // Prefer the link already used for refs/cl/* during pack receive so merge + // can resolve the CL ref. Fall back to generating a fresh link for web edits. + let cl_link = preferred_cl_link + .map(str::to_string) + .filter(|s| !s.is_empty()) + .unwrap_or_else(generate_link); let dst_commit = Commit::from_mega_model( storage .mono_storage() @@ -335,6 +341,7 @@ impl< from_hash: &str, to_hash: &str, username: &str, + preferred_cl_link: Option<&str>, ) -> Result { let path_str = &self.repo_path; match storage @@ -352,14 +359,47 @@ impl< "CL was updated but fresh model lookup returned None; fallback to in-memory to_hash update." ); } - Ok(fresh_or_fallback_cl(cl, fresh, to_hash)) + let cl_model = fresh_or_fallback_cl(cl, fresh, to_hash); + self.sync_cl_ref(storage, &cl_model, to_hash).await?; + Ok(cl_model) } None => Ok(self - .create_new_cl(storage, path_str, from_hash, to_hash, username) + .create_new_cl( + storage, + path_str, + from_hash, + to_hash, + username, + preferred_cl_link, + ) .await?), } } + /// Ensure `refs/cl/{cl.link}` points at `to_hash`. + pub async fn sync_cl_ref( + &self, + storage: &Storage, + cl: &mega_cl::Model, + to_hash: &str, + ) -> Result<(), MegaError> { + let dst_commit = Commit::from_mega_model( + storage + .mono_storage() + .get_commit_by_hash(to_hash) + .await? + .ok_or_else(|| MegaError::Other(format!("invalid to_hash: {to_hash}")))?, + ); + self.clref_acceptor + .accept( + &self.clref_visitor, + cl, + to_hash, + &dst_commit.tree_id.to_string(), + ) + .await + } + pub async fn trigger_build( &self, storage: Storage, diff --git a/ceres/src/application/code_edit/on_edit.rs b/ceres/src/application/code_edit/on_edit.rs index 089624f92..47bac6cb3 100644 --- a/ceres/src/application/code_edit/on_edit.rs +++ b/ceres/src/application/code_edit/on_edit.rs @@ -37,15 +37,14 @@ impl model::CLRefUpdateVisitor for OneditVisitor { commit_hash: &str, tree_hash: &str, ) -> Result { - let cl_ref = mega_refs::Model::new( - &cl.path, - utils::cl_ref_name(&cl.link), - commit_hash.to_string(), - tree_hash.to_string(), - true, - ); - self.mono_storage.save_refs(cl_ref.clone(), None).await?; - Ok(cl_ref) + let ref_name = utils::cl_ref_name(&cl.link); + self.mono_storage + .save_or_update_cl_ref(&cl.path, &ref_name, commit_hash, tree_hash) + .await?; + self.mono_storage + .get_ref_by_name(&ref_name) + .await? + .ok_or_else(|| MegaError::Other(format!("CL ref missing after save: {ref_name}"))) } } @@ -78,6 +77,7 @@ impl model::TriggerContextBuilder for OneditTrigerBuilder { cl.to_hash.clone(), cl.link.clone(), Some(cl.id), + Some(cl.path.clone()), Some(username.to_string()), )) } @@ -112,6 +112,7 @@ impl model::TriggerContextBuilder for OneditTrigerBuilder { cl_model.to_hash.clone(), cl_model.link.clone(), Some(cl_model.id), + Some(cl_model.path.clone()), Some(username), ); BuildTriggerService::build_by_context(storage, git_cache, build_dispatch, context).await @@ -168,31 +169,11 @@ impl OneditCodeEdit { let repo_path = &self.repo_path; match mode { EditCLMode::ForceCreate => Ok(editor - .create_new_cl(storage, repo_path, &self.from_hash, to_hash, username) + .create_new_cl(storage, repo_path, &self.from_hash, to_hash, username, None) + .await?), + EditCLMode::TryReuse(None) => Ok(editor + .update_or_create_cl(storage, &self.from_hash, to_hash, username, None) .await?), - EditCLMode::TryReuse(None) => { - if let Some(existing_cl) = storage - .cl_storage() - .get_open_cl_by_path(repo_path, username) - .await - .map_err(|e| GitError::CustomError(format!("Failed to fetch CL: {}", e)))? - { - editor - .update_existing_cl( - existing_cl.clone(), - storage, - &existing_cl.from_hash, - to_hash, - username, - ) - .await?; - Ok(existing_cl) - } else { - Ok(editor - .create_new_cl(storage, repo_path, &self.from_hash, to_hash, username) - .await?) - } - } EditCLMode::TryReuse(Some(link)) => match storage.cl_storage().get_cl(&link).await { Ok(Some(existing_cl)) => { editor @@ -204,10 +185,93 @@ impl OneditCodeEdit { username, ) .await?; - Ok(existing_cl) + editor + .sync_cl_ref(storage, &existing_cl, to_hash) + .await + .map_err(|e| GitError::CustomError(e.to_string()))?; + let fresh = storage + .cl_storage() + .get_cl(&link) + .await + .map_err(|e| GitError::CustomError(e.to_string()))?; + Ok(fresh.unwrap_or(existing_cl)) } _ => Err(GitError::CustomError(format!("link {} not found", link))), }, } } } + +#[cfg(test)] +mod tests { + use callisto::sea_orm_active_enums::MergeStatusEnum; + use common::utils; + use tempfile::TempDir; + + use super::*; + use crate::application::code_edit::model::{CLRefUpdateAcceptor, CLRefUpdateVisitor}; + + fn sample_cl(link: &str, path: &str) -> mega_cl::Model { + let now = chrono::Utc::now().naive_utc(); + mega_cl::Model { + id: 1, + link: link.to_string(), + title: "test".to_string(), + merge_date: None, + status: MergeStatusEnum::Open, + path: path.to_string(), + from_hash: "1".repeat(40), + to_hash: "2".repeat(40), + created_at: now, + updated_at: now, + username: "tester".to_string(), + base_branch: "main".to_string(), + } + } + + #[tokio::test] + async fn onedit_visitor_creates_and_updates_cl_ref() { + let dir = TempDir::new().unwrap(); + let storage = jupiter::tests::test_storage(dir.path()).await; + let visitor = OneditVisitor { + mono_storage: storage.mono_storage(), + }; + let cl = sample_cl("ONEDIT01", "/project/demo"); + + let created = visitor + .visit(&cl, &"a".repeat(40), &"b".repeat(40)) + .await + .expect("create"); + assert_eq!(created.ref_name, utils::cl_ref_name(&cl.link)); + + let updated = visitor + .visit(&cl, &"c".repeat(40), &"d".repeat(40)) + .await + .expect("update without duplicate insert error"); + assert_eq!(updated.id, created.id); + assert_eq!(updated.ref_commit_hash, "c".repeat(40)); + } + + #[tokio::test] + async fn onedit_acceptor_writes_cl_ref() { + let dir = TempDir::new().unwrap(); + let storage = jupiter::tests::test_storage(dir.path()).await; + let visitor = OneditVisitor { + mono_storage: storage.mono_storage(), + }; + let cl = sample_cl("ONEDIT02", "/project/demo"); + + OneditAcceptor {} + .accept(&visitor, &cl, &"1".repeat(40), &"2".repeat(40)) + .await + .unwrap(); + + let saved = storage + .mono_storage() + .get_ref_by_name(&utils::cl_ref_name(&cl.link)) + .await + .unwrap() + .expect("ref present"); + assert!(saved.is_cl); + } +} diff --git a/ceres/src/application/code_edit/on_push.rs b/ceres/src/application/code_edit/on_push.rs index 5d6ef18e6..ee59b54b5 100644 --- a/ceres/src/application/code_edit/on_push.rs +++ b/ceres/src/application/code_edit/on_push.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use callisto::{mega_cl, mega_refs}; -use common::errors::MegaError; -use jupiter::storage::Storage; +use common::{errors::MegaError, utils}; +use jupiter::storage::{Storage, mono_storage::MonoStorage}; use crate::application::{ api_service::{cache::GitObjectCache, mono::MonoApiService}, @@ -16,25 +16,41 @@ use crate::application::{ pub struct OnpushFormator; impl model::ConversationMessageFormater for OnpushFormator {} -pub struct OnpushVisitor {} +pub struct OnpushVisitor { + mono_storage: MonoStorage, +} + impl model::CLRefUpdateVisitor for OnpushVisitor { async fn visit( &self, - _: &mega_cl::Model, - _: &str, - _: &str, + cl: &mega_cl::Model, + commit_hash: &str, + tree_hash: &str, ) -> Result { - Err(MegaError::Other( - "OnpushVisitor::visit is unused; OnpushAcceptor does not delegate to the visitor" - .to_string(), - )) + // Pack receive usually writes refs/cl/{link} first; save_or_update keeps create and + // update paths aligned when that step was skipped (e.g. empty pack / no current_commit). + let ref_name = utils::cl_ref_name(&cl.link); + self.mono_storage + .save_or_update_cl_ref(&cl.path, &ref_name, commit_hash, tree_hash) + .await?; + self.mono_storage + .get_ref_by_name(&ref_name) + .await? + .ok_or_else(|| MegaError::Other(format!("CL ref missing after save: {ref_name}"))) } } pub struct OnpushAcceptor {} impl model::CLRefUpdateAcceptor for OnpushAcceptor { - async fn accept(&self, _: &VT, _: &mega_cl::Model, _: &str, _: &str) -> Result<(), MegaError> { + async fn accept( + &self, + visitor: &VT, + cl: &mega_cl::Model, + commit_hash: &str, + tree_hash: &str, + ) -> Result<(), MegaError> { + visitor.visit(cl, commit_hash, tree_hash).await?; Ok(()) } } @@ -53,6 +69,7 @@ impl model::TriggerContextBuilder for OnpushTrigerBuilder { cl.to_hash.clone(), cl.link.clone(), Some(cl.id), + Some(cl.path.clone()), Some(username.to_string()), )) } @@ -89,6 +106,7 @@ impl model::TriggerContextBuilder for OnpushTrigerBuilder { cl_model.to_hash.clone(), cl_model.link.clone(), Some(cl_model.id), + Some(cl_model.path.clone()), Some(username), ); BuildTriggerService::build_by_context(storage, git_cache, build_dispatch, context).await @@ -125,7 +143,9 @@ impl OnpushCodeEdit { base_branch, from_hash, OnpushFormator {}, - OnpushVisitor {}, + OnpushVisitor { + mono_storage: handler.storage().mono_storage(), + }, OnpushAcceptor {}, OnpushTrigerBuilder {}, OnpushChecker {}, @@ -135,3 +155,80 @@ impl OnpushCodeEdit { ) } } + +#[cfg(test)] +mod tests { + use callisto::sea_orm_active_enums::MergeStatusEnum; + use common::utils; + use tempfile::TempDir; + + use super::*; + use crate::application::code_edit::model::{CLRefUpdateAcceptor, CLRefUpdateVisitor}; + + fn sample_cl(link: &str, path: &str) -> mega_cl::Model { + let now = chrono::Utc::now().naive_utc(); + mega_cl::Model { + id: 1, + link: link.to_string(), + title: "test".to_string(), + merge_date: None, + status: MergeStatusEnum::Open, + path: path.to_string(), + from_hash: "1".repeat(40), + to_hash: "2".repeat(40), + created_at: now, + updated_at: now, + username: "tester".to_string(), + base_branch: "main".to_string(), + } + } + + #[tokio::test] + async fn onpush_visitor_creates_and_updates_cl_ref() { + let dir = TempDir::new().unwrap(); + let storage = jupiter::tests::test_storage(dir.path()).await; + let visitor = OnpushVisitor { + mono_storage: storage.mono_storage(), + }; + let cl = sample_cl("ONPUSH01", "/toolchains"); + + let created = visitor + .visit(&cl, &"a".repeat(40), &"b".repeat(40)) + .await + .expect("create"); + assert_eq!(created.ref_name, utils::cl_ref_name(&cl.link)); + assert_eq!(created.ref_commit_hash, "a".repeat(40)); + + let updated = visitor + .visit(&cl, &"c".repeat(40), &"d".repeat(40)) + .await + .expect("update"); + assert_eq!(updated.id, created.id); + assert_eq!(updated.ref_commit_hash, "c".repeat(40)); + assert_eq!(updated.ref_tree_hash, "d".repeat(40)); + } + + #[tokio::test] + async fn onpush_acceptor_delegates_to_visitor() { + let dir = TempDir::new().unwrap(); + let storage = jupiter::tests::test_storage(dir.path()).await; + let visitor = OnpushVisitor { + mono_storage: storage.mono_storage(), + }; + let cl = sample_cl("ONPUSH02", "/project/demo"); + + OnpushAcceptor {} + .accept(&visitor, &cl, &"1".repeat(40), &"2".repeat(40)) + .await + .unwrap(); + + let saved = storage + .mono_storage() + .get_ref_by_name(&utils::cl_ref_name(&cl.link)) + .await + .unwrap() + .expect("ref written by acceptor"); + assert!(saved.is_cl); + assert_eq!(saved.path, cl.path); + } +} diff --git a/ceres/src/application/code_edit/post_receive/mono.rs b/ceres/src/application/code_edit/post_receive/mono.rs index 071216a78..8f2ae15eb 100644 --- a/ceres/src/application/code_edit/post_receive/mono.rs +++ b/ceres/src/application/code_edit/post_receive/mono.rs @@ -36,6 +36,7 @@ pub async fn dispatch_mono_receive_pack_finalized( from_hash: String, to_hash: String, username: Option, + preferred_cl_link: Option, ) -> Result<(), MegaError> { let username = username.unwrap_or_else(|| String::from("Anonymous")); let repo_path_str = repo_path @@ -44,7 +45,13 @@ pub async fn dispatch_mono_receive_pack_finalized( let editor = OnpushCodeEdit::from(repo_path_str, &base_branch, &from_hash, git); let cl_model = editor - .update_or_create_cl(&storage, &from_hash, &to_hash, &username) + .update_or_create_cl( + &storage, + &from_hash, + &to_hash, + &username, + preferred_cl_link.as_deref(), + ) .await?; if from_hash == ZERO_ID && repo_path_str.starts_with("/project/") { @@ -223,6 +230,7 @@ impl ApplicationEventHandler for RuntimeApplicationHandler { from_hash, to_hash, username, + cl_link, } => { dispatch_mono_receive_pack_finalized( self.git.storage().clone(), @@ -235,6 +243,7 @@ impl ApplicationEventHandler for RuntimeApplicationHandler { from_hash, to_hash, username, + cl_link, ) .await } diff --git a/ceres/src/application/code_edit/utils.rs b/ceres/src/application/code_edit/utils.rs index b9684f7cb..9be79941b 100644 --- a/ceres/src/application/code_edit/utils.rs +++ b/ceres/src/application/code_edit/utils.rs @@ -8,10 +8,7 @@ use callisto::{mega_cl, mega_refs}; use common::{self, errors::MegaError, utils::ZERO_ID}; use git_internal::{ hash::ObjectHash, - internal::object::{ - commit::Commit, - tree::{Tree, TreeItemMode}, - }, + internal::object::{commit::Commit, tree::Tree}, }; use jupiter::{storage::Storage, utils::converter::FromMegaModel}; @@ -77,12 +74,10 @@ fn has_buck_root_markers(tree: &Tree) -> bool { .iter() .map(|item| item.name.as_str()) .collect(); - let has_toolchains_dir = tree - .tree_items - .iter() - .any(|item| item.mode == TreeItemMode::Tree && item.name == "toolchains"); - let has_buck_markers = names.contains(".buckroot") && names.contains(".buckconfig"); - has_toolchains_dir || has_buck_markers + // Only explicit Buck markers identify a build root. A `toolchains/` directory + // alone must not qualify — otherwise edits under `/toolchains` resolve the CL + // path to `/` and a bad tip can wipe the monorepo root on merge. + names.contains(".buckroot") && names.contains(".buckconfig") } #[cfg(test)] @@ -140,6 +135,25 @@ pub async fn resolve_build_repo_root(storage: &Storage, path: &str) -> Result Result { + let normalized_path = MonoServiceLogic::normalize_repo_path(path)?; + let mono_storage = storage.mono_storage(); + let candidates = MonoServiceLogic::repo_root_candidates(Path::new(&normalized_path)); + + for candidate in &candidates { + if mono_storage.get_main_ref(candidate).await?.is_some() { + return Ok(candidate.clone()); + } + } + + resolve_build_repo_root(storage, &normalized_path).await +} + /// Get list of files changed between from_hash and to_hash commits. /// Returns paths relative to the CL root directory with forward slashes. pub async fn get_changed_files( @@ -490,7 +504,7 @@ mod tests { } #[test] - fn test_has_buck_root_markers_accepts_toolchains_or_buck_markers() { + fn test_has_buck_root_markers_requires_buck_files_not_toolchains_dir() { let buckroot = TreeItem { mode: TreeItemMode::Blob, id: ObjectHash::from_str("1111111111111111111111111111111111111111").unwrap(), @@ -517,9 +531,12 @@ mod tests { .unwrap(); assert!(has_buck_root_markers(&root_tree)); - let root_with_toolchains = + let root_with_toolchains_only = Tree::from_tree_items(vec![src_dir.clone(), toolchains_dir]).unwrap(); - assert!(has_buck_root_markers(&root_with_toolchains)); + assert!( + !has_buck_root_markers(&root_with_toolchains_only), + "toolchains/ alone must not mark a Buck root" + ); let subtree = Tree::from_tree_items(vec![src_dir]).unwrap(); assert!(!has_buck_root_markers(&subtree)); diff --git a/ceres/src/bus/event.rs b/ceres/src/bus/event.rs index 1324f8c90..10f5bd23c 100644 --- a/ceres/src/bus/event.rs +++ b/ceres/src/bus/event.rs @@ -15,6 +15,9 @@ pub enum TransportEvent { from_hash: String, to_hash: String, username: Option, + /// Link already allocated while writing `refs/cl/*` during pack receive. + /// Must be reused when creating the mega_cl row so merge can find the ref. + cl_link: Option, }, ImportReceivePackFinalized { repo_path: PathBuf, diff --git a/ceres/src/merge_checker/mod.rs b/ceres/src/merge_checker/mod.rs index e446248f4..9d5c9d348 100644 --- a/ceres/src/merge_checker/mod.rs +++ b/ceres/src/merge_checker/mod.rs @@ -102,7 +102,9 @@ impl CheckType { CheckType::CodeReview => { "Ensure the required reviewers have approved the merge request" } - CheckType::ClaSign => "Ensure the CL author has signed CLA", + CheckType::ClaSign => { + "Report whether the CL author has signed CLA (advisory; does not block merge)" + } } } } diff --git a/ceres/src/model/change_list.rs b/ceres/src/model/change_list.rs index 729036d78..0e9614a52 100644 --- a/ceres/src/model/change_list.rs +++ b/ceres/src/model/change_list.rs @@ -256,7 +256,8 @@ impl MergeBoxRes { pub fn from_condition(conditions: Vec) -> Self { let mut state = RequirementsState::MERGEABLE; for cond in &conditions { - if cond.result == ConditionResult::FAILED { + // CLA is advisory-only: keep reporting the result, but do not mark the CL unmergeable. + if cond.result == ConditionResult::FAILED && cond.condition_type != CheckType::ClaSign { state = RequirementsState::UNMERGEABLE } } diff --git a/ceres/src/model/git.rs b/ceres/src/model/git.rs index 6e82949f1..f2ff88353 100644 --- a/ceres/src/model/git.rs +++ b/ceres/src/model/git.rs @@ -195,6 +195,10 @@ pub enum EditCLMode { pub struct EditFilePayload { /// Full file path like "/project/dir/file.rs" pub path: String, + /// Optional destination path for same-directory rename (e.g. "/project/dir/renamed.rs"). + /// Must share the same parent directory as `path`. + #[serde(default)] + pub new_path: Option, /// New file content to save pub content: String, /// Commit message to use when creating the commit diff --git a/ceres/src/transport/pack/monorepo.rs b/ceres/src/transport/pack/monorepo.rs index eeef6a056..cbaa5b26a 100644 --- a/ceres/src/transport/pack/monorepo.rs +++ b/ceres/src/transport/pack/monorepo.rs @@ -164,6 +164,7 @@ impl RepoHandler for MonoRepo { async fn finalize_receive_pack(&self) -> Result<(), MegaError> { self.persist_mono_branch_cl_mega_refs_transaction().await?; self.traverses_tree_and_update_filepath().await?; + let cl_link = self.cl_link.read().await.clone(); self.application .handle(TransportEvent::MonoReceivePackFinalized { repo_path: self.path.clone(), @@ -171,6 +172,7 @@ impl RepoHandler for MonoRepo { from_hash: self.from_hash.clone(), to_hash: self.to_hash.clone(), username: self.username.clone(), + cl_link, }) .await } @@ -491,12 +493,23 @@ impl MonoRepo { txn: Option<&DatabaseTransaction>, ) -> Result<(), MegaError> { let storage = self.storage.mono_storage(); - let current_commit = self.current_commit.read().await; let cl_link = self.fetch_or_new_cl_link().await?; let ref_name = utils::cl_ref_name(&cl_link); - let Some(c) = &*current_commit else { - return Ok(()); + let (commit_hash, tree_hash) = if let Some(c) = &*self.current_commit.read().await { + (cmd.new_id.clone(), c.tree_id.to_string()) + } else { + // Empty pack / ref-only push: still materialize refs/cl/{link} from the target commit. + let commit = storage + .get_commit_by_hash(&cmd.new_id) + .await? + .ok_or_else(|| { + MegaError::Other(format!( + "Commit {} not found while writing CL ref {}", + cmd.new_id, ref_name + )) + })?; + (cmd.new_id.clone(), commit.tree) }; let existing = match txn { @@ -505,17 +518,11 @@ impl MonoRepo { }; if let Some(mut cl_ref) = existing { - cl_ref.ref_commit_hash = cmd.new_id.clone(); - cl_ref.ref_tree_hash = c.tree_id.to_string(); + cl_ref.ref_commit_hash = commit_hash; + cl_ref.ref_tree_hash = tree_hash; storage.update_ref(cl_ref, txn).await?; } else { - let new_ref = mega_refs::Model::new( - &self.path, - ref_name, - cmd.new_id.clone(), - c.tree_id.to_string(), - true, - ); + let new_ref = mega_refs::Model::new(&self.path, ref_name, commit_hash, tree_hash, true); storage.save_refs(new_ref, txn).await?; } Ok(()) diff --git a/ci/jenkins/mega-ui/Jenkinsfile b/ci/jenkins/mega-ui/Jenkinsfile new file mode 100644 index 000000000..e09a5d7d0 --- /dev/null +++ b/ci/jenkins/mega-ui/Jenkinsfile @@ -0,0 +1,61 @@ +pipeline { + agent any + + environment { + HARBOR_REGISTRY = 'registry.xuanwu.openatom.cn' + HARBOR_REPO = 'mega/mega-ui' + DOCKERFILE = 'moon/apps/web/Dockerfile' + BUILD_CONTEXT = 'moon' + GIT_URL = 'https://github.com/benjamin-747/mega' + GIT_BRANCH = 'main' + GIT_CREDENTIALS = 'd4cedbd2-3e68-411b-8367-0960974d7b6d' + HARBOR_CREDENTIALS = '3cefd32b-0fae-4749-b125-0a3f0c537f88' + } + + stages { + stage('Checkout') { + steps { + git branch: "${GIT_BRANCH}", + credentialsId: "${GIT_CREDENTIALS}", + url: "${GIT_URL}" + script { + env.SHORT_SHA = sh(script: 'git rev-parse --short=7 HEAD', returnStdout: true).trim() + env.IMAGE_SHA = "${HARBOR_REGISTRY}/${HARBOR_REPO}:${env.SHORT_SHA}" + env.IMAGE_LATEST = "${HARBOR_REGISTRY}/${HARBOR_REPO}:latest" + env.IMAGE_SHA_AMD64 = "${HARBOR_REGISTRY}/${HARBOR_REPO}:${env.SHORT_SHA}-amd64" + env.IMAGE_LATEST_AMD64 = "${HARBOR_REGISTRY}/${HARBOR_REPO}:latest-amd64" + currentBuild.description = "${HARBOR_REPO}:${env.SHORT_SHA}" + } + echo "Building image: ${env.IMAGE_SHA}" + } + } + + stage('Build Docker Image') { + steps { + sh '/opt/docker-cli/docker build --network=host -f ${DOCKERFILE} -t ${IMAGE_SHA} ${BUILD_CONTEXT}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_LATEST}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_SHA_AMD64}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_LATEST_AMD64}' + } + } + + stage('Push to Harbor') { + steps { + withCredentials([ + usernamePassword( + credentialsId: "${HARBOR_CREDENTIALS}", + usernameVariable: 'HARBOR_USER', + passwordVariable: 'HARBOR_PASS' + ) + ]) { + sh '/opt/docker-cli/docker login ${HARBOR_REGISTRY} -u ${HARBOR_USER} -p ${HARBOR_PASS}' + sh '/opt/docker-cli/docker push ${IMAGE_SHA}' + sh '/opt/docker-cli/docker push ${IMAGE_LATEST}' + sh '/opt/docker-cli/docker push ${IMAGE_SHA_AMD64}' + sh '/opt/docker-cli/docker push ${IMAGE_LATEST_AMD64}' + sh '/opt/docker-cli/docker logout ${HARBOR_REGISTRY}' + } + } + } + } +} diff --git a/ci/jenkins/mono-engine/Jenkinsfile b/ci/jenkins/mono-engine/Jenkinsfile new file mode 100644 index 000000000..4dbc3ff34 --- /dev/null +++ b/ci/jenkins/mono-engine/Jenkinsfile @@ -0,0 +1,61 @@ +pipeline { + agent any + + environment { + HARBOR_REGISTRY = 'registry.xuanwu.openatom.cn' + HARBOR_REPO = 'mega/mono-engine' + DOCKERFILE = 'mono/Dockerfile' + BUILD_CONTEXT = '.' + GIT_URL = 'https://github.com/benjamin-747/mega' + GIT_BRANCH = 'main' + GIT_CREDENTIALS = 'd4cedbd2-3e68-411b-8367-0960974d7b6d' + HARBOR_CREDENTIALS = '3cefd32b-0fae-4749-b125-0a3f0c537f88' + } + + stages { + stage('Checkout') { + steps { + git branch: "${GIT_BRANCH}", + credentialsId: "${GIT_CREDENTIALS}", + url: "${GIT_URL}" + script { + env.SHORT_SHA = sh(script: 'git rev-parse --short=7 HEAD', returnStdout: true).trim() + env.IMAGE_SHA = "${HARBOR_REGISTRY}/${HARBOR_REPO}:${env.SHORT_SHA}" + env.IMAGE_LATEST = "${HARBOR_REGISTRY}/${HARBOR_REPO}:latest" + env.IMAGE_SHA_AMD64 = "${HARBOR_REGISTRY}/${HARBOR_REPO}:${env.SHORT_SHA}-amd64" + env.IMAGE_LATEST_AMD64 = "${HARBOR_REGISTRY}/${HARBOR_REPO}:latest-amd64" + currentBuild.description = "${HARBOR_REPO}:${env.SHORT_SHA}" + } + echo "Building image: ${env.IMAGE_SHA}" + } + } + + stage('Build Docker Image') { + steps { + sh '/opt/docker-cli/docker build --network=host -f ${DOCKERFILE} -t ${IMAGE_SHA} ${BUILD_CONTEXT}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_LATEST}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_SHA_AMD64}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_LATEST_AMD64}' + } + } + + stage('Push to Harbor') { + steps { + withCredentials([ + usernamePassword( + credentialsId: "${HARBOR_CREDENTIALS}", + usernameVariable: 'HARBOR_USER', + passwordVariable: 'HARBOR_PASS' + ) + ]) { + sh '/opt/docker-cli/docker login ${HARBOR_REGISTRY} -u ${HARBOR_USER} -p ${HARBOR_PASS}' + sh '/opt/docker-cli/docker push ${IMAGE_SHA}' + sh '/opt/docker-cli/docker push ${IMAGE_LATEST}' + sh '/opt/docker-cli/docker push ${IMAGE_SHA_AMD64}' + sh '/opt/docker-cli/docker push ${IMAGE_LATEST_AMD64}' + sh '/opt/docker-cli/docker logout ${HARBOR_REGISTRY}' + } + } + } + } +} diff --git a/ci/jenkins/orion-server/Jenkinsfile b/ci/jenkins/orion-server/Jenkinsfile new file mode 100644 index 000000000..03423e814 --- /dev/null +++ b/ci/jenkins/orion-server/Jenkinsfile @@ -0,0 +1,61 @@ +pipeline { + agent any + + environment { + HARBOR_REGISTRY = 'registry.xuanwu.openatom.cn' + HARBOR_REPO = 'mega/orion-server' + DOCKERFILE = 'orion-server/Dockerfile' + BUILD_CONTEXT = '.' + GIT_URL = 'https://github.com/benjamin-747/mega' + GIT_BRANCH = 'main' + GIT_CREDENTIALS = 'd4cedbd2-3e68-411b-8367-0960974d7b6d' + HARBOR_CREDENTIALS = '3cefd32b-0fae-4749-b125-0a3f0c537f88' + } + + stages { + stage('Checkout') { + steps { + git branch: "${GIT_BRANCH}", + credentialsId: "${GIT_CREDENTIALS}", + url: "${GIT_URL}" + script { + env.SHORT_SHA = sh(script: 'git rev-parse --short=7 HEAD', returnStdout: true).trim() + env.IMAGE_SHA = "${HARBOR_REGISTRY}/${HARBOR_REPO}:${env.SHORT_SHA}" + env.IMAGE_LATEST = "${HARBOR_REGISTRY}/${HARBOR_REPO}:latest" + env.IMAGE_SHA_AMD64 = "${HARBOR_REGISTRY}/${HARBOR_REPO}:${env.SHORT_SHA}-amd64" + env.IMAGE_LATEST_AMD64 = "${HARBOR_REGISTRY}/${HARBOR_REPO}:latest-amd64" + currentBuild.description = "${HARBOR_REPO}:${env.SHORT_SHA}" + } + echo "Building image: ${env.IMAGE_SHA}" + } + } + + stage('Build Docker Image') { + steps { + sh '/opt/docker-cli/docker build --network=host -f ${DOCKERFILE} -t ${IMAGE_SHA} ${BUILD_CONTEXT}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_LATEST}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_SHA_AMD64}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_LATEST_AMD64}' + } + } + + stage('Push to Harbor') { + steps { + withCredentials([ + usernamePassword( + credentialsId: "${HARBOR_CREDENTIALS}", + usernameVariable: 'HARBOR_USER', + passwordVariable: 'HARBOR_PASS' + ) + ]) { + sh '/opt/docker-cli/docker login ${HARBOR_REGISTRY} -u ${HARBOR_USER} -p ${HARBOR_PASS}' + sh '/opt/docker-cli/docker push ${IMAGE_SHA}' + sh '/opt/docker-cli/docker push ${IMAGE_LATEST}' + sh '/opt/docker-cli/docker push ${IMAGE_SHA_AMD64}' + sh '/opt/docker-cli/docker push ${IMAGE_LATEST_AMD64}' + sh '/opt/docker-cli/docker logout ${HARBOR_REGISTRY}' + } + } + } + } +} diff --git a/ci/jenkins/web-sync-server/Jenkinsfile b/ci/jenkins/web-sync-server/Jenkinsfile new file mode 100644 index 000000000..1417d23af --- /dev/null +++ b/ci/jenkins/web-sync-server/Jenkinsfile @@ -0,0 +1,61 @@ +pipeline { + agent any + + environment { + HARBOR_REGISTRY = 'registry.xuanwu.openatom.cn' + HARBOR_REPO = 'mega/web-sync-server' + DOCKERFILE = 'moon/apps/sync-server/Dockerfile' + BUILD_CONTEXT = 'moon' + GIT_URL = 'https://github.com/benjamin-747/mega' + GIT_BRANCH = 'main' + GIT_CREDENTIALS = 'd4cedbd2-3e68-411b-8367-0960974d7b6d' + HARBOR_CREDENTIALS = '3cefd32b-0fae-4749-b125-0a3f0c537f88' + } + + stages { + stage('Checkout') { + steps { + git branch: "${GIT_BRANCH}", + credentialsId: "${GIT_CREDENTIALS}", + url: "${GIT_URL}" + script { + env.SHORT_SHA = sh(script: 'git rev-parse --short=7 HEAD', returnStdout: true).trim() + env.IMAGE_SHA = "${HARBOR_REGISTRY}/${HARBOR_REPO}:${env.SHORT_SHA}" + env.IMAGE_LATEST = "${HARBOR_REGISTRY}/${HARBOR_REPO}:latest" + env.IMAGE_SHA_AMD64 = "${HARBOR_REGISTRY}/${HARBOR_REPO}:${env.SHORT_SHA}-amd64" + env.IMAGE_LATEST_AMD64 = "${HARBOR_REGISTRY}/${HARBOR_REPO}:latest-amd64" + currentBuild.description = "${HARBOR_REPO}:${env.SHORT_SHA}" + } + echo "Building image: ${env.IMAGE_SHA}" + } + } + + stage('Build Docker Image') { + steps { + sh '/opt/docker-cli/docker build --network=host -f ${DOCKERFILE} -t ${IMAGE_SHA} ${BUILD_CONTEXT}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_LATEST}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_SHA_AMD64}' + sh '/opt/docker-cli/docker tag ${IMAGE_SHA} ${IMAGE_LATEST_AMD64}' + } + } + + stage('Push to Harbor') { + steps { + withCredentials([ + usernamePassword( + credentialsId: "${HARBOR_CREDENTIALS}", + usernameVariable: 'HARBOR_USER', + passwordVariable: 'HARBOR_PASS' + ) + ]) { + sh '/opt/docker-cli/docker login ${HARBOR_REGISTRY} -u ${HARBOR_USER} -p ${HARBOR_PASS}' + sh '/opt/docker-cli/docker push ${IMAGE_SHA}' + sh '/opt/docker-cli/docker push ${IMAGE_LATEST}' + sh '/opt/docker-cli/docker push ${IMAGE_SHA_AMD64}' + sh '/opt/docker-cli/docker push ${IMAGE_LATEST_AMD64}' + sh '/opt/docker-cli/docker logout ${HARBOR_REGISTRY}' + } + } + } + } +} diff --git a/jupiter-migrate/Cargo.toml b/jupiter-migrate/Cargo.toml index 522b9b0b1..30a1a0858 100644 --- a/jupiter-migrate/Cargo.toml +++ b/jupiter-migrate/Cargo.toml @@ -23,3 +23,4 @@ chrono = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/jupiter-migrate/src/migration/m20260304_013434_seed_cla_sign_check_config.rs b/jupiter-migrate/src/migration/m20260304_013434_seed_cla_sign_check_config.rs index 15c560a45..b8df4256a 100644 --- a/jupiter-migrate/src/migration/m20260304_013434_seed_cla_sign_check_config.rs +++ b/jupiter-migrate/src/migration/m20260304_013434_seed_cla_sign_check_config.rs @@ -13,7 +13,7 @@ impl MigrationTrait for Migration { db_backend, r#" INSERT INTO path_check_configs (created_at, updated_at, id, path, check_type_code, enabled, required) - SELECT CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, COALESCE(MAX(id), 0) + 1, '/', 'cla_sign', true, true + SELECT CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, COALESCE(MAX(id), 0) + 1, '/', 'cla_sign', true, false FROM path_check_configs WHERE NOT EXISTS ( SELECT 1 FROM path_check_configs WHERE path = '/' AND check_type_code = 'cla_sign' diff --git a/jupiter-migrate/src/migration/m20260723_080000_cla_sign_check_not_required.rs b/jupiter-migrate/src/migration/m20260723_080000_cla_sign_check_not_required.rs new file mode 100644 index 000000000..e450adc8a --- /dev/null +++ b/jupiter-migrate/src/migration/m20260723_080000_cla_sign_check_not_required.rs @@ -0,0 +1,34 @@ +use sea_orm_migration::{prelude::*, sea_orm::Statement}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db_backend = manager.get_database_backend(); + // CLA remains enabled (still reported) but no longer blocks merge. + manager + .get_connection() + .execute_raw(Statement::from_string( + db_backend, + r#"UPDATE path_check_configs SET required = false, updated_at = CURRENT_TIMESTAMP WHERE check_type_code = 'cla_sign';"#, + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db_backend = manager.get_database_backend(); + manager + .get_connection() + .execute_raw(Statement::from_string( + db_backend, + r#"UPDATE path_check_configs SET required = true, updated_at = CURRENT_TIMESTAMP WHERE check_type_code = 'cla_sign';"#, + )) + .await?; + + Ok(()) + } +} diff --git a/jupiter-migrate/src/migration/mod.rs b/jupiter-migrate/src/migration/mod.rs index 3dd5a5fd3..0447cfbdd 100644 --- a/jupiter-migrate/src/migration/mod.rs +++ b/jupiter-migrate/src/migration/mod.rs @@ -100,6 +100,7 @@ mod m20260413_033315_create_artifact_tables; mod m20260612_011232_drop_build_events_log; mod m20260714_021900_create_user_approval_status; mod m20260720_060000_rename_webhook_event_type_underscores; +mod m20260723_080000_cla_sign_check_not_required; mod runner; pub use runner::apply_migrations; @@ -187,6 +188,7 @@ impl MigratorTrait for Migrator { Box::new(m20260612_011232_drop_build_events_log::Migration), Box::new(m20260714_021900_create_user_approval_status::Migration), Box::new(m20260720_060000_rename_webhook_event_type_underscores::Migration), + Box::new(m20260723_080000_cla_sign_check_not_required::Migration), ] } } diff --git a/jupiter/src/storage/mono_storage.rs b/jupiter/src/storage/mono_storage.rs index 22c703a10..6ea4627a8 100644 --- a/jupiter/src/storage/mono_storage.rs +++ b/jupiter/src/storage/mono_storage.rs @@ -295,6 +295,54 @@ impl MonoStorage { Ok(()) } + /// Ensure `refs/cl/{cl_link}` exists at `path`. + /// + /// If the canonical ref is missing but an orphaned `refs/cl/*` under the same path + /// already points at `commit_id`, adopt that tip onto the canonical name and drop the + /// orphan. Otherwise create/update the canonical ref from `commit_id` / `tree_hash`. + pub async fn ensure_cl_ref( + &self, + path: &str, + cl_link: &str, + commit_id: &str, + tree_hash: &str, + ) -> Result { + let cl_ref_name = common::utils::cl_ref_name(cl_link); + + if let Some(existing) = self.get_ref_by_name(&cl_ref_name).await? { + return Ok(existing); + } + + let path_refs = self.get_all_refs(path, false).await?; + if let Some(orphan) = path_refs.iter().find(|r| { + r.is_cl + && r.ref_name != cl_ref_name + && r.ref_name.starts_with("refs/cl/") + && r.ref_commit_hash == commit_id + }) { + tracing::warn!( + cl_link, + orphan_ref = %orphan.ref_name, + "Adopting orphaned CL ref onto canonical refs/cl link" + ); + self.save_or_update_cl_ref( + path, + &cl_ref_name, + &orphan.ref_commit_hash, + &orphan.ref_tree_hash, + ) + .await?; + let _ = self.remove_ref(orphan.clone()).await; + } else { + self.save_or_update_cl_ref(path, &cl_ref_name, commit_id, tree_hash) + .await?; + } + + self.get_ref_by_name(&cl_ref_name) + .await? + .ok_or_else(|| MegaError::Other(format!("CL ref missing after ensure: {cl_ref_name}"))) + } + pub async fn batch_update_by_path_concurrent( &self, updates: Vec, @@ -839,4 +887,144 @@ impl MonoStorage { } #[cfg(test)] -mod test {} +mod tests { + use common::utils; + use tempfile::TempDir; + + use crate::tests::test_storage; + + #[tokio::test] + async fn ensure_cl_ref_creates_missing_canonical_ref() { + let dir = TempDir::new().unwrap(); + let storage = test_storage(dir.path()).await; + let mono = storage.mono_storage(); + + let commit = "a".repeat(40); + let tree = "b".repeat(40); + let link = "LADRHWDL"; + let path = "/project/dagrs-derive"; + + let created = mono + .ensure_cl_ref(path, link, &commit, &tree) + .await + .expect("create cl ref"); + + assert_eq!(created.ref_name, utils::cl_ref_name(link)); + assert_eq!(created.path, path); + assert_eq!(created.ref_commit_hash, commit); + assert_eq!(created.ref_tree_hash, tree); + assert!(created.is_cl); + } + + #[tokio::test] + async fn ensure_cl_ref_returns_existing_without_changing_tip() { + let dir = TempDir::new().unwrap(); + let storage = test_storage(dir.path()).await; + let mono = storage.mono_storage(); + + let commit = "c".repeat(40); + let tree = "d".repeat(40); + let link = "T34FWD3A"; + let path = "/toolchains"; + + let first = mono + .ensure_cl_ref(path, link, &commit, &tree) + .await + .unwrap(); + let second = mono + .ensure_cl_ref(path, link, &"e".repeat(40), &"f".repeat(40)) + .await + .unwrap(); + + assert_eq!(first.id, second.id); + assert_eq!(second.ref_commit_hash, commit); + assert_eq!(second.ref_tree_hash, tree); + } + + #[tokio::test] + async fn ensure_cl_ref_adopts_orphaned_ref_with_matching_tip() { + let dir = TempDir::new().unwrap(); + let storage = test_storage(dir.path()).await; + let mono = storage.mono_storage(); + + let tip = "35f26cd32f9a0d5df4f9b2f6acd5de27dcc604c1"; + let tree = "7da0b4869e7a3036483517f3dedcdb378ca3a8a8"; + let path = "/project/dagrs-derive"; + let orphan_link = "HH8PKWSS"; + let canonical_link = "LADRHWDL"; + + mono.save_or_update_cl_ref(path, &utils::cl_ref_name(orphan_link), tip, tree) + .await + .unwrap(); + + let adopted = mono + .ensure_cl_ref(path, canonical_link, tip, tree) + .await + .expect("adopt orphan"); + + assert_eq!(adopted.ref_name, utils::cl_ref_name(canonical_link)); + assert_eq!(adopted.ref_commit_hash, tip); + assert!( + mono.get_ref_by_name(&utils::cl_ref_name(orphan_link)) + .await + .unwrap() + .is_none(), + "orphan ref should be removed after adopt" + ); + } + + #[tokio::test] + async fn ensure_cl_ref_does_not_adopt_orphan_with_different_tip() { + let dir = TempDir::new().unwrap(); + let storage = test_storage(dir.path()).await; + let mono = storage.mono_storage(); + + let path = "/project/demo"; + mono.save_or_update_cl_ref( + path, + &utils::cl_ref_name("ORPHAN01"), + &"1".repeat(40), + &"2".repeat(40), + ) + .await + .unwrap(); + + let created = mono + .ensure_cl_ref(path, "CANON001", &"3".repeat(40), &"4".repeat(40)) + .await + .unwrap(); + + assert_eq!(created.ref_name, utils::cl_ref_name("CANON001")); + assert_eq!(created.ref_commit_hash, "3".repeat(40)); + assert!( + mono.get_ref_by_name(&utils::cl_ref_name("ORPHAN01")) + .await + .unwrap() + .is_some(), + "unrelated orphan tip must be left alone" + ); + } + + #[tokio::test] + async fn save_or_update_cl_ref_updates_existing_tip() { + let dir = TempDir::new().unwrap(); + let storage = test_storage(dir.path()).await; + let mono = storage.mono_storage(); + + let link = "ABCDEF12"; + let path = "/project/demo"; + let ref_name = utils::cl_ref_name(link); + + mono.save_or_update_cl_ref(path, &ref_name, &"1".repeat(40), &"2".repeat(40)) + .await + .unwrap(); + mono.save_or_update_cl_ref(path, &ref_name, &"3".repeat(40), &"4".repeat(40)) + .await + .unwrap(); + + let updated = mono.get_ref_by_name(&ref_name).await.unwrap().unwrap(); + assert_eq!(updated.ref_commit_hash, "3".repeat(40)); + assert_eq!(updated.ref_tree_hash, "4".repeat(40)); + assert!(updated.is_cl); + } +} diff --git a/mono/src/api/router/admin_router.rs b/mono/src/api/router/admin_router.rs index e6fe3e56b..4f637a535 100644 --- a/mono/src/api/router/admin_router.rs +++ b/mono/src/api/router/admin_router.rs @@ -62,8 +62,33 @@ async fn is_admin_me( ) -> Result>, ApiError> { let admin = state.services().admin(); let cedar_id = user.cedar_user_id(); - let is_admin = admin.check_is_admin(cedar_id).await? - || (cedar_id != user.username.as_str() && admin.check_is_admin(&user.username).await?); + // Prefer GitHub login; also accept Campsite username during migration. + // On config/load failure (e.g. missing `.mega_cedar.json`), treat as non-admin + // so AccountApprovalGuard can still honor an approved user_approval_status. + let is_admin = match admin.check_is_admin(cedar_id).await { + Ok(true) => true, + Ok(false) if cedar_id != user.username.as_str() => admin + .check_is_admin(&user.username) + .await + .unwrap_or_else(|e| { + tracing::warn!( + error = %e, + actor = %user.username, + "admin check failed; treating as non-admin" + ); + false + }), + Ok(false) => false, + Err(e) => { + tracing::warn!( + error = %e, + actor = %user.username, + cedar_user_id = %cedar_id, + "admin check failed; treating as non-admin" + ); + false + } + }; Ok(Json(CommonResult::success(Some(IsAdminResponse { is_admin, diff --git a/moon/api/README.md b/moon/api/README.md index be1ae98c8..d8054e668 100644 --- a/moon/api/README.md +++ b/moon/api/README.md @@ -16,20 +16,22 @@ Also avoid hand-editing other checked-in OpenAPI dumps that feed the same pipeli ## Regenerate workflow -From the `moon/` directory: +From the `moon/` directory (mono HTTP must be running on port 8000 by default): ```bash -# 1) Refresh mono OpenAPI (mono HTTP must be running; default port 8000) -curl -sS http://localhost:8000/api/openapi.json -o api/gen/gitmono.json +# Fetches http://localhost:8000/api/openapi.json → pretty-prints → replaces api/gen/gitmono.json, +# then merges api/gen/*.json → api/gen/merged_swagger.json and regenerates packages/types/generated.ts +./script/gen-client + +# Optional: override mono OpenAPI URL +# GITMONO_OPENAPI_URL=http://127.0.0.1:8000/api/openapi.json ./script/gen-client # Optional: refresh orion OpenAPI when orion-server routes/DTOs changed (default port 8004) curl -sS http://localhost:8004/api-doc/openapi.json -o api/gen/orion.json - -# 2) Merge api/gen/*.json → api/gen/merged_swagger.json and write packages/types/generated.ts ./script/gen-client ``` -`script/gen-client` runs [`merge-swagger.js`](merge-swagger.js) (merges every `api/gen/*.json` except `merged_swagger.json` / `openapi_schema.json`), then regenerates `packages/types/generated.ts`. +`script/gen-client` refreshes `gitmono.json` from the live mono OpenAPI, runs [`merge-swagger.js`](merge-swagger.js) (merges every `api/gen/*.json` except `merged_swagger.json` / `openapi_schema.json`), then regenerates `packages/types/generated.ts`. ## Related endpoints diff --git a/moon/apps/web/Dockerfile b/moon/apps/web/Dockerfile index 6ab499f53..94c37e9e8 100644 --- a/moon/apps/web/Dockerfile +++ b/moon/apps/web/Dockerfile @@ -10,9 +10,6 @@ RUN corepack enable && corepack prepare pnpm@9.7.1 --activate # Throw-away build stage to reduce size of final image FROM base AS builder -# Install packages needed to build node modules -RUN apt-get update -qq && \ - apt-get install -y build-essential pkg-config python-is-python3 ca-certificates WORKDIR /app RUN pnpm install turbo@2.9.16 --global COPY . . @@ -22,6 +19,7 @@ WORKDIR /app FROM base AS installer ARG TARGETARCH +ARG TIPTAP_PRIVATE_REGISTRY_KEY RUN apt-get update -qq && \ apt-get install -y build-essential pkg-config python-is-python3 ca-certificates WORKDIR /app @@ -33,7 +31,7 @@ COPY --from=builder /app/pnpm-workspace.yaml ./ RUN pnpm config set "@tiptap-pro:registry" https://registry.tiptap.dev/ -RUN pnpm config set "//registry.tiptap.dev/:_authToken" CjqFil0n7z4GGur+RPric21fBBeSB4R4FoNdRYOE1bQEz6AXLCoANCc+o9rLIg6Q +RUN pnpm config set "//registry.tiptap.dev/:_authToken" "${TIPTAP_PRIVATE_REGISTRY_KEY}" RUN --mount=type=cache,id=mega-ui-pnpm-store-${TARGETARCH},target=/pnpm/store,sharing=locked \ pnpm config set store-dir /pnpm/store && \ pnpm install --frozen-lockfile @@ -47,7 +45,9 @@ COPY --from=builder /app/out/full/ . RUN rm -f /app/apps/web/.env.local \ && cp /app/apps/web/.env.runtime /app/apps/web/.env.production -RUN npx turbo run build --filter=@gitmono/web +RUN --mount=type=cache,id=mega-ui-next-${TARGETARCH},target=/app/apps/web/.next/cache,sharing=locked \ + --mount=type=cache,id=mega-ui-turbo-${TARGETARCH},target=/app/.turbo,sharing=locked \ + npx turbo run build --filter=@gitmono/web diff --git a/moon/apps/web/components/AccountApprovalGuard.tsx b/moon/apps/web/components/AccountApprovalGuard.tsx index 56b94d0b3..21aaf6745 100644 --- a/moon/apps/web/components/AccountApprovalGuard.tsx +++ b/moon/apps/web/components/AccountApprovalGuard.tsx @@ -29,11 +29,15 @@ const AccountApprovalGuard: React.FC = ({ children, allowLoggedOut }) => // Account settings must stay reachable (admin review tab, back navigation). Only gate org/home. const isAccountRoute = router.pathname.startsWith('/me/') - const needsApprovalCheck = !allowLoggedOut && loggedIn && !isAccountRoute && !isAdmin && !adminPending + // Admin-check failure (e.g. missing `.mega_cedar.json`) is treated as non-admin: + // still require / fall through to user_approval_status. + const adminCheckDone = !adminPending || adminError + const needsApprovalCheck = !allowLoggedOut && loggedIn && !isAccountRoute && !isAdmin && adminCheckDone const { data: approvalRes, isPending: approvalPending, - isError: approvalError + isError: approvalError, + isFetched: approvalFetched } = useMyApprovalStatus(needsApprovalCheck) if (allowLoggedOut) { @@ -58,8 +62,8 @@ const AccountApprovalGuard: React.FC = ({ children, allowLoggedOut }) => return <>{children} } - // Wait for admin check unless it already failed (e.g. CORS) — treat failure as non-admin - if (adminPending && !adminError) { + // Wait for admin check unless it already failed — failure ⇒ non-admin + approval check + if (!adminCheckDone) { return } @@ -68,7 +72,8 @@ const AccountApprovalGuard: React.FC = ({ children, allowLoggedOut }) => return <>{children} } - if (approvalPending) { + // Do not default to "pending" before approval status has actually loaded. + if (needsApprovalCheck && (approvalPending || (!approvalFetched && !approvalError))) { return } diff --git a/moon/apps/web/components/ClBox/ChecksSection.tsx b/moon/apps/web/components/ClBox/ChecksSection.tsx index e932d3ce1..c14524535 100644 --- a/moon/apps/web/components/ClBox/ChecksSection.tsx +++ b/moon/apps/web/components/ClBox/ChecksSection.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react' import * as Collapsible from '@radix-ui/react-collapsible' -import { AlertIcon, ArrowDownIcon, ArrowUpIcon, CheckIcon, LoadingSpinner } from '@gitmono/ui' +import { CheckType, ConditionResult } from '@gitmono/types' +import { AlertIcon, ArrowDownIcon, ArrowUpIcon, CheckIcon, LoadingSpinner, WarningTriangleIcon } from '@gitmono/ui' import { useGetClUpdateStatus } from '@/hooks/CL/useGetClUpdateStatus' import { usePostClUpdateBranch } from '@/hooks/CL/usePostClUpdateBranch' @@ -128,7 +129,10 @@ interface AdditionalCheckItemProps { check: AdditionalCheckItem } -const getStatusIcon = (status: AdditionalCheckStatus) => { +const getStatusIcon = (status: AdditionalCheckStatus, advisory = false) => { + if (advisory && status === 'FAILED') { + return + } switch (status) { case 'PASSED': return @@ -139,32 +143,42 @@ const getStatusIcon = (status: AdditionalCheckStatus) => { } } -const AdditionalCheckItemComponent = ({ check }: AdditionalCheckItemProps) => ( -

-
{getStatusIcon(check.result)}
-
-
-
{ADDITIONAL_CHECK_LABELS[check.type]}
- - {check.result.toLowerCase()} - +const AdditionalCheckItemComponent = ({ check }: AdditionalCheckItemProps) => { + const isClaAdvisory = check.type === CheckType.ClaSign && check.result === ConditionResult.FAILED + + return ( +
+
{getStatusIcon(check.result, isClaAdvisory)}
+
+
+
{ADDITIONAL_CHECK_LABELS[check.type]}
+ + {isClaAdvisory ? 'advisory' : check.result.toLowerCase()} + +
+ {check.result === 'FAILED' && ( +
    +
  • {isClaAdvisory ? `${check.message} (does not block merge)` : check.message}
  • +
+ )}
- {check.result === 'FAILED' && ( -
    -
  • {check.message}
  • -
- )}
-
-) + ) +} interface AdditionalChecksSectionProps { additionalChecks: AdditionalCheckItem[] diff --git a/moon/apps/web/components/ClBox/MergeSection.tsx b/moon/apps/web/components/ClBox/MergeSection.tsx index 2a17de95a..ed9cb3230 100644 --- a/moon/apps/web/components/ClBox/MergeSection.tsx +++ b/moon/apps/web/components/ClBox/MergeSection.tsx @@ -80,8 +80,10 @@ export const MergeSection = React.memo( let statusNode: React.ReactNode - const isMergeable = isAllReviewerApproved && claCheck !== false + // CLA is advisory-only for now: show a hint but do not block merge. + const isMergeable = isAllReviewerApproved const isDraft = clStatus?.toLowerCase() === 'draft' + const showClaHint = claCheck === false if (isDraft) { statusNode = ( @@ -125,6 +127,16 @@ export const MergeSection = React.memo(
{statusNode} + {showClaHint && ( +
+ + + CLA is not signed yet. This is a reminder only and does not block merging. + {isClAuthor && ' Please sign the CLA when you can.'} + +
+ )} + {/* Queue Status Info */} {inQueue && queueItem && (
@@ -151,7 +163,7 @@ export const MergeSection = React.memo( )} - {claCheck === false && isClAuthor && ( + {showClaHint && isClAuthor && (
) + // Prevent Radix from closing the menu when focusing/clicking the search input + const searchItem: MenuItem | null = hasSearch + ? { + type: 'item' as const, + label: , + onSelect: (e: Event) => e.preventDefault() + } + : null + const defaultTrigger = ( + + + + + + + ) +} diff --git a/moon/apps/web/components/Home/HomeSidebar.tsx b/moon/apps/web/components/Home/HomeSidebar.tsx index 06f72b49d..2eaa9afbf 100644 --- a/moon/apps/web/components/Home/HomeSidebar.tsx +++ b/moon/apps/web/components/Home/HomeSidebar.tsx @@ -349,6 +349,23 @@ function SearchInput({ query, setQuery }: { query: string; setQuery: (query: str ) } +function needsGithubRelogin(user: { github_login?: string | null; integration?: boolean; system?: boolean }) { + if (user.integration || user.system) return false + return !user.github_login +} + +function GithubReloginHint() { + return ( + + + + Re-login required + + + + ) +} + export function CurrentUserStatus() { const [statusDialogOpen, setStatusDialogOpen] = useState(false) const { data: currentUser } = useGetCurrentUser() @@ -356,6 +373,8 @@ export function CurrentUserStatus() { if (!currentUser || !member) return null + const showRelogin = needsGithubRelogin(currentUser) + return ( <> @@ -372,14 +391,17 @@ export function CurrentUserStatus() {
{currentUser.display_name} - - {!member.status && ( - - Update status - + {showRelogin ? ( + + ) : ( + !member.status && ( + + Update status + + ) )}
@@ -429,13 +451,18 @@ function InnerMember({ member }: { member: OrganizationMember }) {
{member.user.display_name} - {!member.status && memberIsViewer && ( - - Update status - + {needsGithubRelogin(member.user) ? ( + + ) : ( + !member.status && + memberIsViewer && ( + + Update status + + ) )}
diff --git a/moon/apps/web/components/Labels/NewLabelDialog.tsx b/moon/apps/web/components/Labels/NewLabelDialog.tsx index 97b3d487f..6643192ea 100644 --- a/moon/apps/web/components/Labels/NewLabelDialog.tsx +++ b/moon/apps/web/components/Labels/NewLabelDialog.tsx @@ -1,11 +1,30 @@ // components/Labels/NewLabelDialog.tsx import React, { useState } from 'react' -import { random } from 'colord' +import { colord, random } from 'colord' import { Button, Dialog, RefreshIcon, TextField } from '@gitmono/ui' import { getFontColor } from '@/utils/getFontColor' +const PRESET_COLORS = [ + '#b60205', + '#d93f0b', + '#fbca04', + '#0e8a16', + '#006b75', + '#1d76db', + '#0052cc', + '#5319e7', + '#e99695', + '#f9d0c4', + '#fef2c0', + '#c2e0c6', + '#bfdadc', + '#c5def5', + '#bfd4f2', + '#d4c5f9' +] + interface NewLabelDialogProps { isOpen: boolean onClose: () => void @@ -18,14 +37,19 @@ export const NewLabelDialog: React.FC = ({ isOpen, onClose, const [description, setDescription] = useState('') const fontColor = getFontColor(color) + const normalizedColor = colord(color).isValid() ? colord(color).toHex() : '#000000' const generateRandomColor = () => { setColor(random().toHex()) } + const handleColorInputChange = (value: string) => { + setColor(value.startsWith('#') ? value : `#${value}`) + } + const handleCreateLabel = () => { if (name.trim()) { - onCreateLabel(name, description, color) + onCreateLabel(name, description, normalizedColor) setName('') setDescription('') generateRandomColor() @@ -42,7 +66,7 @@ export const NewLabelDialog: React.FC = ({ isOpen, onClose,
= ({ isOpen, onClose,
-
- +
+ setColor(e.target.value)} + className='h-6 w-6 cursor-pointer border-none bg-transparent p-0' + title='Pick a color' + /> setColor(e.target.value)} + onChange={(e) => handleColorInputChange(e.target.value)} + placeholder='#ffffff' + spellCheck={false} />
+ +
+ {PRESET_COLORS.map((preset) => { + const selected = normalizedColor.toLowerCase() === preset.toLowerCase() + + return ( +
diff --git a/moon/apps/web/components/OrionClient/ClientsTable.tsx b/moon/apps/web/components/OrionClient/ClientsTable.tsx index 76cf467eb..75fc01896 100644 --- a/moon/apps/web/components/OrionClient/ClientsTable.tsx +++ b/moon/apps/web/components/OrionClient/ClientsTable.tsx @@ -4,7 +4,7 @@ import React from 'react' import { DataTable } from '@primer/react/experimental' import { CoreWorkerStatus, TaskPhase } from '@gitmono/types/generated' -import { Select, SelectTrigger, SelectValue, UIText } from '@gitmono/ui' +import { Button, Select, SelectTrigger, SelectValue, UIText } from '@gitmono/ui' import { useGetOrionClientStatusById } from '@/hooks/OrionClient/OrionClientStatusById' @@ -17,6 +17,9 @@ interface ClientsTableProps { statusFilter: OrionClientStatus | 'all' onStatusChange: (v: OrionClientStatus | 'all') => void statusOptions: { value: OrionClientStatus | 'all'; label: string }[] + /** When set, show a per-row action to open that client's runner logs. */ + onViewLogs?: (client: OrionClient) => void + canViewLogs?: boolean capabilityFilter?: string capabilityOptions?: string[] onCapabilityChange?: (v: string) => void @@ -25,7 +28,15 @@ interface ClientsTableProps { type Row = OrionClient & { statusDerived: OrionClientStatus } type UniqueRow = Row & { id: string } -export function ClientsTable({ clients, isLoading, statusFilter, onStatusChange, statusOptions }: ClientsTableProps) { +export function ClientsTable({ + clients, + isLoading, + statusFilter, + onStatusChange, + statusOptions, + onViewLogs, + canViewLogs = false +}: ClientsTableProps) { const rows = React.useMemo(() => { return clients.map((c) => ({ ...c, @@ -34,13 +45,15 @@ export function ClientsTable({ clients, isLoading, statusFilter, onStatusChange, })) }, [clients]) + const showLogsAction = Boolean(canViewLogs && onViewLogs) + const columns = React.useMemo( () => [ { header: 'Client ID', field: 'client_id', rowHeader: true, - width: '18%', + width: showLogsAction ? '16%' : '18%', renderCell: (row: Row) => (
@@ -52,20 +65,20 @@ export function ClientsTable({ clients, isLoading, statusFilter, onStatusChange, { header: 'Hostname', field: 'hostname', - width: '18%', + width: showLogsAction ? '16%' : '18%', renderCell: (row: Row) =>
{row.hostname || '—'}
}, { header: 'Version', field: 'orion_version', width: '10%' }, { header: 'Start Time', field: 'start_time', - width: '18%', + width: showLogsAction ? '16%' : '18%', renderCell: (row: Row) =>
{formatDateTime(row.start_time)}
}, { header: 'Last Heartbeat', field: 'last_heartbeat', - width: '22%', + width: showLogsAction ? '18%' : '22%', renderCell: (row: Row) => (
{formatDateTime(row.last_heartbeat)}
@@ -90,9 +103,28 @@ export function ClientsTable({ clients, isLoading, statusFilter, onStatusChange, field: 'statusDerived', width: '14%', renderCell: (row: Row) => - } + }, + ...(showLogsAction + ? [ + { + header: 'Logs', + field: 'client_id', + width: '10%', + renderCell: (row: Row) => ( + + ) + } + ] + : []) ], - [onStatusChange, statusFilter, statusOptions] + [onStatusChange, onViewLogs, showLogsAction, statusFilter, statusOptions] ) if (isLoading) { diff --git a/moon/apps/web/components/Providers/AuthAppProviders.tsx b/moon/apps/web/components/Providers/AuthAppProviders.tsx index 2052023d1..49ca080e6 100644 --- a/moon/apps/web/components/Providers/AuthAppProviders.tsx +++ b/moon/apps/web/components/Providers/AuthAppProviders.tsx @@ -14,6 +14,7 @@ import { AutoTimezoneSwitcher } from '@/components/AutoTimezoneSwitcher' import { IncomingCallRoomInvitationToast } from '@/components/Call/IncomingCallRoomInvitationToast' import { LocalCommandMenu } from '@/components/CommandMenu' import { FeedbackDialog } from '@/components/Feedback/FeedbackDialog' +import { GithubLoginRequiredDialog } from '@/components/GithubLoginRequiredDialog' import { GlobalKeyboardShortcuts } from '@/components/GlobalKeyboardShortcuts' import { PostComposer } from '@/components/PostComposer' import { AuthProvider } from '@/components/Providers/AuthProvider' @@ -79,6 +80,7 @@ export const AuthAppProviders: PageWithProviders = ({ children, allowLogged {children} + diff --git a/moon/apps/web/hooks/OrionClient/useRunnerLogsSSE.ts b/moon/apps/web/hooks/OrionClient/useRunnerLogsSSE.ts index 183839337..05d8d30fd 100644 --- a/moon/apps/web/hooks/OrionClient/useRunnerLogsSSE.ts +++ b/moon/apps/web/hooks/OrionClient/useRunnerLogsSSE.ts @@ -6,37 +6,72 @@ export type RunnerLogsStatus = 'idle' | 'connecting' | 'streaming' | 'error' const MAX_LOG_CHARS = 400_000 +/** Older schedulers spam this every second while the VM is still provisioning. */ +const TRANSIENT_NO_VM_RE = /^Error:\s*No running VM for key\b/i + +/** Strip CSI / OSC ANSI sequences so terminal-colored scheduler logs render cleanly in HTML. */ +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') +} + function runnerLogsStreamUrl(vmId: string): string { const base = MONO_API_URL.replace(/\/$/, '') return `${base}/api/v1/orion/runners/${encodeURIComponent(vmId)}/logs/stream` } +/** + * Drop repeated "No running VM" errors from older schedulers, keeping a single + * waiting notice until real log lines arrive. + */ +function filterTransientVmErrors(chunk: string, alreadyWaiting: boolean): { text: string; waiting: boolean } { + const lines = chunk.split('\n') + const kept: string[] = [] + let waiting = alreadyWaiting + + for (const line of lines) { + if (TRANSIENT_NO_VM_RE.test(line.trim())) { + if (!waiting) { + kept.push('Waiting for VM to finish provisioning…') + waiting = true + } + continue + } + kept.push(line) + } + + return { text: kept.join('\n'), waiting } +} + /** * Subscribe to mono-proxied Orion runner startup logs (SSE). - * Enabled while `vmId` is set; closes the EventSource when cleared or unmounted. + * `streamKey` is a scheduler VM id or domain host (client hostname is the WS URL). + * Enabled while set; closes the EventSource when cleared or unmounted. */ -export function useRunnerLogsSSE(vmId: string | null) { +export function useRunnerLogsSSE(streamKey: string | null) { const [logs, setLogs] = useState('') const [status, setStatus] = useState('idle') const [error, setError] = useState(null) const esRef = useRef(null) + const waitingForVmRef = useRef(false) useEffect(() => { - if (!vmId) { + if (!streamKey) { esRef.current?.close() esRef.current = null setLogs('') setStatus('idle') setError(null) + waitingForVmRef.current = false return } setLogs('') setError(null) setStatus('connecting') + waitingForVmRef.current = false - const es = new EventSource(runnerLogsStreamUrl(vmId), { withCredentials: true }) + const es = new EventSource(runnerLogsStreamUrl(streamKey), { withCredentials: true }) esRef.current = es @@ -45,12 +80,27 @@ export function useRunnerLogsSSE(vmId: string | null) { } es.onmessage = (event) => { - const chunk = event.data + const raw = stripAnsi(event.data ?? '') + + if (!raw) return + + const { text: chunk, waiting } = filterTransientVmErrors(raw, waitingForVmRef.current) - if (!chunk) return + waitingForVmRef.current = waiting + + if (!chunk.trim()) return + + // Real log content arrived — clear the transient-wait gate so a later + // reprovision can announce waiting again if needed. + if (!TRANSIENT_NO_VM_RE.test(chunk.trim()) && !chunk.includes('Waiting for VM')) { + waitingForVmRef.current = false + } setLogs((prev) => { - const next = prev ? `${prev}${chunk}` : chunk + // EventSource joins multi-line SSE `data:` fields with `\n` but does not + // guarantee a trailing newline between successive events. + const sep = prev && !prev.endsWith('\n') && !chunk.startsWith('\n') ? '\n' : '' + const next = prev ? `${prev}${sep}${chunk}` : chunk if (next.length <= MAX_LOG_CHARS) return next return next.slice(next.length - MAX_LOG_CHARS) @@ -72,7 +122,7 @@ export function useRunnerLogsSSE(vmId: string | null) { esRef.current = null } } - }, [vmId]) + }, [streamKey]) return { logs, status, error } } diff --git a/moon/apps/web/pages/[org]/oc/index.tsx b/moon/apps/web/pages/[org]/oc/index.tsx index 8db8cc6f0..a5f10ca1a 100644 --- a/moon/apps/web/pages/[org]/oc/index.tsx +++ b/moon/apps/web/pages/[org]/oc/index.tsx @@ -14,7 +14,7 @@ import { Button, UIText } from '@gitmono/ui' import { RefreshIcon } from '@gitmono/ui/Icons' import { AppLayout } from '@/components/Layout/AppLayout' -import { ClientsTable, OrionClientStatus } from '@/components/OrionClient' +import { ClientsTable, OrionClient, OrionClientStatus } from '@/components/OrionClient' import AuthAppProviders from '@/components/Providers/AuthAppProviders' import { useAdminCheck } from '@/hooks/admin/useAdminCheck' import { usePostOrionClientsInfo } from '@/hooks/OrionClient/OrionClientsInfo' @@ -23,27 +23,88 @@ import { usePostStartRunner } from '@/hooks/OrionClient/usePostStartRunner' import { useRunnerLogsSSE } from '@/hooks/OrionClient/useRunnerLogsSSE' import { PageWithLayout } from '@/utils/types' +/** Client `hostname` is the WS URL (e.g. wss://orion.example/ws); scheduler keys VMs by that host. */ +function domainFromClientHostname(hostname: string): string | null { + const raw = hostname.trim() + + if (!raw) return null + + try { + const url = new URL(raw.includes('://') ? raw : `ws://${raw}`) + + return url.hostname || null + } catch { + const host = raw.split('/')[0]?.split(':')[0] + + return host || null + } +} + +type LogPanelSource = 'runner' | 'client' + const OrionClientPage: PageWithLayout = () => { const [hostnameInput, setHostnameInput] = React.useState('') const [debouncedHostname, setDebouncedHostname] = React.useState('') const [statusFilter, setStatusFilter] = React.useState('all') const [currentPage, setCurrentPage] = React.useState(1) - const [activeVmId, setActiveVmId] = React.useState(null) + /** Stream key for scheduler logs: VM id (after Start Runner) or domain host (from client list). */ + const [activeLogKey, setActiveLogKey] = React.useState(null) const [activePhase, setActivePhase] = React.useState(null) const [activeDomain, setActiveDomain] = React.useState(null) + const [logSource, setLogSource] = React.useState(null) + const [logClientId, setLogClientId] = React.useState(null) + const [copyFeedback, setCopyFeedback] = React.useState(false) + const logPanelRef = React.useRef(null) + const logsScrollRef = React.useRef(null) + const logsPreRef = React.useRef(null) + const logsFollowRef = React.useRef(true) + const runnerLogsRef = React.useRef('') const perPage = 8 + const showingLogs = Boolean(activeLogKey) const { data: adminCheck } = useAdminCheck() const isAdmin = adminCheck?.data?.is_admin || false const { mutate: startRunner, isPending: isStartingRunner } = usePostStartRunner() - const { data: runnerStatus } = useGetRunnerStatus(activeVmId, activePhase) - const { logs: runnerLogs, status: runnerLogsStatus, error: runnerLogsError } = useRunnerLogsSSE(activeVmId) + const runnerStatusVmId = logSource === 'runner' ? activeLogKey : null + const { data: runnerStatus } = useGetRunnerStatus(runnerStatusVmId, activePhase) + const { logs: runnerLogs, status: runnerLogsStatus, error: runnerLogsError } = useRunnerLogsSSE(activeLogKey) + + runnerLogsRef.current = runnerLogs const { mutate, isPending, error } = usePostOrionClientsInfo() const [clientsPage, setClientsPage] = React.useState(null) + const copyLogsToClipboard = React.useCallback(async (text: string) => { + if (!text) return false + + try { + await navigator.clipboard.writeText(text) + setCopyFeedback(true) + window.setTimeout(() => setCopyFeedback(false), 1500) + return true + } catch { + return false + } + }, []) + + React.useEffect(() => { + if (!logsFollowRef.current) return + + const el = logsScrollRef.current + + if (!el) return + + // Defer until after the
 text paints, otherwise scrollHeight is stale.
+    const id = window.requestAnimationFrame(() => {
+      if (!logsFollowRef.current || !logsScrollRef.current) return
+      logsScrollRef.current.scrollTop = logsScrollRef.current.scrollHeight
+    })
+
+    return () => window.cancelAnimationFrame(id)
+  }, [runnerLogs])
+
   React.useEffect(() => {
     const handle = setTimeout(() => {
       setDebouncedHostname(hostnameInput)
@@ -83,12 +144,14 @@ const OrionClientPage: PageWithLayout = () => {
   }, [currentPage, debouncedHostname, perPage, statusFilter])
 
   const handleRefresh = React.useCallback(() => {
+    if (showingLogs) return
+
     mutate(requestPayload, {
       onSuccess: (data) => {
         setClientsPage(data)
       }
     })
-  }, [mutate, requestPayload])
+  }, [mutate, requestPayload, showingLogs])
 
   React.useEffect(() => {
     if (!runnerStatus) return
@@ -98,11 +161,41 @@ const OrionClientPage: PageWithLayout = () => {
     }
   }, [runnerStatus])
 
+  // Do not refresh the client list while the log panel is open.
   React.useEffect(() => {
+    if (showingLogs) return
     if (runnerStatus?.phase === 'running') {
       handleRefresh()
     }
-  }, [runnerStatus?.phase, handleRefresh])
+  }, [runnerStatus?.phase, handleRefresh, showingLogs])
+
+  const openLogPanel = React.useCallback(
+    (
+      key: string,
+      source: LogPanelSource,
+      opts?: { domain?: string | null; clientId?: string | null; phase?: string | null }
+    ) => {
+      setActiveLogKey(key)
+      setLogSource(source)
+      setActiveDomain(opts?.domain ?? null)
+      setLogClientId(opts?.clientId ?? null)
+      setActivePhase(opts?.phase ?? null)
+      logsFollowRef.current = true
+      requestAnimationFrame(() => {
+        logsScrollRef.current?.focus({ preventScroll: true })
+        logPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+      })
+    },
+    []
+  )
+
+  const handleCloseLogs = React.useCallback(() => {
+    setActiveLogKey(null)
+    setLogSource(null)
+    setActiveDomain(null)
+    setLogClientId(null)
+    setActivePhase(null)
+  }, [])
 
   const handleStartRunner = React.useCallback(
     (replace = false) => {
@@ -110,23 +203,72 @@ const OrionClientPage: PageWithLayout = () => {
         { replace },
         {
           onSuccess: (data) => {
-            setActiveVmId(data.vm_id)
-            setActivePhase(data.phase)
-            setActiveDomain(data.domain ?? null)
+            openLogPanel(data.vm_id, 'runner', {
+              domain: data.domain ?? null,
+              phase: data.phase
+            })
           }
         }
       )
     },
-    [startRunner]
+    [openLogPanel, startRunner]
+  )
+
+  const handleViewClientLogs = React.useCallback(
+    (client: OrionClient) => {
+      const domain = domainFromClientHostname(client.hostname)
+
+      if (!domain) {
+        return
+      }
+
+      openLogPanel(domain, 'client', { domain, clientId: client.client_id })
+    },
+    [openLogPanel]
+  )
+
+  const handleLogsKeyDown = React.useCallback(
+    async (e: React.KeyboardEvent) => {
+      const meta = e.metaKey || e.ctrlKey
+
+      if (!meta) return
+
+      if (e.key === 'a' || e.key === 'A') {
+        e.preventDefault()
+        const pre = logsPreRef.current
+
+        if (!pre) return
+        const selection = window.getSelection()
+        const range = document.createRange()
+
+        range.selectNodeContents(pre)
+        selection?.removeAllRanges()
+        selection?.addRange(range)
+        return
+      }
+
+      if (e.key === 'c' || e.key === 'C') {
+        const selection = window.getSelection()?.toString() ?? ''
+        const text = selection || runnerLogsRef.current
+
+        if (!text) return
+        e.preventDefault()
+        await copyLogsToClipboard(text)
+      }
+    },
+    [copyLogsToClipboard]
   )
 
+  // Fetch client list only while the log panel is closed.
   React.useEffect(() => {
+    if (showingLogs) return
+
     mutate(requestPayload, {
       onSuccess: (data) => {
         setClientsPage(data)
       }
     })
-  }, [mutate, requestPayload])
+  }, [mutate, requestPayload, showingLogs])
 
   const total = clientsPage?.total ?? 0
 
@@ -159,51 +301,73 @@ const OrionClientPage: PageWithLayout = () => {
       
         Orion Client
       
-      
-
+ {/* AppLayout main is overflow-hidden; this page must own scrolling when the list is visible. */} +
+

Orion Clients

- - Total clients {total} - + {!showingLogs ? ( + + Total clients {total} + + ) : null}
{isAdmin ? ( ) : null} -
- {activeVmId ? ( -
- - Runner {activeVmId} - + {showingLogs ? ( +
+
+
+ + {logSource === 'client' && logClientId ? `Client ${logClientId}` : `Runner ${activeLogKey}`} + + {logSource === 'client' ? ( + + Streaming scheduler logs for domain {activeDomain ?? activeLogKey} + + ) : null} +
+ +
{(runnerStatus?.domain ?? activeDomain) ? ( Domain: {runnerStatus?.domain ?? activeDomain} ) : null} - - Phase:{' '} - {runnerStatus?.phase ?? activePhase ?? 'unknown'} - + {logSource === 'runner' ? ( + + Phase:{' '} + {runnerStatus?.phase ?? activePhase ?? 'unknown'} + + ) : null} {runnerStatus?.vm_ip ? ( VM IP: {runnerStatus.vm_ip} @@ -219,103 +383,151 @@ const OrionClientPage: PageWithLayout = () => { {runnerStatus.error} ) : null} - {runnerStatus?.phase === 'failed' ? ( + {logSource === 'runner' && runnerStatus?.phase === 'failed' ? ( ) : null}
-
+
- Startup logs - - - {runnerLogsStatus === 'connecting' - ? 'Connecting…' - : runnerLogsStatus === 'streaming' - ? 'Live' - : runnerLogsStatus === 'error' - ? 'Disconnected' - : 'Idle'} + {logSource === 'client' ? 'Runner logs' : 'Startup logs'} +
+ + {runnerLogsStatus === 'connecting' + ? 'Connecting…' + : runnerLogsStatus === 'streaming' + ? 'Live' + : runnerLogsStatus === 'error' + ? 'Disconnected' + : 'Idle'} + + {runnerLogs ? ( + + ) : null} +
{runnerLogsError ? ( {runnerLogsError} ) : null} -
-                  {runnerLogs ||
-                    (runnerLogsStatus === 'connecting'
-                      ? 'Waiting for log stream…'
-                      : 'No log lines yet. Logs appear while the runner provisions.')}
-                
+
{ + // Stop auto-follow as soon as the user scrolls up. + if (e.deltaY < 0) { + logsFollowRef.current = false + } + }} + onScroll={(e) => { + const el = e.currentTarget + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight + + logsFollowRef.current = distanceFromBottom < 40 + }} + style={{ height: 320, maxHeight: 320, overflowY: 'auto', overflowX: 'auto' }} + className='w-full cursor-text select-text rounded border border-gray-200 bg-black/90 outline-none focus:ring-2 focus:ring-blue-500/40 dark:border-gray-700' + > +
+                    {runnerLogs ||
+                      (runnerLogsStatus === 'connecting'
+                        ? 'Waiting for log stream…'
+                        : 'No log lines yet. Logs appear while the runner is running.')}
+                  
+
+ + Scroll inside the box to browse. ⌘/Ctrl+A select all, ⌘/Ctrl+C copy. Scroll to bottom to resume live + follow. +
) : null} -
+ {!showingLogs ?
: null}
-
-
- - +
+
+ + + +
+ setHostnameInput(e.target.value)} + placeholder='Search by Hostname' + className='w-full flex-1 border-none bg-transparent text-sm text-gray-700 outline-none ring-0 placeholder:text-gray-400 focus:outline-none focus:ring-0 dark:text-gray-100 dark:placeholder:text-gray-500' /> - -
- setHostnameInput(e.target.value)} - placeholder='Search by Hostname' - className='w-full flex-1 border-none bg-transparent text-sm text-gray-700 outline-none ring-0 placeholder:text-gray-400 focus:outline-none focus:ring-0 dark:text-gray-100 dark:placeholder:text-gray-500' - /> -
- - setStatusFilter(value)} - statusOptions={[ - { value: 'all', label: 'All statuses' }, - { value: 'idle', label: 'Idle' }, - { value: 'busy', label: 'Busy' }, - { value: 'downloading', label: '\u00A0\u00A0Downloading source' }, - { value: 'running', label: '\u00A0\u00A0Running build' }, - { value: 'error', label: 'Error' }, - { value: 'offline', label: 'Lost / Offline' } - ]} - /> - - {error ? ( - - Failed to load Orion clients: {error.message} - - ) : null} +
- {pageCount > 1 ? ( -
- setCurrentPage(page)} + setStatusFilter(value)} + canViewLogs={isAdmin} + onViewLogs={handleViewClientLogs} + statusOptions={[ + { value: 'all', label: 'All statuses' }, + { value: 'idle', label: 'Idle' }, + { value: 'busy', label: 'Busy' }, + { value: 'downloading', label: '\u00A0\u00A0Downloading source' }, + { value: 'running', label: '\u00A0\u00A0Running build' }, + { value: 'error', label: 'Error' }, + { value: 'offline', label: 'Lost / Offline' } + ]} /> -
+ + {error ? ( + + Failed to load Orion clients: {error.message} + + ) : null} + + {pageCount > 1 ? ( +
+ setCurrentPage(page)} + /> +
+ ) : null} + ) : null}
diff --git a/moon/packages/types/generated.ts b/moon/packages/types/generated.ts index 9bd8e9a1c..49b138712 100644 --- a/moon/packages/types/generated.ts +++ b/moon/packages/types/generated.ts @@ -4959,6 +4959,11 @@ export type EditFilePayload = { content: string /** force create new cl */ mode?: EditCLMode + /** + * Optional destination path for same-directory rename (e.g. "/project/dir/renamed.rs"). + * Must share the same parent directory as `path`. + */ + new_path?: string | null /** Full file path like "/project/dir/file.rs" */ path: string /** if true, skip build */ diff --git a/moon/script/gen-client b/moon/script/gen-client old mode 100644 new mode 100755 index e2970e9d2..b6d496650 --- a/moon/script/gen-client +++ b/moon/script/gen-client @@ -4,6 +4,22 @@ set -eou pipefail OPENAPI_FILE_PATH=api/gen/openapi_schema.json SWAGGER_FILE_PATH=api/gen/merged_swagger.json +GITMONO_FILE_PATH=api/gen/gitmono.json +GITMONO_OPENAPI_URL="${GITMONO_OPENAPI_URL:-http://localhost:8000/api/openapi.json}" + +# Fetch mono OpenAPI, pretty-print, and replace the checked-in dump +echo "Fetching OpenAPI from ${GITMONO_OPENAPI_URL} ..." +tmp_openapi="$(mktemp)" +trap 'rm -f "${tmp_openapi}"' EXIT +curl -fsS "${GITMONO_OPENAPI_URL}" -o "${tmp_openapi}" +node -e " +const fs = require('fs'); +const src = process.argv[1]; +const dest = process.argv[2]; +const json = JSON.parse(fs.readFileSync(src, 'utf8')); +fs.writeFileSync(dest, JSON.stringify(json, null, 2) + '\n'); +console.log('Updated ' + dest); +" "${tmp_openapi}" "${GITMONO_FILE_PATH}" # swagger-typescript-api does not work with ES Modules which is required for our prettier v3 plugins # and swagger-typescript-api automatically detects and runs prettier on generated files diff --git a/orion-scheduler/src/handlers.rs b/orion-scheduler/src/handlers.rs index dd3143185..092ef850e 100644 --- a/orion-scheduler/src/handlers.rs +++ b/orion-scheduler/src/handlers.rs @@ -829,6 +829,10 @@ fn hash_line(line: &str) -> u64 { /// First tick sends the last `INITIAL_TAIL_LINES` lines, then only newly /// appended lines on each subsequent tick. /// Multi-VM: pass `?domain=` or `?vm_id=` to select which runner's logs to stream. +/// +/// While the selected VM is still provisioning (no machine handle yet), the +/// stream emits a single waiting line instead of repeating "No running VM" +/// errors every tick. pub async fn logs_stream_handler( State(state): State>, Query(q): Query, @@ -838,6 +842,8 @@ pub async fn logs_stream_handler( let mut ticker = interval(std::time::Duration::from_secs(1)); let mut journal_cursor = LogCursor::default(); let mut orion_log_offset: u64 = 0; + let mut waiting_announced = false; + let mut failure_announced = false; loop { ticker.tick().await; @@ -849,8 +855,48 @@ pub async fn logs_stream_handler( ) .await { - Ok(snapshot) => snapshot, + Ok(snapshot) => { + waiting_announced = false; + failure_announced = false; + snapshot + } Err(e) => { + let msg = e.to_string(); + if is_vm_not_ready_error(&msg) { + match orion_deployer::get_status_by_key(&state, key.as_deref()).await { + Some(vm) if vm.phase == VmPhase::Provisioning => { + if !waiting_announced { + waiting_announced = true; + yield Ok(Event::default().data(format!( + "Waiting for VM {} to finish provisioning…", + vm.id + ))); + } + } + Some(vm) if vm.phase == VmPhase::Failed => { + if !failure_announced { + failure_announced = true; + let detail = vm + .error + .unwrap_or_else(|| "unknown error".to_string()); + yield Ok(Event::default().data(format!( + "VM {} failed: {}", + vm.id, detail + ))); + } + } + Some(_) | None => { + if !waiting_announced { + waiting_announced = true; + yield Ok(Event::default().data( + "Waiting for VM to become available…", + )); + } + } + } + continue; + } + yield Ok(Event::default().data(format!("Error: {}", e))); continue; } @@ -880,16 +926,32 @@ pub async fn logs_stream_handler( Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default()) } +fn is_vm_not_ready_error(msg: &str) -> bool { + msg.contains("No running VM for key") + || msg.contains("No VM is currently running") + || msg.contains("No VM machine handle available") +} + /// Append a log section with a title header and colored log lines to `output`. fn append_logs_section(output: &mut String, title: &str, lines: &[&str]) { use std::fmt::Write; - let _ = writeln!(output, "\n─── {} ───", title); + let mut wrote_any = false; for line in lines { let trimmed = line.trim(); - if trimmed.is_empty() { + if trimmed.is_empty() || is_noisy_orion_log_line(trimmed) { continue; } + if !wrote_any { + let _ = writeln!(output, "\n─── {} ───", title); + wrote_any = true; + } output.push_str(&format_log_line(trimmed)); output.push('\n'); } } + +/// Drop high-frequency routine lines that drown out useful startup/build output. +fn is_noisy_orion_log_line(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("sending heartbeat") || lower.contains("orion::ws: sending heartbeat") +} diff --git a/orion-scheduler/src/orion_deployer.rs b/orion-scheduler/src/orion_deployer.rs index c94ca4ede..b2f6895aa 100644 --- a/orion-scheduler/src/orion_deployer.rs +++ b/orion-scheduler/src/orion_deployer.rs @@ -336,6 +336,15 @@ pub async fn get_status_by_domain(state: &AppState, domain: &str) -> Option) -> Option { + let key = domain_or_vm_id?; + if let Some(vm) = state.get_vm_by_domain(key).await { + return Some(vm); + } + state.get_vm_by_id(key).await +} + pub async fn get_scorpio_status( state: &AppState, domain_or_vm_id: Option<&str>, diff --git a/orion/src/antares.rs b/orion/src/antares.rs index cbcd12fc2..4f7d94ae4 100644 --- a/orion/src/antares.rs +++ b/orion/src/antares.rs @@ -165,6 +165,8 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to /// # Arguments /// * `job_id` - Unique identifier for this build job /// * `cl` - Optional changelist layer name + /// * `cl_path` - Optional CL directory under the Buck root (e.g. `project/dagrs-derive`) + /// used to rebase CL-relative `files-list` paths onto the monorepo overlay. /// /// # Returns /// The `AntaresConfig` containing mountpoint and job metadata on success. @@ -172,16 +174,18 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to job_id: &str, repo: &str, cl: Option<&str>, + cl_path: Option<&str>, ) -> Result { tracing::debug!( - "Mounting Antares job: job_id={}, repo={}, cl={:?}", + "Mounting Antares job: job_id={}, repo={}, cl={:?}, cl_path={:?}", job_id, repo, - cl + cl, + cl_path ); if let Some(cl_link) = cl { - return mount_job_with_prepopulated_cl(job_id, repo, cl_link).await; + return mount_job_with_prepopulated_cl(job_id, repo, cl_link, cl_path).await; } let mountpoint = AntaresPaths::from_global_config().mount_root.join(job_id); @@ -227,6 +231,7 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to job_id: &str, repo: &str, cl_link: &str, + cl_path: Option<&str>, ) -> Result { let manager = get_manager().await?; let paths = AntaresPaths::from_global_config(); @@ -241,6 +246,7 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to job_id = job_id, repo = repo, cl_link = cl_link, + cl_path = ?cl_path, upper_id = %upper_id, cl_id = %cl_id, upper_dir = %upper_dir.display(), @@ -260,11 +266,12 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to job_id = job_id, repo = repo, cl_link = cl_link, + cl_path = ?cl_path, cl_dir = %cl_dir.display(), "DEBUG: About to populate CL overlay directory" ); - populate_cl_overlay_dir(job_id, repo, cl_link, &cl_dir).await?; + populate_cl_overlay_dir(job_id, repo, cl_link, cl_path, &cl_dir).await?; // DEBUG: Verify CL overlay directory contents after population match tokio::fs::read_dir(&cl_dir).await { @@ -639,14 +646,114 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to Ok(body.data.unwrap_or_default()) } - fn resolve_overlay_relative_path(repo: &str, entry_path: &str) -> Result { + /// Rebase a CL-relative file path onto the Buck root when `cl_path` is a + /// subdirectory of `repo` (same semantics as Mega `rebase_cl_relative_path`). + /// + /// Examples (`repo=/`, `cl_path=/project/dagrs-derive`): + /// - `src/lib.rs` → `project/dagrs-derive/src/lib.rs` + /// - `project/dagrs-derive/BUCK` → unchanged (already prefixed) + fn rebase_cl_relative_path(repo: &str, cl_path: &str, file: &str) -> String { + let repo = repo.trim_matches('/'); + let cl = cl_path.trim_matches('/'); + let file_str = file.trim().trim_start_matches('/').replace('\\', "/"); + + let cl_rel = if repo.is_empty() { + if cl.is_empty() { + return file_str; + } + cl.to_string() + } else if cl == repo { + return file_str; + } else if let Some(stripped) = cl.strip_prefix(&format!("{repo}/")) { + stripped.to_string() + } else { + return file_str; + }; + + if cl_rel.is_empty() { + return file_str; + } + + if file_str == cl_rel || file_str.starts_with(&format!("{cl_rel}/")) { + return file_str; + } + + format!("{cl_rel}/{file_str}") + } + + /// Longest common path-component prefix of `paths`, dropping a trailing + /// component when the prefix equals any full path (i.e. captured a filename). + fn common_dir_prefix<'a, I>(paths: I) -> Option + where + I: IntoIterator, + { + let split: Vec> = paths + .into_iter() + .map(|p| { + p.trim_matches('/') + .split('/') + .filter(|s| !s.is_empty()) + .collect::>() + }) + .filter(|parts| !parts.is_empty()) + .collect(); + if split.is_empty() { + return None; + } + + let mut prefix = split[0].clone(); + for parts in &split[1..] { + let n = prefix + .iter() + .zip(parts.iter()) + .take_while(|(a, b)| a == b) + .count(); + prefix.truncate(n); + if prefix.is_empty() { + return None; + } + } + + // If the LCP is exactly one of the input paths, it included a filename. + while !prefix.is_empty() && split.contains(&prefix) { + prefix.pop(); + } + if prefix.is_empty() { + return None; + } + Some(prefix.join("/")) + } + + /// Infer CL directory for overlay rebase from task change paths. + /// + /// Only when `repo` is the monorepo root (`/` / empty): take the longest + /// common directory prefix of `change_paths` (e.g. `project/dagrs-derive`). + /// For a registered sub-repo, return `None` and let repo-prefix logic apply. + pub fn infer_cl_path_from_changes(repo: &str, change_paths: &[&str]) -> Option { + if !repo.trim_matches('/').is_empty() { + return None; + } + common_dir_prefix(change_paths.iter().copied()) + } + + fn resolve_overlay_relative_path( + repo: &str, + entry_path: &str, + cl_path: Option<&str>, + ) -> Result { + let rebased = match cl_path { + Some(cl) if !cl.trim().is_empty() => rebase_cl_relative_path(repo, cl, entry_path), + _ => entry_path.trim().trim_start_matches('/').replace('\\', "/"), + }; + let repo_prefix = repo.trim_matches('/'); - let trimmed_entry = entry_path.trim().trim_start_matches('/'); + let trimmed_entry = rebased.trim().trim_start_matches('/'); // DEBUG: Log path resolution inputs tracing::debug!( repo = %repo, entry_path = %entry_path, + cl_path = ?cl_path, repo_prefix = %repo_prefix, trimmed_entry = %trimmed_entry, "DEBUG: resolve_overlay_relative_path inputs" @@ -686,7 +793,7 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to } Err(Box::new(io_other(format!( - "Rejected unsafe CL overlay path: repo={repo}, entry_path={entry_path}, relative={relative}" + "Rejected unsafe CL overlay path: repo={repo}, entry_path={entry_path}, cl_path={cl_path:?}, relative={relative}" )))) } @@ -774,6 +881,7 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to job_id: &str, repo: &str, cl_link: &str, + cl_path: Option<&str>, cl_dir: &Path, ) -> Result<(), DynError> { if cl_dir.exists() { @@ -805,7 +913,7 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to let client = http_client()?; let mut applied_paths = Vec::new(); for file in files { - let overlay_path = resolve_overlay_relative_path(repo, &file.path)?; + let overlay_path = resolve_overlay_relative_path(repo, &file.path, cl_path)?; match file.action.as_str() { "new" | "modified" => { download_blob_to_path(&client, &file.sha, &cl_dir.join(&overlay_path)).await?; @@ -837,6 +945,7 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to job_id = job_id, repo = repo, cl_link = cl_link, + cl_path = ?cl_path, cl_dir = %cl_dir.display(), applied_file_count = applied_paths.len(), applied_files = ?applied_paths, @@ -1090,8 +1199,9 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to use tempfile::tempdir; use super::{ - fusermount_output_indicates_safe_detach, panic_payload_to_string, - remove_mountpoint_path, resolve_overlay_relative_path, run_with_panic_guard, + fusermount_output_indicates_safe_detach, infer_cl_path_from_changes, + panic_payload_to_string, rebase_cl_relative_path, remove_mountpoint_path, + resolve_overlay_relative_path, run_with_panic_guard, }; #[tokio::test] @@ -1117,7 +1227,7 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to #[test] fn test_resolve_overlay_relative_path_prefixes_repo_relative_path() { - let path = resolve_overlay_relative_path("/project/buck2_test", "src/main.rs") + let path = resolve_overlay_relative_path("/project/buck2_test", "src/main.rs", None) .expect("path should resolve"); assert_eq!(path.to_string_lossy(), "project/buck2_test/src/main.rs"); } @@ -1127,6 +1237,7 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to let path = resolve_overlay_relative_path( "/project/buck2_test", "project/buck2_test/toolchains/BUCK", + None, ) .expect("path should resolve"); assert_eq!(path.to_string_lossy(), "project/buck2_test/toolchains/BUCK"); @@ -1134,7 +1245,62 @@ Hint: set SCORPIO_CONFIG=/path/to/scorpio.toml or create /etc/scorpio/scorpio.to #[test] fn test_resolve_overlay_relative_path_rejects_escape_sequences() { - assert!(resolve_overlay_relative_path("/project/buck2_test", "../etc/passwd").is_err()); + assert!( + resolve_overlay_relative_path("/project/buck2_test", "../etc/passwd", None) + .is_err() + ); + } + + #[test] + fn test_rebase_cl_relative_path_prefixes_monorepo_subdir_cl() { + assert_eq!( + rebase_cl_relative_path("/", "/project/dagrs-derive", "src/lib.rs"), + "project/dagrs-derive/src/lib.rs" + ); + assert_eq!( + rebase_cl_relative_path("/", "/project/dagrs-derive", "project/dagrs-derive/BUCK"), + "project/dagrs-derive/BUCK" + ); + } + + #[test] + fn test_resolve_overlay_rebases_cl_path_under_monorepo_root() { + let path = + resolve_overlay_relative_path("/", "src/lib.rs", Some("project/dagrs-derive")) + .expect("path should resolve"); + assert_eq!(path.to_string_lossy(), "project/dagrs-derive/src/lib.rs"); + } + + #[test] + fn test_resolve_overlay_no_double_prefix_when_already_rebased() { + let path = resolve_overlay_relative_path( + "/", + "project/dagrs-derive/BUCK", + Some("/project/dagrs-derive"), + ) + .expect("path should resolve"); + assert_eq!(path.to_string_lossy(), "project/dagrs-derive/BUCK"); + } + + #[test] + fn test_infer_cl_path_from_changes_monorepo_root_only() { + let changes = [ + "project/dagrs-derive/BUCK", + "project/dagrs-derive/src/lib.rs", + "project/dagrs-derive/Cargo.toml", + ]; + assert_eq!( + infer_cl_path_from_changes("/", &changes).as_deref(), + Some("project/dagrs-derive") + ); + assert_eq!( + infer_cl_path_from_changes("", &changes).as_deref(), + Some("project/dagrs-derive") + ); + assert_eq!( + infer_cl_path_from_changes("/project/buck2_test", &["src/main.rs"]).as_deref(), + None + ); } #[tokio::test] @@ -1198,12 +1364,18 @@ mod imp { _job_id: &str, _repo: &str, _cl: Option<&str>, + _cl_path: Option<&str>, ) -> Result { Err(Box::new(std::io::Error::other( "Antares/scorpiofs is only supported on Linux", ))) } + /// Infer CL directory for overlay rebase (stub: always `None` off Linux). + pub fn infer_cl_path_from_changes(_repo: &str, _change_paths: &[&str]) -> Option { + None + } + /// Unmounting Antares requires `scorpiofs` (Linux-only in this repository). #[allow(dead_code)] pub async fn unmount_job(_job_id: &str) -> Result, DynError> { diff --git a/orion/src/buck_controller.rs b/orion/src/buck_controller.rs index 556b4fe26..e640f0830 100644 --- a/orion/src/buck_controller.rs +++ b/orion/src/buck_controller.rs @@ -200,6 +200,7 @@ async fn unmount_discovery_old_mount(task_id: &str, mounts: &mut AntaresMountPai /// # Arguments /// * `job_id` - Unique identifier for this build job /// * `cl` - Optional changelist layer name +/// * `cl_path` - Optional CL directory under the Buck root for overlay path rebase /// /// # Returns /// Returns a tuple `(mountpoint, mount_id)` on success. @@ -207,15 +208,17 @@ pub async fn mount_antares_fs( job_id: &str, repo: &str, cl: Option<&str>, + cl_path: Option<&str>, ) -> Result<(String, String), Box> { tracing::debug!( - "Preparing to mount Antares FS: job_id={}, repo={}, cl={:?}", + "Preparing to mount Antares FS: job_id={}, repo={}, cl={:?}, cl_path={:?}", job_id, repo, - cl + cl, + cl_path ); - let config = crate::antares::mount_job(job_id, repo, cl).await?; + let config = crate::antares::mount_job(job_id, repo, cl, cl_path).await?; let mountpoint = config.mountpoint.to_string_lossy().to_string(); let mount_id = config.job_id.clone(); @@ -1705,7 +1708,7 @@ pub async fn build( // naturally get separate daemons without needing `--isolation-dir`. let id_for_old_repo = format!("{id}-old-{attempt}"); let (old_repo_mount_point, _mount_id_old_repo) = - match mount_antares_fs(&id_for_old_repo, &repo, None).await { + match mount_antares_fs(&id_for_old_repo, &repo, None, None).await { Ok(mount) => mount, Err(err) => { cleanup_antares_mount( @@ -1719,28 +1722,43 @@ pub async fn build( } }; + let change_paths: Vec<&str> = changes.iter().map(|c| c.get().as_str()).collect(); + let inferred_cl_path = crate::antares::infer_cl_path_from_changes(&repo, &change_paths); + if let Some(ref cl_path) = inferred_cl_path { + tracing::info!( + cl_path = %cl_path, + "Inferred CL overlay path prefix from task changes (monorepo root)." + ); + } + let id_for_repo = format!("{id}-{attempt}"); - let (repo_mount_point, _mount_id) = - match mount_antares_fs(&id_for_repo, &repo, cl_arg).await { - Ok(mount) => mount, - Err(err) => { - cleanup_antares_mount( - &id, - &id_for_old_repo, - Some(&old_repo_mount_point), - "cleanup old-repo mount after failed new-repo mount", - ) - .await; - cleanup_antares_mount( - &id, - &id_for_repo, - None, - "cleanup after failed new-repo mount", - ) - .await; - return Err(err); - } - }; + let (repo_mount_point, _mount_id) = match mount_antares_fs( + &id_for_repo, + &repo, + cl_arg, + inferred_cl_path.as_deref(), + ) + .await + { + Ok(mount) => mount, + Err(err) => { + cleanup_antares_mount( + &id, + &id_for_old_repo, + Some(&old_repo_mount_point), + "cleanup old-repo mount after failed new-repo mount", + ) + .await; + cleanup_antares_mount( + &id, + &id_for_repo, + None, + "cleanup after failed new-repo mount", + ) + .await; + return Err(err); + } + }; let attempt_mounts = AntaresMountPair { old_mount_id: id_for_old_repo, diff --git a/orion/src/ws.rs b/orion/src/ws.rs index ebce6e57d..cc38aab32 100644 --- a/orion/src/ws.rs +++ b/orion/src/ws.rs @@ -296,7 +296,7 @@ async fn handle_connection( tokio::select! { biased; _ = heartbeat_interval.tick() => { - tracing::info!("Sending heartbeat..."); + tracing::debug!("Sending heartbeat..."); match serde_json::to_string(&WSMessage::Heartbeat) { Ok(payload) => { if let Err(e) = ws_sender.send(Message::Text(payload.into())).await { diff --git a/scripts/demo/build-demo-images-local.sh b/scripts/demo/build-demo-images-local.sh index 6f3f7df7a..b90795c83 100644 --- a/scripts/demo/build-demo-images-local.sh +++ b/scripts/demo/build-demo-images-local.sh @@ -6,12 +6,13 @@ set -euo pipefail # ============================================================================ # This script builds Docker images for the demo environment on your local machine. # The script automatically detects the platform (arm64/amd64) based on your machine. -# By default, images are only built locally. Use --push to push to AWS ECR. +# By default, images are only built locally. Use --push to push to AWS ECR and Harbor. # # Prerequisites: # - Docker Desktop with Buildx enabled # - (Optional) AWS CLI configured with credentials (only needed for --push) # - (Optional) Access to Amazon ECR Public (only needed for --push) +# - (Optional) docker login registry.xuanwu.openatom.cn (only needed for --push) # - QEMU (usually auto-installed by Docker Desktop) # # Usage: @@ -25,8 +26,8 @@ set -euo pipefail # ./scripts/demo/build-demo-images-local.sh --prune-buildkit # Build and prune buildkit cache after build # # If IMAGE_NAME is provided, only that image will be built. -# Otherwise, all 4 images will be built. -# Use --push flag to push images to AWS ECR (requires AWS credentials). +# Otherwise, all images will be built. +# Use --push to push to ECR Public and Harbor (registry.xuanwu.openatom.cn). # ============================================================================ # Colors for output @@ -39,6 +40,7 @@ NC='\033[0m' # No Color REGISTRY_ALIAS="m8q5m4u3" REPOSITORY="mega" REGISTRY="public.ecr.aws" +HARBOR_REGISTRY="${HARBOR_REGISTRY:-registry.xuanwu.openatom.cn}" SHOULD_PUSH=false # Default: only build, don't push SHOULD_PRUNE_BUILDKIT=false # Optional: prune buildkit cache after build BUILDX_BUILDER_ARGS=() @@ -315,6 +317,7 @@ build_and_push() { fi local image_tag_with_arch="${image_tag}-${arch_suffix}" local image_repo="${REGISTRY}/${REGISTRY_ALIAS}/${REPOSITORY}/${image_name}" + local harbor_repo="${HARBOR_REGISTRY}/${REPOSITORY}/${image_name}" # Verify paths exist (use absolute paths) local full_dockerfile="${REPO_ROOT}/${dockerfile_path}" @@ -395,6 +398,7 @@ build_and_push() { --platform "${TARGET_PLATFORMS}" --file "${dockerfile_path}" --tag "${image_repo}:${image_tag_with_arch}" + --tag "${harbor_repo}:${image_tag_with_arch}" --progress=auto --build-arg BUILDKIT_INLINE_CACHE=1 ) @@ -428,14 +432,21 @@ build_and_push() { # Push if requested (ensures single-arch manifest is uploaded) if [ "$SHOULD_PUSH" = "true" ]; then if ! docker push "${image_repo}:${image_tag_with_arch}"; then - log_error "Failed to push ${image_name}" + log_error "Failed to push ${image_name} to ECR" + return 1 + fi + if ! docker push "${harbor_repo}:${image_tag_with_arch}"; then + log_error "Failed to push ${image_name} to Harbor (${harbor_repo})" + log_error "Login first: docker login ${HARBOR_REGISTRY}" return 1 fi log_info "${image_name} built and pushed successfully ✓" - log_info " Image: ${image_repo}:${image_tag_with_arch}" + log_info " ECR: ${image_repo}:${image_tag_with_arch}" + log_info " Harbor: ${harbor_repo}:${image_tag_with_arch}" else log_info "${image_name} built successfully ✓" log_info " Image: ${image_repo}:${image_tag_with_arch} (local only)" + log_info " Harbor tag also applied: ${harbor_repo}:${image_tag_with_arch}" fi return 0 } diff --git a/scripts/init_mega/README.md b/scripts/init_mega/README.md index 259e7ec4f..f3d018583 100644 --- a/scripts/init_mega/README.md +++ b/scripts/init_mega/README.md @@ -73,12 +73,13 @@ In a temporary directory, it performs: 2. Configure the commit identity (repo-local): - `git config user.email mega-bot@example.com` - `git config user.name Mega Bot` + - `git config commit.gpgsign false` (and `git commit --no-gpg-sign`) so a global `commit.gpgsign=true` does not fail without a Mega Bot GPG key 3. Clone `buckal-bundles` inside the `toolchains` repo: - `git clone --depth 1 https://github.com/buck2hub/buckal-bundles.git` 4. Remove `buckal-bundles/.git` so it becomes a regular directory tracked by `toolchains` (vendoring). 5. Commit and push: - `git add .` - - `git commit -m "import buckal-bundles"` + - `git commit --no-gpg-sign -m "import buckal-bundles"` (skipped if already imported / no changes) - `git push` 6. Use Mega APIs to find and merge the corresponding CL: - `POST {base_url}/api/v1/cl/list` (paginate open CLs and match `title == "import buckal-bundles"`) diff --git a/scripts/init_mega/init_mega.py b/scripts/init_mega/init_mega.py index 6cf6640cd..4447d5444 100644 --- a/scripts/init_mega/init_mega.py +++ b/scripts/init_mega/init_mega.py @@ -53,8 +53,11 @@ def api_request(method, url, data=None, headers=None): return json.loads(resp_body) if resp_body else {} else: raise RuntimeError(f"API request failed with status {response.status}: {resp_body}") + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", errors="replace") + raise RuntimeError(f"API request to {url} failed: HTTP Error {e.code}: {e.reason}; body={body}") from e except Exception as e: - raise RuntimeError(f"API request to {url} failed: {e}") + raise RuntimeError(f"API request to {url} failed: {e}") from e def wait_for_server(base_url, timeout=60): """Waits for the Mega server to be ready.""" @@ -121,7 +124,18 @@ def merge_cl(base_url, link, timeout=60): else: print(f"Merge pending: {resp.get('err_message')}") except Exception as e: - print(f"Merge attempt failed: {e}") + # urllib HTTPError body often has the real reason (e.g. CL ref not found). + detail = "" + if hasattr(e, "__cause__") and e.__cause__ is not None: + cause = e.__cause__ + if hasattr(cause, "read"): + try: + detail = cause.read().decode("utf-8", errors="replace") + except Exception: + detail = str(cause) + else: + detail = str(cause) + print(f"Merge attempt failed: {e}" + (f" body={detail}" if detail else "")) time.sleep(2) @@ -139,9 +153,11 @@ def run_buckal_bundles_workflow(base_url): toolchains_dir = temp_path / "toolchains" - # Config git + # Config git (repo-local). Disable GPG so CI/dev machines with + # commit.gpgsign=true globally do not fail without a Mega Bot key. run_git(toolchains_dir, ["config", "user.email", GIT_USER_EMAIL]) run_git(toolchains_dir, ["config", "user.name", GIT_USER_NAME]) + run_git(toolchains_dir, ["config", "commit.gpgsign", "false"]) # Clone buckal-bundles inside print("Importing buckal-bundles...") @@ -157,7 +173,12 @@ def run_buckal_bundles_workflow(base_url): # Commit and push run_git(toolchains_dir, ["add", "."]) - run_git(toolchains_dir, ["commit", "-m", COMMIT_MSG]) + status = run_git(toolchains_dir, ["status", "--porcelain"], check=False) + if not status.stdout.strip(): + print("buckal-bundles already present with no changes; skipping commit/push/merge.") + return + + run_git(toolchains_dir, ["commit", "--no-gpg-sign", "-m", COMMIT_MSG]) run_git(toolchains_dir, ["push"]) # Handle merge request diff --git a/tmp/gha-cache-smoke/Dockerfile b/tmp/gha-cache-smoke/Dockerfile new file mode 100644 index 000000000..30994913a --- /dev/null +++ b/tmp/gha-cache-smoke/Dockerfile @@ -0,0 +1,8 @@ +# Tiny multi-layer image so BuildKit can show CACHED hits on re-import. +FROM alpine:3.20 + +RUN apk add --no-cache curl + +COPY hello.txt /hello.txt + +RUN cat /hello.txt && echo "gha-cache-smoke ok" \ No newline at end of file diff --git a/tmp/gha-cache-smoke/hello.txt b/tmp/gha-cache-smoke/hello.txt new file mode 100644 index 000000000..c70720339 --- /dev/null +++ b/tmp/gha-cache-smoke/hello.txt @@ -0,0 +1 @@ +hello from gha-cache-smoke