diff --git a/.github/labeler.yml b/.github/labeler.yml deleted file mode 100644 index fb6d6ad76..000000000 --- a/.github/labeler.yml +++ /dev/null @@ -1,51 +0,0 @@ -documentation: - - docs/* - - docs/**/* - - site/* - - site/**/* - -workflows: - - .github/workflows/* - - .github/workflows/**/* - -deploy: - - deploy/* - - deploy/**/* - -examples: - - examples/* - - examples/**/* - -"area:dashboard": - - pkg/server/* - - pkg/server/**/* - - pkg/web/* - - pkg/web/**/* - -"area:autoscaling": - - pkg/prediction/* - - pkg/prediction/**/* - - pkg/metricprovider/* - - pkg/metricprovider/**/* - - pkg/controller/evpa/* - - pkg/controller/evpa/**/* - - pkg/controller/ehpa/* - - pkg/controller/ehpa/**/* - -"area:algorithm": - - pkg/prediction/* - - pkg/prediction/**/* - - pkg/predictor/* - - pkg/predictor/**/* - - pkg/controller/timeseriesprediction/* - - pkg/controller/timeseriesprediction/**/* - -"area:recommendation": - - pkg/recommend/* - - pkg/recommend/**/* - - pkg/recommendation/* - - pkg/recommendation/**/* - - pkg/controller/analytics/* - - pkg/controller/analytics/**/* - - pkg/controller/recommendation/* - - pkg/controller/recommendation/**/* diff --git a/.github/workflows/auto-close-issues.yml b/.github/workflows/auto-close-issues.yml deleted file mode 100644 index c57e5aa0a..000000000 --- a/.github/workflows/auto-close-issues.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Auto close issues - -on: - schedule: - - cron: "0 0 * * *" - -jobs: - # if issues not clear, maintainer add labels - need info. - # if issues include labels - need info and inactive, then will be close after 7 days. - check-need-info: - runs-on: ubuntu-latest - steps: - - name: close-issues - uses: actions-cool/issues-helper@v2 - with: - actions: 'close-issues' - token: ${{ secrets.GITHUB_TOKEN }} - labels: 'need info' - inactive-day: 7 - body: | - This issue seems not clear enough and inactive for over 7 days. - So the issues will be closed by Github Actions. - You can reopen this issue if you need. - # if issues include labels - question and complete, then will be close after 7 days if inactive. - check-question-complete: - runs-on: ubuntu-latest - steps: - - name: close-issues - uses: actions-cool/issues-helper@v2 - with: - actions: 'close-issues' - token: ${{ secrets.GITHUB_TOKEN }} - labels: 'question, complete' - inactive-day: 7 - body: | - This issue seems complete and inactive for over 7 days. - So the issues will be closed by Github Actions. - You can reopen this issue if you need. - check-need-reproduce: - runs-on: ubuntu-latest - steps: - - name: close-issues - uses: actions-cool/issues-helper@v2 - with: - actions: 'close-issues' - token: ${{ secrets.GITHUB_TOKEN }} - labels: 'Need Reproduce' - inactive-day: 3 - body: | - This issue needs some reproduce information and inactive for over 3 days. - So the issues will be closed by Github Actions. - You can reopen this issue if you need. diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml deleted file mode 100644 index 28bda3ca2..000000000 --- a/.github/workflows/build-images.yml +++ /dev/null @@ -1,486 +0,0 @@ -name: build-images - -on: - pull_request_target: - types: [ opened, synchronize, reopened ] - paths: - - 'cmd/**' - - 'pkg/**' - - '**.go' - - 'go.*' - - '**.yml' - push: - paths: - - 'cmd/**' - - 'pkg/**' - - '**.go' - - 'go.*' - - '**.yml' - workflow_dispatch: - -jobs: - build-craned: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - id: git-versions - run: | - echo "::set-output name=git-version::$(git describe --tags --always)" - - id: git-branch - run: | - echo "::set-output name=git-branch::$(echo ${GITHUB_REF##*/} | tr '[A-Z]' '[a-z]')" - - id: build-name-image - run: | - echo "::set-output name=build-name-image::craned" - - id: build-name-file - run: | - echo "::set-output name=build-name-file::$(echo "${{steps.build-name-image.outputs.build-name-image}}" | tr '/' '-')" - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: Inspect builder - run: | - echo "Name: ${{ steps.buildx.outputs.name }}" - echo "Endpoint: ${{ steps.buildx.outputs.endpoint }}" - echo "Status: ${{ steps.buildx.outputs.status }}" - echo "Flags: ${{ steps.buildx.outputs.flags }}" - echo "Platforms: ${{ steps.buildx.outputs.platforms }}" - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Login to Coding Container Registry - uses: docker/login-action@v1 - with: - registry: finops-docker.pkg.coding.net - username: ${{ secrets.CODING_USERNAME }} - password: ${{ secrets.CODING_PASSWORD }} - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - id: build-ldflags - run: | - echo "::set-output name=build-ldflags::$(make echoLDFLAGS)" - - - name: Build craned and push - push - if: ${{ github.event_name == 'push' }} - uses: docker/build-push-action@v3 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - PKGNAME=craned - LDFLAGS=${{steps.build-ldflags.outputs.build-ldflags}} - BUILD=CI - tags: | - gocrane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - gocrane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - - - name: Build craned and push - pull_request_target - if: ${{ github.event_name == 'pull_request_target' }} - uses: docker/build-push-action@v3 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - PKGNAME=craned - LDFLAGS=${{steps.build-ldflags.outputs.build-ldflags}} - BUILD=CI - tags: | - gocrane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - - build-metric-adapter: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - id: git-versions - run: | - echo "::set-output name=git-version::$(git describe --tags --always)" - - id: git-branch - run: | - echo "::set-output name=git-branch::$(echo ${GITHUB_REF##*/} | tr '[A-Z]' '[a-z]')" - - id: build-name-image - run: | - echo "::set-output name=build-name-image::metric-adapter" - - id: build-name-file - run: | - echo "::set-output name=build-name-file::$(echo "${{steps.build-name-image.outputs.build-name-image}}" | tr '/' '-')" - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: Inspect builder - run: | - echo "Name: ${{ steps.buildx.outputs.name }}" - echo "Endpoint: ${{ steps.buildx.outputs.endpoint }}" - echo "Status: ${{ steps.buildx.outputs.status }}" - echo "Flags: ${{ steps.buildx.outputs.flags }}" - echo "Platforms: ${{ steps.buildx.outputs.platforms }}" - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Login to Coding Container Registry - uses: docker/login-action@v1 - with: - registry: finops-docker.pkg.coding.net - username: ${{ secrets.CODING_USERNAME }} - password: ${{ secrets.CODING_PASSWORD }} - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - id: build-ldflags - run: | - echo "::set-output name=build-ldflags::$(make echoLDFLAGS)" - - - name: Build crane-agent and push - push - if: ${{ github.event_name == 'push' }} - uses: docker/build-push-action@v3 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - PKGNAME=metric-adapter - LDFLAGS=${{steps.build-ldflags.outputs.build-ldflags}} - BUILD=CI - tags: | - gocrane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - gocrane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - - - name: Build crane-agent and push - pull_request_target - if: ${{ github.event_name == 'pull_request_target' }} - uses: docker/build-push-action@v3 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - PKGNAME=metric-adapter - LDFLAGS=${{steps.build-ldflags.outputs.build-ldflags}} - BUILD=CI - tags: | - gocrane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - - build-crane-agent: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - id: git-versions - run: | - echo "::set-output name=git-version::$(git describe --tags --always)" - - id: git-branch - run: | - echo "::set-output name=git-branch::$(echo ${GITHUB_REF##*/} | tr '[A-Z]' '[a-z]')" - - id: build-name-image - run: | - echo "::set-output name=build-name-image::crane-agent" - - id: build-name-file - run: | - echo "::set-output name=build-name-file::$(echo "${{steps.build-name-image.outputs.build-name-image}}" | tr '/' '-')" - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: Inspect builder - run: | - echo "Name: ${{ steps.buildx.outputs.name }}" - echo "Endpoint: ${{ steps.buildx.outputs.endpoint }}" - echo "Status: ${{ steps.buildx.outputs.status }}" - echo "Flags: ${{ steps.buildx.outputs.flags }}" - echo "Platforms: ${{ steps.buildx.outputs.platforms }}" - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Login to Coding Container Registry - uses: docker/login-action@v1 - with: - registry: finops-docker.pkg.coding.net - username: ${{ secrets.CODING_USERNAME }} - password: ${{ secrets.CODING_PASSWORD }} - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - id: build-ldflags - run: | - echo "::set-output name=build-ldflags::$(make echoLDFLAGS)" - - - name: Build crane-agent and push - push - uses: docker/build-push-action@v3 - if: ${{ github.event_name == 'push' }} - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - PKGNAME=crane-agent - LDFLAGS=${{steps.build-ldflags.outputs.build-ldflags}} - BUILD=CI - tags: | - gocrane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - gocrane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - - - name: Build crane-agent and push - pull_request_target - uses: docker/build-push-action@v3 - if: ${{ github.event_name == 'pull_request_target' }} - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - build-args: | - PKGNAME=crane-agent - LDFLAGS=${{steps.build-ldflags.outputs.build-ldflags}} - BUILD=CI - tags: | - gocrane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - - build-dashboard: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v2 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - id: git-versions - run: | - echo "::set-output name=git-version::$(git describe --tags --always)" - - id: git-branch - run: | - echo "::set-output name=git-branch::$(echo ${GITHUB_REF##*/} | tr '[A-Z]' '[a-z]')" - - id: build-name-image - run: | - echo "::set-output name=build-name-image::dashboard" - - id: build-name-file - run: | - echo "::set-output name=build-name-file::$(echo "${{steps.build-name-image.outputs.build-name-image}}" | tr '/' '-')" - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 - - name: Inspect builder - run: | - echo "Name: ${{ steps.buildx.outputs.name }}" - echo "Endpoint: ${{ steps.buildx.outputs.endpoint }}" - echo "Status: ${{ steps.buildx.outputs.status }}" - echo "Flags: ${{ steps.buildx.outputs.flags }}" - echo "Platforms: ${{ steps.buildx.outputs.platforms }}" - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Login to Coding Container Registry - uses: docker/login-action@v1 - with: - registry: finops-docker.pkg.coding.net - username: ${{ secrets.CODING_USERNAME }} - password: ${{ secrets.CODING_PASSWORD }} - - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build Dashboard and push - push to branch - if: ${{ github.event_name == 'push' }} - uses: docker/build-push-action@v3 - with: - context: ./pkg/web - platforms: linux/amd64,linux/arm64 - push: true - tags: | - gocrane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - gocrane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}}-${{steps.git-versions.outputs.git-version}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:${{steps.git-branch.outputs.git-branch}} - - - name: Build Dashboard and push - pull_request_target - if: ${{ github.event_name == 'pull_request_target' }} - uses: docker/build-push-action@v3 - with: - context: ./pkg/web - platforms: linux/amd64,linux/arm64 - push: true - tags: | - gocrane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - ghcr.io/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - finops-docker.pkg.coding.net/gocrane/crane/${{steps.build-name-image.outputs.build-name-image}}:pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}} - post-comment: - runs-on: ubuntu-latest - if: ${{ github.event_name == 'pull_request_target' }} - needs: - - build-craned - - build-metric-adapter - - build-crane-agent - - build-dashboard - steps: - - name: Checkout - uses: actions/checkout@v2 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - id: git-versions - run: | - echo "::set-output name=git-version::$(git describe --tags --always)" - - id: image-tags - run: | - echo "::set-output name=image-tags::pr-${{github.event.number}}-${{steps.git-versions.outputs.git-version}}" - - name: Get current date - id: date - run: echo "::set-output name=date::$(TZ='Asia/Shanghai' date +'%Y-%m-%d %H:%M')" - - name: maintain-comment - uses: actions-cool/maintain-one-comment@v2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - body: | - 🎉 Successfully Build Images. - **Now Support ARM Platforms.** - Comment Post Time: ${{ steps.date.outputs.date }} - Git Version: ${{steps.git-versions.outputs.git-version}} - - #### Docker Registry - - Overview: https://hub.docker.com/u/gocrane - - | Image | Pull Command | - | --------------------------------------------- | --------------------------------------------------------- | - | crane-agent:${{steps.image-tags.outputs.image-tags}} | docker pull gocrane/crane-agent:${{steps.image-tags.outputs.image-tags}} | - | dashboard:${{steps.image-tags.outputs.image-tags}} | docker pull gocrane/dashboard:${{steps.image-tags.outputs.image-tags}} | - | metric-adapter:${{steps.image-tags.outputs.image-tags}} | docker pull gocrane/metric-adapter:${{steps.image-tags.outputs.image-tags}} | - | craned:${{steps.image-tags.outputs.image-tags}} | docker pull gocrane/craned:${{steps.image-tags.outputs.image-tags}} | - - Quick Deploy - Helm - ```bash - helm repo add crane https://finops-helm.pkg.coding.net/gocrane/gocrane - helm install crane -n crane-system --create-namespace \ - --set craned.image.repository=gocrane/craned \ - --set craned.image.tag=${{steps.image-tags.outputs.image-tags}} \ - --set metricAdapter.image.repository=gocrane/metric-adapter \ - --set metricAdapter.image.tag=${{steps.image-tags.outputs.image-tags}} \ - --set craneAgent.image.repository=gocrane/crane-agent \ - --set craneAgent.image.tag=${{steps.image-tags.outputs.image-tags}} \ - --set cranedDashboard.image.repository=gocrane/dashboard \ - --set cranedDashboard.image.tag=${{steps.image-tags.outputs.image-tags}} crane/crane - ``` - - --- - - #### Coding Registry - - Overview: https://finops.coding.net/public-artifacts/gocrane/crane/packages - - | Image | Pull Command | - | --------------------------------------------- | --------------------------------------------------------- | - | crane-agent:${{steps.image-tags.outputs.image-tags}} | docker pull finops-docker.pkg.coding.net/gocrane/crane/crane-agent:${{steps.image-tags.outputs.image-tags}} | - | dashboard:${{steps.image-tags.outputs.image-tags}} | docker pull finops-docker.pkg.coding.net/gocrane/crane/dashboard:${{steps.image-tags.outputs.image-tags}} | - | metric-adapter:${{steps.image-tags.outputs.image-tags}} | docker pull finops-docker.pkg.coding.net/gocrane/crane/metric-adapter:${{steps.image-tags.outputs.image-tags}} | - | craned:${{steps.image-tags.outputs.image-tags}} | docker pull finops-docker.pkg.coding.net/gocrane/crane/craned:${{steps.image-tags.outputs.image-tags}} | - - Quick Deploy - Helm - ```bash - helm repo add crane https://finops-helm.pkg.coding.net/gocrane/gocrane - helm install crane -n crane-system --create-namespace \ - --set craned.image.repository=finops-docker.pkg.coding.net/gocrane/crane/craned \ - --set craned.image.tag=${{steps.image-tags.outputs.image-tags}} \ - --set metricAdapter.image.repository=finops-docker.pkg.coding.net/gocrane/crane/metric-adapter \ - --set metricAdapter.image.tag=${{steps.image-tags.outputs.image-tags}} \ - --set craneAgent.image.repository=finops-docker.pkg.coding.net/gocrane/crane/crane-agent \ - --set craneAgent.image.tag=${{steps.image-tags.outputs.image-tags}} \ - --set cranedDashboard.image.repository=finops-docker.pkg.coding.net/gocrane/crane/dashboard \ - --set cranedDashboard.image.tag=${{steps.image-tags.outputs.image-tags}} crane/crane - ``` - - --- - - #### Ghcr Registry - - Overview: https://github.com/orgs/gocrane/packages?repo_name=crane - - | Image | Pull Command | - | --------------------------------------------- | --------------------------------------------------------- | - | crane-agent:${{steps.image-tags.outputs.image-tags}} | docker pull ghcr.io/gocrane/crane/crane-agent:${{steps.image-tags.outputs.image-tags}} | - | dashboard:${{steps.image-tags.outputs.image-tags}} | docker pull ghcr.io/gocrane/crane/dashboard:${{steps.image-tags.outputs.image-tags}} | - | metric-adapter:${{steps.image-tags.outputs.image-tags}} | docker pull ghcr.io/gocrane/crane/metric-adapter:${{steps.image-tags.outputs.image-tags}} | - | craned:${{steps.image-tags.outputs.image-tags}} | docker pull ghcr.io/gocrane/crane/craned:${{steps.image-tags.outputs.image-tags}} | - - Quick Deploy - Helm - ```bash - helm repo add crane https://finops-helm.pkg.coding.net/gocrane/gocrane - helm install crane -n crane-system --create-namespace \ - --set craned.image.repository=ghcr.io/gocrane/crane/craned \ - --set craned.image.tag=${{steps.image-tags.outputs.image-tags}} \ - --set metricAdapter.image.repository=ghcr.io/gocrane/crane/metric-adapter \ - --set metricAdapter.image.tag=${{steps.image-tags.outputs.image-tags}} \ - --set craneAgent.image.repository=ghcr.io/gocrane/crane/crane-agent \ - --set craneAgent.image.tag=${{steps.image-tags.outputs.image-tags}} \ - --set cranedDashboard.image.repository=ghcr.io/gocrane/crane/dashboard \ - --set cranedDashboard.image.tag=${{steps.image-tags.outputs.image-tags}} crane/crane - ``` - - - body-include: '' diff --git a/.github/workflows/cleanup-infra.yml b/.github/workflows/cleanup-infra.yml new file mode 100644 index 000000000..5d8d55cbb --- /dev/null +++ b/.github/workflows/cleanup-infra.yml @@ -0,0 +1,36 @@ +name: Cleanup infrastructure + +on: + workflow_dispatch: + +jobs: + deploy: + name: Cleanup of infrastructure + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_wrapper: false + + - name: Azure Login + uses: azure/login@v2 + with: + creds: | + { + "clientId": "${{ secrets.AZURE_CLIENT_ID }}", + "clientSecret": "${{ secrets.AZURE_CLIENT_SECRET }}", + "subscriptionId": "${{ secrets.AZURE_SUBSCRIPTION_ID }}", + "tenantId": "${{ secrets.AZURE_TENANT_ID }}" + } + + - name: Delete k8s cluster + working-directory: ./terraform/deploy-kubernetes-cluster + run : | + terraform init + terraform destroy -auto-approve + diff --git a/.github/workflows/deploy-carbon-stack.yml b/.github/workflows/deploy-carbon-stack.yml new file mode 100644 index 000000000..c90e60e0a --- /dev/null +++ b/.github/workflows/deploy-carbon-stack.yml @@ -0,0 +1,194 @@ +name: Deploy Carbon Optimization Stack + +on: + workflow_dispatch: + +jobs: + deploy: + name: Deploy AKS, App, Prometheus + Kepler + Crane + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_wrapper: false + + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Azure Login + uses: azure/login@v2 + with: + creds: | + { + "clientId": "${{ secrets.AZURE_CLIENT_ID }}", + "clientSecret": "${{ secrets.AZURE_CLIENT_SECRET }}", + "subscriptionId": "${{ secrets.AZURE_SUBSCRIPTION_ID }}", + "tenantId": "${{ secrets.AZURE_TENANT_ID }}" + } + + - name: Deploy AKS cluster + working-directory: ./terraform/deploy-kubernetes-cluster + run : | + terraform init + terraform apply -auto-approve + + - name: Get Terraform Outputs + id: tf_outputs + working-directory: ./terraform/deploy-kubernetes-cluster + run: | + echo "resource_group=$(terraform output -raw Resource_group_name)" >> $GITHUB_OUTPUT + echo "cluster_name=$(terraform output -raw K8s_aks_name)" >> $GITHUB_OUTPUT + + - name: Get AKS credentials + run: | + az aks get-credentials \ + --resource-group ${{ steps.tf_outputs.outputs.resource_group }} \ + --name ${{ steps.tf_outputs.outputs.cluster_name }} \ + --overwrite-existing + + - name: Checkout AKS application + uses: actions/checkout@v4 + with: + repository: ekaterina-despotova/aks-store-demo + ref: main + token: ${{ secrets.GH_PAT }} + path: k8s-manifests + + - name: Deploy application + working-directory: k8s-manifests + run: | + kubectl create namespace pets --dry-run=client -o yaml | kubectl apply -f - + kubectl apply -f aks-store-all-in-one.yaml -n pets + + + - name: Create monitoring namespace + run: kubectl create namespace monitoring --dry-run=client -o yaml | kubectl apply -f - + + - name: Add Prometheus Helm repo + run: | + helm repo add prometheus-community https://prometheus-community.github.io/helm-charts + helm repo update + + - name: Deploy kube-prometheus-stack + run: | + helm upgrade --install prometheus prometheus-community/kube-prometheus-stack \ + --namespace monitoring \ + --values deploy/monitoring/prometheus-values.yaml \ + --timeout 10m + + - name: Wait for Prometheus operator + run: | + kubectl rollout status deployment/prometheus-kube-prometheus-operator \ + -n monitoring --timeout=180s + + - name: Create kepler namespace + run: kubectl create namespace kepler --dry-run=client -o yaml | kubectl apply -f - + + - name: Add Kepler Helm repo + run: | + helm repo add kepler https://sustainable-computing-io.github.io/kepler-helm-chart + helm repo update + + - name: Deploy Kepler + run: | + helm upgrade --install kepler kepler/kepler \ + --namespace kepler \ + --values deploy/monitoring/kepler-values.yaml \ + --timeout 5m + + - name: Wait for Kepler DaemonSet + run: | + kubectl rollout status daemonset/kepler \ + -n kepler --timeout=180s + + + - name: Install Crane CRDs + run: kubectl apply -f deploy/manifests/ + + + - name: Create crane-system namespace + run: kubectl create namespace crane-system --dry-run=client -o yaml | kubectl apply -f - + + - name: Install Crane RBAC + run: kubectl apply -f deploy/craned/rbac.yaml + + + - name: Deploy craned with carbon recommenders + run: | + kubectl create secret generic electricity-maps-secret \ + -n crane-system \ + --from-literal=api-key=${{ secrets.ELM_API_KEY }} \ + --dry-run=client -o yaml | kubectl apply -f - + kubectl apply -f deploy/crane/craned-deploy.yaml + + - name: Wait for craned to be ready + run: | + kubectl rollout status deployment/craned \ + -n crane-system --timeout=300s + + - name: Apply CarbonIdleResource rule + run: kubectl apply -f examples/analytics/carbon-idle-rule.yaml + + - name: Apply CarbonRightSizing rule + run: kubectl apply -f examples/analytics/carbon-rightsize-rule.yaml + + - name: Apply CarbonLoadShifting rule + run: kubectl apply -f examples/analytics/carbon-loadshift-rule.yaml + + - name: Deploy Crane Dashboard + run: kubectl apply -f deploy/crane/dashboard-deploy.yaml + + - name: Create Carbon Dashboard ConfigMap + run: | + kubectl create configmap crane-carbon-dashboard -n crane-system \ + --from-file=index.html=deploy/crane/carbon-dashboard/index.html \ + --dry-run=client -o yaml | kubectl apply -f - + + - name: RBAC for cluster registration + run: | + kubectl create clusterrolebinding craned-secrets \ + --clusterrole=admin \ + --serviceaccount=crane-system:craned \ + --dry-run=client -o yaml | kubectl apply -f - + + - name: Restart dashboard to pick up ConfigMap + run: | + kubectl rollout restart deployment/crane-dashboard -n crane-system + kubectl rollout status deployment/crane-dashboard -n crane-system --timeout=60s + + - name: Register cluster in craned + run: | + kubectl exec -n crane-system deploy/crane-dashboard -- \ + wget -qO- --post-data='{"clusters":[{"name":"demo-carbon-aks","craneUrl":"http://craned:8082","prometheusAddress":"http://prometheus-kube-prometheus-prometheus.monitoring:9090"}]}' \ + --header='Content-Type: application/json' \ + http://craned:8082/api/v1/cluster || true + + - name: Create Kepler Grafana dashboard + run: | + kubectl exec -n crane-system deploy/crane-dashboard -- \ + wget -qO- --post-data='{"dashboard":{"title":"Kepler Carbon Emissions","uid":"kepler-carbon","panels":[{"id":1,"title":"Energy per Pod (Watts)","type":"timeseries","gridPos":{"h":8,"w":24,"x":0,"y":0},"targets":[{"expr":"sum by (pod_name, container_namespace) (rate(kepler_container_package_joules_total{mode=\"dynamic\"}[5m]))","legendFormat":"{{container_namespace}}/{{pod_name}}"}],"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":5,"title":"Energy per Container (Watts)","type":"timeseries","gridPos":{"h":8,"w":24,"x":0,"y":8},"targets":[{"expr":"rate(kepler_container_package_joules_total{mode=\"dynamic\"}[5m])","legendFormat":"{{container_namespace}}/{{pod_name}}/{{container_name}}"}],"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":2,"title":"Total Cluster Energy (Watts)","type":"timeseries","gridPos":{"h":8,"w":12,"x":0,"y":16},"targets":[{"expr":"sum(rate(kepler_container_package_joules_total{mode=\"dynamic\"}[5m]))","legendFormat":"Total Cluster Watts"}],"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":3,"title":"Energy per Namespace (Watts)","type":"piechart","gridPos":{"h":8,"w":12,"x":12,"y":16},"targets":[{"expr":"sum by (container_namespace) (rate(kepler_container_package_joules_total{mode=\"dynamic\"}[5m]))","legendFormat":"{{container_namespace}}"}],"datasource":{"type":"prometheus","uid":"prometheus"}},{"id":4,"title":"Node Energy (Watts)","type":"timeseries","gridPos":{"h":8,"w":24,"x":0,"y":24},"targets":[{"expr":"sum by (instance) (rate(kepler_node_package_joules_total{mode=\"dynamic\"}[5m]))","legendFormat":"{{instance}}"}],"datasource":{"type":"prometheus","uid":"prometheus"}}],"time":{"from":"now-1h","to":"now"},"refresh":"30s"},"overwrite":true}' \ + --header='Content-Type: application/json' \ + --header='Authorization: Basic YWRtaW46Y3JhbmUtZ3JhZmFuYQ==' \ + http://prometheus-grafana.monitoring/grafana/api/dashboards/db || true + + + - name: Verify deployment + run: | + echo "=== Pods in monitoring ===" + kubectl get pods -n monitoring + echo "" + echo "=== Pods in kepler ===" + kubectl get pods -n kepler + echo "" + echo "=== Pods in crane-system ===" + kubectl get pods -n crane-system + echo "" + echo "=== Recommendation Rules ===" + kubectl get recommendationrules + echo "" + echo "=== Deployment complete ===" diff --git a/.github/workflows/docsy.yml b/.github/workflows/docsy.yml deleted file mode 100644 index 751d21580..000000000 --- a/.github/workflows/docsy.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: 🔂 Crane PR Docs Preview - Docsy - -on: - push: - paths: - - "site/**" - -jobs: - preview: - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - contents: write - env: - HUGO_VERSION: 0.102.3 - HUGO_ENVIRONMENT: production - HUGO_ENV: production - steps: - - uses: actions/checkout@v2 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - id: git-branch - run: | - echo "::set-output name=git-branch::$(echo ${GITHUB_REF##*/} | tr '[A-Z]' '[a-z]')" - - name: Install Hugo CLI - run: | - wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_Linux-64bit.deb \ - && sudo dpkg -i ${{ runner.temp }}/hugo.deb - - - name: Use Node.js 16 - uses: actions/setup-node@v3 - with: - node-version: 16 - - - name: Build Docs - run: | - echo ${{steps.git-branch.outputs.git-branch}} - cd site - npm i - hugo --minify - - - name: Deploy - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: site/public diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml deleted file mode 100644 index b4d75a72e..000000000 --- a/.github/workflows/go.yml +++ /dev/null @@ -1,191 +0,0 @@ -name: Go - -on: - pull_request: - paths: - - 'cmd/**' - - 'pkg/**' - - '**.go' - - 'go.*' - -jobs: - - cover: - runs-on: ubuntu-latest - steps: - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: 1.17 - - # Get values for cache paths to be used in later steps - - id: go-cache-paths - run: | - echo "::set-output name=go-build::$(go env GOCACHE)" - echo "::set-output name=go-mod::$(go env GOMODCACHE)" - - - name: Checkout - uses: actions/checkout@v2 - - # Cache go build cache, used to speedup go test - - name: Go Build Cache - uses: actions/cache@v2 - with: - path: ${{ steps.go-cache-paths.outputs.go-build }} - key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} - - # Cache go mod cache, used to speedup builds - - name: Go Mod Cache - uses: actions/cache@v2 - with: - path: ${{ steps.go-cache-paths.outputs.go-mod }} - key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} - - - name: Go Lint Cache - uses: actions/cache@v2 - with: - path: | - ~/.cache/golangci-lint - key: ${{ runner.os }}-go-lint-${{ hashFiles('**/go.sum') }} - - - name: Generate - run: make generate - - - name: Fmt - run: make fmt - - - name: Vet - run: make vet - - - name: Cover - run: go test -coverprofile coverage.out -covermode=atomic ./... -v - - - name: Convert cover report - run: | - go tool cover -o coverage.html -html=coverage.out - sed -i 's/black/whitesmoke/g' coverage.html - - - name: Upload a Build Artifact - uses: actions/upload-artifact@v3.0.0 - with: - # Artifact name - name: coverage.html # optional, default is artifact - # A file, directory or wildcard pattern that describes what to upload - path: coverage.html - retention-days: 5 - - craned: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: 1.17 - - # Get values for cache paths to be used in later steps - - id: go-cache-paths - run: | - echo "::set-output name=go-build::$(go env GOCACHE)" - echo "::set-output name=go-mod::$(go env GOMODCACHE)" - - # Cache go build cache, used to speedup go test - - name: Go Build Cache - uses: actions/cache@v2 - with: - path: ${{ steps.go-cache-paths.outputs.go-build }} - key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} - - # Cache go mod cache, used to speedup builds - - name: Go Mod Cache - uses: actions/cache@v2 - with: - path: ${{ steps.go-cache-paths.outputs.go-mod }} - key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} - - - name: Build Craned - run: make craned - - crane-agent: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: 1.17 - - # Get values for cache paths to be used in later steps - - id: go-cache-paths - run: | - echo "::set-output name=go-build::$(go env GOCACHE)" - echo "::set-output name=go-mod::$(go env GOMODCACHE)" - - # Cache go build cache, used to speedup go test - - name: Go Build Cache - uses: actions/cache@v2 - with: - path: ${{ steps.go-cache-paths.outputs.go-build }} - key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} - - # Cache go mod cache, used to speedup builds - - name: Go Mod Cache - uses: actions/cache@v2 - with: - path: ${{ steps.go-cache-paths.outputs.go-mod }} - key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} - - - name: Build Crane Agent - run: make crane-agent - - metric-adapter: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: 1.17 - - # Get values for cache paths to be used in later steps - - id: go-cache-paths - run: | - echo "::set-output name=go-build::$(go env GOCACHE)" - echo "::set-output name=go-mod::$(go env GOMODCACHE)" - - # Cache go build cache, used to speedup go test - - name: Go Build Cache - uses: actions/cache@v2 - with: - path: ${{ steps.go-cache-paths.outputs.go-build }} - key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} - - # Cache go mod cache, used to speedup builds - - name: Go Mod Cache - uses: actions/cache@v2 - with: - path: ${{ steps.go-cache-paths.outputs.go-mod }} - key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} - - - name: Build Metric Adapter - run: make metric-adapter - - golangci: - name: lint - runs-on: macos-latest - steps: - - uses: actions/checkout@v2 - - name: Setup Go environment - uses: actions/setup-go@v3.0.0 - with: - # The Go version to download (if necessary) and use. Supports semver spec and ranges. - go-version: 1.17 # optional - - - name: golangci-lint - uses: golangci/golangci-lint-action@v3 - with: - # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: v1.45.2 diff --git a/.github/workflows/issue-labeled.yml b/.github/workflows/issue-labeled.yml deleted file mode 100644 index 43f43b56f..000000000 --- a/.github/workflows/issue-labeled.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Issue Labeled - -on: - issues: - types: [labeled] - -permissions: - contents: read - -jobs: - issue-labeled: - permissions: - issues: write # for actions-cool/issues-helper to update issues - pull-requests: write # for actions-cool/issues-helper to update PRs - runs-on: ubuntu-latest - steps: - - name: help wanted - if: github.event.label.name == 'help wanted' - uses: actions-cool/issues-helper@v3 - with: - actions: 'create-comment' - token: ${{ secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.issue.number }} - body: | - Hello @${{ github.event.issue.user.login }}. - We totally like your proposal/feedback, welcome to [send us a Pull Request](https://help.github.com/en/articles/creating-a-pull-request) for it. - Please send your Pull Request to main branch, fill the [Pull Request Template](https://github.com/gocrane/crane/blob/main/.github/PULL_REQUEST_TEMPLATE.md) here, provide changelog/documentation/test cases if needed and make sure CI passed, we will review it soon. - We appreciate your effort in advance and looking forward to your contribution! - - 你好 @${{ github.event.issue.user.login }},我们完全同意你的提议/反馈。 - 欢迎直接在此仓库 [创建一个 Pull Request](https://help.github.com/en/articles/creating-a-pull-request) 来解决这个问题。 - 请将 Pull Request 发到 main 分支,务必填写 Pull Request 内的[预设模板](https://github.com/gocrane/crane/blob/main/.github/PULL_REQUEST_TEMPLATE.md),提供改动所需相应的 changelog、测试用例、文档等,并确保 CI 通过。 - 我们会尽快进行 Review,提前感谢和期待您的贡献。 - - - name: Need Reproduce - if: github.event.label.name == 'Need Reproduce' - uses: actions-cool/issues-helper@v3 - with: - actions: 'create-comment' - token: ${{ secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.issue.number }} - body: | - Hello @${{ github.event.issue.user.login }}. - Please provide a reproduction by a minimal GitHub repository. - Or provide a detail steps for us to reproduce the issues. - Issues labeled by `Need Reproduce` will be closed if no activities in 3 days. - - 你好 @${{ github.event.issue.user.login }}, 我们需要你提供一个重现实例以便于我们帮你排查问题。 - 你可以提供一个最小化的 GitHub 仓库或一个详细的复现步骤。 - 3 天内未跟进此 issue 将会被自动关闭。 - - - name: Usage - if: github.event.label.name == 'usage' || github.event.label.name == 'question' - uses: actions-cool/issues-helper@v3 - with: - actions: 'create-comment,close-issue' - token: ${{ secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.issue.number }} - body: | - Hello @${{ github.event.issue.user.login }}, we use GitHub issues to trace bugs or discuss plans of Crane. - So, please [don't ask usage questions](https://github.com/gocrane/crane/issues/501) here. - You can try to open a new discussion in [crane discussions](https://github.com/gocrane/crane/discussions), select `Q&A` to ask questions, and [also can join our chat groups](https://github.com/gocrane/crane#community). - - 你好 @${{ github.event.issue.user.login }},Crane Issue 板块是用于 bug 反馈与需求讨论的地方。 - 请[勿询问如何使用的问题](https://github.com/gocrane/crane/issues/501)。 - 你可以试着在 [crane discussions](https://github.com/gocrane/crane/discussions) 新开一个 discussion,选择 `Q&A` 类别进行提问,也可以[加入我们的聊天群组](https://github.com/gocrane/crane#community)。 - - Thanks. diff --git a/.github/workflows/issue-remove-inactive.yml b/.github/workflows/issue-remove-inactive.yml deleted file mode 100644 index 788890ca3..000000000 --- a/.github/workflows/issue-remove-inactive.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Issue Remove Inactive - -on: - issues: - types: [edited] - issue_comment: - types: [created, edited] - -permissions: - contents: read - -jobs: - issue-remove-inactive: - permissions: - issues: write # for actions-cool/issues-helper to update issues - pull-requests: write # for actions-cool/issues-helper to update PRs - runs-on: ubuntu-latest - steps: - - name: remove inactive - if: github.event.issue.state == 'open' && github.actor == github.event.issue.user.login - uses: actions-cool/issues-helper@v3 - with: - actions: 'remove-labels' - issue-number: ${{ github.event.issue.number }} - labels: 'Inactive, needs-more-info, complete' diff --git a/.github/workflows/issues-similarity-analysis.yml b/.github/workflows/issues-similarity-analysis.yml deleted file mode 100644 index 4da16e7f2..000000000 --- a/.github/workflows/issues-similarity-analysis.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Issues Similarity Analysis - -on: - issues: - types: [opened, edited] - -permissions: - contents: read - -jobs: - similarity-analysis: - permissions: - issues: write # for actions-cool/issues-similarity-analysis to create issue comments - runs-on: ubuntu-latest - steps: - - name: analysis - uses: actions-cool/issues-similarity-analysis@v1 - with: - filter-threshold: 0.5 - title-excludes: '' - comment-title: '### You may look for issues:' - comment-body: '${index}. ${similarity} #${number}' diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index 356bbb911..000000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: "Pull Request Labeler" -on: - - pull_request_target - -jobs: - triage: - permissions: - contents: read - pull-requests: write - runs-on: ubuntu-latest - steps: - - uses: actions/labeler@v4 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index ab1714cfc..000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Lint - -on: pull_request - -jobs: - eslint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: mrdivyansh/eslint-action@v1.0.7 - # GITHUB_TOKEN in forked repositories is read-only - # https://help.github.com/en/actions/reference/events-that-trigger-workflows#pull-request-event-pull_request - if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} - with: - repo-token: ${{secrets.GITHUB_TOKEN}} - eslint-rc: /pkg/web/.eslintrc.js - execute-on-files: /pkg/web/src diff --git a/.github/workflows/preview docsy.yml b/.github/workflows/preview docsy.yml deleted file mode 100644 index 6badbb2dc..000000000 --- a/.github/workflows/preview docsy.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: 🔂 Crane PR Docs Preview - Docsy - -on: - pull_request_target: - # when using teardown: 'true', add default event types + closed event type - types: [opened, synchronize, reopened, closed] - paths: - - "site/**" - - "**.md" - - "*.toml" - -jobs: - preview: - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - contents: write - env: - HUGO_VERSION: 0.102.3 - HUGO_ENVIRONMENT: production - HUGO_ENV: production - steps: - - uses: actions/checkout@v2 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - - name: Install Hugo CLI - run: | - wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_Linux-64bit.deb \ - && sudo dpkg -i ${{ runner.temp }}/hugo.deb - - - name: Use Node.js 16 - uses: actions/setup-node@v3 - with: - node-version: 16 - - - name: Build Docs - run: | - cd site - npm i - hugo --minify - - - uses: afc163/surge-preview@v1 - with: - surge_token: ${{ secrets.SURGE_TOKEN }} - github_token: ${{ secrets.GITHUB_TOKEN }} - dist: site/public - teardown: "true" - build: | - date diff --git a/.gitignore b/.gitignore index 25f4800b8..1ee8042d2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ *.so *.dylib +# Compiled craned binary (built into the Docker image, not versioned) +craned-linux-amd64 +bin/ + # Test binary, built with `go test -c` *.test @@ -23,4 +27,22 @@ site/resources site/public site/.hugo_build.lock site/node_modules -site/package-lock.json \ No newline at end of file +site/package-lock.json + +# Terraform +.terraform/ +*.tfstate +*.tfstate.* +.terraform.lock.hcl + +# Sensitive files +*.tfvars +*.auto.tfvars +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# CLI config +.terraformrc +terraform.rc \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 742bfcf35..d897f7963 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,14 +10,16 @@ ARG BUILD WORKDIR /go/src/github.com/gocrane/crane # Add build deps -RUN apk add build-base +RUN apk add build-base git # Copy the Go Modules manifests COPY go.mod go.mod COPY go.sum go.sum # cache deps before building and copying source so that we don't need to re-download as much # and so that source changes don't invalidate our downloaded layer -RUN if [[ "${BUILD}" != "CI" ]]; then go env -w GOPROXY=https://goproxy.io,direct; fi +RUN if [[ "${BUILD}" != "CI" ]]; then go env -w GOPROXY=https://proxy.golang.org,https://goproxy.io,direct; fi +ENV GONOSUMCHECK=github.com/lyft/* +ENV GONOSUMDB=github.com/lyft/* RUN go env RUN go mod download diff --git a/bin/craned b/bin/craned new file mode 100755 index 000000000..f0c7303fe Binary files /dev/null and b/bin/craned differ diff --git a/craned-linux-amd64 b/craned-linux-amd64 new file mode 100755 index 000000000..9b7b7e86e Binary files /dev/null and b/craned-linux-amd64 differ diff --git a/deploy/crane/Dockerfile.craned b/deploy/crane/Dockerfile.craned new file mode 100644 index 000000000..ec6eb0e64 --- /dev/null +++ b/deploy/crane/Dockerfile.craned @@ -0,0 +1,6 @@ +FROM alpine:3.18 +RUN apk add --no-cache tzdata ca-certificates +WORKDIR / +COPY craned-linux-amd64 /craned +RUN chmod +x /craned +ENTRYPOINT ["/craned"] diff --git a/deploy/crane/carbon-dashboard/index.html b/deploy/crane/carbon-dashboard/index.html new file mode 100644 index 000000000..9b69f1353 --- /dev/null +++ b/deploy/crane/carbon-dashboard/index.html @@ -0,0 +1,430 @@ + + + + + + Crane Carbon Dashboard + + + +
+ +
+ +
+

Carbon Emission Analysis

+
+
Loading Kepler energy data...
+
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+

Energy Consumption by Workload (Raw Data)

+
+ + + +
PodNamespaceCPU Power (W)Source
Loading...
+
+
+ + + +
+
+ + + + + + + diff --git a/deploy/crane/craned-deploy.yaml b/deploy/crane/craned-deploy.yaml new file mode 100644 index 000000000..b5b04bef9 --- /dev/null +++ b/deploy/crane/craned-deploy.yaml @@ -0,0 +1,185 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: craned + namespace: crane-system + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: recommendation-configuration + namespace: crane-system +data: + config.yaml: |- + apiVersion: analysis.crane.io/v1alpha1 + kind: RecommendationConfiguration + recommenders: + - name: Replicas + acceptedResources: + - kind: Deployment + apiVersion: apps/v1 + - kind: StatefulSet + apiVersion: apps/v1 + - name: Resource + acceptedResources: + - kind: Deployment + apiVersion: apps/v1 + - kind: StatefulSet + apiVersion: apps/v1 + - name: IdleNode + acceptedResources: + - kind: Node + apiVersion: v1 + - name: Volume + acceptedResources: + - kind: PersistentVolume + apiVersion: v1 + - name: Service + acceptedResources: + - kind: Service + apiVersion: v1 + ## Carbon recommenders (Kepler-powered) + - name: CarbonIdleResource + config: + energy-idle-threshold: "0.8" + observation-window-days: "7" + min-energy-watts: "0.5" + cpu-usage-threshold: "0.05" + acceptedResources: + - kind: Deployment + apiVersion: apps/v1 + - kind: StatefulSet + apiVersion: apps/v1 + - kind: DaemonSet + apiVersion: apps/v1 + - kind: Node + apiVersion: v1 + - kind: Pod + apiVersion: v1 + - name: CarbonRightSizing + config: + energy-efficiency-target: "0.7" + observation-window-days: "7" + cpu-percentile: "0.95" + memory-percentile: "0.95" + acceptedResources: + - kind: Deployment + apiVersion: apps/v1 + - kind: StatefulSet + apiVersion: apps/v1 + - kind: DaemonSet + apiVersion: apps/v1 + - name: CarbonLoadShifting + config: + low-carbon-start-hour: "0" + low-carbon-end-hour: "6" + high-carbon-gco2: "300" + low-carbon-gco2: "120" + observation-window-days: "7" + min-energy-watts: "0.01" + acceptedResources: + - kind: Deployment + apiVersion: apps/v1 + - kind: StatefulSet + apiVersion: apps/v1 + - kind: DaemonSet + apiVersion: apps/v1 + - kind: CronJob + apiVersion: batch/v1 + - kind: Job + apiVersion: batch/v1 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: craned + namespace: crane-system + labels: + app: craned +spec: + replicas: 1 + selector: + matchLabels: + app: craned + template: + metadata: + labels: + app: craned + spec: + serviceAccountName: craned + containers: + - name: craned + image: ekaterinadespotova/crane-kepler:carbon-recommenders-v11 + imagePullPolicy: Always + env: + - name: TZ + value: UTC + - name: CRANE_SYSTEM_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: ELECTRICITY_MAPS_API_KEY + valueFrom: + secretKeyRef: + name: electricity-maps-secret + key: api-key + optional: true + - name: ELECTRICITY_MAPS_ZONE + value: "SE" + command: + - /craned + - --prometheus-address=http://prometheus-kube-prometheus-prometheus.monitoring:9090 + - --feature-gates=Analysis=true,TimeSeriesPrediction=true,Autoscaling=false + - --recommendation-configuration-file=/tmp/recommendation-config/config.yaml + - --webhook-enabled=false + - --leader-elect=false + - --health-probe-bind-address=:8081 + - --metrics-bind-address=:8080 + - -v=4 + volumeMounts: + - name: config + mountPath: /tmp/recommendation-config + readinessProbe: + httpGet: + path: /healthz + port: 8081 + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: 8081 + scheme: HTTP + initialDelaySeconds: 30 + periodSeconds: 30 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: config + configMap: + name: recommendation-configuration + +--- +apiVersion: v1 +kind: Service +metadata: + name: craned + namespace: crane-system +spec: + selector: + app: craned + ports: + - name: crane-server + port: 8082 + targetPort: 8082 + - name: metrics + port: 8080 + targetPort: 8080 diff --git a/deploy/crane/dashboard-deploy.yaml b/deploy/crane/dashboard-deploy.yaml new file mode 100644 index 000000000..8f1e8cfc8 --- /dev/null +++ b/deploy/crane/dashboard-deploy.yaml @@ -0,0 +1,122 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: crane-dashboard-nginx + namespace: crane-system +data: + default.conf: | + server { + listen 80; + server_name localhost; + + # Our carbon dashboard is the main page + location = / { + return 301 /carbon/; + } + + location /carbon/ { + alias /usr/share/nginx/carbon/; + index index.html; + } + + # Stock Crane dashboard at /crane/ + location /crane/ { + alias /usr/share/nginx/html/; + index index.html; + try_files $uri $uri/ /crane/index.html; + } + + # Serve stock crane assets + location /assets/ { + root /usr/share/nginx/html; + } + + location /api/ { + proxy_pass http://craned:8082/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /prometheus/ { + proxy_pass http://prometheus-kube-prometheus-prometheus.monitoring:9090/api/v1/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /grafana/ { + proxy_pass http://prometheus-grafana.monitoring/grafana/; + proxy_set_header Host $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; + } + } + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: crane-dashboard + namespace: crane-system + labels: + app: crane-dashboard +spec: + replicas: 1 + selector: + matchLabels: + app: crane-dashboard + template: + metadata: + labels: + app: crane-dashboard + spec: + serviceAccountName: craned + containers: + - name: dashboard + image: gocrane/dashboard:v0.11.0 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + name: http + env: + - name: TZ + value: UTC + - name: CRANE_SERVER_URL + value: "http://craned:8082" + - name: PROMETHEUS_URL + value: "http://prometheus-kube-prometheus-prometheus.monitoring:9090" + volumeMounts: + - name: nginx-config + mountPath: /etc/nginx/conf.d/default.conf + subPath: default.conf + - name: carbon-dashboard + mountPath: /usr/share/nginx/carbon + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + volumes: + - name: nginx-config + configMap: + name: crane-dashboard-nginx + - name: carbon-dashboard + configMap: + name: crane-carbon-dashboard + +--- +apiVersion: v1 +kind: Service +metadata: + name: crane-dashboard + namespace: crane-system +spec: + selector: + app: crane-dashboard + ports: + - name: http + port: 9090 + targetPort: 80 + type: ClusterIP diff --git a/deploy/monitoring/kepler-values.yaml b/deploy/monitoring/kepler-values.yaml new file mode 100644 index 000000000..1d34988d0 --- /dev/null +++ b/deploy/monitoring/kepler-values.yaml @@ -0,0 +1,24 @@ +## Kepler helm values +## Enables ServiceMonitor so kube-prometheus-stack auto-scrapes Kepler + +serviceMonitor: + enabled: true + ## Must match the prometheus operator's serviceMonitorSelector (we set it to {} = any) + labels: + release: prometheus + +## Tolerate any taints so it runs on all nodes including system nodes +tolerations: + - operator: Exists + +## Container-level security context (privileged needed for eBPF/energy counters) +securityContext: + privileged: true + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 200m + memory: 256Mi diff --git a/deploy/monitoring/prometheus-values.yaml b/deploy/monitoring/prometheus-values.yaml new file mode 100644 index 000000000..79c4d2ad3 --- /dev/null +++ b/deploy/monitoring/prometheus-values.yaml @@ -0,0 +1,39 @@ +grafana: + enabled: true + adminPassword: "crane-grafana" + service: + type: ClusterIP + grafana.ini: + auth.anonymous: + enabled: true + org_name: Main Org. + org_role: Viewer + security: + allow_embedding: true + server: + root_url: "%(protocol)s://%(domain)s:%(http_port)s/grafana/" + serve_from_sub_path: true + +prometheus: + prometheusSpec: + retention: 7d + serviceMonitorSelectorNilUsesHelmValues: false + podMonitorSelectorNilUsesHelmValues: false + serviceMonitorSelector: {} + podMonitorSelector: {} + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + cpu: 1000m + memory: 2Gi + +nodeExporter: + enabled: true + +kubeStateMetrics: + enabled: true + +alertmanager: + enabled: false diff --git a/examples/analytics/carbon-idle-rule.yaml b/examples/analytics/carbon-idle-rule.yaml new file mode 100644 index 000000000..34a6f79c3 --- /dev/null +++ b/examples/analytics/carbon-idle-rule.yaml @@ -0,0 +1,23 @@ +apiVersion: analysis.crane.io/v1alpha1 +kind: RecommendationRule +metadata: + name: carbon-idle-rule + labels: + analysis.crane.io/recommendation-rule-preinstall: "true" +spec: + runInterval: 2m + resourceSelectors: + - kind: Deployment + apiVersion: apps/v1 + - kind: StatefulSet + apiVersion: apps/v1 + - kind: DaemonSet + apiVersion: apps/v1 + - kind: Node + apiVersion: v1 + - kind: Pod + apiVersion: v1 + namespaceSelector: + any: true + recommenders: + - name: CarbonIdleResource diff --git a/examples/analytics/carbon-loadshift-rule.yaml b/examples/analytics/carbon-loadshift-rule.yaml new file mode 100644 index 000000000..21fd19066 --- /dev/null +++ b/examples/analytics/carbon-loadshift-rule.yaml @@ -0,0 +1,19 @@ +apiVersion: analysis.crane.io/v1alpha1 +kind: RecommendationRule +metadata: + name: carbon-loadshift-rule + labels: + analysis.crane.io/recommendation-rule-preinstall: "true" +spec: + runInterval: 2m + resourceSelectors: + - kind: Deployment + apiVersion: apps/v1 + - kind: StatefulSet + apiVersion: apps/v1 + - kind: DaemonSet + apiVersion: apps/v1 + namespaceSelector: + any: true + recommenders: + - name: CarbonLoadShifting diff --git a/examples/analytics/carbon-nighttime-shutdown.yaml b/examples/analytics/carbon-nighttime-shutdown.yaml new file mode 100644 index 000000000..2e1a58f5b --- /dev/null +++ b/examples/analytics/carbon-nighttime-shutdown.yaml @@ -0,0 +1,107 @@ +# Carbon Efficiency: Nighttime Workload Shutdown +# Scales non-critical Deployments to 0 replicas at 22:00 and restores at 07:00. +# Apply to a dedicated namespace or label-select workloads marked for nighttime shutdown. +# +# Prerequisites: +# - ServiceAccount with permissions to patch deployments (RBAC below) +# - Label target deployments with: carbon-policy/nighttime-shutdown: "true" +# +# Timezone: Adjust schedule fields for your cluster's timezone (cron uses UTC by default). +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: carbon-nighttime-scaler + namespace: default +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: carbon-nighttime-scaler +rules: + - apiGroups: ["apps"] + resources: ["deployments", "deployments/scale"] + verbs: ["get", "list", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: carbon-nighttime-scaler +subjects: + - kind: ServiceAccount + name: carbon-nighttime-scaler + namespace: default +roleRef: + kind: ClusterRole + name: carbon-nighttime-scaler + apiGroup: rbac.authorization.k8s.io +--- +# Scale to 0 at 22:00 UTC every day +apiVersion: batch/v1 +kind: CronJob +metadata: + name: carbon-nighttime-scale-down + namespace: default +spec: + schedule: "0 22 * * *" + jobTemplate: + spec: + template: + spec: + serviceAccountName: carbon-nighttime-scaler + restartPolicy: OnFailure + containers: + - name: scaler + image: bitnami/kubectl:latest + command: + - /bin/sh + - -c + - | + for deploy in $(kubectl get deployments -A \ + -l carbon-policy/nighttime-shutdown=true \ + -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}'); do + ns=$(echo "$deploy" | cut -d/ -f1) + name=$(echo "$deploy" | cut -d/ -f2) + # Save current replicas as annotation before scaling down + current=$(kubectl get deployment "$name" -n "$ns" -o jsonpath='{.spec.replicas}') + kubectl annotate deployment "$name" -n "$ns" \ + carbon-policy/pre-shutdown-replicas="$current" --overwrite + kubectl scale deployment "$name" -n "$ns" --replicas=0 + echo "Scaled down $ns/$name from $current to 0" + done +--- +# Restore at 07:00 UTC every day +apiVersion: batch/v1 +kind: CronJob +metadata: + name: carbon-nighttime-scale-up + namespace: default +spec: + schedule: "0 7 * * *" + jobTemplate: + spec: + template: + spec: + serviceAccountName: carbon-nighttime-scaler + restartPolicy: OnFailure + containers: + - name: scaler + image: bitnami/kubectl:latest + command: + - /bin/sh + - -c + - | + for deploy in $(kubectl get deployments -A \ + -l carbon-policy/nighttime-shutdown=true \ + -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}'); do + ns=$(echo "$deploy" | cut -d/ -f1) + name=$(echo "$deploy" | cut -d/ -f2) + # Restore replicas from annotation + replicas=$(kubectl get deployment "$name" -n "$ns" \ + -o jsonpath='{.metadata.annotations.carbon-policy/pre-shutdown-replicas}') + if [ -z "$replicas" ] || [ "$replicas" = "0" ]; then + replicas=1 + fi + kubectl scale deployment "$name" -n "$ns" --replicas="$replicas" + echo "Scaled up $ns/$name to $replicas" + done diff --git a/examples/analytics/carbon-rightsize-rule.yaml b/examples/analytics/carbon-rightsize-rule.yaml new file mode 100644 index 000000000..1e7d5a8ca --- /dev/null +++ b/examples/analytics/carbon-rightsize-rule.yaml @@ -0,0 +1,19 @@ +apiVersion: analysis.crane.io/v1alpha1 +kind: RecommendationRule +metadata: + name: carbon-rightsize-rule + labels: + analysis.crane.io/recommendation-rule-preinstall: "true" +spec: + runInterval: 2m + resourceSelectors: + - kind: Deployment + apiVersion: apps/v1 + - kind: StatefulSet + apiVersion: apps/v1 + - kind: DaemonSet + apiVersion: apps/v1 + namespaceSelector: + any: true + recommenders: + - name: CarbonRightSizing diff --git a/pkg/recommendation/manager.go b/pkg/recommendation/manager.go index a2bb3e7c4..f53dfeb00 100644 --- a/pkg/recommendation/manager.go +++ b/pkg/recommendation/manager.go @@ -18,6 +18,10 @@ import ( _ "github.com/gocrane/crane/pkg/recommendation/recommender/resource" _ "github.com/gocrane/crane/pkg/recommendation/recommender/service" _ "github.com/gocrane/crane/pkg/recommendation/recommender/volume" + + _ "github.com/gocrane/crane/pkg/recommendation/recommender/carbonidle" + _ "github.com/gocrane/crane/pkg/recommendation/recommender/carbonrightsize" + _ "github.com/gocrane/crane/pkg/recommendation/recommender/carbonshift" ) type RecommenderManager interface { diff --git a/pkg/recommendation/recommender/carbonidle/filter.go b/pkg/recommendation/recommender/carbonidle/filter.go new file mode 100644 index 000000000..a1f4a0f56 --- /dev/null +++ b/pkg/recommendation/recommender/carbonidle/filter.go @@ -0,0 +1,56 @@ +package carbonidle + +import ( + "fmt" + + "github.com/gocrane/crane/pkg/recommendation/framework" +) + +// acceptedKinds lists the Kubernetes resource kinds supported by CarbonIdleResource. +var acceptedKinds = map[string]bool{ + "Deployment": true, + "StatefulSet": true, + "DaemonSet": true, + "Node": true, + "Pod": true, +} + +// Filter checks whether the target resource kind is supported by CarbonIdleResource. +func (r *CarbonIdleResourceRecommender) Filter(ctx *framework.RecommendationContext) error { + kind := ctx.Recommendation.Spec.TargetRef.Kind + if !acceptedKinds[kind] { + return fmt.Errorf("CarbonIdleResource recommender does not support resource kind %q; accepted kinds are Deployment, StatefulSet, DaemonSet, Node, Pod", kind) + } + + // Delegate base filtering (label selectors, cooldown, deletion check). + if err := r.BaseRecommender.Filter(ctx); err != nil { + return err + } + + // RetrievePods dispatches based on Kind. For Deployment/StatefulSet it calls + // GetPodsFromScale which dereferences ctx.Scale — so we must populate Scale first. + // For Node/DaemonSet, RetrievePods has its own path that doesn't need Scale. + // For Pod, RetrievePods falls into the "else" path (GetPodsFromScale) which panics + // on nil Scale — so we skip RetrievePods entirely for Pod targets. + switch kind { + case "Node", "DaemonSet": + // These have dedicated paths in RetrievePods that don't need Scale. + if err := framework.RetrievePods(ctx); err != nil { + return err + } + case "Pod": + // Standalone Pod: RetrievePods would panic (nil Scale in GetPodsFromScale). + // The pod info comes from TargetRef; CollectData queries by pod name directly. + // Nothing to do here. + default: + // Deployment/StatefulSet: need Scale populated before RetrievePods. + if err := framework.RetrieveScale(ctx); err != nil { + return err + } + if err := framework.RetrievePods(ctx); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/recommendation/recommender/carbonidle/observe.go b/pkg/recommendation/recommender/carbonidle/observe.go new file mode 100644 index 000000000..1567b7931 --- /dev/null +++ b/pkg/recommendation/recommender/carbonidle/observe.go @@ -0,0 +1,60 @@ +package carbonidle + +import ( + "fmt" + "strings" + + "k8s.io/klog/v2" + + "github.com/gocrane/crane/pkg/common" + "github.com/gocrane/crane/pkg/recommendation/framework" +) + +// Observe records the idle resource count and estimated energy savings in the Recommendation status. +func (r *CarbonIdleResourceRecommender) Observe(ctx *framework.RecommendationContext) error { + if ctx.Recommendation.Status.Action != "Delete" { + return nil + } + + idleCount := countIdleResources(ctx.Recommendation.Status.Description) + estimatedSavingsWatts := r.estimateEnergySavings(ctx) + + observation := fmt.Sprintf("Observation: %d idle resource(s) detected, estimated energy savings: %.2fW", + idleCount, estimatedSavingsWatts) + + if ctx.Recommendation.Status.Description != "" { + ctx.Recommendation.Status.Description += "; " + observation + } else { + ctx.Recommendation.Status.Description = observation + } + + klog.Infof("%s: %s for %s/%s", r.Name(), observation, + ctx.Recommendation.Spec.TargetRef.Namespace, ctx.Recommendation.Spec.TargetRef.Name) + + return nil +} + +// countIdleResources counts the number of idle classifications in the description. +// Each "idle:" substring corresponds to one classified idle resource. +func countIdleResources(description string) int { + return strings.Count(description, "idle:") +} + +func (r *CarbonIdleResourceRecommender) estimateEnergySavings(ctx *framework.RecommendationContext) float64 { + var totalSavings float64 + + podWattsList := ctx.InputValue(keyPodCPUWatts) + for _, ts := range podWattsList { + podName := labelValue(ts, "pod_name", common.LabelNamePodName) + if podName == "" { + continue + } + avgPower := avgSamples(ts.Samples) + cpuUtil := r.cpuUtilForPod(podName, ctx) + if avgPower < r.minEnergyWatts && cpuUtil < r.cpuUsageThreshold { + totalSavings += avgPower + } + } + + return totalSavings +} diff --git a/pkg/recommendation/recommender/carbonidle/prepare.go b/pkg/recommendation/recommender/carbonidle/prepare.go new file mode 100644 index 000000000..b93a88346 --- /dev/null +++ b/pkg/recommendation/recommender/carbonidle/prepare.go @@ -0,0 +1,261 @@ +package carbonidle + +import ( + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/klog/v2" + + "github.com/gocrane/crane/pkg/metricnaming" + "github.com/gocrane/crane/pkg/providers" + "github.com/gocrane/crane/pkg/recommendation/framework" +) + +const callerFormat = "CarbonIdleResourceRecommender-%s-%s" + +const ( + keyPodCPUJoules = "kepler-pod-cpu-joules" + keyPodCPUWatts = "kepler-pod-cpu-watts" + keyPodGPUWatts = "kepler-pod-gpu-watts" + keyContainerCPUJoules = "kepler-container-cpu-joules" + keyContainerCPUWatts = "kepler-container-cpu-watts" + keyContainerGPUWatts = "kepler-container-gpu-watts" + keyNodeCPUJoules = "kepler-node-cpu-joules" + keyNodeCPUWatts = "kepler-node-cpu-watts" + keyNodeCPUActiveWatts = "kepler-node-cpu-active-watts" +) + +const keplerAvailabilityExpr = `kepler_container_package_joules_total` + +const ( + keplerPodCPUJoulesExpr = `sum by (pod_name, container_namespace) (kepler_container_package_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"})` + keplerPodCPUWattsExpr = `sum by (pod_name, container_namespace) (rate(kepler_container_package_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"}[5m]))` + keplerPodGPUWattsExpr = `sum by (pod_name, container_namespace) (rate(kepler_container_other_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"}[5m]))` +) + +const ( + keplerContainerCPUJoulesExpr = `kepler_container_package_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"}` + keplerContainerCPUWattsExpr = `rate(kepler_container_package_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"}[5m])` + keplerContainerGPUWattsExpr = `rate(kepler_container_other_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"}[5m])` +) + +const ( + keplerNodeCPUJoulesExpr = `sum by (instance) (kepler_node_package_joules_total{instance="%s",mode="dynamic"})` + keplerNodeCPUWattsExpr = `sum by (instance) (rate(kepler_node_package_joules_total{instance="%s",mode="dynamic"}[5m]))` + keplerNodeCPUActiveWattsExpr = `sum by (instance) (rate(kepler_node_package_joules_total{instance="%s",mode="dynamic"}[5m]))` +) + +const ( + keplerPodCPUWattsNoNSExpr = `sum by (pod_name, container_namespace) (rate(kepler_container_package_joules_total{pod_name=~"%s",mode="dynamic"}[5m]))` + keplerContainerCPUWattsNoNSExpr = `rate(kepler_container_package_joules_total{pod_name=~"%s",mode="dynamic"}[5m])` +) + +func (r *CarbonIdleResourceRecommender) CheckDataProviders(ctx *framework.RecommendationContext) error { + if err := r.BaseRecommender.CheckDataProviders(ctx); err != nil { + return err + } + + // Verify Kepler metrics exist by querying kepler_container_package_joules_total. + caller := fmt.Sprintf(callerFormat, klog.KObj(ctx.Recommendation), ctx.Recommendation.UID) + metricNamer := metricnaming.ResourceToGeneralMetricNamer( + keplerAvailabilityExpr, + corev1.ResourceCPU, + labels.Everything(), + caller, + ) + if err := metricNamer.Validate(); err != nil { + return fmt.Errorf("Kepler availability metric validation failed: %v", err) + } + + now := time.Now() + tsList, err := ctx.DataProviders[providers.PrometheusDataSource].QueryTimeSeries( + metricNamer, now.Add(-time.Hour), now, time.Minute, + ) + if err != nil { + return fmt.Errorf("Prometheus connection failed: %v", err) + } + if len(tsList) == 0 { + return fmt.Errorf("Kepler metrics not available: kepler_container_package_joules_total not found. Ensure Kepler is installed and exporting to Prometheus.") + } + + return nil +} + +// CollectData queries Kepler energy metrics from Prometheus and stores them in the context. +func (r *CarbonIdleResourceRecommender) CollectData(ctx *framework.RecommendationContext) error { + caller := fmt.Sprintf(callerFormat, klog.KObj(ctx.Recommendation), ctx.Recommendation.UID) + now := time.Now() + start := now.Add(-time.Hour * 24 * time.Duration(r.observationDays)) + step := time.Minute + ns := ctx.Recommendation.Spec.TargetRef.Namespace + kind := ctx.Recommendation.Spec.TargetRef.Kind + name := ctx.Recommendation.Spec.TargetRef.Name + + switch kind { + case "Node": + r.collectNodeMetrics(ctx, caller, name, start, now, step) + podNameRegex := buildPodNameRegex(ctx) + if podNameRegex != "" { + r.queryAndStore(ctx, caller, keyPodCPUWatts, + fmt.Sprintf(keplerPodCPUWattsNoNSExpr, podNameRegex), start, now, step) + r.queryAndStore(ctx, caller, keyContainerCPUWatts, + fmt.Sprintf(keplerContainerCPUWattsNoNSExpr, podNameRegex), start, now, step) + } + return nil + + case "Pod": + r.collectPodMetrics(ctx, caller, ns, name, start, now, step) + r.collectContainerMetrics(ctx, caller, ns, name, start, now, step) + return nil + + default: + podNameRegex := buildPodNameRegex(ctx) + if podNameRegex == "" { + return fmt.Errorf("no pods found matching selector for %s/%s", ns, name) + } + r.collectPodMetrics(ctx, caller, ns, podNameRegex, start, now, step) + r.collectContainerMetrics(ctx, caller, ns, podNameRegex, start, now, step) + return nil + } +} + +func buildPodNameRegex(ctx *framework.RecommendationContext) string { + if len(ctx.Pods) == 0 { + return "" + } + names := make([]string, 0, len(ctx.Pods)) + for _, pod := range ctx.Pods { + names = append(names, pod.Name) + } + result := "" + for i, name := range names { + if i > 0 { + result += "|" + } + result += name + } + return result +} + +func (r *CarbonIdleResourceRecommender) collectPodMetrics( + ctx *framework.RecommendationContext, + caller, namespace, podNameRegex string, + start, end time.Time, step time.Duration, +) { + podMetrics := []struct { + key string + expr string + }{ + {keyPodCPUJoules, fmt.Sprintf(keplerPodCPUJoulesExpr, namespace, podNameRegex)}, + {keyPodCPUWatts, fmt.Sprintf(keplerPodCPUWattsExpr, namespace, podNameRegex)}, + {keyPodGPUWatts, fmt.Sprintf(keplerPodGPUWattsExpr, namespace, podNameRegex)}, + } + + for _, m := range podMetrics { + r.queryAndStore(ctx, caller, m.key, m.expr, start, end, step) + } +} + +func (r *CarbonIdleResourceRecommender) collectContainerMetrics( + ctx *framework.RecommendationContext, + caller, namespace, podNameRegex string, + start, end time.Time, step time.Duration, +) { + containerMetrics := []struct { + key string + expr string + }{ + {keyContainerCPUJoules, fmt.Sprintf(keplerContainerCPUJoulesExpr, namespace, podNameRegex)}, + {keyContainerCPUWatts, fmt.Sprintf(keplerContainerCPUWattsExpr, namespace, podNameRegex)}, + {keyContainerGPUWatts, fmt.Sprintf(keplerContainerGPUWattsExpr, namespace, podNameRegex)}, + } + + for _, m := range containerMetrics { + r.queryAndStore(ctx, caller, m.key, m.expr, start, end, step) + } +} + +func (r *CarbonIdleResourceRecommender) collectNodeMetrics( + ctx *framework.RecommendationContext, + caller, nodeName string, + start, end time.Time, step time.Duration, +) { + nodeMetrics := []struct { + key string + expr string + }{ + {keyNodeCPUJoules, fmt.Sprintf(keplerNodeCPUJoulesExpr, nodeName)}, + {keyNodeCPUWatts, fmt.Sprintf(keplerNodeCPUWattsExpr, nodeName)}, + {keyNodeCPUActiveWatts, fmt.Sprintf(keplerNodeCPUActiveWattsExpr, nodeName)}, + } + + for _, m := range nodeMetrics { + r.queryAndStore(ctx, caller, m.key, m.expr, start, end, step) + } +} + +func (r *CarbonIdleResourceRecommender) queryAndStore( + ctx *framework.RecommendationContext, + caller, key, expr string, + start, end time.Time, step time.Duration, +) { + metricNamer := metricnaming.ResourceToGeneralMetricNamer( + expr, + corev1.ResourceCPU, + labels.Everything(), + caller, + ) + if err := metricNamer.Validate(); err != nil { + klog.Warningf("%s: failed to validate metric namer for %s: %v", r.Name(), key, err) + return + } + + klog.Infof("%s: %s query %s", ctx.String(), r.Name(), key) + tsList, err := ctx.DataProviders[providers.PrometheusDataSource].QueryTimeSeries(metricNamer, start, end, step) + if err != nil { + klog.Warningf("%s: failed to query %s: %v", r.Name(), key, err) + return + } + if len(tsList) == 0 { + klog.Warningf("%s: no data returned for %s, excluding from analysis", r.Name(), key) + return + } + + ctx.AddInputValue(key, tsList) +} + + +func (r *CarbonIdleResourceRecommender) PostProcessing(ctx *framework.RecommendationContext) error { + if err := r.validateMetricAvailability(ctx); err != nil { + return err + } + return nil +} + +func (r *CarbonIdleResourceRecommender) validateMetricAvailability(ctx *framework.RecommendationContext) error { + kind := ctx.Recommendation.Spec.TargetRef.Kind + + var required []string + switch kind { + case "Node": + required = []string{keyNodeCPUWatts, keyPodCPUWatts} + case "Pod": + required = []string{keyPodCPUWatts} + default: + required = []string{keyPodCPUWatts, keyContainerCPUWatts} + } + + for _, key := range required { + if ts := ctx.InputValue(key); len(ts) == 0 { + msg := fmt.Sprintf("Insufficient energy data for %s/%s: missing %s", + ctx.Recommendation.Spec.TargetRef.Namespace, + ctx.Recommendation.Spec.TargetRef.Name, key) + ctx.Recommendation.Status.Description = msg + return fmt.Errorf(msg) + } + } + + return nil +} diff --git a/pkg/recommendation/recommender/carbonidle/recommend.go b/pkg/recommendation/recommender/carbonidle/recommend.go new file mode 100644 index 000000000..11abf8b25 --- /dev/null +++ b/pkg/recommendation/recommender/carbonidle/recommend.go @@ -0,0 +1,289 @@ +package carbonidle + +import ( + "encoding/json" + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/klog/v2" + + "github.com/gocrane/crane/pkg/common" + "github.com/gocrane/crane/pkg/recommendation/framework" +) + +// PreRecommend is a no-op for CarbonIdleResource. +func (r *CarbonIdleResourceRecommender) PreRecommend(ctx *framework.RecommendationContext) error { + return nil +} + +// Recommend classifies pods, containers, and nodes as idle based on energy thresholds +// and sets Action="Delete" for idle resources. +func (r *CarbonIdleResourceRecommender) Recommend(ctx *framework.RecommendationContext) error { + kind := ctx.Recommendation.Spec.TargetRef.Kind + + switch kind { + case "Node": + return r.recommendNode(ctx) + default: + return r.recommendWorkload(ctx) + } +} + +func (r *CarbonIdleResourceRecommender) recommendWorkload(ctx *framework.RecommendationContext) error { + podWattsList := ctx.InputValue(keyPodCPUWatts) + if len(podWattsList) == 0 { + return fmt.Errorf("no pod energy data available for idle classification") + } + + var idleDescriptions []string + + for _, ts := range podWattsList { + podName := labelValue(ts, "pod_name", common.LabelNamePodName) + podNS := labelValue(ts, "container_namespace", "") + if podName == "" { + continue + } + avgPower := avgSamples(ts.Samples) + cpuUtil := r.cpuUtilForPod(podName, ctx) + + if avgPower < r.minEnergyWatts && cpuUtil < r.cpuUsageThreshold { + desc := fmt.Sprintf("Pod %s/%s idle: avg power %.4fW < %.4fW, CPU util %.2f%% < %.2f%%", + podNS, podName, avgPower, r.minEnergyWatts, cpuUtil*100, r.cpuUsageThreshold*100) + idleDescriptions = append(idleDescriptions, desc) + klog.Infof("%s: %s", r.Name(), desc) + } + } + + containerWattsList := ctx.InputValue(keyContainerCPUWatts) + for _, ts := range containerWattsList { + podName := labelValue(ts, "pod_name", common.LabelNamePodName) + podNS := labelValue(ts, "container_namespace", "") + cName := labelValue(ts, "container_name", common.LabelNameContainerName) + if podName == "" || cName == "" { + continue + } + avgPower := avgSamples(ts.Samples) + cpuUtil := r.cpuUtilForPod(podName, ctx) + + if avgPower < r.minEnergyWatts && cpuUtil < r.cpuUsageThreshold { + desc := fmt.Sprintf("Container %s/%s/%s idle: avg power %.4fW < %.4fW, CPU util %.2f%% < %.2f%%", + podNS, podName, cName, avgPower, r.minEnergyWatts, cpuUtil*100, r.cpuUsageThreshold*100) + idleDescriptions = append(idleDescriptions, desc) + } + } + + if len(idleDescriptions) == 0 { + return fmt.Errorf("no idle resources detected for %s/%s", + ctx.Recommendation.Spec.TargetRef.Namespace, ctx.Recommendation.Spec.TargetRef.Name) + } + + ctx.Recommendation.Status.Action = "Delete" + ctx.Recommendation.Status.Description = strings.Join(idleDescriptions, "; ") + return nil +} + +func (r *CarbonIdleResourceRecommender) recommendNode(ctx *framework.RecommendationContext) error { + podWattsList := ctx.InputValue(keyPodCPUWatts) + if len(podWattsList) == 0 { + return fmt.Errorf("no pod energy data available for node %s", ctx.Recommendation.Spec.TargetRef.Name) + } + + daemonSetPods := make(map[string]bool) + for i := range ctx.Pods { + if isDaemonSetPod(&ctx.Pods[i]) { + daemonSetPods[ctx.Pods[i].Name] = true + } + } + + var activePods []string + var idleCount int + for _, ts := range podWattsList { + podName := labelValue(ts, "pod_name", common.LabelNamePodName) + if podName == "" || daemonSetPods[podName] { + continue + } + avgPower := avgSamples(ts.Samples) + cpuUtil := r.cpuUtilForPod(podName, ctx) + if avgPower >= r.minEnergyWatts || cpuUtil >= r.cpuUsageThreshold { + activePods = append(activePods, podName) + } else { + idleCount++ + } + } + + if len(activePods) > 0 { + return fmt.Errorf("node %s not idle: %d active non-DaemonSet pod(s) (e.g. %s)", + ctx.Recommendation.Spec.TargetRef.Name, len(activePods), activePods[0]) + } + + ctx.Recommendation.Status.Action = "Delete" + ctx.Recommendation.Status.Description = fmt.Sprintf( + "Node %s idle: all %d non-DaemonSet pod(s) are energy-idle (< %.4fW, CPU < %.2f%%)", + ctx.Recommendation.Spec.TargetRef.Name, idleCount, r.minEnergyWatts, r.cpuUsageThreshold*100) + return nil +} + +func labelValue(ts *common.TimeSeries, primary, fallback string) string { + v := common.GetValueByName(ts.Labels, primary) + if v == "" && fallback != "" { + v = common.GetValueByName(ts.Labels, fallback) + } + return v +} + +func isDaemonSetPod(pod *corev1.Pod) bool { + for _, ref := range pod.OwnerReferences { + if ref.Kind == "DaemonSet" { + return true + } + } + return false +} + +func (r *CarbonIdleResourceRecommender) avgPowerForPod(podName string, tsList []*common.TimeSeries) float64 { + for _, ts := range tsList { + name := common.GetValueByName(ts.Labels, "pod_name") + if name == "" { + name = common.GetValueByName(ts.Labels, common.LabelNamePodName) + } + if name == podName { + return avgSamples(ts.Samples) + } + } + return 0 +} + + +func (r *CarbonIdleResourceRecommender) cpuUtilForPod(podName string, ctx *framework.RecommendationContext) float64 { + podWattsList := ctx.InputValue(keyPodCPUWatts) + nodeActiveList := ctx.InputValue(keyNodeCPUActiveWatts) + + podAvg := r.avgPowerForPod(podName, podWattsList) + if podAvg <= 0 { + return 0 + } + + if len(nodeActiveList) > 0 && len(nodeActiveList[0].Samples) > 0 { + nodeActive := avgSamples(nodeActiveList[0].Samples) + if nodeActive > 0 { + return podAvg / nodeActive + } + } + + if podAvg < r.minEnergyWatts { + return 0 + } + return r.cpuUsageThreshold +} + +func avgSamples(samples []common.Sample) float64 { + if len(samples) == 0 { + return 0 + } + var sum float64 + for _, s := range samples { + sum += s.Value + } + return sum / float64(len(samples)) +} + +type PatchReplicas struct { + Spec PatchReplicasSpec `json:"spec,omitempty"` +} + +type PatchReplicasSpec struct { + Replicas *int32 `json:"replicas,omitempty"` +} + +type PatchNodeUnschedulable struct { + Spec PatchNodeSpec `json:"spec,omitempty"` +} + +type PatchNodeSpec struct { + Unschedulable bool `json:"unschedulable"` +} + +func (r *CarbonIdleResourceRecommender) Policy(ctx *framework.RecommendationContext) error { + if ctx.Recommendation.Status.Action != "Delete" { + return nil + } + + kind := ctx.Recommendation.Spec.TargetRef.Kind + + switch kind { + case "Node": + return r.policyNode(ctx) + case "Pod": + return r.policyPod(ctx) + default: + return r.policyController(ctx) + } +} + +func (r *CarbonIdleResourceRecommender) policyController(ctx *framework.RecommendationContext) error { + var zero int32 + + var newPatch PatchReplicas + newPatch.Spec.Replicas = &zero + + currentReplicas := int32(1) + if ctx.Scale != nil { + currentReplicas = ctx.Scale.Spec.Replicas + } + var oldPatch PatchReplicas + oldPatch.Spec.Replicas = ¤tReplicas + + newPatchBytes, err := json.Marshal(newPatch) + if err != nil { + return fmt.Errorf("failed to encode shutdown manifest: %v", err) + } + oldPatchBytes, err := json.Marshal(oldPatch) + if err != nil { + return fmt.Errorf("failed to encode current manifest: %v", err) + } + + ctx.Recommendation.Status.RecommendedInfo = string(newPatchBytes) + ctx.Recommendation.Status.CurrentInfo = string(oldPatchBytes) + return nil +} + +func (r *CarbonIdleResourceRecommender) policyPod(ctx *framework.RecommendationContext) error { + for _, pod := range ctx.Pods { + ownerKind := getControllerOwnerKind(&pod) + if ownerKind != "" { + return r.policyController(ctx) + } + } + + ctx.Recommendation.Status.RecommendedInfo = "" + ctx.Recommendation.Status.CurrentInfo = "" + return nil +} + +func (r *CarbonIdleResourceRecommender) policyNode(ctx *framework.RecommendationContext) error { + newPatch := PatchNodeUnschedulable{Spec: PatchNodeSpec{Unschedulable: true}} + oldPatch := PatchNodeUnschedulable{Spec: PatchNodeSpec{Unschedulable: false}} + + newPatchBytes, err := json.Marshal(newPatch) + if err != nil { + return fmt.Errorf("failed to encode cordon manifest: %v", err) + } + oldPatchBytes, err := json.Marshal(oldPatch) + if err != nil { + return fmt.Errorf("failed to encode current node manifest: %v", err) + } + + ctx.Recommendation.Status.RecommendedInfo = string(newPatchBytes) + ctx.Recommendation.Status.CurrentInfo = string(oldPatchBytes) + return nil +} + +func getControllerOwnerKind(pod *corev1.Pod) string { + for _, ref := range pod.OwnerReferences { + if ref.Controller != nil && *ref.Controller { + return ref.Kind + } + } + return "" +} diff --git a/pkg/recommendation/recommender/carbonidle/registry.go b/pkg/recommendation/recommender/carbonidle/registry.go new file mode 100644 index 000000000..f0e58651a --- /dev/null +++ b/pkg/recommendation/recommender/carbonidle/registry.go @@ -0,0 +1,60 @@ +package carbonidle + +import ( + analysisv1alph1 "github.com/gocrane/api/analysis/v1alpha1" + "github.com/gocrane/crane/pkg/recommendation/config" + "github.com/gocrane/crane/pkg/recommendation/recommender" + "github.com/gocrane/crane/pkg/recommendation/recommender/apis" + "github.com/gocrane/crane/pkg/recommendation/recommender/base" +) + +var _ recommender.Recommender = &CarbonIdleResourceRecommender{} + +type CarbonIdleResourceRecommender struct { + base.BaseRecommender + energyIdleThreshold float64 + observationDays int64 + minEnergyWatts float64 + cpuUsageThreshold float64 +} + +func init() { + recommender.RegisterRecommenderProvider(recommender.CarbonIdleResourceRecommender, NewCarbonIdleResourceRecommender) +} + +func (r *CarbonIdleResourceRecommender) Name() string { + return recommender.CarbonIdleResourceRecommender +} + +// NewCarbonIdleResourceRecommender creates a new CarbonIdleResource recommender. +func NewCarbonIdleResourceRecommender(rec apis.Recommender, recommendationRule analysisv1alph1.RecommendationRule) (recommender.Recommender, error) { + rec = config.MergeRecommenderConfigFromRule(rec, recommendationRule) + + energyIdleThreshold, err := rec.GetConfigFloat("energy-idle-threshold", 0.8) + if err != nil { + return nil, err + } + + observationDays, err := rec.GetConfigInt("observation-window-days", 7) + if err != nil { + return nil, err + } + + minEnergyWatts, err := rec.GetConfigFloat("min-energy-watts", 0.5) + if err != nil { + return nil, err + } + + cpuUsageThreshold, err := rec.GetConfigFloat("cpu-usage-threshold", 0.05) + if err != nil { + return nil, err + } + + return &CarbonIdleResourceRecommender{ + BaseRecommender: *base.NewBaseRecommender(rec), + energyIdleThreshold: energyIdleThreshold, + observationDays: observationDays, + minEnergyWatts: minEnergyWatts, + cpuUsageThreshold: cpuUsageThreshold, + }, nil +} diff --git a/pkg/recommendation/recommender/carbonrightsize/filter.go b/pkg/recommendation/recommender/carbonrightsize/filter.go new file mode 100644 index 000000000..f51989a36 --- /dev/null +++ b/pkg/recommendation/recommender/carbonrightsize/filter.go @@ -0,0 +1,41 @@ +package carbonrightsize + +import ( + "fmt" + + "github.com/gocrane/crane/pkg/recommendation/framework" +) + +// acceptedKinds lists the Kubernetes resource kinds supported by CarbonRightSizing. +var acceptedKinds = map[string]bool{ + "Deployment": true, + "StatefulSet": true, + "DaemonSet": true, +} + +// Filter checks whether the target resource kind is supported by CarbonRightSizing. +func (r *CarbonRightSizingRecommender) Filter(ctx *framework.RecommendationContext) error { + kind := ctx.Recommendation.Spec.TargetRef.Kind + if !acceptedKinds[kind] { + return fmt.Errorf("CarbonRightSizing recommender does not support resource kind %q; accepted kinds are Deployment, StatefulSet, DaemonSet", kind) + } + + // Delegate base filtering (label selectors, cooldown, deletion check). + if err := r.BaseRecommender.Filter(ctx); err != nil { + return err + } + + if err := framework.RetrievePodTemplate(ctx); err != nil { + return err + } + + if err := framework.RetrieveScale(ctx); err != nil { + return err + } + + if err := framework.RetrievePods(ctx); err != nil { + return err + } + + return nil +} diff --git a/pkg/recommendation/recommender/carbonrightsize/observe.go b/pkg/recommendation/recommender/carbonrightsize/observe.go new file mode 100644 index 000000000..3a1f4cd44 --- /dev/null +++ b/pkg/recommendation/recommender/carbonrightsize/observe.go @@ -0,0 +1,85 @@ +package carbonrightsize + +import ( + "encoding/json" + "fmt" + + "k8s.io/klog/v2" + + "github.com/gocrane/crane/pkg/recommendation/framework" +) + +// Observe records the total energy reduction estimate and number of right-sized containers +// in the Recommendation status. +func (r *CarbonRightSizingRecommender) Observe(ctx *framework.RecommendationContext) error { + if ctx.Recommendation.Status.Action != "Patch" { + return nil + } + + rightSizedCount := r.countRightSizedContainers(ctx) + energyReduction := r.estimateEnergyReduction(ctx) + + observation := fmt.Sprintf("Observation: %d container(s) right-sized, estimated energy reduction: %.2fW", + rightSizedCount, energyReduction) + + if ctx.Recommendation.Status.Description != "" { + ctx.Recommendation.Status.Description += "; " + observation + } else { + ctx.Recommendation.Status.Description = observation + } + + klog.Infof("%s: %s for %s/%s", r.Name(), observation, + ctx.Recommendation.Spec.TargetRef.Namespace, ctx.Recommendation.Spec.TargetRef.Name) + + return nil +} + +// countRightSizedContainers returns the number of containers in the recommended patch. +func (r *CarbonRightSizingRecommender) countRightSizedContainers(ctx *framework.RecommendationContext) int { + if ctx.Recommendation.Status.RecommendedInfo == "" { + return 0 + } + + var patch PatchResource + if err := json.Unmarshal([]byte(ctx.Recommendation.Status.RecommendedInfo), &patch); err != nil { + klog.Warningf("%s: failed to parse recommended info for container count: %v", r.Name(), err) + return 0 + } + + return len(patch.Spec.Template.Spec.Containers) +} + +// estimateEnergyReduction estimates the total energy reduction in watts by comparing +// current pod energy consumption against the efficiency target. Pods below the target +// are expected to save energy proportional to the gap between current and target efficiency. +func (r *CarbonRightSizingRecommender) estimateEnergyReduction(ctx *framework.RecommendationContext) float64 { + podWattsList := ctx.InputValue(keyPodCPUWatts) + efficiencyList := ctx.InputValue(keyEnergyEfficiency) + + if len(podWattsList) == 0 { + return 0 + } + + var totalReduction float64 + for i, ts := range podWattsList { + if len(ts.Samples) == 0 { + continue + } + + avgPower := avgSamples(ts.Samples) + + // Get the efficiency ratio for this pod (or use the first available). + var effRatio float64 + if i < len(efficiencyList) && len(efficiencyList[i].Samples) > 0 { + effRatio = efficiencyList[i].Samples[0].Value + } + + if effRatio < r.energyEfficiencyTarget && effRatio > 0 { + // Estimated savings: the wasted energy fraction that right-sizing would reclaim. + wastedFraction := 1.0 - (effRatio / r.energyEfficiencyTarget) + totalReduction += avgPower * wastedFraction + } + } + + return totalReduction +} diff --git a/pkg/recommendation/recommender/carbonrightsize/prepare.go b/pkg/recommendation/recommender/carbonrightsize/prepare.go new file mode 100644 index 000000000..2fb3ee2a3 --- /dev/null +++ b/pkg/recommendation/recommender/carbonrightsize/prepare.go @@ -0,0 +1,328 @@ +package carbonrightsize + +import ( + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/klog/v2" + + "github.com/gocrane/crane/pkg/common" + "github.com/gocrane/crane/pkg/metricnaming" + "github.com/gocrane/crane/pkg/providers" + "github.com/gocrane/crane/pkg/recommendation/framework" + "github.com/gocrane/crane/pkg/utils" +) + +const callerFormat = "CarbonRightSizingRecommender-%s-%s" + +// Input value keys for RecommendationContext. +const ( + keyPodCPUJoules = "kepler-pod-cpu-joules" + keyPodCPUWatts = "kepler-pod-cpu-watts" + keyPodGPUWatts = "kepler-pod-gpu-watts" + keyCPUUsage = "cpu-usage" + keyMemUsage = "mem-usage" + keyEnergyEfficiency = "kepler-energy-efficiency-ratio" +) + +// Kepler metric availability check — kepler_container_package_joules_total is the +// primary counter exported by this Kepler version. +const keplerAvailabilityExpr = `kepler_container_package_joules_total` + +// Kepler PromQL expression templates for pod-level metrics. +// Labels: container_namespace, pod_name, mode. rate() gives watts. +const ( + keplerPodCPUJoulesExpr = `sum by (pod_name, container_namespace) (kepler_container_package_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"})` + keplerPodCPUWattsExpr = `sum by (pod_name, container_namespace) (rate(kepler_container_package_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"}[5m]))` + keplerPodGPUWattsExpr = `sum by (pod_name, container_namespace) (rate(kepler_container_other_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"}[5m]))` +) + +// expectedMetricKeys lists the metrics CarbonRightSizing expects to collect. +var expectedMetricKeys = []string{ + keyPodCPUJoules, keyPodCPUWatts, keyPodGPUWatts, + keyCPUUsage, keyMemUsage, +} + +// CheckDataProviders verifies that Kepler metrics are available in Prometheus. +// Returns an error if Kepler is not installed or not exporting metrics. +func (r *CarbonRightSizingRecommender) CheckDataProviders(ctx *framework.RecommendationContext) error { + if err := r.BaseRecommender.CheckDataProviders(ctx); err != nil { + return err + } + + // Verify Kepler metrics exist by querying kepler_container_package_joules_total. + caller := fmt.Sprintf(callerFormat, klog.KObj(ctx.Recommendation), ctx.Recommendation.UID) + metricNamer := metricnaming.ResourceToGeneralMetricNamer( + keplerAvailabilityExpr, + corev1.ResourceCPU, + labels.Everything(), + caller, + ) + if err := metricNamer.Validate(); err != nil { + return fmt.Errorf("Kepler availability metric validation failed: %v", err) + } + + now := time.Now() + tsList, err := ctx.DataProviders[providers.PrometheusDataSource].QueryTimeSeries( + metricNamer, now.Add(-time.Hour), now, time.Minute, + ) + if err != nil { + return fmt.Errorf("Prometheus connection failed: %v", err) + } + if len(tsList) == 0 { + return fmt.Errorf("Kepler metrics not available: kepler_container_package_joules_total not found. Ensure Kepler is installed and exporting to Prometheus.") + } + + return nil +} + +// CollectData queries Kepler energy metrics and CPU/memory utilization from Prometheus. +func (r *CarbonRightSizingRecommender) CollectData(ctx *framework.RecommendationContext) error { + caller := fmt.Sprintf(callerFormat, klog.KObj(ctx.Recommendation), ctx.Recommendation.UID) + now := time.Now() + start := now.Add(-time.Hour * 24 * time.Duration(r.observationDays)) + step := time.Minute + ns := ctx.Recommendation.Spec.TargetRef.Namespace + kind := ctx.Recommendation.Spec.TargetRef.Kind + name := ctx.Recommendation.Spec.TargetRef.Name + + // Build pod name regex from retrieved pods. + podNameRegex := buildPodNameRegex(ctx) + if podNameRegex == "" { + return fmt.Errorf("no pods found matching selector for %s/%s", ns, name) + } + + // Collect pod-level Kepler energy metrics. + r.collectPodEnergyMetrics(ctx, caller, ns, podNameRegex, start, now, step) + + // Collect CPU/memory utilization metrics using Crane's standard expressions. + r.collectUtilizationMetrics(ctx, caller, ns, name, kind, start, now, step) + + return nil +} + +// buildPodNameRegex constructs a regex matching all pod names from the context. +func buildPodNameRegex(ctx *framework.RecommendationContext) string { + if len(ctx.Pods) == 0 { + return "" + } + result := "" + for i, pod := range ctx.Pods { + if i > 0 { + result += "|" + } + result += pod.Name + } + return result +} + +// collectPodEnergyMetrics queries pod-level Kepler energy metrics. +func (r *CarbonRightSizingRecommender) collectPodEnergyMetrics( + ctx *framework.RecommendationContext, + caller, namespace, podNameRegex string, + start, end time.Time, step time.Duration, +) { + podMetrics := []struct { + key string + expr string + }{ + {keyPodCPUJoules, fmt.Sprintf(keplerPodCPUJoulesExpr, namespace, podNameRegex)}, + {keyPodCPUWatts, fmt.Sprintf(keplerPodCPUWattsExpr, namespace, podNameRegex)}, + {keyPodGPUWatts, fmt.Sprintf(keplerPodGPUWattsExpr, namespace, podNameRegex)}, + } + + for _, m := range podMetrics { + r.queryAndStore(ctx, caller, m.key, m.expr, start, end, step) + } +} + +// collectUtilizationMetrics queries standard CPU/memory utilization metrics. +func (r *CarbonRightSizingRecommender) collectUtilizationMetrics( + ctx *framework.RecommendationContext, + caller, namespace, name, kind string, + start, end time.Time, step time.Duration, +) { + cpuExpr := utils.GetWorkloadCpuUsageExpression(namespace, name, kind) + r.queryAndStore(ctx, caller, keyCPUUsage, cpuExpr, start, end, step) + + memExpr := utils.GetWorkloadMemUsageExpression(namespace, name, kind) + r.queryAndStore(ctx, caller, keyMemUsage, memExpr, start, end, step) +} + +// queryAndStore queries a single metric and stores the result in the context. +// Errors are logged as warnings and the metric is skipped. +func (r *CarbonRightSizingRecommender) queryAndStore( + ctx *framework.RecommendationContext, + caller, key, expr string, + start, end time.Time, step time.Duration, +) { + metricNamer := metricnaming.ResourceToGeneralMetricNamer( + expr, + corev1.ResourceCPU, + labels.Everything(), + caller, + ) + if err := metricNamer.Validate(); err != nil { + klog.Warningf("%s: failed to validate metric namer for %s: %v", r.Name(), key, err) + return + } + + klog.Infof("%s: %s query %s", ctx.String(), r.Name(), key) + tsList, err := ctx.DataProviders[providers.PrometheusDataSource].QueryTimeSeries(metricNamer, start, end, step) + if err != nil { + klog.Warningf("%s: failed to query %s: %v", r.Name(), key, err) + return + } + if len(tsList) == 0 { + klog.Warningf("%s: no data returned for %s, excluding from analysis", r.Name(), key) + return + } + + ctx.AddInputValue(key, tsList) +} + +// PostProcessing computes the energy-efficiency ratio for each pod and validates +// that sufficient metrics are available. +func (r *CarbonRightSizingRecommender) PostProcessing(ctx *framework.RecommendationContext) error { + // Compute energy-efficiency ratio per pod. + if err := r.computeEnergyEfficiencyRatio(ctx); err != nil { + klog.Warningf("%s: failed to compute energy-efficiency ratio: %v", r.Name(), err) + } + + // Validate metric availability: require ≥50% of expected metrics. + if err := r.validateMetricAvailability(ctx); err != nil { + return err + } + + return nil +} + +// computeEnergyEfficiencyRatio computes the energy-efficiency ratio for each pod. +// The ratio is active_energy / total_energy, derived from pod CPU watts correlated +// with CPU utilization. Stored as a synthetic time series in the context. +func (r *CarbonRightSizingRecommender) computeEnergyEfficiencyRatio(ctx *framework.RecommendationContext) error { + podWattsList := ctx.InputValue(keyPodCPUWatts) + cpuUsageList := ctx.InputValue(keyCPUUsage) + + if len(podWattsList) == 0 || len(cpuUsageList) == 0 { + return fmt.Errorf("missing pod watts or CPU usage data for energy-efficiency computation") + } + + // Compute per-pod efficiency: correlate energy with CPU utilization. + // Active energy is approximated as total_watts * cpu_utilization_fraction. + // Efficiency = active_energy / total_energy = cpu_utilization_fraction (when + // energy is proportional to allocation). + var ratioSeries []*common.TimeSeries + for _, podTS := range podWattsList { + podName := getLabel(podTS, "pod_name") + if podName == "" { + continue + } + + totalAvg := avgSamples(podTS.Samples) + if totalAvg <= 0 { + continue + } + + // Estimate active energy fraction from CPU utilization. + // If workload-level CPU usage is available, use it as a proxy for the + // fraction of energy doing useful work. + activeAvg := estimateActiveEnergy(cpuUsageList, totalAvg) + + ratio := activeAvg / totalAvg + if ratio < 0 { + ratio = 0 + } + if ratio > 1 { + ratio = 1 + } + + ts := &common.TimeSeries{ + Labels: []common.Label{ + {Name: "pod_name", Value: podName}, + {Name: "metric", Value: "energy_efficiency_ratio"}, + }, + Samples: []common.Sample{{Value: ratio, Timestamp: time.Now().Unix()}}, + } + ratioSeries = append(ratioSeries, ts) + + klog.Infof("%s: pod %s energy-efficiency ratio = %.4f", r.Name(), podName, ratio) + } + + if len(ratioSeries) > 0 { + ctx.AddInputValue(keyEnergyEfficiency, ratioSeries) + } + + return nil +} + +// estimateActiveEnergy estimates the active energy from CPU usage time series. +// Returns the average CPU usage value as a proxy for active energy fraction. +func estimateActiveEnergy(cpuUsageList []*common.TimeSeries, totalWatts float64) float64 { + if len(cpuUsageList) == 0 { + return 0 + } + // Use the first (aggregated) CPU usage time series. + cpuAvg := avgSamples(cpuUsageList[0].Samples) + // CPU usage in cores; normalize against total watts to get active fraction. + // This is a simplified model: active_energy ≈ total_watts * (cpu_usage / max_cpu). + // Since we don't have max_cpu here, we use the raw ratio as a proxy. + if cpuAvg <= 0 { + return 0 + } + // Return the fraction of total watts attributable to active work. + // Capped at totalWatts to keep ratio ≤ 1. + active := cpuAvg * totalWatts + if active > totalWatts { + active = totalWatts + } + return active +} + +// getLabel returns the value of a label from a time series, or empty string if not found. +func getLabel(ts *common.TimeSeries, name string) string { + for _, l := range ts.Labels { + if l.Name == name { + return l.Value + } + } + return "" +} + +// avgSamples computes the arithmetic mean of sample values. +func avgSamples(samples []common.Sample) float64 { + if len(samples) == 0 { + return 0 + } + var sum float64 + for _, s := range samples { + sum += s.Value + } + return sum / float64(len(samples)) +} + +// validateMetricAvailability checks that at least 50% of expected metrics +// are present in the context. If fewer than 50% are available, the recommendation +// is skipped with a descriptive status. +func (r *CarbonRightSizingRecommender) validateMetricAvailability(ctx *framework.RecommendationContext) error { + available := 0 + for _, key := range expectedMetricKeys { + if ts := ctx.InputValue(key); len(ts) > 0 { + available++ + } + } + + total := len(expectedMetricKeys) + if total > 0 && available*2 < total { + msg := fmt.Sprintf("Insufficient energy data for %s/%s: only %d/%d metrics available", + ctx.Recommendation.Spec.TargetRef.Namespace, + ctx.Recommendation.Spec.TargetRef.Name, + available, total) + ctx.Recommendation.Status.Description = msg + return fmt.Errorf(msg) + } + + return nil +} diff --git a/pkg/recommendation/recommender/carbonrightsize/recommend.go b/pkg/recommendation/recommender/carbonrightsize/recommend.go new file mode 100644 index 000000000..a6ad2c8c8 --- /dev/null +++ b/pkg/recommendation/recommender/carbonrightsize/recommend.go @@ -0,0 +1,250 @@ +package carbonrightsize + +import ( + "encoding/json" + "fmt" + "math" + "sort" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/klog/v2" + + "github.com/gocrane/crane/pkg/common" + "github.com/gocrane/crane/pkg/recommendation/framework" +) + +// PatchResource represents a JSON patch for updating container resources. +type PatchResource struct { + Spec PatchResourceSpec `json:"spec,omitempty"` +} + +// PatchResourceSpec holds the template for patching. +type PatchResourceSpec struct { + Template PatchResourcePodTemplateSpec `json:"template"` +} + +// PatchResourcePodTemplateSpec wraps the pod spec for patching. +type PatchResourcePodTemplateSpec struct { + Spec PatchResourcePodSpec `json:"spec,omitempty"` +} + +// PatchResourcePodSpec holds containers for patching. +type PatchResourcePodSpec struct { + Containers []corev1.Container `json:"containers" patchStrategy:"merge" patchMergeKey:"name"` +} + +// PreRecommend is a no-op for CarbonRightSizing. +func (r *CarbonRightSizingRecommender) PreRecommend(ctx *framework.RecommendationContext) error { + return nil +} + +// Recommend computes recommended CPU/memory from percentiles weighted by energy, +// adjusts limits proportionally, and sets Action="Patch" for resources that need right-sizing. +func (r *CarbonRightSizingRecommender) Recommend(ctx *framework.RecommendationContext) error { + efficiencyList := ctx.InputValue(keyEnergyEfficiency) + cpuUsageList := ctx.InputValue(keyCPUUsage) + memUsageList := ctx.InputValue(keyMemUsage) + + if len(cpuUsageList) == 0 || len(memUsageList) == 0 { + return fmt.Errorf("missing CPU or memory usage data for right-sizing") + } + + var newContainers []corev1.Container + var oldContainers []corev1.Container + var descriptions []string + + for _, c := range ctx.PodTemplate.Spec.Containers { + effRatio := r.getEfficiencyForPod(efficiencyList) + + // Only right-size if efficiency is below target. + if effRatio >= r.energyEfficiencyTarget { + klog.Infof("%s: container %s efficiency %.4f >= target %.4f, skipping", + r.Name(), c.Name, effRatio, r.energyEfficiencyTarget) + continue + } + + // Compute recommended CPU from percentile of actual usage weighted by energy. + recCPU := r.computePercentile(cpuUsageList, r.cpuPercentile) + recMem := r.computePercentile(memUsageList, r.memoryPercentile) + + if recCPU <= 0 || recMem <= 0 { + klog.Warningf("%s: container %s computed zero recommendation (cpu=%.4f, mem=%.4f), skipping", + r.Name(), c.Name, recCPU, recMem) + continue + } + + cpuQuantity := resource.NewMilliQuantity(int64(math.Ceil(recCPU*1000)), resource.DecimalSI) + memQuantity := resource.NewQuantity(int64(math.Ceil(recMem)), resource.BinarySI) + + // Adjust limits proportionally if recommendation exceeds current limit. + cpuLimit, memLimit := r.adjustLimits(c, cpuQuantity, memQuantity) + + newContainerSpec := corev1.Container{ + Name: c.Name, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: *cpuQuantity, + corev1.ResourceMemory: *memQuantity, + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: cpuLimit, + corev1.ResourceMemory: memLimit, + }, + }, + } + + oldContainerSpec := corev1.Container{ + Name: c.Name, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: c.Resources.Requests[corev1.ResourceCPU], + corev1.ResourceMemory: c.Resources.Requests[corev1.ResourceMemory], + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: c.Resources.Limits[corev1.ResourceCPU], + corev1.ResourceMemory: c.Resources.Limits[corev1.ResourceMemory], + }, + }, + } + + newContainers = append(newContainers, newContainerSpec) + oldContainers = append(oldContainers, oldContainerSpec) + + desc := fmt.Sprintf("Right-size: %s, current CPU req %s -> %s, mem req %s -> %s", + c.Name, + c.Resources.Requests.Cpu().String(), cpuQuantity.String(), + c.Resources.Requests.Memory().String(), memQuantity.String()) + descriptions = append(descriptions, desc) + klog.Infof("%s: %s", r.Name(), desc) + } + + if len(newContainers) == 0 { + ctx.Recommendation.Status.Action = "None" + ctx.Recommendation.Status.Description = "No containers require right-sizing" + return nil + } + + ctx.Recommendation.Status.Action = "Patch" + ctx.Recommendation.Status.Description = strings.Join(descriptions, "; ") + + // Encode patches into recommendation status. + var newPatch PatchResource + newPatch.Spec.Template.Spec.Containers = newContainers + newPatchBytes, err := json.Marshal(newPatch) + if err != nil { + return fmt.Errorf("failed to encode recommended manifest: %v", err) + } + + var oldPatch PatchResource + oldPatch.Spec.Template.Spec.Containers = oldContainers + oldPatchBytes, err := json.Marshal(oldPatch) + if err != nil { + return fmt.Errorf("failed to encode current manifest: %v", err) + } + + ctx.Recommendation.Status.RecommendedInfo = string(newPatchBytes) + ctx.Recommendation.Status.CurrentInfo = string(oldPatchBytes) + + return nil +} + +// getEfficiencyForPod returns the energy-efficiency ratio for the first pod in the list, +// or 0 if no efficiency data is available. +func (r *CarbonRightSizingRecommender) getEfficiencyForPod(efficiencyList []*common.TimeSeries) float64 { + if len(efficiencyList) == 0 { + return 0 + } + // Use the first pod's efficiency ratio as representative. + if len(efficiencyList[0].Samples) == 0 { + return 0 + } + return efficiencyList[0].Samples[0].Value +} + +// computePercentile computes the p-th percentile from a set of time series samples. +// All samples across all time series are merged and sorted before computing the percentile. +func (r *CarbonRightSizingRecommender) computePercentile(tsList []*common.TimeSeries, p float64) float64 { + var values []float64 + for _, ts := range tsList { + for _, s := range ts.Samples { + if s.Value > 0 { + values = append(values, s.Value) + } + } + } + if len(values) == 0 { + return 0 + } + sort.Float64s(values) + + // Nearest-rank percentile method. + idx := int(math.Ceil(p*float64(len(values)))) - 1 + if idx < 0 { + idx = 0 + } + if idx >= len(values) { + idx = len(values) - 1 + } + return values[idx] +} + +// adjustLimits computes new CPU and memory limits. If the recommended request exceeds +// the current limit, the limit is adjusted proportionally to maintain the original +// request-to-limit ratio. Otherwise, the current limit is preserved. +func (r *CarbonRightSizingRecommender) adjustLimits( + c corev1.Container, + newCPUReq *resource.Quantity, + newMemReq *resource.Quantity, +) (resource.Quantity, resource.Quantity) { + cpuLimit := adjustSingleLimit( + c.Resources.Requests[corev1.ResourceCPU], + c.Resources.Limits[corev1.ResourceCPU], + *newCPUReq, + ) + memLimit := adjustSingleLimit( + c.Resources.Requests[corev1.ResourceMemory], + c.Resources.Limits[corev1.ResourceMemory], + *newMemReq, + ) + return cpuLimit, memLimit +} + +// adjustSingleLimit adjusts a single resource limit based on the new request. +// If newReq > currentLimit, the new limit is set to newReq / ratio where +// ratio = currentReq / currentLimit, preserving the original request-to-limit ratio. +func adjustSingleLimit(currentReq, currentLimit, newReq resource.Quantity) resource.Quantity { + if currentLimit.IsZero() { + // No limit set; use the new request as the limit. + return newReq.DeepCopy() + } + + if newReq.Cmp(currentLimit) <= 0 { + // New request fits within current limit; preserve it. + return currentLimit.DeepCopy() + } + + // New request exceeds current limit; adjust proportionally. + if currentReq.IsZero() { + // No current request; just use the new request as the limit. + return newReq.DeepCopy() + } + + // ratio = currentReq / currentLimit + // newLimit = newReq / ratio = newReq * (currentLimit / currentReq) + ratio := float64(currentReq.MilliValue()) / float64(currentLimit.MilliValue()) + if ratio <= 0 { + return newReq.DeepCopy() + } + newLimitValue := float64(newReq.MilliValue()) / ratio + return *resource.NewMilliQuantity(int64(math.Ceil(newLimitValue)), currentLimit.Format) +} + +// Policy generates right-sizing manifests preserving unmodified fields, +// encoded via ConvertToRecommendationInfos. +func (r *CarbonRightSizingRecommender) Policy(ctx *framework.RecommendationContext) error { + // The Recommend phase already encodes the patches into RecommendedInfo/CurrentInfo. + // Policy is a no-op for CarbonRightSizing since manifest generation is done in Recommend. + return nil +} diff --git a/pkg/recommendation/recommender/carbonrightsize/registry.go b/pkg/recommendation/recommender/carbonrightsize/registry.go new file mode 100644 index 000000000..6346f2311 --- /dev/null +++ b/pkg/recommendation/recommender/carbonrightsize/registry.go @@ -0,0 +1,60 @@ +package carbonrightsize + +import ( + analysisv1alph1 "github.com/gocrane/api/analysis/v1alpha1" + "github.com/gocrane/crane/pkg/recommendation/config" + "github.com/gocrane/crane/pkg/recommendation/recommender" + "github.com/gocrane/crane/pkg/recommendation/recommender/apis" + "github.com/gocrane/crane/pkg/recommendation/recommender/base" +) + +var _ recommender.Recommender = &CarbonRightSizingRecommender{} + +type CarbonRightSizingRecommender struct { + base.BaseRecommender + energyEfficiencyTarget float64 + observationDays int64 + cpuPercentile float64 + memoryPercentile float64 +} + +func init() { + recommender.RegisterRecommenderProvider(recommender.CarbonRightSizingRecommender, NewCarbonRightSizingRecommender) +} + +func (r *CarbonRightSizingRecommender) Name() string { + return recommender.CarbonRightSizingRecommender +} + +// NewCarbonRightSizingRecommender creates a new CarbonRightSizing recommender. +func NewCarbonRightSizingRecommender(rec apis.Recommender, recommendationRule analysisv1alph1.RecommendationRule) (recommender.Recommender, error) { + rec = config.MergeRecommenderConfigFromRule(rec, recommendationRule) + + energyEfficiencyTarget, err := rec.GetConfigFloat("energy-efficiency-target", 0.7) + if err != nil { + return nil, err + } + + observationDays, err := rec.GetConfigInt("observation-window-days", 7) + if err != nil { + return nil, err + } + + cpuPercentile, err := rec.GetConfigFloat("cpu-percentile", 0.95) + if err != nil { + return nil, err + } + + memoryPercentile, err := rec.GetConfigFloat("memory-percentile", 0.95) + if err != nil { + return nil, err + } + + return &CarbonRightSizingRecommender{ + BaseRecommender: *base.NewBaseRecommender(rec), + energyEfficiencyTarget: energyEfficiencyTarget, + observationDays: observationDays, + cpuPercentile: cpuPercentile, + memoryPercentile: memoryPercentile, + }, nil +} diff --git a/pkg/recommendation/recommender/carbonshift/carbonintensity.go b/pkg/recommendation/recommender/carbonshift/carbonintensity.go new file mode 100644 index 000000000..cc14f024a --- /dev/null +++ b/pkg/recommendation/recommender/carbonshift/carbonintensity.go @@ -0,0 +1,197 @@ +package carbonshift + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sync" + "time" + + "k8s.io/klog/v2" +) + +// ElectricityMaps API base URL and default zone. +const ( + electricityMapsBaseURL = "https://api.electricitymaps.com/v3" + defaultZone = "SE" // Sweden +) + +// CarbonIntensityEntry represents a single data point from the Electricity Maps API. +type CarbonIntensityEntry struct { + Zone string `json:"zone"` + CarbonIntensity float64 `json:"carbonIntensity"` + Datetime string `json:"datetime"` + UpdatedAt string `json:"updatedAt"` +} + +// CarbonIntensityHistory represents the API response for carbon intensity history. +type CarbonIntensityHistory struct { + Zone string `json:"zone"` + History []CarbonIntensityEntry `json:"history"` +} + +// HourlyCarbonIntensity holds the average carbon intensity per hour (0-23). +type HourlyCarbonIntensity struct { + Zone string + HourlyGCO2 [24]float64 // gCO2/kWh per hour of day (UTC) + FetchedAt time.Time +} + +// carbonIntensityCache caches the result to avoid repeated API calls within the same cycle. +var ( + ciCache *HourlyCarbonIntensity + ciCacheMu sync.Mutex + ciCacheTTL = 30 * time.Minute +) + +// GetHourlyCarbonIntensity fetches the 24h carbon intensity history from Electricity Maps +// and returns the average gCO2/kWh per hour of day. Results are cached for ciCacheTTL. +// If the API key is not set or the API fails, returns nil (caller should fall back to config). +func GetHourlyCarbonIntensity() *HourlyCarbonIntensity { + ciCacheMu.Lock() + defer ciCacheMu.Unlock() + + // Return cache if fresh. + if ciCache != nil && time.Since(ciCache.FetchedAt) < ciCacheTTL { + return ciCache + } + + apiKey := os.Getenv("ELECTRICITY_MAPS_API_KEY") + if apiKey == "" { + klog.V(2).Infof("CarbonLoadShifting: ELECTRICITY_MAPS_API_KEY not set, using static config") + return nil + } + + zone := os.Getenv("ELECTRICITY_MAPS_ZONE") + if zone == "" { + zone = defaultZone + } + + url := fmt.Sprintf("%s/carbon-intensity/history?zone=%s", electricityMapsBaseURL, zone) + + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", url, nil) + if err != nil { + klog.Warningf("CarbonLoadShifting: failed to create request: %v", err) + return nil + } + req.Header.Set("auth-token", apiKey) + + resp, err := client.Do(req) + if err != nil { + klog.Warningf("CarbonLoadShifting: Electricity Maps API call failed: %v", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + klog.Warningf("CarbonLoadShifting: Electricity Maps API returned %d: %s", resp.StatusCode, string(body)) + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + klog.Warningf("CarbonLoadShifting: failed to read response body: %v", err) + return nil + } + + var history CarbonIntensityHistory + if err := json.Unmarshal(body, &history); err != nil { + // Try parsing as array directly (some API versions return array of entries). + var entries []CarbonIntensityEntry + if err2 := json.Unmarshal(body, &entries); err2 != nil { + klog.Warningf("CarbonLoadShifting: failed to parse API response: %v (also tried array: %v)", err, err2) + return nil + } + history.Zone = zone + history.History = entries + } + + if len(history.History) == 0 { + klog.Warningf("CarbonLoadShifting: Electricity Maps returned empty history for zone %s", zone) + return nil + } + + // Bucket by hour of day (UTC). + var hourSums [24]float64 + var hourCounts [24]int + + for _, entry := range history.History { + t, err := time.Parse(time.RFC3339, entry.Datetime) + if err != nil { + continue + } + hour := t.UTC().Hour() + hourSums[hour] += entry.CarbonIntensity + hourCounts[hour]++ + } + + result := &HourlyCarbonIntensity{ + Zone: zone, + FetchedAt: time.Now(), + } + for h := 0; h < 24; h++ { + if hourCounts[h] > 0 { + result.HourlyGCO2[h] = hourSums[h] / float64(hourCounts[h]) + } + } + + klog.Infof("CarbonLoadShifting: fetched real carbon intensity for zone %s (%d data points)", zone, len(history.History)) + ciCache = result + return result +} + +// FindOptimalWindow finds the lowest-carbon window of the given duration (hours). +// Returns startHour and the average gCO2/kWh during that window. +func (ci *HourlyCarbonIntensity) FindOptimalWindow(windowHours int) (startHour int, avgIntensity float64) { + if windowHours <= 0 || windowHours > 24 { + windowHours = 6 + } + + bestStart := 0 + bestAvg := float64(999999) + + for start := 0; start < 24; start++ { + var sum float64 + for i := 0; i < windowHours; i++ { + hour := (start + i) % 24 + sum += ci.HourlyGCO2[hour] + } + avg := sum / float64(windowHours) + if avg < bestAvg { + bestAvg = avg + bestStart = start + } + } + + return bestStart, bestAvg +} + +// FindPeakWindow finds the highest-carbon window of the given duration (hours). +// Returns startHour and the average gCO2/kWh during that window. +func (ci *HourlyCarbonIntensity) FindPeakWindow(windowHours int) (startHour int, avgIntensity float64) { + if windowHours <= 0 || windowHours > 24 { + windowHours = 6 + } + + worstStart := 0 + worstAvg := float64(0) + + for start := 0; start < 24; start++ { + var sum float64 + for i := 0; i < windowHours; i++ { + hour := (start + i) % 24 + sum += ci.HourlyGCO2[hour] + } + avg := sum / float64(windowHours) + if avg > worstAvg { + worstAvg = avg + worstStart = start + } + } + + return worstStart, worstAvg +} diff --git a/pkg/recommendation/recommender/carbonshift/filter.go b/pkg/recommendation/recommender/carbonshift/filter.go new file mode 100644 index 000000000..84b2b4314 --- /dev/null +++ b/pkg/recommendation/recommender/carbonshift/filter.go @@ -0,0 +1,47 @@ +package carbonshift + +import ( + "fmt" + + "github.com/gocrane/crane/pkg/recommendation/framework" +) + +// acceptedKinds lists the Kubernetes resource kinds supported by CarbonLoadShifting. +var acceptedKinds = map[string]bool{ + "Deployment": true, + "StatefulSet": true, + "DaemonSet": true, + "CronJob": true, + "Job": true, +} + +// Filter checks whether the target resource kind is supported by CarbonLoadShifting. +func (r *CarbonLoadShiftingRecommender) Filter(ctx *framework.RecommendationContext) error { + kind := ctx.Recommendation.Spec.TargetRef.Kind + if !acceptedKinds[kind] { + return fmt.Errorf("CarbonLoadShifting recommender does not support resource kind %q; accepted kinds are Deployment, StatefulSet, DaemonSet, CronJob, Job", kind) + } + + // Delegate base filtering (label selectors, cooldown, deletion check). + if err := r.BaseRecommender.Filter(ctx); err != nil { + return err + } + + // Retrieve pods for workload types that support it. + switch kind { + case "CronJob", "Job": + // Jobs may not have running pods at the time of analysis. + // RetrievePods will get any currently running pods; if none exist, + // we rely on historical Kepler data from prior runs. + _ = framework.RetrievePods(ctx) + default: + if err := framework.RetrieveScale(ctx); err != nil { + return err + } + if err := framework.RetrievePods(ctx); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/recommendation/recommender/carbonshift/observe.go b/pkg/recommendation/recommender/carbonshift/observe.go new file mode 100644 index 000000000..038fbf383 --- /dev/null +++ b/pkg/recommendation/recommender/carbonshift/observe.go @@ -0,0 +1,40 @@ +package carbonshift + +import ( + "encoding/json" + "fmt" + + "k8s.io/klog/v2" + + "github.com/gocrane/crane/pkg/recommendation/framework" +) + +// Observe records the carbon savings estimate in the Recommendation status. +func (r *CarbonLoadShiftingRecommender) Observe(ctx *framework.RecommendationContext) error { + if ctx.Recommendation.Status.Action != "Patch" { + return nil + } + + // Parse the manifest to extract savings for the observation. + var manifest ShiftManifest + if err := json.Unmarshal([]byte(ctx.Recommendation.Status.RecommendedInfo), &manifest); err != nil { + klog.Warningf("%s: failed to parse manifest for observation: %v", r.Name(), err) + return nil + } + + observation := fmt.Sprintf("Observation: temporal shift to %s could save %.1f gCO₂/day (%.0f%% reduction)", + manifest.TargetWindow, + manifest.EstimatedSavings.GramsCO2PerDay, + manifest.EstimatedSavings.ReductionPercent) + + if ctx.Recommendation.Status.Description != "" { + ctx.Recommendation.Status.Description += "; " + observation + } else { + ctx.Recommendation.Status.Description = observation + } + + klog.Infof("%s: %s for %s/%s", r.Name(), observation, + ctx.Recommendation.Spec.TargetRef.Namespace, ctx.Recommendation.Spec.TargetRef.Name) + + return nil +} diff --git a/pkg/recommendation/recommender/carbonshift/prepare.go b/pkg/recommendation/recommender/carbonshift/prepare.go new file mode 100644 index 000000000..f90478b30 --- /dev/null +++ b/pkg/recommendation/recommender/carbonshift/prepare.go @@ -0,0 +1,222 @@ +package carbonshift + +import ( + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/klog/v2" + + "github.com/gocrane/crane/pkg/common" + "github.com/gocrane/crane/pkg/metricnaming" + "github.com/gocrane/crane/pkg/providers" + "github.com/gocrane/crane/pkg/recommendation/framework" +) + +const callerFormat = "CarbonLoadShiftingRecommender-%s-%s" + +// Input value keys for RecommendationContext. +const ( + keyPodCPUJoules = "kepler-pod-cpu-joules" + keyPodCPUWatts = "kepler-pod-cpu-watts" + keyPodGPUWatts = "kepler-pod-gpu-watts" + keyNodeCPUWatts = "kepler-node-cpu-watts" + keyHourlyProfile = "hourly-energy-profile" +) + +// Kepler metric availability check. +const keplerAvailabilityExpr = `kepler_container_package_joules_total` + +// Kepler PromQL expression templates for pod-level metrics. +const ( + keplerPodCPUJoulesExpr = `sum by (pod_name, container_namespace) (kepler_container_package_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"})` + keplerPodCPUWattsExpr = `sum by (pod_name, container_namespace) (rate(kepler_container_package_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"}[5m]))` + keplerPodGPUWattsExpr = `sum by (pod_name, container_namespace) (rate(kepler_container_other_joules_total{container_namespace="%s",pod_name=~"%s",mode="dynamic"}[5m]))` +) + +// Kepler PromQL expression for node-level watts (used for node targets). +const keplerNodeCPUWattsExpr = `sum by (instance) (rate(kepler_node_package_joules_total{instance="%s",mode="dynamic"}[5m]))` + +// CheckDataProviders verifies that Kepler metrics are available in Prometheus. +func (r *CarbonLoadShiftingRecommender) CheckDataProviders(ctx *framework.RecommendationContext) error { + if err := r.BaseRecommender.CheckDataProviders(ctx); err != nil { + return err + } + + // Verify Kepler metrics exist by querying kepler_container_cpu_joules_total. + caller := fmt.Sprintf(callerFormat, klog.KObj(ctx.Recommendation), ctx.Recommendation.UID) + metricNamer := metricnaming.ResourceToGeneralMetricNamer( + keplerAvailabilityExpr, + corev1.ResourceCPU, + labels.Everything(), + caller, + ) + if err := metricNamer.Validate(); err != nil { + return fmt.Errorf("Kepler availability metric validation failed: %v", err) + } + + now := time.Now() + tsList, err := ctx.DataProviders[providers.PrometheusDataSource].QueryTimeSeries( + metricNamer, now.Add(-time.Hour), now, time.Minute, + ) + if err != nil { + return fmt.Errorf("Prometheus connection failed: %v", err) + } + if len(tsList) == 0 { + return fmt.Errorf("Kepler metrics not available: kepler_container_package_joules_total not found. Ensure Kepler is installed and exporting to Prometheus.") + } + + return nil +} + +// CollectData queries Kepler energy metrics from Prometheus and stores them in the context. +func (r *CarbonLoadShiftingRecommender) CollectData(ctx *framework.RecommendationContext) error { + caller := fmt.Sprintf(callerFormat, klog.KObj(ctx.Recommendation), ctx.Recommendation.UID) + now := time.Now() + start := now.Add(-time.Hour * 24 * time.Duration(r.observationDays)) + step := time.Minute + ns := ctx.Recommendation.Spec.TargetRef.Namespace + kind := ctx.Recommendation.Spec.TargetRef.Kind + + // Build pod name regex from retrieved pods. + podNameRegex := buildPodNameRegex(ctx) + + // For Jobs/CronJobs, pods may have completed. Use workload name as fallback. + if podNameRegex == "" && (kind == "CronJob" || kind == "Job") { + // Match pods whose name starts with the job/cronjob name. + podNameRegex = ctx.Recommendation.Spec.TargetRef.Name + ".*" + } + + if podNameRegex == "" { + return fmt.Errorf("no pods found matching selector for %s/%s", ns, ctx.Recommendation.Spec.TargetRef.Name) + } + + // Collect pod-level energy metrics. + r.collectPodMetrics(ctx, caller, ns, podNameRegex, start, now, step) + + return nil +} + +// buildPodNameRegex constructs a regex matching all pod names from the context. +func buildPodNameRegex(ctx *framework.RecommendationContext) string { + if len(ctx.Pods) == 0 { + return "" + } + result := "" + for i, pod := range ctx.Pods { + if i > 0 { + result += "|" + } + result += pod.Name + } + return result +} + +// collectPodMetrics queries pod-level Kepler energy metrics. +func (r *CarbonLoadShiftingRecommender) collectPodMetrics( + ctx *framework.RecommendationContext, + caller, namespace, podNameRegex string, + start, end time.Time, step time.Duration, +) { + podMetrics := []struct { + key string + expr string + }{ + {keyPodCPUJoules, fmt.Sprintf(keplerPodCPUJoulesExpr, namespace, podNameRegex)}, + {keyPodCPUWatts, fmt.Sprintf(keplerPodCPUWattsExpr, namespace, podNameRegex)}, + {keyPodGPUWatts, fmt.Sprintf(keplerPodGPUWattsExpr, namespace, podNameRegex)}, + } + + for _, m := range podMetrics { + r.queryAndStore(ctx, caller, m.key, m.expr, start, end, step) + } +} + +// queryAndStore queries a single Kepler metric and stores the result in the context. +func (r *CarbonLoadShiftingRecommender) queryAndStore( + ctx *framework.RecommendationContext, + caller, key, expr string, + start, end time.Time, step time.Duration, +) { + metricNamer := metricnaming.ResourceToGeneralMetricNamer( + expr, + corev1.ResourceCPU, + labels.Everything(), + caller, + ) + if err := metricNamer.Validate(); err != nil { + klog.Warningf("%s: failed to validate metric namer for %s: %v", r.Name(), key, err) + return + } + + klog.Infof("%s: %s query %s", ctx.String(), r.Name(), key) + tsList, err := ctx.DataProviders[providers.PrometheusDataSource].QueryTimeSeries(metricNamer, start, end, step) + if err != nil { + klog.Warningf("%s: failed to query %s: %v", r.Name(), key, err) + return + } + if len(tsList) == 0 { + klog.Warningf("%s: no data returned for %s, excluding from analysis", r.Name(), key) + return + } + + ctx.AddInputValue(key, tsList) +} + +// PostProcessing computes the hourly energy profile from collected time series. +// It buckets energy consumption by hour-of-day to identify when the workload runs. +func (r *CarbonLoadShiftingRecommender) PostProcessing(ctx *framework.RecommendationContext) error { + podWattsList := ctx.InputValue(keyPodCPUWatts) + if len(podWattsList) == 0 { + return fmt.Errorf("no energy data available for hourly profile computation") + } + + // Compute hourly energy profile: average watts per hour-of-day (0-23). + hourlyProfile := r.computeHourlyProfile(podWattsList) + + // Store as synthetic time series (hour as timestamp, avg watts as value). + var samples []common.Sample + for hour := 0; hour < 24; hour++ { + samples = append(samples, common.Sample{ + Value: hourlyProfile[hour], + Timestamp: int64(hour), + }) + } + + ts := &common.TimeSeries{ + Labels: []common.Label{{Name: "metric", Value: "hourly_energy_profile"}}, + Samples: samples, + } + ctx.AddInputValue(keyHourlyProfile, []*common.TimeSeries{ts}) + + klog.Infof("%s: computed hourly energy profile for %s/%s", + r.Name(), ctx.Recommendation.Spec.TargetRef.Namespace, ctx.Recommendation.Spec.TargetRef.Name) + + return nil +} + +// computeHourlyProfile buckets time series samples by hour-of-day and returns +// the average watts for each hour (0-23). +func (r *CarbonLoadShiftingRecommender) computeHourlyProfile(tsList []*common.TimeSeries) [24]float64 { + var hourSums [24]float64 + var hourCounts [24]int + + for _, ts := range tsList { + for _, s := range ts.Samples { + // Timestamp is Unix seconds. Extract hour-of-day. + t := time.Unix(s.Timestamp, 0).UTC() + hour := t.Hour() + hourSums[hour] += s.Value + hourCounts[hour]++ + } + } + + var profile [24]float64 + for h := 0; h < 24; h++ { + if hourCounts[h] > 0 { + profile[h] = hourSums[h] / float64(hourCounts[h]) + } + } + return profile +} diff --git a/pkg/recommendation/recommender/carbonshift/recommend.go b/pkg/recommendation/recommender/carbonshift/recommend.go new file mode 100644 index 000000000..1e01a7322 --- /dev/null +++ b/pkg/recommendation/recommender/carbonshift/recommend.go @@ -0,0 +1,234 @@ +package carbonshift + +import ( + "encoding/json" + "fmt" + "strings" + + "k8s.io/klog/v2" + + "github.com/gocrane/crane/pkg/common" + "github.com/gocrane/crane/pkg/recommendation/framework" +) + +// PreRecommend is a no-op for CarbonLoadShifting. +func (r *CarbonLoadShiftingRecommender) PreRecommend(ctx *framework.RecommendationContext) error { + return nil +} + +// Recommend analyzes the hourly energy profile and recommends temporal shifting +// if the workload runs primarily during high-carbon hours. +// When the Electricity Maps API key is configured (env ELECTRICITY_MAPS_API_KEY), +// real grid carbon intensity data is used to identify optimal windows dynamically. +// Otherwise, static config thresholds apply as fallback. +func (r *CarbonLoadShiftingRecommender) Recommend(ctx *framework.RecommendationContext) error { + profileList := ctx.InputValue(keyHourlyProfile) + if len(profileList) == 0 || len(profileList[0].Samples) < 24 { + return fmt.Errorf("no hourly energy profile available for load shifting analysis") + } + + profile := profileList[0].Samples // 24 samples, one per hour + + // Compute total energy. + var totalEnergy float64 + for _, s := range profile { + totalEnergy += s.Value + } + + if totalEnergy <= 0 { + ctx.Recommendation.Status.Action = "None" + ctx.Recommendation.Status.Description = fmt.Sprintf( + "Workload %s/%s has no measurable energy consumption — nothing to shift", + ctx.Recommendation.Spec.TargetRef.Namespace, ctx.Recommendation.Spec.TargetRef.Name) + return nil + } + + avgWatts := totalEnergy / 24.0 + if avgWatts < r.minEnergyWatts { + ctx.Recommendation.Status.Action = "None" + ctx.Recommendation.Status.Description = fmt.Sprintf( + "Workload average power %.2fW is below threshold %.2fW, not worth shifting", + avgWatts, r.minEnergyWatts) + return nil + } + + // Try to get real carbon intensity data from Electricity Maps API. + carbonData := GetHourlyCarbonIntensity() + + var lowCarbonStart, lowCarbonEnd int + var highCarbonGCO2, lowCarbonGCO2 float64 + var dataSource string + + if carbonData != nil { + // Use real API data: find the optimal 6-hour low-carbon window dynamically. + optStart, optAvg := carbonData.FindOptimalWindow(6) + _, peakAvg := carbonData.FindPeakWindow(6) + lowCarbonStart = optStart + lowCarbonEnd = (optStart + 6) % 24 + lowCarbonGCO2 = optAvg + highCarbonGCO2 = peakAvg + dataSource = fmt.Sprintf("Electricity Maps API (zone: %s, fetched: %s)", + carbonData.Zone, carbonData.FetchedAt.Format("15:04 UTC")) + } else { + // Fallback: use static config values. + lowCarbonStart = int(r.lowCarbonStartHour) + lowCarbonEnd = int(r.lowCarbonEndHour) + highCarbonGCO2 = r.highCarbonGCO2 + lowCarbonGCO2 = r.lowCarbonGCO2 + dataSource = "static configuration" + } + + // Compute energy during high-carbon hours. + var highCarbonEnergy float64 + var peakHours []int + + for _, s := range profile { + hour := int(s.Timestamp) + watts := s.Value + if !isInWindow(hour, lowCarbonStart, lowCarbonEnd) && watts > 0 { + highCarbonEnergy += watts + if watts > r.minEnergyWatts { + peakHours = append(peakHours, hour) + } + } + } + + highCarbonFraction := highCarbonEnergy / totalEnergy + + if highCarbonFraction < 0.3 { + ctx.Recommendation.Status.Action = "None" + ctx.Recommendation.Status.Description = fmt.Sprintf( + "Only %.0f%% of energy consumed during high-carbon hours — shifting not needed (data: %s)", + highCarbonFraction*100, dataSource) + return nil + } + + // Compute estimated carbon savings. + currentCarbonGrams := highCarbonEnergy*highCarbonGCO2 + (totalEnergy-highCarbonEnergy)*lowCarbonGCO2 + shiftedCarbonGrams := totalEnergy * lowCarbonGCO2 + savingsGrams := currentCarbonGrams - shiftedCarbonGrams + + if savingsGrams <= 0 { + ctx.Recommendation.Status.Action = "None" + ctx.Recommendation.Status.Description = fmt.Sprintf( + "No carbon savings achievable through temporal shifting (data: %s)", dataSource) + return nil + } + + // Build the recommendation. + ctx.Recommendation.Status.Action = "Patch" + ctx.Recommendation.Status.Description = fmt.Sprintf( + "Temporal load shifting recommended: shift workload from high-carbon hours [%s] to low-carbon window [%02d:00-%02d:00 UTC]. "+ + "High-carbon intensity: %.1f gCO2/kWh, low-carbon intensity: %.1f gCO2/kWh. "+ + "Current high-carbon energy: %.2fWh (%.0f%% of total). "+ + "Estimated carbon savings: %.1f gCO2/day (from %.1f to %.1f gCO2/day). Data source: %s", + formatHours(peakHours), + lowCarbonStart, lowCarbonEnd, + highCarbonGCO2, lowCarbonGCO2, + highCarbonEnergy, highCarbonFraction*100, + savingsGrams, currentCarbonGrams, shiftedCarbonGrams, + dataSource) + + // Generate the shift manifest. + manifest := ShiftManifest{ + Recommendation: "TemporalShift", + TargetWindow: fmt.Sprintf("%02d:00-%02d:00 UTC", lowCarbonStart, lowCarbonEnd), + CurrentPeakHours: formatHours(peakHours), + HighCarbonFraction: highCarbonFraction, + DataSource: dataSource, + EstimatedSavings: CarbonSavings{ + GramsCO2PerDay: savingsGrams, + CurrentGCO2: currentCarbonGrams, + ShiftedGCO2: shiftedCarbonGrams, + ReductionPercent: (savingsGrams / currentCarbonGrams) * 100, + HighCarbonIntensity: highCarbonGCO2, + LowCarbonIntensity: lowCarbonGCO2, + }, + EnergyProfile: buildEnergyProfileMap(profile), + } + + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("failed to encode shift manifest: %v", err) + } + + ctx.Recommendation.Status.RecommendedInfo = string(manifestBytes) + + currentInfo := CurrentScheduleInfo{ + Kind: ctx.Recommendation.Spec.TargetRef.Kind, + Schedule: "always-running", + } + currentBytes, err := json.Marshal(currentInfo) + if err != nil { + return fmt.Errorf("failed to encode current info: %v", err) + } + ctx.Recommendation.Status.CurrentInfo = string(currentBytes) + + klog.Infof("%s: recommending temporal shift for %s/%s, savings: %.1f gCO2/day (source: %s)", + r.Name(), ctx.Recommendation.Spec.TargetRef.Namespace, + ctx.Recommendation.Spec.TargetRef.Name, savingsGrams, dataSource) + + return nil +} + +// Policy is a no-op — manifest generation is done in Recommend. +func (r *CarbonLoadShiftingRecommender) Policy(ctx *framework.RecommendationContext) error { + return nil +} + +// isInWindow checks if hour falls within [start, end) (handles wraparound). +func isInWindow(hour, start, end int) bool { + if start <= end { + return hour >= start && hour < end + } + return hour >= start || hour < end +} + +// formatHours formats a slice of hours as a human-readable range string. +func formatHours(hours []int) string { + if len(hours) == 0 { + return "none" + } + parts := make([]string, 0, len(hours)) + for _, h := range hours { + parts = append(parts, fmt.Sprintf("%02d:00", h)) + } + return strings.Join(parts, ", ") +} + +// buildEnergyProfileMap converts hourly profile samples to a map for the manifest. +func buildEnergyProfileMap(profile []common.Sample) map[string]float64 { + result := make(map[string]float64, 24) + for _, s := range profile { + hour := int(s.Timestamp) + result[fmt.Sprintf("%02d:00", hour)] = s.Value + } + return result +} + +// ShiftManifest represents the recommended temporal shift. +type ShiftManifest struct { + Recommendation string `json:"recommendation"` + TargetWindow string `json:"targetWindow"` + CurrentPeakHours string `json:"currentPeakHours"` + HighCarbonFraction float64 `json:"highCarbonFraction"` + DataSource string `json:"dataSource"` + EstimatedSavings CarbonSavings `json:"estimatedSavings"` + EnergyProfile map[string]float64 `json:"energyProfile"` +} + +// CarbonSavings holds the estimated carbon reduction from shifting. +type CarbonSavings struct { + GramsCO2PerDay float64 `json:"gramsCO2PerDay"` + CurrentGCO2 float64 `json:"currentGCO2PerDay"` + ShiftedGCO2 float64 `json:"shiftedGCO2PerDay"` + ReductionPercent float64 `json:"reductionPercent"` + HighCarbonIntensity float64 `json:"highCarbonIntensity_gCO2_kWh"` + LowCarbonIntensity float64 `json:"lowCarbonIntensity_gCO2_kWh"` +} + +// CurrentScheduleInfo describes the workload's current scheduling state. +type CurrentScheduleInfo struct { + Kind string `json:"kind"` + Schedule string `json:"schedule"` +} diff --git a/pkg/recommendation/recommender/carbonshift/registry.go b/pkg/recommendation/recommender/carbonshift/registry.go new file mode 100644 index 000000000..8b92360f0 --- /dev/null +++ b/pkg/recommendation/recommender/carbonshift/registry.go @@ -0,0 +1,76 @@ +package carbonshift + +import ( + analysisv1alph1 "github.com/gocrane/api/analysis/v1alpha1" + "github.com/gocrane/crane/pkg/recommendation/config" + "github.com/gocrane/crane/pkg/recommendation/recommender" + "github.com/gocrane/crane/pkg/recommendation/recommender/apis" + "github.com/gocrane/crane/pkg/recommendation/recommender/base" +) + +var _ recommender.Recommender = &CarbonLoadShiftingRecommender{} + +// CarbonLoadShiftingRecommender analyzes workload energy consumption patterns +// and recommends temporal shifting to low-carbon-intensity time windows. +type CarbonLoadShiftingRecommender struct { + base.BaseRecommender + lowCarbonStartHour int64 // Hour (0-23) when low-carbon window starts + lowCarbonEndHour int64 // Hour (0-23) when low-carbon window ends + highCarbonGCO2 float64 // gCO2/kWh threshold above which a time period is "high carbon" + lowCarbonGCO2 float64 // gCO2/kWh during the low-carbon window + observationDays int64 // Days of historical data to analyze + minEnergyWatts float64 // Minimum average watts for a workload to be worth shifting +} + +func init() { + recommender.RegisterRecommenderProvider(recommender.CarbonLoadShiftingRecommender, NewCarbonLoadShiftingRecommender) +} + +func (r *CarbonLoadShiftingRecommender) Name() string { + return recommender.CarbonLoadShiftingRecommender +} + +// NewCarbonLoadShiftingRecommender creates a new CarbonLoadShifting recommender. +func NewCarbonLoadShiftingRecommender(rec apis.Recommender, recommendationRule analysisv1alph1.RecommendationRule) (recommender.Recommender, error) { + rec = config.MergeRecommenderConfigFromRule(rec, recommendationRule) + + lowCarbonStartHour, err := rec.GetConfigInt("low-carbon-start-hour", 0) + if err != nil { + return nil, err + } + + lowCarbonEndHour, err := rec.GetConfigInt("low-carbon-end-hour", 6) + if err != nil { + return nil, err + } + + highCarbonGCO2, err := rec.GetConfigFloat("high-carbon-gco2", 300.0) + if err != nil { + return nil, err + } + + lowCarbonGCO2, err := rec.GetConfigFloat("low-carbon-gco2", 120.0) + if err != nil { + return nil, err + } + + observationDays, err := rec.GetConfigInt("observation-window-days", 7) + if err != nil { + return nil, err + } + + minEnergyWatts, err := rec.GetConfigFloat("min-energy-watts", 1.0) + if err != nil { + return nil, err + } + + return &CarbonLoadShiftingRecommender{ + BaseRecommender: *base.NewBaseRecommender(rec), + lowCarbonStartHour: lowCarbonStartHour, + lowCarbonEndHour: lowCarbonEndHour, + highCarbonGCO2: highCarbonGCO2, + lowCarbonGCO2: lowCarbonGCO2, + observationDays: observationDays, + minEnergyWatts: minEnergyWatts, + }, nil +} diff --git a/pkg/recommendation/recommender/const.go b/pkg/recommendation/recommender/const.go index 169588edd..9fba0495d 100644 --- a/pkg/recommendation/recommender/const.go +++ b/pkg/recommendation/recommender/const.go @@ -18,4 +18,13 @@ const ( // ServiceRecommender name ServiceRecommender string = "Service" + + // CarbonIdleResourceRecommender name + CarbonIdleResourceRecommender string = "CarbonIdleResource" + + // CarbonRightSizingRecommender name + CarbonRightSizingRecommender string = "CarbonRightSizing" + + // CarbonLoadShiftingRecommender name + CarbonLoadShiftingRecommender string = "CarbonLoadShifting" ) diff --git a/pkg/recommendation/recommender/registration_test.go b/pkg/recommendation/recommender/registration_test.go new file mode 100644 index 000000000..61a141df4 --- /dev/null +++ b/pkg/recommendation/recommender/registration_test.go @@ -0,0 +1,46 @@ +package recommender_test + +import ( + "testing" + + analysisv1alph1 "github.com/gocrane/api/analysis/v1alpha1" + "github.com/gocrane/crane/pkg/recommendation/recommender" + "github.com/gocrane/crane/pkg/recommendation/recommender/apis" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + // Blank imports trigger init() registration for each recommender. + _ "github.com/gocrane/crane/pkg/recommendation/recommender/carbonidle" + _ "github.com/gocrane/crane/pkg/recommendation/recommender/carbonrightsize" +) + +func TestCarbonRecommenderRegistration(t *testing.T) { + emptyRec := apis.Recommender{ + Config: map[string]string{}, + } + emptyRule := analysisv1alph1.RecommendationRule{} + + t.Run("CarbonIdleResource returns valid instance", func(t *testing.T) { + instance, err := recommender.GetRecommenderProvider( + recommender.CarbonIdleResourceRecommender, emptyRec, emptyRule, + ) + require.NoError(t, err) + assert.NotNil(t, instance) + assert.Equal(t, recommender.CarbonIdleResourceRecommender, instance.Name()) + + // Verify the instance implements the Recommender interface. + var _ recommender.Recommender = instance + }) + + t.Run("CarbonRightSizing returns valid instance", func(t *testing.T) { + instance, err := recommender.GetRecommenderProvider( + recommender.CarbonRightSizingRecommender, emptyRec, emptyRule, + ) + require.NoError(t, err) + assert.NotNil(t, instance) + assert.Equal(t, recommender.CarbonRightSizingRecommender, instance.Name()) + + // Verify the instance implements the Recommender interface. + var _ recommender.Recommender = instance + }) +} diff --git a/pkg/server/handler/recommendation/recommendation.go b/pkg/server/handler/recommendation/recommendation.go index d9cb56bc5..b46d673fb 100644 --- a/pkg/server/handler/recommendation/recommendation.go +++ b/pkg/server/handler/recommendation/recommendation.go @@ -115,7 +115,9 @@ func (h *Handler) AdoptRecommendation(c *gin.Context) { } if string(recommendationExist.Spec.Type) == recommender.ReplicasRecommender || - string(recommendationExist.Spec.Type) == recommender.ResourceRecommender { + string(recommendationExist.Spec.Type) == recommender.ResourceRecommender || + string(recommendationExist.Spec.Type) == recommender.CarbonIdleResourceRecommender || + string(recommendationExist.Spec.Type) == recommender.CarbonRightSizingRecommender { gvr, err := utils.GetGroupVersionResource(h.discoveryClient, recommendationExist.Spec.TargetRef.APIVersion, recommendationExist.Spec.TargetRef.Kind) if err != nil { ginwrapper.WriteResponse(c, err, nil) diff --git a/terraform/deploy-kubernetes-cluster/main.tf b/terraform/deploy-kubernetes-cluster/main.tf new file mode 100644 index 000000000..12050cf45 --- /dev/null +++ b/terraform/deploy-kubernetes-cluster/main.tf @@ -0,0 +1,30 @@ +resource "azurerm_resource_group" "Demo_Carbon" { + name = "demoCarbonrg" + location = var.region +} + +resource "azurerm_kubernetes_cluster" "aks" { + name = "demo-carbon-aks" + location = azurerm_resource_group.Demo_Carbon.location + resource_group_name = azurerm_resource_group.Demo_Carbon.name + dns_prefix = "demo-carbon-aks" + + default_node_pool { + name = "default" + node_count = 2 + vm_size = "Standard_B2s_v2" + } + + identity { + type = "SystemAssigned" + } +} + +output "Resource_group_name"{ + value = azurerm_resource_group.Demo_Carbon.name + +} + +output "K8s_aks_name"{ + value = azurerm_kubernetes_cluster.aks.name +} \ No newline at end of file diff --git a/terraform/deploy-kubernetes-cluster/providers.tf b/terraform/deploy-kubernetes-cluster/providers.tf new file mode 100644 index 000000000..fdc85638a --- /dev/null +++ b/terraform/deploy-kubernetes-cluster/providers.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } + + backend "azurerm" { + resource_group_name = "terrytfStateRG" + storage_account_name = "terrytfstatestorageacc" + container_name = "terrytfstatecontainer" + key = "akstfstate.tfstate" + } + required_version = ">= 1.1.0" +} + +provider "azurerm" { + subscription_id = var.subscription_id + resource_provider_registrations = "none" + features {} +} \ No newline at end of file diff --git a/terraform/deploy-kubernetes-cluster/variables.tf b/terraform/deploy-kubernetes-cluster/variables.tf new file mode 100644 index 000000000..38711ba72 --- /dev/null +++ b/terraform/deploy-kubernetes-cluster/variables.tf @@ -0,0 +1,9 @@ +variable "subscription_id" { + type = string + default = "" +} + +variable "region" { + type = string + default = "swedencentral" +} \ No newline at end of file