diff --git a/.github/workflows/depsreview.yaml b/.github/workflows/depsreview.yaml deleted file mode 100644 index b9d6d20fff4e7..0000000000000 --- a/.github/workflows/depsreview.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: 'Dependency Review' -on: [pull_request] - -permissions: - contents: read - -jobs: - dependency-review: - runs-on: ubuntu-latest - steps: - - name: 'Checkout Repository' - uses: actions/checkout@v4 - - name: 'Dependency Review' - uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/go-cross.yml b/.github/workflows/go-cross.yml deleted file mode 100644 index 324af3e956eba..0000000000000 --- a/.github/workflows/go-cross.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Crosscompile - -on: - pull_request: - branches: - - master - -# This ensures that previous jobs for the PR are canceled when the PR is -# updated. -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - build: - name: Build Tests with Go ${{ matrix.go-version }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - go-version: [1.24.x] - os: [ubuntu-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - check-latest: true - - name: Build on ${{ matrix.os }} - if: matrix.os == 'ubuntu-latest' - env: - CGO_ENABLED: 0 - GO111MODULE: on - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make crosscompile diff --git a/.github/workflows/go-healing.yml b/.github/workflows/go-healing.yml deleted file mode 100644 index 0ea2505128ca4..0000000000000 --- a/.github/workflows/go-healing.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Healing Functional Tests - -on: - pull_request: - branches: - - master - -# This ensures that previous jobs for the PR are canceled when the PR is -# updated. -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - build: - name: Go ${{ matrix.go-version }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - go-version: [1.24.x] - os: [ubuntu-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - check-latest: true - - name: Build on ${{ matrix.os }} - if: matrix.os == 'ubuntu-latest' - env: - CGO_ENABLED: 0 - GO111MODULE: on - MINIO_KMS_SECRET_KEY: "my-minio-key:oyArl7zlPECEduNbB1KXgdzDn2Bdpvvw0l8VO51HQnY=" - MINIO_KMS_AUTO_ENCRYPTION: on - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make verify-healing - make verify-healing-inconsistent-versions - make verify-healing-with-root-disks - make verify-healing-with-rewrite diff --git a/.github/workflows/go-lint.yml b/.github/workflows/go-lint.yml deleted file mode 100644 index 6b43086a7a14f..0000000000000 --- a/.github/workflows/go-lint.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Linters and Tests - -on: - pull_request: - branches: - - master - -# This ensures that previous jobs for the PR are canceled when the PR is -# updated. -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - build: - name: Go ${{ matrix.go-version }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - go-version: [1.24.x] - os: [ubuntu-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - check-latest: true - - name: Build on ${{ matrix.os }} - if: matrix.os == 'ubuntu-latest' - env: - CGO_ENABLED: 0 - GO111MODULE: on - run: | - sudo apt install jq -y - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make - make test - make test-race diff --git a/.github/workflows/go-resiliency.yml b/.github/workflows/go-resiliency.yml deleted file mode 100644 index 4eb2bbb90ba5c..0000000000000 --- a/.github/workflows/go-resiliency.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Resiliency Functional Tests - -on: - pull_request: - branches: - - master - -# This ensures that previous jobs for the PR are canceled when the PR is -# updated. -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - build: - name: Go ${{ matrix.go-version }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - go-version: [1.24.x] - os: [ubuntu-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - check-latest: true - - name: Build on ${{ matrix.os }} - if: matrix.os == 'ubuntu-latest' - env: - CGO_ENABLED: 0 - GO111MODULE: on - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make test-resiliency diff --git a/.github/workflows/helm-lint.yml b/.github/workflows/helm-lint.yml deleted file mode 100644 index f444106f1cdf0..0000000000000 --- a/.github/workflows/helm-lint.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Helm Chart linting - -on: - pull_request: - branches: - - master - -# This ensures that previous jobs for the PR are canceled when the PR is -# updated. -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install Helm - uses: azure/setup-helm@v4 - - - name: Run helm lint - run: | - cd helm/minio - helm lint . diff --git a/.github/workflows/iam-integrations.yaml b/.github/workflows/iam-integrations.yaml deleted file mode 100644 index cc57a77aa70d8..0000000000000 --- a/.github/workflows/iam-integrations.yaml +++ /dev/null @@ -1,161 +0,0 @@ -name: IAM integration - -on: - pull_request: - branches: - - master - -# This ensures that previous jobs for the PR are canceled when the PR is -# updated. -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - iam-matrix-test: - name: "[Go=${{ matrix.go-version }}|ldap=${{ matrix.ldap }}|etcd=${{ matrix.etcd }}|openid=${{ matrix.openid }}]" - runs-on: ubuntu-latest - - services: - openldap: - image: quay.io/minio/openldap - ports: - - "389:389" - - "636:636" - env: - LDAP_ORGANIZATION: "MinIO Inc" - LDAP_DOMAIN: "min.io" - LDAP_ADMIN_PASSWORD: "admin" - etcd: - image: "quay.io/coreos/etcd:v3.5.1" - env: - ETCD_LISTEN_CLIENT_URLS: "http://0.0.0.0:2379" - ETCD_ADVERTISE_CLIENT_URLS: "http://0.0.0.0:2379" - ports: - - "2379:2379" - options: >- - --health-cmd "etcdctl endpoint health" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - openid: - image: quay.io/minio/dex - ports: - - "5556:5556" - env: - DEX_LDAP_SERVER: "openldap:389" - openid2: - image: quay.io/minio/dex - ports: - - "5557:5557" - env: - DEX_LDAP_SERVER: "openldap:389" - DEX_ISSUER: "http://127.0.0.1:5557/dex" - DEX_WEB_HTTP: "0.0.0.0:5557" - - strategy: - # When ldap, etcd or openid vars are empty below, those external servers - # are turned off - i.e. if ldap="", then ldap server is not enabled for - # the tests. - matrix: - go-version: [1.24.x] - ldap: ["", "localhost:389"] - etcd: ["", "http://localhost:2379"] - openid: ["", "http://127.0.0.1:5556/dex"] - exclude: - # exclude combos where all are empty. - - ldap: "" - etcd: "" - openid: "" - # exclude combos where both ldap and openid IDPs are specified. - - ldap: "localhost:389" - openid: "http://127.0.0.1:5556/dex" - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - check-latest: true - - name: Test LDAP/OpenID/Etcd combo - env: - _MINIO_LDAP_TEST_SERVER: ${{ matrix.ldap }} - _MINIO_ETCD_TEST_SERVER: ${{ matrix.etcd }} - _MINIO_OPENID_TEST_SERVER: ${{ matrix.openid }} - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make test-iam - - name: Test with multiple OpenID providers - if: matrix.openid == 'http://127.0.0.1:5556/dex' - env: - _MINIO_LDAP_TEST_SERVER: ${{ matrix.ldap }} - _MINIO_ETCD_TEST_SERVER: ${{ matrix.etcd }} - _MINIO_OPENID_TEST_SERVER: ${{ matrix.openid }} - _MINIO_OPENID_TEST_SERVER_2: "http://127.0.0.1:5557/dex" - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make test-iam - - name: Test with Access Management Plugin enabled - env: - _MINIO_LDAP_TEST_SERVER: ${{ matrix.ldap }} - _MINIO_ETCD_TEST_SERVER: ${{ matrix.etcd }} - _MINIO_OPENID_TEST_SERVER: ${{ matrix.openid }} - _MINIO_POLICY_PLUGIN_TEST_ENDPOINT: "http://127.0.0.1:8080" - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - go run docs/iam/access-manager-plugin.go & - make test-iam - - name: Test MinIO Old Version data to IAM import current version - if: matrix.ldap == 'ldaphost:389' - env: - _MINIO_LDAP_TEST_SERVER: ${{ matrix.ldap }} - run: | - make test-iam-ldap-upgrade-import - - name: Test LDAP for automatic site replication - if: matrix.ldap == 'localhost:389' - run: | - make test-site-replication-ldap - - name: Test OIDC for automatic site replication - if: matrix.openid == 'http://127.0.0.1:5556/dex' - run: | - make test-site-replication-oidc - iam-import-with-missing-entities: - name: Test IAM import in new cluster with missing entities - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - check-latest: true - - name: Checkout minio-iam-testing - uses: actions/checkout@v4 - with: - repository: minio/minio-iam-testing - path: minio-iam-testing - - name: Test import of IAM artifacts when in fresh cluster there are missing groups etc - run: | - make test-iam-import-with-missing-entities - iam-import-with-openid: - name: Test IAM import in new cluster with opendid configurations - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - check-latest: true - - name: Checkout minio-iam-testing - uses: actions/checkout@v4 - with: - repository: minio/minio-iam-testing - path: minio-iam-testing - - name: Test import of IAM artifacts when in fresh cluster with openid configurations - run: | - make test-iam-import-with-openid diff --git a/.github/workflows/issues.yaml b/.github/workflows/issues.yaml deleted file mode 100644 index d91950aeb2b77..0000000000000 --- a/.github/workflows/issues.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# @format - -name: Issue Workflow - -on: - issues: - types: - - opened - -jobs: - add-to-project: - name: Add issue to project - runs-on: ubuntu-latest - steps: - - uses: actions/add-to-project@v0.5.0 - with: - project-url: https://github.com/orgs/miniohq/projects/2 - github-token: ${{ secrets.BOT_PAT }} diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml deleted file mode 100644 index da3afe3c4627e..0000000000000 --- a/.github/workflows/lock.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: 'Lock Threads' - -on: - schedule: - - cron: '0 0 * * *' - workflow_dispatch: - -permissions: - issues: write - -concurrency: - group: lock - -jobs: - action: - runs-on: ubuntu-latest - steps: - - uses: dessant/lock-threads@v3 - with: - github-token: ${{ github.token }} - issue-inactive-days: '365' - exclude-any-issue-labels: 'do-not-close' - issue-lock-reason: 'resolved' - log-output: true diff --git a/.github/workflows/mint.yml b/.github/workflows/mint.yml deleted file mode 100644 index 77dc498cec39e..0000000000000 --- a/.github/workflows/mint.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Mint Tests - -on: - pull_request: - branches: - - master - -# This ensures that previous jobs for the PR are canceled when the PR is -# updated. -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - mint-test: - runs-on: mint - timeout-minutes: 120 - steps: - - name: cleanup #https://github.com/actions/checkout/issues/273 - run: | - sudo -S rm -rf ${GITHUB_WORKSPACE} - mkdir ${GITHUB_WORKSPACE} - - name: checkout-step - uses: actions/checkout@v4 - - - name: setup-go-step - uses: actions/setup-go@v5 - with: - go-version: 1.24.x - - - name: github sha short - id: vars - run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - - - name: build-minio - run: | - TAG="quay.io/minio/minio:${{ steps.vars.outputs.sha_short }}" make docker - - - name: multipart uploads test - run: | - ${GITHUB_WORKSPACE}/.github/workflows/multipart/migrate.sh "${{ steps.vars.outputs.sha_short }}" - - - name: compress and encrypt - run: | - ${GITHUB_WORKSPACE}/.github/workflows/run-mint.sh "compress-encrypt" "minio" "minio123" "${{ steps.vars.outputs.sha_short }}" - - - name: multiple pools - run: | - ${GITHUB_WORKSPACE}/.github/workflows/run-mint.sh "pools" "minio" "minio123" "${{ steps.vars.outputs.sha_short }}" - - - name: standalone erasure - run: | - ${GITHUB_WORKSPACE}/.github/workflows/run-mint.sh "erasure" "minio" "minio123" "${{ steps.vars.outputs.sha_short }}" - - # FIXME: renable this back when we have a valid way to add deadlines for PUT()s (internode CreateFile) - # - name: resiliency - # run: | - # ${GITHUB_WORKSPACE}/.github/workflows/run-mint.sh "resiliency" "minio" "minio123" "${{ steps.vars.outputs.sha_short }}" - - - name: The job must cleanup - if: ${{ always() }} - run: | - export JOB_NAME=${{ steps.vars.outputs.sha_short }} - for mode in $(echo compress-encrypt pools erasure); do - docker-compose -f ${GITHUB_WORKSPACE}/.github/workflows/mint/minio-${mode}.yaml down || true - docker-compose -f ${GITHUB_WORKSPACE}/.github/workflows/mint/minio-${mode}.yaml rm || true - done - - docker-compose -f ${GITHUB_WORKSPACE}/.github/workflows/multipart/docker-compose-site1.yaml rm -s -f || true - docker-compose -f ${GITHUB_WORKSPACE}/.github/workflows/multipart/docker-compose-site2.yaml rm -s -f || true - for volume in $(docker volume ls -q | grep minio); do - docker volume rm ${volume} || true - done - - docker rmi -f quay.io/minio/minio:${{ steps.vars.outputs.sha_short }} - docker system prune -f || true - docker volume prune -f || true - docker volume rm $(docker volume ls -q -f dangling=true) || true diff --git a/.github/workflows/mint/minio-compress-encrypt.yaml b/.github/workflows/mint/minio-compress-encrypt.yaml deleted file mode 100644 index fe1238e80d3d8..0000000000000 --- a/.github/workflows/mint/minio-compress-encrypt.yaml +++ /dev/null @@ -1,80 +0,0 @@ -version: '3.7' - -# Settings and configurations that are common for all containers -x-minio-common: &minio-common - image: quay.io/minio/minio:${JOB_NAME} - command: server --console-address ":9001" http://minio{1...4}/cdata{1...2} - expose: - - "9000" - - "9001" - environment: - MINIO_CI_CD: "on" - MINIO_ROOT_USER: "minio" - MINIO_ROOT_PASSWORD: "minio123" - MINIO_COMPRESSION_ENABLE: "on" - MINIO_COMPRESSION_MIME_TYPES: "*" - MINIO_COMPRESSION_ALLOW_ENCRYPTION: "on" - MINIO_KMS_SECRET_KEY: "my-minio-key:OSMM+vkKUTCvQs9YL/CVMIMt43HFhkUpqJxTmGl6rYw=" - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 5s - timeout: 5s - retries: 5 - -# starts 4 docker containers running minio server instances. -# using nginx reverse proxy, load balancing, you can access -# it through port 9000. -services: - minio1: - <<: *minio-common - hostname: minio1 - volumes: - - cdata1-1:/cdata1 - - cdata1-2:/cdata2 - - minio2: - <<: *minio-common - hostname: minio2 - volumes: - - cdata2-1:/cdata1 - - cdata2-2:/cdata2 - - minio3: - <<: *minio-common - hostname: minio3 - volumes: - - cdata3-1:/cdata1 - - cdata3-2:/cdata2 - - minio4: - <<: *minio-common - hostname: minio4 - volumes: - - cdata4-1:/cdata1 - - cdata4-2:/cdata2 - - nginx: - image: nginx:1.19.2-alpine - hostname: nginx - volumes: - - ./nginx-4-node.conf:/etc/nginx/nginx.conf:ro - ports: - - "9000:9000" - - "9001:9001" - depends_on: - - minio1 - - minio2 - - minio3 - - minio4 - -## By default this config uses default local driver, -## For custom volumes replace with volume driver configuration. -volumes: - cdata1-1: - cdata1-2: - cdata2-1: - cdata2-2: - cdata3-1: - cdata3-2: - cdata4-1: - cdata4-2: diff --git a/.github/workflows/mint/minio-erasure.yaml b/.github/workflows/mint/minio-erasure.yaml deleted file mode 100644 index a3fd6dbc0de21..0000000000000 --- a/.github/workflows/mint/minio-erasure.yaml +++ /dev/null @@ -1,51 +0,0 @@ -version: '3.7' - -# Settings and configurations that are common for all containers -x-minio-common: &minio-common - image: quay.io/minio/minio:${JOB_NAME} - command: server --console-address ":9001" edata{1...4} - expose: - - "9000" - - "9001" - environment: - MINIO_CI_CD: "on" - MINIO_ROOT_USER: "minio" - MINIO_ROOT_PASSWORD: "minio123" - MINIO_KMS_SECRET_KEY: "my-minio-key:OSMM+vkKUTCvQs9YL/CVMIMt43HFhkUpqJxTmGl6rYw=" - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 5s - timeout: 5s - retries: 5 - -# starts 4 docker containers running minio server instances. -# using nginx reverse proxy, load balancing, you can access -# it through port 9000. -services: - minio1: - <<: *minio-common - hostname: minio1 - volumes: - - edata1-1:/edata1 - - edata1-2:/edata2 - - edata1-3:/edata3 - - edata1-4:/edata4 - - nginx: - image: nginx:1.19.2-alpine - hostname: nginx - volumes: - - ./nginx-1-node.conf:/etc/nginx/nginx.conf:ro - ports: - - "9000:9000" - - "9001:9001" - depends_on: - - minio1 - -## By default this config uses default local driver, -## For custom volumes replace with volume driver configuration. -volumes: - edata1-1: - edata1-2: - edata1-3: - edata1-4: diff --git a/.github/workflows/mint/minio-pools.yaml b/.github/workflows/mint/minio-pools.yaml deleted file mode 100644 index bd79fdae048e6..0000000000000 --- a/.github/workflows/mint/minio-pools.yaml +++ /dev/null @@ -1,117 +0,0 @@ -version: '3.7' - -# Settings and configurations that are common for all containers -x-minio-common: &minio-common - image: quay.io/minio/minio:${JOB_NAME} - command: server --console-address ":9001" http://minio{1...4}/pdata{1...2} http://minio{5...8}/pdata{1...2} - expose: - - "9000" - - "9001" - environment: - MINIO_CI_CD: "on" - MINIO_ROOT_USER: "minio" - MINIO_ROOT_PASSWORD: "minio123" - MINIO_KMS_SECRET_KEY: "my-minio-key:OSMM+vkKUTCvQs9YL/CVMIMt43HFhkUpqJxTmGl6rYw=" - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 5s - timeout: 5s - retries: 5 - -# starts 4 docker containers running minio server instances. -# using nginx reverse proxy, load balancing, you can access -# it through port 9000. -services: - minio1: - <<: *minio-common - hostname: minio1 - volumes: - - pdata1-1:/pdata1 - - pdata1-2:/pdata2 - - minio2: - <<: *minio-common - hostname: minio2 - volumes: - - pdata2-1:/pdata1 - - pdata2-2:/pdata2 - - minio3: - <<: *minio-common - hostname: minio3 - volumes: - - pdata3-1:/pdata1 - - pdata3-2:/pdata2 - - minio4: - <<: *minio-common - hostname: minio4 - volumes: - - pdata4-1:/pdata1 - - pdata4-2:/pdata2 - - minio5: - <<: *minio-common - hostname: minio5 - volumes: - - pdata5-1:/pdata1 - - pdata5-2:/pdata2 - - minio6: - <<: *minio-common - hostname: minio6 - volumes: - - pdata6-1:/pdata1 - - pdata6-2:/pdata2 - - minio7: - <<: *minio-common - hostname: minio7 - volumes: - - pdata7-1:/pdata1 - - pdata7-2:/pdata2 - - minio8: - <<: *minio-common - hostname: minio8 - volumes: - - pdata8-1:/pdata1 - - pdata8-2:/pdata2 - - nginx: - image: nginx:1.19.2-alpine - hostname: nginx - volumes: - - ./nginx-8-node.conf:/etc/nginx/nginx.conf:ro - ports: - - "9000:9000" - - "9001:9001" - depends_on: - - minio1 - - minio2 - - minio3 - - minio4 - - minio5 - - minio6 - - minio7 - - minio8 - -## By default this config uses default local driver, -## For custom volumes replace with volume driver configuration. -volumes: - pdata1-1: - pdata1-2: - pdata2-1: - pdata2-2: - pdata3-1: - pdata3-2: - pdata4-1: - pdata4-2: - pdata5-1: - pdata5-2: - pdata6-1: - pdata6-2: - pdata7-1: - pdata7-2: - pdata8-1: - pdata8-2: diff --git a/.github/workflows/mint/minio-resiliency.yaml b/.github/workflows/mint/minio-resiliency.yaml deleted file mode 100644 index 9d569c59e4cff..0000000000000 --- a/.github/workflows/mint/minio-resiliency.yaml +++ /dev/null @@ -1,78 +0,0 @@ -version: '3.7' - -# Settings and configurations that are common for all containers -x-minio-common: &minio-common - image: quay.io/minio/minio:${JOB_NAME} - command: server --console-address ":9001" http://minio{1...4}/rdata{1...2} - expose: - - "9000" - - "9001" - environment: - MINIO_CI_CD: "on" - MINIO_ROOT_USER: "minio" - MINIO_ROOT_PASSWORD: "minio123" - MINIO_KMS_SECRET_KEY: "my-minio-key:OSMM+vkKUTCvQs9YL/CVMIMt43HFhkUpqJxTmGl6rYw=" - MINIO_DRIVE_MAX_TIMEOUT: "5s" - healthcheck: - test: ["CMD", "mc", "ready", "local"] - interval: 5s - timeout: 5s - retries: 5 - -# starts 4 docker containers running minio server instances. -# using nginx reverse proxy, load balancing, you can access -# it through port 9000. -services: - minio1: - <<: *minio-common - hostname: minio1 - volumes: - - rdata1-1:/rdata1 - - rdata1-2:/rdata2 - - minio2: - <<: *minio-common - hostname: minio2 - volumes: - - rdata2-1:/rdata1 - - rdata2-2:/rdata2 - - minio3: - <<: *minio-common - hostname: minio3 - volumes: - - rdata3-1:/rdata1 - - rdata3-2:/rdata2 - - minio4: - <<: *minio-common - hostname: minio4 - volumes: - - rdata4-1:/rdata1 - - rdata4-2:/rdata2 - - nginx: - image: nginx:1.19.2-alpine - hostname: nginx - volumes: - - ./nginx-4-node.conf:/etc/nginx/nginx.conf:ro - ports: - - "9000:9000" - - "9001:9001" - depends_on: - - minio1 - - minio2 - - minio3 - - minio4 - -## By default this config uses default local driver, -## For custom volumes replace with volume driver configuration. -volumes: - rdata1-1: - rdata1-2: - rdata2-1: - rdata2-2: - rdata3-1: - rdata3-2: - rdata4-1: - rdata4-2: diff --git a/.github/workflows/mint/nginx-1-node.conf b/.github/workflows/mint/nginx-1-node.conf deleted file mode 100644 index bb13bde24d71c..0000000000000 --- a/.github/workflows/mint/nginx-1-node.conf +++ /dev/null @@ -1,100 +0,0 @@ -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 4096; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - sendfile on; - keepalive_timeout 65; - - # include /etc/nginx/conf.d/*.conf; - - upstream minio { - server minio1:9000; - } - - upstream console { - ip_hash; - server minio1:9001; - } - - server { - listen 9000; - listen [::]:9000; - server_name localhost; - - # To allow special characters in headers - ignore_invalid_headers off; - # Allow any size file to be uploaded. - # Set to a value such as 1000m; to restrict file size to a specific value - client_max_body_size 0; - # To disable buffering - proxy_buffering off; - proxy_request_buffering off; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_connect_timeout 300; - # Default is HTTP/1, keepalive is only enabled in HTTP/1.1 - proxy_http_version 1.1; - proxy_set_header Connection ""; - chunked_transfer_encoding off; - - proxy_pass http://minio; - } - } - - server { - listen 9001; - listen [::]:9001; - server_name localhost; - - # To allow special characters in headers - ignore_invalid_headers off; - # Allow any size file to be uploaded. - # Set to a value such as 1000m; to restrict file size to a specific value - client_max_body_size 0; - # To disable buffering - proxy_buffering off; - proxy_request_buffering off; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-NginX-Proxy true; - - # This is necessary to pass the correct IP to be hashed - real_ip_header X-Real-IP; - - proxy_connect_timeout 300; - - # To support websocket - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - - chunked_transfer_encoding off; - - proxy_pass http://console; - } - } -} diff --git a/.github/workflows/mint/nginx-4-node.conf b/.github/workflows/mint/nginx-4-node.conf deleted file mode 100644 index b849940d972a9..0000000000000 --- a/.github/workflows/mint/nginx-4-node.conf +++ /dev/null @@ -1,105 +0,0 @@ -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 4096; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - sendfile on; - keepalive_timeout 65; - - # include /etc/nginx/conf.d/*.conf; - - upstream minio { - server minio1:9000 max_fails=1 fail_timeout=10s; - server minio2:9000 max_fails=1 fail_timeout=10s; - server minio3:9000 max_fails=1 fail_timeout=10s; - } - - upstream console { - ip_hash; - server minio1:9001; - server minio2:9001; - server minio3:9001; - server minio4:9001; - } - - server { - listen 9000; - listen [::]:9000; - server_name localhost; - - # To allow special characters in headers - ignore_invalid_headers off; - # Allow any size file to be uploaded. - # Set to a value such as 1000m; to restrict file size to a specific value - client_max_body_size 0; - # To disable buffering - proxy_buffering off; - proxy_request_buffering off; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_connect_timeout 300; - # Default is HTTP/1, keepalive is only enabled in HTTP/1.1 - proxy_http_version 1.1; - proxy_set_header Connection ""; - chunked_transfer_encoding off; - - proxy_pass http://minio; - } - } - - server { - listen 9001; - listen [::]:9001; - server_name localhost; - - # To allow special characters in headers - ignore_invalid_headers off; - # Allow any size file to be uploaded. - # Set to a value such as 1000m; to restrict file size to a specific value - client_max_body_size 0; - # To disable buffering - proxy_buffering off; - proxy_request_buffering off; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-NginX-Proxy true; - - # This is necessary to pass the correct IP to be hashed - real_ip_header X-Real-IP; - - proxy_connect_timeout 300; - - # To support websocket - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - - chunked_transfer_encoding off; - - proxy_pass http://console; - } - } -} diff --git a/.github/workflows/mint/nginx-8-node.conf b/.github/workflows/mint/nginx-8-node.conf deleted file mode 100644 index 278b5ae76e6c2..0000000000000 --- a/.github/workflows/mint/nginx-8-node.conf +++ /dev/null @@ -1,114 +0,0 @@ -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 4096; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - sendfile on; - keepalive_timeout 65; - - # include /etc/nginx/conf.d/*.conf; - - upstream minio { - server minio1:9000 max_fails=1 fail_timeout=10s; - server minio2:9000 max_fails=1 fail_timeout=10s; - server minio3:9000 max_fails=1 fail_timeout=10s; - server minio4:9000 max_fails=1 fail_timeout=10s; - server minio5:9000 max_fails=1 fail_timeout=10s; - server minio6:9000 max_fails=1 fail_timeout=10s; - server minio7:9000 max_fails=1 fail_timeout=10s; - server minio8:9000 max_fails=1 fail_timeout=10s; - } - - upstream console { - ip_hash; - server minio1:9001; - server minio2:9001; - server minio3:9001; - server minio4:9001; - server minio5:9001; - server minio6:9001; - server minio7:9001; - server minio8:9001; - } - - server { - listen 9000; - listen [::]:9000; - server_name localhost; - - # To allow special characters in headers - ignore_invalid_headers off; - # Allow any size file to be uploaded. - # Set to a value such as 1000m; to restrict file size to a specific value - client_max_body_size 0; - # To disable buffering - proxy_buffering off; - proxy_request_buffering off; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_connect_timeout 300; - # Default is HTTP/1, keepalive is only enabled in HTTP/1.1 - proxy_http_version 1.1; - proxy_set_header Connection ""; - chunked_transfer_encoding off; - - proxy_pass http://minio; - } - } - - server { - listen 9001; - listen [::]:9001; - server_name localhost; - - # To allow special characters in headers - ignore_invalid_headers off; - # Allow any size file to be uploaded. - # Set to a value such as 1000m; to restrict file size to a specific value - client_max_body_size 0; - # To disable buffering - proxy_buffering off; - proxy_request_buffering off; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-NginX-Proxy true; - - # This is necessary to pass the correct IP to be hashed - real_ip_header X-Real-IP; - - proxy_connect_timeout 300; - - # To support websocket - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - - chunked_transfer_encoding off; - - proxy_pass http://console; - } - } -} diff --git a/.github/workflows/mint/nginx.conf b/.github/workflows/mint/nginx.conf deleted file mode 100644 index 1455a3e2f13b3..0000000000000 --- a/.github/workflows/mint/nginx.conf +++ /dev/null @@ -1,106 +0,0 @@ -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 4096; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - sendfile on; - keepalive_timeout 65; - - # include /etc/nginx/conf.d/*.conf; - - upstream minio { - server minio1:9000 max_fails=1 fail_timeout=10s; - server minio2:9000 max_fails=1 fail_timeout=10s; - server minio3:9000 max_fails=1 fail_timeout=10s; - server minio4:9000 max_fails=1 fail_timeout=10s; - } - - upstream console { - ip_hash; - server minio1:9001; - server minio2:9001; - server minio3:9001; - server minio4:9001; - } - - server { - listen 9000; - listen [::]:9000; - server_name localhost; - - # To allow special characters in headers - ignore_invalid_headers off; - # Allow any size file to be uploaded. - # Set to a value such as 1000m; to restrict file size to a specific value - client_max_body_size 0; - # To disable buffering - proxy_buffering off; - proxy_request_buffering off; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_connect_timeout 300; - # Default is HTTP/1, keepalive is only enabled in HTTP/1.1 - proxy_http_version 1.1; - proxy_set_header Connection ""; - chunked_transfer_encoding off; - - proxy_pass http://minio; - } - } - - server { - listen 9001; - listen [::]:9001; - server_name localhost; - - # To allow special characters in headers - ignore_invalid_headers off; - # Allow any size file to be uploaded. - # Set to a value such as 1000m; to restrict file size to a specific value - client_max_body_size 0; - # To disable buffering - proxy_buffering off; - proxy_request_buffering off; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-NginX-Proxy true; - - # This is necessary to pass the correct IP to be hashed - real_ip_header X-Real-IP; - - proxy_connect_timeout 300; - - # To support websocket - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - - chunked_transfer_encoding off; - - proxy_pass http://console; - } - } -} diff --git a/.github/workflows/multipart/docker-compose-site1.yaml b/.github/workflows/multipart/docker-compose-site1.yaml deleted file mode 100644 index b4a665ef068b1..0000000000000 --- a/.github/workflows/multipart/docker-compose-site1.yaml +++ /dev/null @@ -1,66 +0,0 @@ -version: '3.7' - -# Settings and configurations that are common for all containers -x-minio-common: &minio-common - image: quay.io/minio/minio:${RELEASE} - command: server http://site1-minio{1...4}/data{1...2} - environment: - - MINIO_PROMETHEUS_AUTH_TYPE=public - - CI=true - -# starts 4 docker containers running minio server instances. -# using nginx reverse proxy, load balancing, you can access -# it through port 9000. -services: - site1-minio1: - <<: *minio-common - hostname: site1-minio1 - volumes: - - site1-data1-1:/data1 - - site1-data1-2:/data2 - - site1-minio2: - <<: *minio-common - hostname: site1-minio2 - volumes: - - site1-data2-1:/data1 - - site1-data2-2:/data2 - - site1-minio3: - <<: *minio-common - hostname: site1-minio3 - volumes: - - site1-data3-1:/data1 - - site1-data3-2:/data2 - - site1-minio4: - <<: *minio-common - hostname: site1-minio4 - volumes: - - site1-data4-1:/data1 - - site1-data4-2:/data2 - - site1-nginx: - image: nginx:1.19.2-alpine - hostname: site1-nginx - volumes: - - ./nginx-site1.conf:/etc/nginx/nginx.conf:ro - ports: - - "9001:9001" - depends_on: - - site1-minio1 - - site1-minio2 - - site1-minio3 - - site1-minio4 - -## By default this config uses default local driver, -## For custom volumes replace with volume driver configuration. -volumes: - site1-data1-1: - site1-data1-2: - site1-data2-1: - site1-data2-2: - site1-data3-1: - site1-data3-2: - site1-data4-1: - site1-data4-2: diff --git a/.github/workflows/multipart/docker-compose-site2.yaml b/.github/workflows/multipart/docker-compose-site2.yaml deleted file mode 100644 index 31a1f7a8c9e41..0000000000000 --- a/.github/workflows/multipart/docker-compose-site2.yaml +++ /dev/null @@ -1,66 +0,0 @@ -version: '3.7' - -# Settings and configurations that are common for all containers -x-minio-common: &minio-common - image: quay.io/minio/minio:${RELEASE} - command: server http://site2-minio{1...4}/data{1...2} - environment: - - MINIO_PROMETHEUS_AUTH_TYPE=public - - CI=true - -# starts 4 docker containers running minio server instances. -# using nginx reverse proxy, load balancing, you can access -# it through port 9000. -services: - site2-minio1: - <<: *minio-common - hostname: site2-minio1 - volumes: - - site2-data1-1:/data1 - - site2-data1-2:/data2 - - site2-minio2: - <<: *minio-common - hostname: site2-minio2 - volumes: - - site2-data2-1:/data1 - - site2-data2-2:/data2 - - site2-minio3: - <<: *minio-common - hostname: site2-minio3 - volumes: - - site2-data3-1:/data1 - - site2-data3-2:/data2 - - site2-minio4: - <<: *minio-common - hostname: site2-minio4 - volumes: - - site2-data4-1:/data1 - - site2-data4-2:/data2 - - site2-nginx: - image: nginx:1.19.2-alpine - hostname: site2-nginx - volumes: - - ./nginx-site2.conf:/etc/nginx/nginx.conf:ro - ports: - - "9002:9002" - depends_on: - - site2-minio1 - - site2-minio2 - - site2-minio3 - - site2-minio4 - -## By default this config uses default local driver, -## For custom volumes replace with volume driver configuration. -volumes: - site2-data1-1: - site2-data1-2: - site2-data2-1: - site2-data2-2: - site2-data3-1: - site2-data3-2: - site2-data4-1: - site2-data4-2: diff --git a/.github/workflows/multipart/migrate.sh b/.github/workflows/multipart/migrate.sh deleted file mode 100755 index 6058b008c8836..0000000000000 --- a/.github/workflows/multipart/migrate.sh +++ /dev/null @@ -1,147 +0,0 @@ -#!/bin/bash - -set -x - -## change working directory -cd .github/workflows/multipart/ - -function cleanup() { - docker-compose -f docker-compose-site1.yaml rm -s -f || true - docker-compose -f docker-compose-site2.yaml rm -s -f || true - for volume in $(docker volume ls -q | grep minio); do - docker volume rm ${volume} || true - done - - docker system prune -f || true - docker volume prune -f || true - docker volume rm $(docker volume ls -q -f dangling=true) || true -} - -cleanup - -if [ ! -f ./mc ]; then - wget --quiet -O mc https://dl.minio.io/client/mc/release/linux-amd64/mc && - chmod +x mc -fi - -export RELEASE=RELEASE.2023-08-29T23-07-35Z - -docker-compose -f docker-compose-site1.yaml up -d -docker-compose -f docker-compose-site2.yaml up -d - -sleep 30s - -./mc alias set site1 http://site1-nginx:9001 minioadmin minioadmin --api s3v4 -./mc alias set site2 http://site2-nginx:9002 minioadmin minioadmin --api s3v4 - -./mc ready site1/ -./mc ready site2/ - -./mc admin replicate add site1 site2 -./mc mb site1/testbucket/ -./mc cp -r --quiet /usr/bin site1/testbucket/ - -sleep 5 - -./s3-check-md5 -h - -failed_count_site1=$(./s3-check-md5 -versions -access-key minioadmin -secret-key minioadmin -endpoint http://site1-nginx:9001 -bucket testbucket 2>&1 | grep FAILED | wc -l) -failed_count_site2=$(./s3-check-md5 -versions -access-key minioadmin -secret-key minioadmin -endpoint http://site2-nginx:9002 -bucket testbucket 2>&1 | grep FAILED | wc -l) - -if [ $failed_count_site1 -ne 0 ]; then - echo "failed with multipart on site1 uploads" - exit 1 -fi - -if [ $failed_count_site2 -ne 0 ]; then - echo "failed with multipart on site2 uploads" - exit 1 -fi - -./mc cp -r --quiet /usr/bin site1/testbucket/ - -sleep 5 - -failed_count_site1=$(./s3-check-md5 -versions -access-key minioadmin -secret-key minioadmin -endpoint http://site1-nginx:9001 -bucket testbucket 2>&1 | grep FAILED | wc -l) -failed_count_site2=$(./s3-check-md5 -versions -access-key minioadmin -secret-key minioadmin -endpoint http://site2-nginx:9002 -bucket testbucket 2>&1 | grep FAILED | wc -l) - -## we do not need to fail here, since we are going to test -## upgrading to master, healing and being able to recover -## the last version. -if [ $failed_count_site1 -ne 0 ]; then - echo "failed with multipart on site1 uploads ${failed_count_site1}" -fi - -if [ $failed_count_site2 -ne 0 ]; then - echo "failed with multipart on site2 uploads ${failed_count_site2}" -fi - -export RELEASE=${1} - -docker-compose -f docker-compose-site1.yaml up -d -docker-compose -f docker-compose-site2.yaml up -d - -./mc ready site1/ -./mc ready site2/ - -for i in $(seq 1 10); do - # mc admin heal -r --remove when used against a LB endpoint - # behaves flaky, let this run 10 times before giving up - ./mc admin heal -r --remove --json site1/ 2>&1 >/dev/null - ./mc admin heal -r --remove --json site2/ 2>&1 >/dev/null -done - -failed_count_site1=$(./s3-check-md5 -versions -access-key minioadmin -secret-key minioadmin -endpoint http://site1-nginx:9001 -bucket testbucket 2>&1 | grep FAILED | wc -l) -failed_count_site2=$(./s3-check-md5 -versions -access-key minioadmin -secret-key minioadmin -endpoint http://site2-nginx:9002 -bucket testbucket 2>&1 | grep FAILED | wc -l) - -if [ $failed_count_site1 -ne 0 ]; then - echo "failed with multipart on site1 uploads" - exit 1 -fi - -if [ $failed_count_site2 -ne 0 ]; then - echo "failed with multipart on site2 uploads" - exit 1 -fi - -# Add user group test -./mc admin user add site1 site-replication-issue-user site-replication-issue-password -./mc admin group add site1 site-replication-issue-group site-replication-issue-user - -max_wait_attempts=30 -wait_interval=5 - -attempt=1 -while true; do - diff <(./mc admin group info site1 site-replication-issue-group) <(./mc admin group info site2 site-replication-issue-group) - - if [[ $? -eq 0 ]]; then - echo "Outputs are consistent." - break - fi - - remaining_attempts=$((max_wait_attempts - attempt)) - if ((attempt >= max_wait_attempts)); then - echo "Outputs remain inconsistent after $max_wait_attempts attempts. Exiting with error." - exit 1 - else - echo "Outputs are inconsistent. Waiting for $wait_interval seconds (attempt $attempt/$max_wait_attempts)." - sleep $wait_interval - fi - - ((attempt++)) -done - -status=$(./mc admin group info site1 site-replication-issue-group --json | jq .groupStatus | tr -d '"') - -if [[ $status == "enabled" ]]; then - echo "Success" -else - echo "Expected status: enabled, actual status: $status" - exit 1 -fi - -cleanup - -## change working directory -cd ../../../ diff --git a/.github/workflows/multipart/nginx-site1.conf b/.github/workflows/multipart/nginx-site1.conf deleted file mode 100644 index 1a37b9d2bc964..0000000000000 --- a/.github/workflows/multipart/nginx-site1.conf +++ /dev/null @@ -1,61 +0,0 @@ -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 4096; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - sendfile on; - keepalive_timeout 65; - - # include /etc/nginx/conf.d/*.conf; - - upstream minio { - server site1-minio1:9000; - server site1-minio2:9000; - server site1-minio3:9000; - server site1-minio4:9000; - } - - server { - listen 9001; - listen [::]:9001; - server_name localhost; - - # To allow special characters in headers - ignore_invalid_headers off; - # Allow any size file to be uploaded. - # Set to a value such as 1000m; to restrict file size to a specific value - client_max_body_size 0; - # To disable buffering - proxy_buffering off; - proxy_request_buffering off; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_connect_timeout 300; - # Default is HTTP/1, keepalive is only enabled in HTTP/1.1 - proxy_http_version 1.1; - proxy_set_header Connection ""; - chunked_transfer_encoding off; - - proxy_pass http://minio; - } - } -} diff --git a/.github/workflows/multipart/nginx-site2.conf b/.github/workflows/multipart/nginx-site2.conf deleted file mode 100644 index b93bba24d6135..0000000000000 --- a/.github/workflows/multipart/nginx-site2.conf +++ /dev/null @@ -1,61 +0,0 @@ -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 4096; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - sendfile on; - keepalive_timeout 65; - - # include /etc/nginx/conf.d/*.conf; - - upstream minio { - server site2-minio1:9000; - server site2-minio2:9000; - server site2-minio3:9000; - server site2-minio4:9000; - } - - server { - listen 9002; - listen [::]:9002; - server_name localhost; - - # To allow special characters in headers - ignore_invalid_headers off; - # Allow any size file to be uploaded. - # Set to a value such as 1000m; to restrict file size to a specific value - client_max_body_size 0; - # To disable buffering - proxy_buffering off; - proxy_request_buffering off; - - location / { - proxy_set_header Host $http_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_connect_timeout 300; - # Default is HTTP/1, keepalive is only enabled in HTTP/1.1 - proxy_http_version 1.1; - proxy_set_header Connection ""; - chunked_transfer_encoding off; - - proxy_pass http://minio; - } - } -} diff --git a/.github/workflows/replication.yaml b/.github/workflows/replication.yaml deleted file mode 100644 index 18c3277496b7b..0000000000000 --- a/.github/workflows/replication.yaml +++ /dev/null @@ -1,79 +0,0 @@ -name: MinIO advanced tests - -on: - pull_request: - branches: - - master - -# This ensures that previous jobs for the PR are canceled when the PR is -# updated. -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - replication-test: - name: Advanced Tests with Go ${{ matrix.go-version }} - runs-on: ubuntu-latest - - strategy: - matrix: - go-version: [1.24.x] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - check-latest: true - - name: Test Decom - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make test-decom - - - name: Test ILM - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make test-ilm - make test-ilm-transition - - - name: Test PBAC - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make test-pbac - - - name: Test Config File - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make test-configfile - - - name: Test Replication - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make test-replication - - - name: Test MinIO IDP for automatic site replication - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make test-site-replication-minio - - - name: Test Versioning - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make test-versioning - - - name: Test Multipart upload with failures - run: | - sudo sysctl net.ipv6.conf.all.disable_ipv6=0 - sudo sysctl net.ipv6.conf.default.disable_ipv6=0 - make test-multipart diff --git a/.github/workflows/root-disable.yml b/.github/workflows/root-disable.yml deleted file mode 100644 index c08fb8b1fbd9e..0000000000000 --- a/.github/workflows/root-disable.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Root lockdown tests - -on: - pull_request: - branches: - - master - -# This ensures that previous jobs for the PR are canceled when the PR is -# updated. -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - build: - name: Go ${{ matrix.go-version }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - go-version: [1.24.x] - os: [ubuntu-latest] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - check-latest: true - - name: Start root lockdown tests - run: | - make test-root-disable diff --git a/.github/workflows/root.cert b/.github/workflows/root.cert deleted file mode 100644 index 5f220f79bd429..0000000000000 --- a/.github/workflows/root.cert +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIBKDCB26ADAgECAhB6vebGMUfKnmBKyqoApRSOMAUGAytlcDAbMRkwFwYDVQQD -DBByb290QHBsYXkubWluLmlvMB4XDTIwMDQzMDE1MjIyNVoXDTI1MDQyOTE1MjIy -NVowGzEZMBcGA1UEAwwQcm9vdEBwbGF5Lm1pbi5pbzAqMAUGAytlcAMhALzn735W -fmSH/ghKs+4iPWziZMmWdiWr/sqvqeW+WwSxozUwMzAOBgNVHQ8BAf8EBAMCB4Aw -EwYDVR0lBAwwCgYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADAFBgMrZXADQQDZOrGK -b2ATkDlu2pTcP3LyhSBDpYh7V4TvjRkBTRgjkacCzwFLm+mh+7US8V4dBpIDsJ4u -uWoF0y6vbLVGIlkG ------END CERTIFICATE----- diff --git a/.github/workflows/root.key b/.github/workflows/root.key deleted file mode 100644 index 53a47e25da515..0000000000000 --- a/.github/workflows/root.key +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEID9E7FSYWrMD+VjhI6q545cYT9YOyFxZb7UnjEepYDRc ------END PRIVATE KEY----- diff --git a/.github/workflows/run-mint.sh b/.github/workflows/run-mint.sh deleted file mode 100755 index 0bbc1cbaf6150..0000000000000 --- a/.github/workflows/run-mint.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/bash - -set -ex - -export MODE="$1" -export ACCESS_KEY="$2" -export SECRET_KEY="$3" -export JOB_NAME="$4" -export MINT_MODE="full" - -docker system prune -f || true -docker volume prune -f || true -docker volume rm $(docker volume ls -f dangling=true) || true - -## change working directory -cd .github/workflows/mint - -## always pull latest -docker pull docker.io/minio/mint:edge - -docker-compose -f minio-${MODE}.yaml up -d -sleep 1m - -docker system prune -f || true -docker volume prune -f || true -docker volume rm $(docker volume ls -q -f dangling=true) || true - -# Stop two nodes, one of each pool, to check that all S3 calls work while quorum is still there -[ "${MODE}" == "pools" ] && docker-compose -f minio-${MODE}.yaml stop minio2 -[ "${MODE}" == "pools" ] && docker-compose -f minio-${MODE}.yaml stop minio6 - -# Pause one node, to check that all S3 calls work while one node goes wrong -[ "${MODE}" == "resiliency" ] && docker-compose -f minio-${MODE}.yaml pause minio4 - -docker run --rm --net=mint_default \ - --name="mint-${MODE}-${JOB_NAME}" \ - -e SERVER_ENDPOINT="nginx:9000" \ - -e ACCESS_KEY="${ACCESS_KEY}" \ - -e SECRET_KEY="${SECRET_KEY}" \ - -e ENABLE_HTTPS=0 \ - -e MINT_MODE="${MINT_MODE}" \ - docker.io/minio/mint:edge - -# FIXME: enable this after fixing aws-sdk-java-v2 tests -# # unpause the node, to check that all S3 calls work while one node goes wrong -# [ "${MODE}" == "resiliency" ] && docker-compose -f minio-${MODE}.yaml unpause minio4 -# [ "${MODE}" == "resiliency" ] && docker run --rm --net=mint_default \ -# --name="mint-${MODE}-${JOB_NAME}" \ -# -e SERVER_ENDPOINT="nginx:9000" \ -# -e ACCESS_KEY="${ACCESS_KEY}" \ -# -e SECRET_KEY="${SECRET_KEY}" \ -# -e ENABLE_HTTPS=0 \ -# -e MINT_MODE="${MINT_MODE}" \ -# docker.io/minio/mint:edge - -docker-compose -f minio-${MODE}.yaml down || true -sleep 10s - -docker system prune -f || true -docker volume prune -f || true -docker volume rm $(docker volume ls -q -f dangling=true) || true - -## change working directory -cd ../../../ diff --git a/.github/workflows/shfmt.yml b/.github/workflows/shfmt.yml deleted file mode 100644 index 3e446306bdd55..0000000000000 --- a/.github/workflows/shfmt.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Shell formatting checks - -on: - pull_request: - branches: - - master - -permissions: - contents: read - -jobs: - build: - name: runner / shfmt - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: luizm/action-sh-checker@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SHFMT_OPTS: "-s" - with: - sh_checker_shellcheck_disable: true # disable for now diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml deleted file mode 100644 index 7addb3143b61c..0000000000000 --- a/.github/workflows/typos.yml +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: Spelling -on: [pull_request] - -jobs: - run: - name: Spell Check with Typos - runs-on: ubuntu-latest - steps: - - name: Checkout Actions Repository - uses: actions/checkout@v4 - - - name: Check spelling of repo - uses: crate-ci/typos@master - diff --git a/.github/workflows/upgrade-ci-cd.yaml b/.github/workflows/upgrade-ci-cd.yaml deleted file mode 100644 index d3e71ff593641..0000000000000 --- a/.github/workflows/upgrade-ci-cd.yaml +++ /dev/null @@ -1,34 +0,0 @@ -name: Upgrade old version tests - -on: - pull_request: - branches: - - master - -# This ensures that previous jobs for the PR are canceled when the PR is -# updated. -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - build: - name: Go ${{ matrix.go-version }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - go-version: [1.24.x] - os: [ubuntu-latest] - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - check-latest: true - - name: Start upgrade tests - run: | - make test-upgrade diff --git a/.github/workflows/vulncheck.yml b/.github/workflows/vulncheck.yml deleted file mode 100644 index 5dfcde04bde5e..0000000000000 --- a/.github/workflows/vulncheck.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: VulnCheck -on: - pull_request: - branches: - - master - - push: - branches: - - master - -permissions: - contents: read # to fetch code (actions/checkout) - -jobs: - vulncheck: - name: Analysis - runs-on: ubuntu-latest - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: 1.24.x - cached: false - - name: Get official govulncheck - run: go install golang.org/x/vuln/cmd/govulncheck@latest - shell: bash - - name: Run govulncheck - run: govulncheck -show verbose ./... - shell: bash diff --git a/cmd/auth-handler.go b/cmd/auth-handler.go index 6e824e3126d46..983195241f0dd 100644 --- a/cmd/auth-handler.go +++ b/cmd/auth-handler.go @@ -731,7 +731,11 @@ func isPutActionAllowed(ctx context.Context, atype authType, bucketName, objectN case authTypeStreamingSigned, authTypePresigned, authTypeSigned, authTypeStreamingSignedTrailer: cred, owner, s3Err = getReqAccessKeyV4(r, region, serviceS3) case authTypeStreamingUnsignedTrailer: - cred, owner, s3Err = getReqAccessKeyV4(r, region, serviceS3) + // For unsigned trailer streaming the request must either be fully + // signed (credentials come from the Authorization header) or be + // anonymous. Query-string X-Amz-Credential is not signature-verified + // for this auth type and must not be used for authorization. + cred, owner, s3Err = getReqAccessKeyV4FromHeader(r, region, serviceS3) if s3Err == ErrMissingFields { // Could be anonymous. cred + owner is zero value. s3Err = ErrNone diff --git a/cmd/bucket-handlers.go b/cmd/bucket-handlers.go index 564572f2a94a6..67f3771bd4943 100644 --- a/cmd/bucket-handlers.go +++ b/cmd/bucket-handlers.go @@ -1253,8 +1253,10 @@ func (api objectAPIHandlers) PostPolicyBucketHandler(w http.ResponseWriter, r *h } // Extract metadata to be saved from received Form. + // Post-policy uploads are never replication requests, so replication + // SSE headers must not be accepted (CVE-2026-34204). metadata := make(map[string]string) - err = extractMetadataFromMime(ctx, textproto.MIMEHeader(formValues), metadata) + err = extractMetadataFromMime(ctx, textproto.MIMEHeader(formValues), metadata, false) if err != nil { writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL) return diff --git a/cmd/erasure-decode.go b/cmd/erasure-decode.go index f0cc90ab09fda..18ec7e9f5b710 100644 --- a/cmd/erasure-decode.go +++ b/cmd/erasure-decode.go @@ -24,6 +24,7 @@ import ( "io" "sync" "sync/atomic" + "time" xioutil "github.com/minio/minio/internal/ioutil" ) @@ -39,10 +40,39 @@ type parallelReader struct { buf [][]byte readerToBuf []int stashBuffer []byte + + // Hedged read state. When fanoutDelay is > 0 reads start with + // dataBlocks+1 shards in parallel and fan out to all remaining + // shards after fanoutDelay. + fanoutDelay time.Duration + events chan hedgedResult + inflight []bool + curBlock *hedgedBlock + stateLK sync.Mutex + missingPartsHeal atomic.Bool + bitrotHeal atomic.Bool + disksNotFound atomic.Int32 +} + +// hedgedBlock tracks per block launch state of a hedged read. +// All fields are guarded by parallelReader.stateLK. +type hedgedBlock struct { + launched []bool + outstanding int +} + +// hedgedResult is the outcome of one launched shard read. blk +// identifies the block the read was launched for. +type hedgedResult struct { + blk *hedgedBlock + i int + buf []byte + n int + err error } // newParallelReader returns parallelReader. -func newParallelReader(readers []io.ReaderAt, e Erasure, offset, totalLength int64) *parallelReader { +func newParallelReader(readers []io.ReaderAt, e Erasure, offset, totalLength int64, fanoutDelay time.Duration) *parallelReader { r2b := make([]int, len(readers)) for i := range r2b { r2b[i] = i @@ -51,9 +81,11 @@ func newParallelReader(readers []io.ReaderAt, e Erasure, offset, totalLength int shardSize := int(e.ShardSize()) var b []byte - // We should always have enough capacity, but older objects may be bigger - // we do not need stashbuffer for them. - if globalBytePoolCap.Load().WidthCap() >= len(readers)*shardSize { + // When hedging is enabled shard buffers are allocated per reader + // on first use instead of seeding from the byte pool: straggler + // goroutines may outlive the reader and keep writing into their + // buffer after Done() has returned pool memory. + if fanoutDelay <= 0 && globalBytePoolCap.Load().WidthCap() >= len(readers)*shardSize { // Fill buffers b = globalBytePoolCap.Load().Get() // Seed the buffers. @@ -62,7 +94,7 @@ func newParallelReader(readers []io.ReaderAt, e Erasure, offset, totalLength int } } - return ¶llelReader{ + p := ¶llelReader{ readers: readers, orgReaders: readers, dataBlocks: e.dataBlocks, @@ -73,6 +105,12 @@ func newParallelReader(readers []io.ReaderAt, e Erasure, offset, totalLength int readerToBuf: r2b, stashBuffer: b, } + if fanoutDelay > 0 { + p.fanoutDelay = fanoutDelay + p.events = make(chan hedgedResult, len(readers)) + p.inflight = make([]bool, len(readers)) + } + return p } // Done will release any resources used by the parallelReader. @@ -81,6 +119,18 @@ func (p *parallelReader) Done() { globalBytePoolCap.Load().Put(p.stashBuffer) p.stashBuffer = nil } + if p.inflight != nil { + // Readers still in flight are stragglers of a returned block. + // Nil them in orgReaders so closeBitrotReaders skips them; their + // in-flight goroutine closes the reader after the read returns. + p.stateLK.Lock() + for i := range p.inflight { + if p.inflight[i] { + p.orgReaders[p.readerToBuf[i]] = nil + } + } + p.stateLK.Unlock() + } } // preferReaders can mark readers as preferred. @@ -133,7 +183,6 @@ func (p *parallelReader) Read(dst [][]byte) ([][]byte, error) { newBuf[i] = newBuf[i][:0] } } - var newBufLK sync.RWMutex if p.offset+p.shardSize > p.shardFileSize { p.shardSize = p.shardFileSize - p.offset @@ -142,6 +191,12 @@ func (p *parallelReader) Read(dst [][]byte) ([][]byte, error) { return newBuf, nil } + if p.fanoutDelay > 0 { + return p.readHedged(newBuf) + } + + var newBufLK sync.RWMutex + readTriggerCh := make(chan bool, len(p.readers)) defer xioutil.SafeClose(readTriggerCh) // close the channel upon return @@ -234,6 +289,186 @@ func (p *parallelReader) Read(dst [][]byte) ([][]byte, error) { return nil, fmt.Errorf("%w (offline-disks=%d/%d)", errErasureReadQuorum, disksNotFound, len(p.readers)) } +// readHedged implements the hedged read strategy: it launches +// dataBlocks+1 reads in parallel, substitutes a new shard on error, +// and fans out to all remaining shards after p.fanoutDelay. It +// returns as soon as dataBlocks shards have been delivered, without +// waiting for stragglers. A reader that completes after its block +// returned re-joins on the next block; a straggler still in flight is +// never launched twice, so stateful readers are safe. +func (p *parallelReader) readHedged(newBuf [][]byte) ([][]byte, error) { + p.stateLK.Lock() + + // Drain results of stragglers from a previous block, their + // readers are available again. + for len(p.events) > 0 { + <-p.events + } + + blk := &hedgedBlock{launched: make([]bool, len(p.readers))} + p.curBlock = blk + + // launchNext launches the next launchable reader for this block. + // Must be called with p.stateLK held. + launchNext := func() bool { + for i := range p.readers { + if p.readers[i] == nil || p.inflight[i] || blk.launched[i] { + continue + } + blk.launched[i] = true + p.inflight[i] = true + blk.outstanding++ + bufIdx := p.readerToBuf[i] + if p.buf[bufIdx] == nil { + p.buf[bufIdx] = make([]byte, p.shardSize) + } + buf := p.buf[bufIdx][:p.shardSize] + offset := p.offset + rr := p.readers[i] + go func(i int, bufIdx int, rr io.ReaderAt, buf []byte, offset int64) { + n, err := rr.ReadAt(buf, offset) + p.stateLK.Lock() + p.inflight[i] = false + closeReader := false + if p.orgReaders[bufIdx] == nil { + // Abandoned straggler, marked by Done(); the + // caller no longer tracks this reader so it is + // closed here instead of by closeBitrotReaders. + closeReader = true + } else if err != nil { + switch { + case errors.Is(err, errFileNotFound): + p.missingPartsHeal.Store(true) + case errors.Is(err, errFileCorrupt): + p.bitrotHeal.Store(true) + case errors.Is(err, errDiskNotFound): + p.disksNotFound.Add(1) + } + p.readers[i] = nil + // This will be communicated upstream. + p.orgReaders[bufIdx] = nil + closeReader = true + } + p.stateLK.Unlock() + if closeReader { + if closer, ok := rr.(io.Closer); ok { + closer.Close() + } + } + p.events <- hedgedResult{blk: blk, i: i, buf: buf, n: n, err: err} + }(i, bufIdx, rr, buf, offset) + return true + } + return false + } + + for n := 0; n < p.dataBlocks+1; n++ { + // Launch dataBlocks+1 reads so that a single slow or + // missing shard is covered without waiting for the fan-out. + if !launchNext() { + break + } + } + + fanoutPossible := false + for i := range p.readers { + if p.readers[i] != nil && !p.inflight[i] && !blk.launched[i] { + fanoutPossible = true + break + } + } + var timer *time.Timer + if fanoutPossible { + timer = time.AfterFunc(p.fanoutDelay, func() { + p.stateLK.Lock() + defer p.stateLK.Unlock() + if p.curBlock != blk { + return + } + for launchNext() { + } + }) + } + p.stateLK.Unlock() + + defer func() { + if timer != nil { + timer.Stop() + } + p.stateLK.Lock() + if p.curBlock == blk { + p.curBlock = nil + } + p.stateLK.Unlock() + }() + + bufCount := 0 + for bufCount < p.dataBlocks { + p.stateLK.Lock() + if blk.outstanding == 0 { + launched := launchNext() + waiting := false + if !launched { + for i := range p.inflight { + if p.inflight[i] { + waiting = true + break + } + } + } + p.stateLK.Unlock() + switch { + case launched: + continue + case waiting: + // All launchable readers are exhausted but stragglers + // of earlier blocks may still deliver. + default: + // If we cannot decode, just return read quorum error. + return nil, fmt.Errorf("%w (offline-disks=%d/%d)", errErasureReadQuorum, p.disksNotFound.Load(), len(p.readers)) + } + } else { + p.stateLK.Unlock() + } + + ev := <-p.events + p.stateLK.Lock() + if ev.blk == blk { + blk.outstanding-- + } + switch { + case ev.blk == blk && ev.err == nil: + p.stateLK.Unlock() + newBuf[p.readerToBuf[ev.i]] = ev.buf[:ev.n] + bufCount++ + case ev.blk == blk: + // Since ReadAt returned error, launch a substitute shard. + launchNext() + p.stateLK.Unlock() + default: + // Stale result of an earlier block; its reader has been + // marked available and can be relaunched on the next + // iteration. + p.stateLK.Unlock() + } + } + + p.stateLK.Lock() + if p.curBlock == blk { + p.curBlock = nil + } + p.stateLK.Unlock() + + p.offset += p.shardSize + switch { + case p.missingPartsHeal.Load(): + return newBuf, errFileNotFound + case p.bitrotHeal.Load(): + return newBuf, errFileCorrupt + } + return newBuf, nil +} + // Decode reads from readers, reconstructs data if needed and writes the data to the writer. // A set of preferred drives can be supplied. In that case they will be used and the data reconstructed. func (e Erasure) Decode(ctx context.Context, writer io.Writer, readers []io.ReaderAt, offset, length, totalLength int64, prefer []bool) (written int64, derr error) { @@ -248,7 +483,7 @@ func (e Erasure) Decode(ctx context.Context, writer io.Writer, readers []io.Read return 0, nil } - reader := newParallelReader(readers, e, offset, totalLength) + reader := newParallelReader(readers, e, offset, totalLength, globalDriveConfig.GetReadFanoutDelay()) if len(prefer) == len(readers) { reader.preferReaders(prefer) } @@ -319,7 +554,7 @@ func (e Erasure) Heal(ctx context.Context, writers []io.Writer, readers []io.Rea return errInvalidArgument } - reader := newParallelReader(readers, e, 0, totalLength) + reader := newParallelReader(readers, e, 0, totalLength, 0) if len(readers) == len(prefer) { reader.preferReaders(prefer) } diff --git a/cmd/erasure-decode_test.go b/cmd/erasure-decode_test.go index 229047e16a750..5d3bd6ca38c7a 100644 --- a/cmd/erasure-decode_test.go +++ b/cmd/erasure-decode_test.go @@ -21,11 +21,17 @@ import ( "bytes" "context" crand "crypto/rand" + "errors" "io" "math/rand" + "sync" + "sync/atomic" "testing" + "time" "github.com/dustin/go-humanize" + "github.com/minio/minio/internal/bpool" + "github.com/minio/minio/internal/config/drive" ) func (a badDisk) ReadFile(ctx context.Context, volume string, path string, offset int64, buf []byte, verifier *BitrotVerifier) (n int64, err error) { @@ -84,6 +90,12 @@ var erasureDecodeTests = []struct { } func TestErasureDecode(t *testing.T) { + // The streaming bitrot writer draws from the global byte pool + // which is otherwise only initialized during server pool setup. + if globalBytePoolCap.Load() == nil { + globalBytePoolCap.Store(bpool.NewBytePoolCap(64, int(blockSizeV2), 2*int(blockSizeV2))) + } + for i, test := range erasureDecodeTests { setup, err := newErasureTestSetup(t, test.dataBlocks, test.onDisks-test.dataBlocks, test.blocksize) if err != nil { @@ -201,6 +213,11 @@ func TestErasureDecodeRandomOffsetLength(t *testing.T) { if testing.Short() { t.Skip() } + // The streaming bitrot writer draws from the global byte pool + // which is otherwise only initialized during server pool setup. + if globalBytePoolCap.Load() == nil { + globalBytePoolCap.Store(bpool.NewBytePoolCap(64, int(blockSizeV2), 2*int(blockSizeV2))) + } // Initialize environment needed for the test. dataBlocks := 7 parityBlocks := 7 @@ -383,3 +400,406 @@ func BenchmarkErasureDecode_16_40MB(b *testing.B) { b.Run(" XXXX0000|XXXX0000 ", func(b *testing.B) { benchmarkErasureDecode(8, 8, 4, 4, size, b) }) b.Run(" XXXXXXXX|00000000 ", func(b *testing.B) { benchmarkErasureDecode(8, 8, 8, 0, size, b) }) } + +// gatedReaderAt blocks the first ReadAt call until gate is closed. +// All later calls pass through. started is closed once the first +// ReadAt is blocked, done once it has returned. +type gatedReaderAt struct { + inner io.ReaderAt + gate chan struct{} + started chan struct{} + done chan struct{} + block sync.Once + calls atomic.Int32 +} + +func (g *gatedReaderAt) ReadAt(p []byte, off int64) (int, error) { + g.calls.Add(1) + g.block.Do(func() { + close(g.started) + <-g.gate + close(g.done) + }) + return g.inner.ReadAt(p, off) +} + +func (g *gatedReaderAt) Close() error { + if c, ok := g.inner.(io.Closer); ok { + return c.Close() + } + return nil +} + +// missingShardReader always fails with errFileNotFound. +type missingShardReader struct{} + +func (missingShardReader) ReadAt([]byte, int64) (int, error) { return 0, errFileNotFound } + +// hedgedEncodeShards encodes data block by block and returns the +// shard files, mimicking the layout produced by Erasure.Encode. +func hedgedEncodeShards(t *testing.T, e Erasure, data []byte) [][]byte { + t.Helper() + shards := make([][]byte, e.dataBlocks+e.parityBlocks) + for off := 0; off < len(data); off += int(e.blockSize) { + chunk := data[off:] + if len(chunk) > int(e.blockSize) { + chunk = chunk[:int(e.blockSize)] + } + encoded, err := e.EncodeData(t.Context(), chunk) + if err != nil { + t.Fatalf("EncodeData failed: %v", err) + } + for i := range shards { + shards[i] = append(shards[i], encoded[i]...) + } + } + return shards +} + +func hedgedDecodeShards(t *testing.T, e Erasure, bufs [][]byte) []byte { + t.Helper() + if err := e.DecodeDataBlocks(bufs); err != nil { + t.Fatalf("DecodeDataBlocks failed: %v", err) + } + var out []byte + for i := 0; i < e.dataBlocks; i++ { + out = append(out, bufs[i]...) + } + return out +} + +func hedgedDeliveredCount(bufs [][]byte) int { + n := 0 + for _, b := range bufs { + if len(b) > 0 { + n++ + } + } + return n +} + +func hedgedShardReaders(t *testing.T, shards [][]byte) []io.ReaderAt { + t.Helper() + readers := make([]io.ReaderAt, len(shards)) + for i := range shards { + readers[i] = bytes.NewReader(shards[i]) + } + return readers +} + +// TestParallelReaderHedgedOneSlow verifies that a single slow shard is +// covered by the dataBlocks+1 initial launch without waiting for the +// fan-out delay or the slow disk. +func TestParallelReaderHedgedOneSlow(t *testing.T) { + const dataBlocks = 3 + e, err := NewErasure(t.Context(), dataBlocks, 2, 30) + if err != nil { + t.Fatal(err) + } + data := make([]byte, 60) + for i := range data { + data[i] = byte(i) + } + shards := hedgedEncodeShards(t, e, data) + readers := hedgedShardReaders(t, shards) + + gated := &gatedReaderAt{ + inner: readers[0], + gate: make(chan struct{}), + started: make(chan struct{}), + done: make(chan struct{}), + } + readers[0] = gated + + const fanoutDelay = 10 * time.Second + p := newParallelReader(readers, e, 0, int64(len(data)), fanoutDelay) + defer p.Done() + + for block := 0; block < 2; block++ { + start := time.Now() + bufs, err := p.Read(nil) + if err != nil { + t.Fatalf("block %d: Read failed: %v", block, err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("block %d: Read blocked for %v, hedged read should not wait for the slow shard", block, elapsed) + } + if got := hedgedDeliveredCount(bufs); got != dataBlocks { + t.Fatalf("block %d: got %d delivered shards, want %d", block, got, dataBlocks) + } + if got := hedgedDecodeShards(t, e, bufs); !bytes.Equal(got, data[block*30:(block+1)*30]) { + t.Fatalf("block %d: decoded data mismatch", block) + } + } + + // The slow reader was launched once per block, the never-needed + // 5th shard was never launched since the hedge sufficed. + if got := gated.calls.Load(); got != 1 { + t.Fatalf("gated reader launched %d times, want 1", got) + } + + p.Done() + p.stateLK.Lock() + if readers[0] != nil { + t.Fatal("straggler reader should be marked nil in orgReaders after Done()") + } + p.stateLK.Unlock() + + close(gated.gate) + <-gated.done +} + +// TestParallelReaderHedgedFanout verifies that the fan-out timer +// launches the remaining shards after fanoutDelay when two shards +// are slow. +func TestParallelReaderHedgedFanout(t *testing.T) { + const dataBlocks = 3 + e, err := NewErasure(t.Context(), dataBlocks, 2, 30) + if err != nil { + t.Fatal(err) + } + data := make([]byte, 30) + for i := range data { + data[i] = byte(i) + } + shards := hedgedEncodeShards(t, e, data) + readers := hedgedShardReaders(t, shards) + + gated0 := &gatedReaderAt{inner: readers[0], gate: make(chan struct{}), started: make(chan struct{}), done: make(chan struct{})} + gated1 := &gatedReaderAt{inner: readers[1], gate: make(chan struct{}), started: make(chan struct{}), done: make(chan struct{})} + readers[0] = gated0 + readers[1] = gated1 + + const fanoutDelay = 300 * time.Millisecond + p := newParallelReader(readers, e, 0, int64(len(data)), fanoutDelay) + defer p.Done() + + start := time.Now() + bufs, err := p.Read(nil) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + if elapsed := time.Since(start); elapsed < 200*time.Millisecond { + t.Fatalf("Read returned after %v, fan-out delay did not trigger", elapsed) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("Read returned after %v, too slow", elapsed) + } + if got := hedgedDeliveredCount(bufs); got != dataBlocks { + t.Fatalf("got %d delivered shards, want %d", got, dataBlocks) + } + if got := hedgedDecodeShards(t, e, bufs); !bytes.Equal(got, data) { + t.Fatal("decoded data mismatch") + } + + close(gated0.gate) + close(gated1.gate) +} + +// TestParallelReaderHedgedErrorSubstitution verifies that a shard +// failing immediately is substituted right away, without waiting +// for the fan-out delay. +func TestParallelReaderHedgedErrorSubstitution(t *testing.T) { + const dataBlocks = 3 + e, err := NewErasure(t.Context(), dataBlocks, 2, 30) + if err != nil { + t.Fatal(err) + } + data := make([]byte, 30) + for i := range data { + data[i] = byte(i) + } + shards := hedgedEncodeShards(t, e, data) + readers := hedgedShardReaders(t, shards) + readers[0] = missingShardReader{} + + const fanoutDelay = 10 * time.Second + p := newParallelReader(readers, e, 0, int64(len(data)), fanoutDelay) + defer p.Done() + + start := time.Now() + bufs, err := p.Read(nil) + if !errors.Is(err, errFileNotFound) { + t.Fatalf("expected errFileNotFound heal flag, got %v", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("Read blocked for %v, substitution should be immediate", elapsed) + } + if got := hedgedDeliveredCount(bufs); got != dataBlocks { + t.Fatalf("got %d delivered shards, want %d", got, dataBlocks) + } + if got := hedgedDecodeShards(t, e, bufs); !bytes.Equal(got, data) { + t.Fatal("decoded data mismatch") + } +} + +// TestParallelReaderHedgedQuorum verifies the read quorum error when +// too many shards fail. +func TestParallelReaderHedgedQuorum(t *testing.T) { + const dataBlocks = 3 + e, err := NewErasure(t.Context(), dataBlocks, 2, 30) + if err != nil { + t.Fatal(err) + } + data := make([]byte, 30) + shards := hedgedEncodeShards(t, e, data) + readers := hedgedShardReaders(t, shards) + readers[0] = missingShardReader{} + readers[1] = missingShardReader{} + readers[2] = missingShardReader{} + + p := newParallelReader(readers, e, 0, int64(len(data)), 10*time.Second) + defer p.Done() + + bufs, err := p.Read(nil) + if !errors.Is(err, errErasureReadQuorum) { + t.Fatalf("expected errErasureReadQuorum, got %v", err) + } + if bufs != nil { + t.Fatal("expected nil bufs on quorum error") + } +} + +// TestParallelReaderHedgedRejoin verifies that a straggler that +// completes after its block returned rejoins for the next block. +func TestParallelReaderHedgedRejoin(t *testing.T) { + const dataBlocks = 3 + e, err := NewErasure(t.Context(), dataBlocks, 2, 30) + if err != nil { + t.Fatal(err) + } + data := make([]byte, 60) + for i := range data { + data[i] = byte(i) + } + shards := hedgedEncodeShards(t, e, data) + readers := hedgedShardReaders(t, shards) + + gated := &gatedReaderAt{ + inner: readers[0], + gate: make(chan struct{}), + started: make(chan struct{}), + done: make(chan struct{}), + } + readers[0] = gated + + p := newParallelReader(readers, e, 0, int64(len(data)), 10*time.Second) + defer p.Done() + + // Block 0: reader 0 straggles, delivered via hedge. + bufs, err := p.Read(nil) + if err != nil { + t.Fatalf("block 0: Read failed: %v", err) + } + if got := hedgedDecodeShards(t, e, bufs); !bytes.Equal(got, data[0:30]) { + t.Fatal("block 0: decoded data mismatch") + } + + // Release the straggler and wait until it is available again. + close(gated.gate) + deadline := time.Now().Add(5 * time.Second) + for { + p.stateLK.Lock() + inflight := p.inflight[0] + p.stateLK.Unlock() + if !inflight { + break + } + if time.Now().After(deadline) { + t.Fatal("straggler never completed") + } + time.Sleep(time.Millisecond) + } + + // Block 1: reader 0 must have rejoined and be launched again. + bufs, err = p.Read(nil) + if err != nil { + t.Fatalf("block 1: Read failed: %v", err) + } + if got := hedgedDeliveredCount(bufs); got != dataBlocks { + t.Fatalf("block 1: got %d delivered shards, want %d", got, dataBlocks) + } + if got := hedgedDecodeShards(t, e, bufs); !bytes.Equal(got, data[30:60]) { + t.Fatal("block 1: decoded data mismatch") + } + if got := gated.calls.Load(); got != 2 { + t.Fatalf("gated reader launched %d times, want 2", got) + } +} + +// TestErasureDecodeHedgedSlowDisk verifies the full decode path with +// hedged reads enabled: a disk stuck in a read does not stall the +// object read. +func TestErasureDecodeHedgedSlowDisk(t *testing.T) { + // The streaming bitrot writer draws from the global byte pool + // which is otherwise only initialized during server pool setup. + if globalBytePoolCap.Load() == nil { + globalBytePoolCap.Store(bpool.NewBytePoolCap(64, int(blockSizeV2), 2*int(blockSizeV2))) + } + + dataBlocks := 3 + parityBlocks := 2 + setup, err := newErasureTestSetup(t, dataBlocks, parityBlocks, blockSizeV2) + if err != nil { + t.Fatalf("failed to create test setup: %v", err) + } + erasure, err := NewErasure(t.Context(), dataBlocks, parityBlocks, blockSizeV2) + if err != nil { + t.Fatalf("failed to create ErasureStorage: %v", err) + } + + data := make([]byte, oneMiByte) + if _, err = io.ReadFull(crand.Reader, data); err != nil { + t.Fatal(err) + } + + buffer := make([]byte, blockSizeV2, 2*blockSizeV2) + writers := make([]io.Writer, len(setup.disks)) + for i, disk := range setup.disks { + writers[i] = newBitrotWriter(disk, "", "testbucket", "object", erasure.ShardFileSize(int64(len(data))), DefaultBitrotAlgorithm, erasure.ShardSize()) + } + if _, err = erasure.Encode(t.Context(), bytes.NewReader(data), writers, buffer, erasure.dataBlocks+1); err != nil { + t.Fatal(err) + } + closeBitrotWriters(writers) + + bitrotReaders := make([]io.ReaderAt, len(setup.disks)) + for index, disk := range setup.disks { + tillOffset := erasure.ShardFileOffset(0, int64(len(data)), int64(len(data))) + bitrotReaders[index] = newBitrotReader(disk, nil, "testbucket", "object", tillOffset, DefaultBitrotAlgorithm, bitrotWriterSum(writers[index]), erasure.ShardSize()) + } + gated := &gatedReaderAt{ + inner: bitrotReaders[0], + gate: make(chan struct{}), + started: make(chan struct{}), + done: make(chan struct{}), + } + bitrotReaders[0] = gated + + globalDriveConfig.Update(drive.Config{ReadFanoutDelay: 10 * time.Second}) + defer globalDriveConfig.Update(drive.Config{}) + + writer := bytes.NewBuffer(nil) + start := time.Now() + _, err = erasure.Decode(t.Context(), writer, bitrotReaders, 0, int64(len(data)), int64(len(data)), nil) + if err != nil { + t.Fatalf("Decode failed: %v", err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("Decode blocked for %v, slow disk should not stall the read", elapsed) + } + if !bytes.Equal(writer.Bytes(), data) { + t.Fatal("read returns wrong file content") + } + + // The straggler is marked in the caller's reader slice so that + // closeBitrotReaders skips it; its goroutine closes it after the + // read returns. + if bitrotReaders[0] != nil { + t.Fatal("straggler reader should be marked nil for the caller") + } + + close(gated.gate) + <-gated.done + closeBitrotReaders(bitrotReaders) +} diff --git a/cmd/handler-utils.go b/cmd/handler-utils.go index 7752cfece2db3..e8ef441f8774d 100644 --- a/cmd/handler-utils.go +++ b/cmd/handler-utils.go @@ -29,6 +29,7 @@ import ( "github.com/minio/madmin-go/v3" "github.com/minio/minio/internal/auth" + "github.com/minio/minio/internal/bucket/replication" "github.com/minio/minio/internal/handlers" xhttp "github.com/minio/minio/internal/http" "github.com/minio/minio/internal/logger" @@ -142,15 +143,18 @@ var userMetadataKeyPrefixes = []string{ // extractMetadataFromReq extracts metadata from HTTP header and HTTP queryString. func extractMetadataFromReq(ctx context.Context, r *http.Request) (metadata map[string]string, err error) { - return extractMetadata(ctx, textproto.MIMEHeader(r.Form), textproto.MIMEHeader(r.Header)) + // CVE-2026-34204: only replication traffic is allowed to set internal SSE + // metadata via X-Minio-Replication-* headers. + isReplica := r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() + return extractMetadata(ctx, isReplica, textproto.MIMEHeader(r.Form), textproto.MIMEHeader(r.Header)) } -func extractMetadata(ctx context.Context, mimesHeader ...textproto.MIMEHeader) (metadata map[string]string, err error) { +func extractMetadata(ctx context.Context, isReplica bool, mimesHeader ...textproto.MIMEHeader) (metadata map[string]string, err error) { metadata = make(map[string]string) for _, hdr := range mimesHeader { // Extract all query values. - err = extractMetadataFromMime(ctx, hdr, metadata) + err = extractMetadataFromMime(ctx, hdr, metadata, isReplica) if err != nil { return nil, err } @@ -191,7 +195,9 @@ func extractMetadata(ctx context.Context, mimesHeader ...textproto.MIMEHeader) ( } // extractMetadata extracts metadata from map values. -func extractMetadataFromMime(ctx context.Context, v textproto.MIMEHeader, m map[string]string) error { +// When allowReplicationHeaders is false, X-Minio-Replication-* headers that +// map to internal SSE metadata are ignored (CVE-2026-34204). +func extractMetadataFromMime(ctx context.Context, v textproto.MIMEHeader, m map[string]string, allowReplicationHeaders bool) error { if v == nil { bugLogIf(ctx, errInvalidArgument) return errInvalidArgument @@ -208,6 +214,9 @@ func extractMetadataFromMime(ctx context.Context, v textproto.MIMEHeader, m map[ value, ok := nv[http.CanonicalHeaderKey(supportedHeader)] if ok { if v, ok := replicationToInternalHeaders[supportedHeader]; ok { + if !allowReplicationHeaders { + continue + } m[v] = strings.Join(value, ",") } else { m[supportedHeader] = strings.Join(value, ",") diff --git a/cmd/handler-utils_test.go b/cmd/handler-utils_test.go index 517f93fcc9fbd..caaec920a9e26 100644 --- a/cmd/handler-utils_test.go +++ b/cmd/handler-utils_test.go @@ -158,12 +158,21 @@ func TestExtractMetadataHeaders(t *testing.T) { metadata: nil, shouldFail: true, }, + // CVE-2026-34204: X-Minio-Replication-* SSE headers must not be + // accepted from regular (non-replication) requests. + { + header: http.Header{ + "X-Minio-Replication-Server-Side-Encryption-Sealed-Key": []string{"injected-value"}, + }, + metadata: map[string]string{}, + shouldFail: false, + }, } // Validate if the extracting headers. for i, testCase := range testCases { metadata := make(map[string]string) - err := extractMetadataFromMime(t.Context(), textproto.MIMEHeader(testCase.header), metadata) + err := extractMetadataFromMime(t.Context(), textproto.MIMEHeader(testCase.header), metadata, false) if err != nil && !testCase.shouldFail { t.Fatalf("Test %d failed to extract metadata: %v", i+1, err) } @@ -176,6 +185,24 @@ func TestExtractMetadataHeaders(t *testing.T) { } } +// Tests that replication SSE headers are accepted when explicitly allowed. +func TestExtractMetadataHeadersReplicationAllowed(t *testing.T) { + metadata := make(map[string]string) + header := http.Header{ + "X-Minio-Replication-Server-Side-Encryption-Sealed-Key": []string{"replica-value"}, + } + err := extractMetadataFromMime(t.Context(), textproto.MIMEHeader(header), metadata, true) + if err != nil { + t.Fatalf("failed to extract metadata: %v", err) + } + expected := map[string]string{ + "X-Minio-Internal-Server-Side-Encryption-Sealed-Key": "replica-value", + } + if !reflect.DeepEqual(metadata, expected) { + t.Fatalf("Expected %#v, got %#v", expected, metadata) + } +} + // Test getResource() func TestGetResource(t *testing.T) { testCases := []struct { diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index 6542c46554f89..dfc77b3db2935 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -2242,7 +2242,7 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h // if Content-Length is unknown/missing, deny the request size := r.ContentLength rAuthType := getRequestAuthType(r) - if rAuthType == authTypeStreamingSigned || rAuthType == authTypeStreamingSignedTrailer { + if rAuthType == authTypeStreamingSigned || rAuthType == authTypeStreamingSignedTrailer || rAuthType == authTypeStreamingUnsignedTrailer { if sizeStr, ok := r.Header[xhttp.AmzDecodedContentLength]; ok { if sizeStr[0] == "" { writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrMissingContentLength), r.URL) @@ -2296,6 +2296,13 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL) return } + case authTypeStreamingUnsignedTrailer: + // Initialize stream chunked reader with optional trailers. + reader, s3Err = newUnsignedV4ChunkedReader(r, true, r.Header.Get(xhttp.Authorization) != "") + if s3Err != ErrNone { + writeErrorResponse(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL) + return + } case authTypeSignedV2, authTypePresignedV2: s3Err = isReqAuthenticatedV2(r) if s3Err != ErrNone { @@ -2413,7 +2420,8 @@ func (api objectAPIHandlers) PutObjectExtractHandler(w http.ResponseWriter, r *h hdrs.Set(k, v) } } - m, err := extractMetadata(ctx, textproto.MIMEHeader(hdrs)) + isReplica := r.Header.Get(xhttp.AmzBucketReplicationStatus) == replication.Replica.String() + m, err := extractMetadata(ctx, isReplica, textproto.MIMEHeader(hdrs)) if err != nil { return err } diff --git a/cmd/server_test.go b/cmd/server_test.go index e69117351544b..97a8650b04e41 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -18,6 +18,7 @@ package cmd import ( + "archive/tar" "bytes" "context" "encoding/xml" @@ -128,6 +129,8 @@ func runAllTests(suite *TestSuiteCommon, c *check) { suite.TestBucketSQSNotificationWebHook(c) suite.TestBucketSQSNotificationAMQP(c) suite.TestUnsignedCVE(c) + suite.TestUnsignedTrailerQueryStringCVE(c) + suite.TestSnowballAutoExtractCVE(c) suite.TearDownSuite(c) } @@ -409,6 +412,117 @@ func (s *TestSuiteCommon) TestUnsignedCVE(c *check) { c.Assert(response.StatusCode, http.StatusBadRequest) } +func (s *TestSuiteCommon) TestUnsignedTrailerQueryStringCVE(c *check) { + c.Helper() + + // generate a random bucket Name. + bucketName := getRandomBucketName() + + // HTTP request to create the bucket. + request, err := newTestSignedRequest(http.MethodPut, getMakeBucketURL(s.endPoint, bucketName), + 0, nil, s.accessKey, s.secretKey, s.signer) + c.Assert(err, nil) + + // execute the request. + response, err := s.client.Do(request) + c.Assert(err, nil) + c.Assert(response.StatusCode, http.StatusOK) + + now := UTCNow() + scope := fmt.Sprintf("%s/us-east-1/s3/aws4_request", now.Format(yyyymmdd)) + putURL := getPutObjectURL(s.endPoint, bucketName, "test-cve-object.txt") + // Inject a valid access key via query string, mimicking the + // CVE-2026-41145 attack vector. + putURL = putURL + "?" + xhttp.AmzCredential + "=" + url.QueryEscape(s.accessKey+"/"+scope) + + req, err := http.NewRequest(http.MethodPut, putURL, nil) + c.Assert(err, nil) + + req.Body = io.NopCloser(bytes.NewReader([]byte("foobar!\n"))) + req.Trailer = http.Header{} + req.Trailer.Set("x-amz-checksum-crc32", "rK0DXg==") + + req = signer.StreamingUnsignedV4(req, "", 8, now) + + // Remove the Authorization header; the attacker only provides the + // access key via the query string and does not know the secret key. + req.Header.Del("Authorization") + + // Ensure the headers expected for unsigned trailer streaming remain. + req.Header.Set("X-Amz-Decoded-Content-Length", "8") + req.Header.Set("Content-Encoding", "aws-chunked") + req.Header.Set("X-Amz-Trailer", "x-amz-checksum-crc32") + req.Header.Set("x-amz-content-sha256", unsignedPayloadTrailer) + + // execute the request. + response, err = s.client.Do(req) + c.Assert(err, nil) + + // The request must be rejected because query-string credentials are not + // signature-verified for unsigned trailer streaming. + c.Assert(response.StatusCode, http.StatusForbidden) +} + +func (s *TestSuiteCommon) TestSnowballAutoExtractCVE(c *check) { + c.Helper() + + // generate a random bucket Name. + bucketName := getRandomBucketName() + + // HTTP request to create the bucket. + request, err := newTestSignedRequest(http.MethodPut, getMakeBucketURL(s.endPoint, bucketName), + 0, nil, s.accessKey, s.secretKey, s.signer) + c.Assert(err, nil) + + // execute the request. + response, err := s.client.Do(request) + c.Assert(err, nil) + c.Assert(response.StatusCode, http.StatusOK) + + // Build a minimal valid tar archive. + var tarBuf bytes.Buffer + tw := tar.NewWriter(&tarBuf) + c.Assert(tw.WriteHeader(&tar.Header{ + Name: "test.txt", + Mode: 0644, + Size: int64(len("hello snowball")), + }), nil) + _, err = tw.Write([]byte("hello snowball")) + c.Assert(err, nil) + c.Assert(tw.Close(), nil) + + now := UTCNow() + req, err := http.NewRequest(http.MethodPut, getPutObjectURL(s.endPoint, bucketName, "test-snowball.tar"), nil) + c.Assert(err, nil) + + req.Body = io.NopCloser(bytes.NewReader(tarBuf.Bytes())) + req.Trailer = http.Header{} + req.Trailer.Set("x-amz-checksum-crc32", "rK0DXg==") + + req = signer.StreamingUnsignedV4(req, "", int64(tarBuf.Len()), now) + + // The CVE-2026-40344 attack: provide an Authorization header with a + // valid access key but a completely fabricated signature. The Snowball + // auto-extract handler must still verify the signature. + maliciousAuth := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=deadbeefdeadbeefdeadbeeddeadbeeddeadbeefdeadbeefdeadbeefdeadbeef", s.accessKey, now.Format(yyyymmdd)) + req.Header.Set("Authorization", maliciousAuth) + + // Ensure the headers expected for unsigned trailer streaming remain. + req.Header.Set("X-Amz-Decoded-Content-Length", fmt.Sprintf("%d", tarBuf.Len())) + req.Header.Set("Content-Encoding", "aws-chunked") + req.Header.Set("X-Amz-Trailer", "x-amz-checksum-crc32") + req.Header.Set("x-amz-content-sha256", unsignedPayloadTrailer) + // Trigger the Snowball auto-extract handler. + req.Header.Set("X-Amz-Meta-Snowball-Auto-Extract", "true") + + // execute the request. + response, err = s.client.Do(req) + c.Assert(err, nil) + + // The request must be rejected because the fabricated signature is not valid. + c.Assert(response.StatusCode, http.StatusForbidden) +} + func (s *TestSuiteCommon) TestBucketSQSNotificationAMQP(c *check) { // Sample bucket notification. bucketNotificationBuf := `s3:ObjectCreated:Putprefiximages/1arn:minio:sqs:us-east-1:444455556666:amqp` diff --git a/cmd/signature-v4-parser.go b/cmd/signature-v4-parser.go index f6866cb858870..9d45590586921 100644 --- a/cmd/signature-v4-parser.go +++ b/cmd/signature-v4-parser.go @@ -66,6 +66,23 @@ func getReqAccessKeyV4(r *http.Request, region string, stype serviceType) (auth. return checkKeyValid(r, ch.accessKey) } +// getReqAccessKeyV4FromHeader extracts the access key only from the +// Authorization header. It is used for auth types (such as unsigned trailer +// streaming) where query-string credentials must not be trusted because no +// signature verification is performed for them. +func getReqAccessKeyV4FromHeader(r *http.Request, region string, stype serviceType) (auth.Credentials, bool, APIErrorCode) { + v4Auth := strings.TrimPrefix(r.Header.Get("Authorization"), signV4Algorithm) + authFields := strings.Split(strings.TrimSpace(v4Auth), ",") + if len(authFields) != 3 { + return auth.Credentials{}, false, ErrMissingFields + } + ch, s3Err := parseCredentialHeader(authFields[0], region, stype) + if s3Err != ErrNone { + return auth.Credentials{}, false, s3Err + } + return checkKeyValid(r, ch.accessKey) +} + // parse credentialHeader string into its structured form. func parseCredentialHeader(credElement string, region string, stype serviceType) (ch credentialHeader, aec APIErrorCode) { creds := strings.SplitN(strings.TrimSpace(credElement), "=", 2) diff --git a/docs/changelog/hedged-erasure-reads.md b/docs/changelog/hedged-erasure-reads.md new file mode 100644 index 0000000000000..b9e3a04c8b8da --- /dev/null +++ b/docs/changelog/hedged-erasure-reads.md @@ -0,0 +1,77 @@ +# Changelog: hedged erasure reads (read_fanout_delay) + +## Summary + +Adds an opt-in "hedged read" strategy for erasure-coded object reads +(`GET`/range reads). A single slow drive no longer stalls a read; the +read races `K+1` shards (K = data blocks) and, if that is not enough, +fans out to all remaining shards after a configurable delay. + +## Configuration + +New `drive` subsystem setting, applied dynamically (no restart): + +``` +mc admin config set drive.read_fanout_delay=250ms +``` + +- `0` (default): current behavior, hedging disabled. +- `250ms` (example): enable hedged reads; value is per-block fan-out delay. +- Environment override: `MINIO_DRIVE_READ_FANOUT_DELAY=250ms`. + The environment variable takes precedence over the config value. + +## Behavior when enabled + +Per erasure block (`Erasure.Decode`, normal object reads only): + +1. Launches reads on the first `K+1` online shards (local disks preferred + via the existing `prefer` ordering). One slow or missing shard is + covered instantly at the cost of a single extra shard read. +2. A shard that fails immediately (missing, corrupt, disk error) triggers + an immediate substitute read, as before. +3. If `K` verified shards have not arrived within `read_fanout_delay`, + reads are launched on every remaining shard in parallel. +4. The block completes as soon as `K` bitrot-verified shards (data or + parity) have arrived; the decode step reconstructs missing data + shards from whatever mix arrived. +5. Readers that lose the race ("stragglers") are not waited on. A + straggler that completes late is discarded for its own block and + rejoins for the next block; while slow it is simply skipped. + +Healing (`Erasure.Heal`) and all write paths are unchanged. Read quorum +failure still surfaces as `SlowDownRead` (503). + +## Overheads (when enabled) + +- Steady state: one extra shard read per block (`K+1` instead of `K`). +- Full fan-out reads only on blocks that actually stall for the + configured delay; shards read after the block completed are wasted + reads, bounded by the fan-out. +- RAM: shard buffers are allocated per launched reader instead of + seeded from the shared byte pool (straggler goroutines may outlive + the reader); worst case is `N x shardSize` per in-flight part read. + +## Operational notes + +- Sensible starting point: `250ms`. Must stay well below + `drive.max_timeout` (default 30s). +- Bitrot verification applies to every shard that wins the race, so + the integrity guarantees are unchanged: corrupt shards are replaced + by parity on the fly and the object is queued for async heal. +- Tune with the existing disk metrics (`total-errs-timeout`) and + observe straggler frequency before lowering `drive.max_timeout`; a + slow drive now loses block races instead of blocking them, so the + two settings compound. + +## Files changed + +- `internal/config/drive/drive.go`, `internal/config/drive/help.go`: + new `read_fanout_delay` key, env override, dynamic update, help text. +- `cmd/erasure-decode.go`: hedged read implementation in + `parallelReader` (`readHedged`), in-flight reader tracking, straggler + re-join/abandon lifecycle; `Erasure.Decode` wires the config, + `Erasure.Heal` stays on the plain path. +- `cmd/erasure-decode_test.go`: hedged unit tests (hedge coverage, + fan-out timer, error substitution, quorum error, straggler rejoin), + full-stack slow-disk decode test, and byte-pool self-initialization + for the decode tests (fixes pre-existing panic when run standalone). \ No newline at end of file diff --git a/internal/config/drive/drive.go b/internal/config/drive/drive.go index 862c62ab76b79..7f4186754c8dd 100644 --- a/internal/config/drive/drive.go +++ b/internal/config/drive/drive.go @@ -18,6 +18,7 @@ package drive import ( + "fmt" "sync" "time" @@ -30,6 +31,7 @@ const ( EnvMaxDriveTimeout = "MINIO_DRIVE_MAX_TIMEOUT" EnvMaxDriveTimeoutLegacy = "_MINIO_DRIVE_MAX_TIMEOUT" EnvMaxDiskTimeoutLegacy = "_MINIO_DISK_MAX_TIMEOUT" + EnvReadFanoutDelay = "MINIO_DRIVE_READ_FANOUT_DELAY" ) // DefaultKVS - default KVS for drive @@ -38,6 +40,10 @@ var DefaultKVS = config.KVS{ Key: MaxTimeout, Value: "30s", }, + config.KV{ + Key: ReadFanoutDelay, + Value: "0", + }, } var configLk sync.RWMutex @@ -46,6 +52,10 @@ var configLk sync.RWMutex type Config struct { // MaxTimeout - maximum timeout for a drive operation MaxTimeout time.Duration `json:"maxTimeout"` + + // ReadFanoutDelay - delay after which a hedged erasure read fans out + // to all remaining shards, 0 disables hedged reads. + ReadFanoutDelay time.Duration `json:"readFanoutDelay"` } // Update - updates the config with latest values @@ -53,6 +63,7 @@ func (c *Config) Update(updated Config) error { configLk.Lock() defer configLk.Unlock() c.MaxTimeout = getMaxTimeout(updated.MaxTimeout) + c.ReadFanoutDelay = updated.ReadFanoutDelay return nil } @@ -61,6 +72,14 @@ func (c *Config) GetMaxTimeout() time.Duration { return c.GetOPTimeout() } +// GetReadFanoutDelay - returns the delay after which hedged erasure +// reads fan out to all remaining shards. 0 disables hedged reads. +func (c *Config) GetReadFanoutDelay() time.Duration { + configLk.RLock() + defer configLk.RUnlock() + return c.ReadFanoutDelay +} + // GetOPTimeout - returns the per call drive operation timeout func (c *Config) GetOPTimeout() time.Duration { configLk.RLock() @@ -90,6 +109,16 @@ func LookupConfig(kvs config.KVS) (cfg Config, err error) { cfg.MaxTimeout = getMaxTimeout(dur) } } + + fanoutDelay, err := time.ParseDuration(env.Get(EnvReadFanoutDelay, kvs.GetWithDefault(ReadFanoutDelay, DefaultKVS))) + if err != nil { + return cfg, err + } + if fanoutDelay < 0 { + return cfg, fmt.Errorf("invalid value %v for read_fanout_delay", fanoutDelay) + } + cfg.ReadFanoutDelay = fanoutDelay + return cfg, err } diff --git a/internal/config/drive/help.go b/internal/config/drive/help.go index 5964dcce4181f..37b42e313cb0b 100644 --- a/internal/config/drive/help.go +++ b/internal/config/drive/help.go @@ -23,6 +23,9 @@ var ( // MaxTimeout is the max timeout for drive MaxTimeout = "max_timeout" + // ReadFanoutDelay is the delay before hedged erasure reads fan out to all shards + ReadFanoutDelay = "read_fanout_delay" + // HelpDrive is help for drive HelpDrive = config.HelpKVS{ config.HelpKV{ @@ -31,5 +34,11 @@ var ( Description: "set per call max_timeout for the drive, defaults to 30 seconds", Optional: true, }, + config.HelpKV{ + Key: ReadFanoutDelay, + Type: "string", + Description: "hedged erasure read fan-out delay, e.g. '250ms'. Reads start with K+1 shards and fan out to all shards after this delay. '0' (default) disables hedged reads", + Optional: true, + }, } ) diff --git a/internal/config/identity/openid/jwt.go b/internal/config/identity/openid/jwt.go index 2a422010f842d..001c861235208 100644 --- a/internal/config/identity/openid/jwt.go +++ b/internal/config/identity/openid/jwt.go @@ -79,8 +79,14 @@ func (r *Config) PopulatePublicKey(arn arn.ARN) error { return nil } - // Add client secret for the client ID for HMAC based signature. - r.pubKeys.add(pCfg.ClientID, []byte(pCfg.ClientSecret)) + // Add client secret for the client ID for HMAC based signature, but only + // when the provider advertises HMAC signing algorithms. Otherwise an + // attacker who knows the client secret could perform an algorithm + // confusion attack by sending a token with alg=HS256 even though the + // provider only issues RS256 tokens (CVE-2026-33322). + if hasHMACAlgorithm(pCfg.DiscoveryDoc.IDTokenSigningAlgValuesSupported) { + r.pubKeys.add(pCfg.ClientID, []byte(pCfg.ClientSecret)) + } client := &http.Client{ Transport: r.transport, @@ -103,6 +109,18 @@ var ( ErrTokenExpired = errors.New("token expired") ) +// hasHMACAlgorithm returns true if the provider advertises an HMAC-based +// JWT signing algorithm. +func hasHMACAlgorithm(algs []string) bool { + for _, alg := range algs { + switch alg { + case "HS256", "HS384", "HS512": + return true + } + } + return false +} + func updateClaimsExpiry(dsecs string, claims map[string]any) error { expStr := claims["exp"] if expStr == "" { @@ -134,13 +152,18 @@ const ( // Validate - validates the id_token. func (r *Config) Validate(ctx context.Context, arn arn.ARN, token, accessToken, dsecs string, claims map[string]any) error { + pCfg, ok := r.arnProviderCfgsMap[arn] + if !ok { + return fmt.Errorf("Role %s does not exist", arn) + } + jp := new(jwtgo.Parser) - jp.ValidMethods = []string{ - "RS256", "RS384", "RS512", - "ES256", "ES384", "ES512", - "HS256", "HS384", "HS512", - "RS3256", "RS3384", "RS3512", - "ES3256", "ES3384", "ES3512", + // Only accept the signing algorithms advertised by the provider. If the + // provider does not advertise any, fall back to asymmetric algorithms to + // prevent algorithm confusion attacks (CVE-2026-33322). + jp.ValidMethods = pCfg.DiscoveryDoc.IDTokenSigningAlgValuesSupported + if len(jp.ValidMethods) == 0 { + jp.ValidMethods = []string{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"} } keyFuncCallback := func(jwtToken *jwtgo.Token) (any, error) { @@ -155,11 +178,6 @@ func (r *Config) Validate(ctx context.Context, arn arn.ARN, token, accessToken, return pubkey, nil } - pCfg, ok := r.arnProviderCfgsMap[arn] - if !ok { - return fmt.Errorf("Role %s does not exist", arn) - } - mclaims := jwtgo.MapClaims(claims) jwtToken, err := jp.ParseWithClaims(token, &mclaims, keyFuncCallback) if err != nil { @@ -168,7 +186,7 @@ func (r *Config) Validate(ctx context.Context, arn arn.ARN, token, accessToken, if err = r.PopulatePublicKey(arn); err != nil { return err } - jwtToken, err = jwtgo.ParseWithClaims(token, &mclaims, keyFuncCallback) + jwtToken, err = jp.ParseWithClaims(token, &mclaims, keyFuncCallback) if err != nil { return err } diff --git a/internal/config/identity/openid/jwt_test.go b/internal/config/identity/openid/jwt_test.go index cb54faff95741..7871614441bca 100644 --- a/internal/config/identity/openid/jwt_test.go +++ b/internal/config/identity/openid/jwt_test.go @@ -129,6 +129,9 @@ func TestJWTHMACType(t *testing.T) { provider := providerCfg{ ClientID: "76b95ae5-33ef-4283-97b7-d2a85dc2d8f4", ClientSecret: "WNGvKVyyNmXq0TraSvjaDN9CtpFgx35IXtGEffMCPR0", + DiscoveryDoc: DiscoveryDoc{ + IDTokenSigningAlgValuesSupported: []string{"HS256"}, + }, } provider.JWKS.URL = u1 cfg := Config{ @@ -151,6 +154,71 @@ func TestJWTHMACType(t *testing.T) { } } +// TestJWTAlgorithmConfusion verifies CVE-2026-33322: an attacker with the +// client secret must not be able to forge an HS256 token when the provider +// only advertises RS256. +func TestJWTAlgorithmConfusion(t *testing.T) { + server := initJWKSServer() + defer server.Close() + + jwt := &jwtgo.Token{ + Method: jwtgo.SigningMethodHS256, + Claims: jwtgo.StandardClaims{ + ExpiresAt: 253428928061, + Audience: "76b95ae5-33ef-4283-97b7-d2a85dc2d8f4", + }, + Header: map[string]any{ + "typ": "JWT", + "alg": jwtgo.SigningMethodHS256.Alg(), + "kid": "76b95ae5-33ef-4283-97b7-d2a85dc2d8f4", + }, + } + + token, err := jwt.SignedString([]byte("WNGvKVyyNmXq0TraSvjaDN9CtpFgx35IXtGEffMCPR0")) + if err != nil { + t.Fatal(err) + } + + u1, err := xnet.ParseHTTPURL(server.URL) + if err != nil { + t.Fatal(err) + } + + pubKeys := publicKeys{ + RWMutex: &sync.RWMutex{}, + pkMap: map[string]any{}, + } + pubKeys.add("76b95ae5-33ef-4283-97b7-d2a85dc2d8f4", []byte("WNGvKVyyNmXq0TraSvjaDN9CtpFgx35IXtGEffMCPR0")) + + provider := providerCfg{ + ClientID: "76b95ae5-33ef-4283-97b7-d2a85dc2d8f4", + ClientSecret: "WNGvKVyyNmXq0TraSvjaDN9CtpFgx35IXtGEffMCPR0", + DiscoveryDoc: DiscoveryDoc{ + // Provider only advertises RS256; HS256 must be rejected. + IDTokenSigningAlgValuesSupported: []string{"RS256"}, + }, + } + provider.JWKS.URL = u1 + cfg := Config{ + Enabled: true, + pubKeys: pubKeys, + arnProviderCfgsMap: map[arn.ARN]*providerCfg{ + DummyRoleARN: &provider, + }, + ProviderCfgs: map[string]*providerCfg{ + "1": &provider, + }, + closeRespFn: func(rc io.ReadCloser) { + rc.Close() + }, + } + + var claims jwtgo.MapClaims + if err = cfg.Validate(t.Context(), DummyRoleARN, token, "", "", claims); err == nil { + t.Fatal("expected HS256 token to be rejected when provider advertises only RS256") + } +} + func TestJWT(t *testing.T) { const jsonkey = `{"keys": [