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...
+
+
+
+
+
+ | Pod | Namespace | CPU Power (W) | Source |
+ | Loading... |
+
+
+
+
+
+
+
Carbon Reduction Recommendations
+
+
+
+
+ | Target | Namespace | Type | Action | Description | |
+ | Loading... |
+
+
+
+
+
+
+
+
+
+
Apply Recommendation
+
+
+
+
+
+
+
+
+
+
+
+
+
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