diff --git a/.github/workflows/cluster_scanner.yaml b/.github/workflows/cluster_scanner.yaml new file mode 100644 index 0000000..5292987 --- /dev/null +++ b/.github/workflows/cluster_scanner.yaml @@ -0,0 +1,64 @@ +name: Cluster Scanner + +on: + # For test purposes. Trigger the pipeline on pushes to the cluster-scanner branch. + push: + branches: + - cluster-scanner + # It runs every Friday, 9:30 UTC. The schedule job only works if the workflow is available in the main branch. + schedule: + - cron: "30 9 * * 5" + workflow_dispatch: + +env: + PALETTE_API_KEY: ${{ secrets.PALETTE_API_KEY }} + PALETTE_HOST: ${{ secrets.PALETTE_HOST }} + PALETTE_PROJECT_UID: ${{ secrets.PALETTE_PROJECT_UID }} + +jobs: + scan-clusters: + name: cluster-scan + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set Up Go + uses: actions/setup-go@v5 + with: + go-version-file: "scripts/cluster-scanner/go.mod" + + - name: Install Dependencies + working-directory: scripts/cluster-scanner + run: go get ./... + + - name: Execute Tests + working-directory: scripts/cluster-scanner + run: go test ./... + + - name: Launch the Application and Capture Logs + working-directory: scripts/cluster-scanner + run: go run . | tee result.log + + - name: Get Clusters with More Than 24 Hours and Format Output + working-directory: scripts/cluster-scanner + run: | + if grep -q "The following clusters have been running" result.log; then + echo "CLUSTERS_FOUND=true" >> $GITHUB_ENV + { + echo 'LOG_MESSAGE<> "$GITHUB_ENV" + fi + + - name: Send Slack Notification + if: env.CLUSTERS_FOUND == 'true' + uses: rtCamp/action-slack-notify@v2.3.2 + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_PRIVATE_TEAM_WEBHOOK }} + SLACK_USERNAME: "spectromate" + SLACK_COLOR: "good" + SLACKIFY_MARKDOWN: true + ENABLE_ESCAPES: true + SLACK_MESSAGE: ${{ env.LOG_MESSAGE }} diff --git a/.gitignore b/.gitignore index 9b8a46e..7242c7f 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ override.tf.json # Ignore CLI configuration files .terraformrc terraform.rc + +# Ignore secrets +.secrets \ No newline at end of file diff --git a/scripts/cluster-scanner/README.md b/scripts/cluster-scanner/README.md new file mode 100644 index 0000000..55c2ae4 --- /dev/null +++ b/scripts/cluster-scanner/README.md @@ -0,0 +1,57 @@ +# Cluster Scanner + +The **Cluster Scanner** tool uses the Palette Go SDK to scan your Palette environment and identify clusters that have been active for more than 24 hours. + +## Prerequisites + +- Go version 1.22 or later +- Git +- The `palette-samples` repository available locally +- A Palette acount +- A Palette API key + +## Usage + +1. Open a terminal window and export your Palette URL. Substitute `` with your Palette URL, for example, `console.spectrocloud.com`. + + ```shell + export PALETTE_HOST= + ``` + +2. Export your Palette API key. Replace `` with your Palette API key. + + ```shell + export PALETTE_API_KEY= + ``` + +3. To scan a specific project, export the project's UID. Substitute `` with the Palette project UID. If no project is provided, the tool assumes a tenant scope and scans clusters across all projects. + + ```shell + export PALETTE_PROJECT_UID= + ``` + +4. Navigate to the `cluster-scanner` folder. + + ```shell + cd cluster-scanner + ``` + +5. Issue the command below to download the required Palette SDK modules. + + ```shell + go get ./... + ``` + +6. Execute the `cluster-scanner` application. + + ```shell + go run . + ``` + + The application will print the clusters that have been active in your Palette environment for more than 24 hours. + + ```text hideClipboard + time=2024-10-28T21:21:47.516-04:00 level=INFO msg="Setting scope to tenant." + time=2024-10-28T21:21:47.516-04:00 level=INFO msg="Searching for clusters..." + time=2024-10-28T21:21:48.297-04:00 level=INFO msg="The aws cluster named aws-test has been running for 2 weeks 6 days 2 hours. Are you sure you need this cluster?" + ``` diff --git a/scripts/cluster-scanner/go.mod b/scripts/cluster-scanner/go.mod new file mode 100644 index 0000000..2f12676 --- /dev/null +++ b/scripts/cluster-scanner/go.mod @@ -0,0 +1,38 @@ +module github.com/spectrocloud/palette-samples/cluster-scanner + +go 1.22.5 + +toolchain go1.22.8 + +require ( + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/go-errors/errors v1.5.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/analysis v0.23.0 // indirect + github.com/go-openapi/errors v0.22.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/loads v0.22.0 // indirect + github.com/go-openapi/runtime v0.28.0 // indirect + github.com/go-openapi/spec v0.21.0 // indirect + github.com/go-openapi/strfmt v0.23.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/validate v0.24.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spectrocloud/palette-sdk-go v0.0.0-20240930211255-e224a905d31a // indirect + go.mongodb.org/mongo-driver v1.16.0 // indirect + go.opentelemetry.io/otel v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.28.0 // indirect + go.opentelemetry.io/otel/trace v1.28.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.22.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/scripts/cluster-scanner/go.sum b/scripts/cluster-scanner/go.sum new file mode 100644 index 0000000..9911030 --- /dev/null +++ b/scripts/cluster-scanner/go.sum @@ -0,0 +1,70 @@ +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= +github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= +github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= +github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w= +github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= +github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= +github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= +github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= +github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= +github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spectrocloud/palette-sdk-go v0.0.0-20240930211255-e224a905d31a h1:u1itx2mJzS9VCjVRr3cXqSnJpBxELSay5pdgFHIhz8w= +github.com/spectrocloud/palette-sdk-go v0.0.0-20240930211255-e224a905d31a/go.mod h1:dSlNvDS0qwUWTbrYI6P8x981mcbbRHFrBg67v5zl81U= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.mongodb.org/mongo-driver v1.16.0 h1:tpRsfBJMROVHKpdGyc1BBEzzjDUWjItxbVSZ8Ls4BQ4= +go.mongodb.org/mongo-driver v1.16.0/go.mod h1:oB6AhJQvFQL4LEHyXi6aJzQJtBiTQHiAd83l0GdFaiw= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/scripts/cluster-scanner/internal/format_age.go b/scripts/cluster-scanner/internal/format_age.go new file mode 100644 index 0000000..bc9a75d --- /dev/null +++ b/scripts/cluster-scanner/internal/format_age.go @@ -0,0 +1,55 @@ +package internal + +import ( + "fmt" + "time" +) + +type FormattedAge struct { + Days, Weeks, Hours int +} + +// FormatAge returns the age of a cluster as the number of weeks, days, and hours. +func FormatAge(clusterAge time.Duration) (*FormattedAge, error) { + const hoursPerWeek int = 168 + hoursAge := int(clusterAge.Hours()) + if hoursAge < 0 { + return nil, fmt.Errorf("%v is less than zero hours", hoursAge) + } + weeks := hoursAge / hoursPerWeek + remainingHours := hoursAge - weeks*hoursPerWeek + days := remainingHours / 24 + hours := remainingHours % 24 + return &FormattedAge{ + Days: days, + Weeks: weeks, + Hours: hours, + }, nil +} + +// GetFormattedAge returns a formatted string representation of the cluster's age. +// It uses FormatAge to calculate the age in weeks, days, and hours and formats +// the output accordingly. +func GetFormattedAge(clusterAge time.Duration) (*string, error) { + fa, err := FormatAge(clusterAge) + if err != nil { + return nil, err + } + var formattedString string + if fa.Weeks > 0 { + formattedString = fmt.Sprintf("%dw", fa.Weeks) + } + if fa.Days > 0 { + if len(formattedString) > 0 { + formattedString += " " + } + formattedString = fmt.Sprintf("%s%dd", formattedString, fa.Days) + } + if fa.Hours > 0 { + if len(formattedString) > 0 { + formattedString += " " + } + formattedString = fmt.Sprintf("%s%dh", formattedString, fa.Hours) + } + return &formattedString, nil +} \ No newline at end of file diff --git a/scripts/cluster-scanner/internal/format_age_test.go b/scripts/cluster-scanner/internal/format_age_test.go new file mode 100644 index 0000000..1ced4c0 --- /dev/null +++ b/scripts/cluster-scanner/internal/format_age_test.go @@ -0,0 +1,148 @@ +package internal_test + +import ( + "errors" + "testing" + "time" + + "github.com/spectrocloud/palette-samples/cluster-scanner/internal" +) + +type TestCase struct { + input string + expectedHours, expectedDays, expectedWeeks int + expectedError error + output string +} + +func TestFormatAge (t *testing.T) { + tc := map[string]TestCase{ + "positive duration":{ + input:"2h", + expectedHours:2, + }, + "duration with weeks": { + input: "336h", + expectedWeeks:2, + }, + "duration with days": { + input: "48h", + expectedDays:2, + }, + "duration with hours and days": { + input: "74h", + expectedDays:3, + expectedHours:2, + }, + "duration with hours, days, and weeks": { + input: "914h", + expectedDays:3, + expectedHours:2, + expectedWeeks:5, + }, + "zero duration":{ + input: "0h", + output: "", + }, + "negative duration":{ + input:"-2h", + expectedError: errors.New("-2 is less than zero hours"), + }, + } + + for key, value := range tc { + t.Run(key, func(t *testing.T){ + duration, err := time.ParseDuration(value.input) + if err != nil { + t.Errorf("Error parsing duration: %v", err) + } + + fa, err := internal.FormatAge(duration) + + if value.expectedError != nil && err == nil { + t.Errorf("Expected an error, but got none") + } + if value.expectedError == nil && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if value.expectedError != nil && err != nil { + if value.expectedError.Error() != err.Error() { + t.Errorf("Errors do not match: got %v, want %v", err, value.expectedError.Error()) + } + } + if fa != nil { + if fa.Weeks != value.expectedWeeks { + t.Errorf("got %d weeks, want %d weeks", fa.Weeks, value.expectedWeeks) + } + if fa.Days != value.expectedDays { + t.Errorf("got %d days, want %d days", fa.Days, value.expectedDays) + } + if fa.Hours != value.expectedHours { + t.Errorf("got %d hours, want %d hours", fa.Hours, value.expectedHours) + } + } + }) + } +} + +func TestGetFormattedAge(t *testing.T) { + tc := map[string]TestCase{ + "duration with hours": { + input: "2h", + output: "2h", + }, + "duration with weeks": { + input: "336h", + output: "2w", + }, + "duration with days": { + input: "48h", + output: "2d", + }, + "duration with hours and days": { + input: "74h", + output: "3d 2h", + }, + "duration with hours, days, and weeks": { + input: "914h", + output: "5w 3d 2h", + }, + "negative duration": { + input: "-2h", + expectedError: errors.New("-2 is less than zero hours"), + }, + "zero duration": { + input: "0h", + output: "", + }, + } + + for key, value := range tc { + t.Run(key, func(t *testing.T) { + duration, err := time.ParseDuration(value.input) + if err != nil { + t.Errorf("Error parsing duration: %v", err) + } + + faString, err := internal.GetFormattedAge(duration) + + if value.expectedError != nil && err == nil { + t.Errorf("Expected an error, but got none") + } + if value.expectedError == nil && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if value.expectedError != nil && err != nil { + if value.expectedError.Error() != err.Error() { + t.Errorf("Errors do not match: got %v, want %v", err, value.expectedError.Error()) + } + } + if faString != nil { + if *faString != value.output { + t.Errorf("got '%s', want '%s'", *faString, value.output) + } + } + }) + } + +} diff --git a/scripts/cluster-scanner/internal/search_clusters.go b/scripts/cluster-scanner/internal/search_clusters.go new file mode 100644 index 0000000..70a4cb7 --- /dev/null +++ b/scripts/cluster-scanner/internal/search_clusters.go @@ -0,0 +1,12 @@ +package internal + +import ( + "github.com/spectrocloud/palette-sdk-go/api/models" + "github.com/spectrocloud/palette-sdk-go/client" +) + +// SearchClusters retrieves a list of cluster summaries from the Palette client. +func SearchClusters(paletteClient *client.V1Client) ([]*models.V1SpectroClusterSummary, error) { + // Search for clusters using the provided Palette client with default filters and sorting. + return paletteClient.SearchClusterSummaries(&models.V1SearchFilterSpec{}, []*models.V1SearchFilterSortSpec{}) +} \ No newline at end of file diff --git a/scripts/cluster-scanner/internal/search_old_clusters.go b/scripts/cluster-scanner/internal/search_old_clusters.go new file mode 100644 index 0000000..15a7523 --- /dev/null +++ b/scripts/cluster-scanner/internal/search_old_clusters.go @@ -0,0 +1,32 @@ +package internal + +import ( + "fmt" + "time" + + "github.com/spectrocloud/palette-sdk-go/api/models" +) + +// SearchOldClusters checks for clusters that have been running for more than 24 hours. +// It takes a list of cluster summaries and returns a slice of messages with the +// information of the clusters that were found. +func SearchOldClusters(clusters []*models.V1SpectroClusterSummary) ([]string, error) { + // Slice of strings to keep track of any found clusters older than 24 hours + var messageArray []string + + // Iterate through the clusters to find those running for more than 24 hours + for _, cluster := range clusters { + timeValue := time.Time(cluster.Metadata.CreationTimestamp) + clusterAge := time.Since(timeValue) + + if clusterAge.Hours() >= 24 { + age, err := GetFormattedAge(clusterAge) + if err != nil { + return nil, err + } + message := fmt.Sprintf("❗️%s cluster '%s' - %s ⏳", cluster.SpecSummary.CloudConfig.CloudType, cluster.Metadata.Name, *age) + messageArray = append(messageArray, message) + } + } + return messageArray, nil +} diff --git a/scripts/cluster-scanner/internal/search_old_clusters_test.go b/scripts/cluster-scanner/internal/search_old_clusters_test.go new file mode 100644 index 0000000..c7ef524 --- /dev/null +++ b/scripts/cluster-scanner/internal/search_old_clusters_test.go @@ -0,0 +1,109 @@ +package internal_test + +import ( + "testing" + "time" + + "github.com/spectrocloud/palette-samples/cluster-scanner/internal" + "github.com/spectrocloud/palette-sdk-go/api/models" +) + +type TestCaseSearch struct { + input []*models.V1SpectroClusterSummary + output []string + expectedError error +} + +func TestSearchOldClusters (t *testing.T) { + now := time.Now() + + parseTime := func (now time.Time, input string) time.Time { + creationDate, _ := time.ParseDuration(input) + age := now.Add(creationDate) + return age + } + + createSummary := func (creationTime string, name string, cloudType string) *models.V1SpectroClusterSummary { + return &models.V1SpectroClusterSummary { + Metadata: &models.V1ObjectMeta{ + CreationTimestamp: models.V1Time(parseTime(now, creationTime)), + Name: name, + }, + SpecSummary: &models.V1SpectroClusterSummarySpecSummary{ + CloudConfig: &models.V1CloudConfigMeta{ + CloudType: cloudType, + }, + }, + } + } + + tc := map[string]TestCaseSearch{ + "one cluster older than 24h":{ + input: []*models.V1SpectroClusterSummary{ + createSummary("-30h", "test-cluster", "aws"), + }, + output: []string{ + "❗️aws cluster 'test-cluster' - 1d 6h ⏳", + }, + }, + "two clusters older than 24h":{ + input: []*models.V1SpectroClusterSummary{ + createSummary("-30h", "test-cluster", "aws"), + createSummary("-50h", "test-cluster-azure", "azure"), + }, + output: []string{ + "❗️aws cluster 'test-cluster' - 1d 6h ⏳", + "❗️azure cluster 'test-cluster-azure' - 2d 2h ⏳", + }, + }, + "one cluster with 24h":{ + input: []*models.V1SpectroClusterSummary{ + createSummary("-24h", "test-cluster", "aws"), + }, + output: []string{ + "❗️aws cluster 'test-cluster' - 1d ⏳", + }, + }, + "one cluster with less than 24h":{ + input: []*models.V1SpectroClusterSummary{ + createSummary("-20h", "test-cluster", "aws"), + }, + output: nil, + }, + "one cluster with negative age":{ + input: []*models.V1SpectroClusterSummary{ + createSummary("20h", "test-cluster", "aws"), + }, + output: nil, + }, + } + + for key, value := range tc { + t.Run(key, func(t *testing.T) { + clustersGot, err := internal.SearchOldClusters(value.input) + + if value.expectedError != nil && err == nil { + t.Errorf("Expected an error, but got none") + } + if value.expectedError == nil && err != nil { + t.Errorf("Unexpected error: %v", err) + } + if value.expectedError != nil && err != nil { + if value.expectedError.Error() != err.Error() { + t.Errorf("Errors do not match: got %v, want %v", err, value.expectedError.Error()) + } + } + if len(clustersGot) != len(value.output) { + t.Errorf("Lenghts mismatch. Got %v elements, want %v elements", len(clustersGot), len(value.output)) + } + for _, got := range clustersGot { + for _, want := range value.output { + if got == want { + return + } + } + t.Errorf("Got unexpected value %v, want %v", got, value.output) + } + }) + } +} \ No newline at end of file diff --git a/scripts/cluster-scanner/main.go b/scripts/cluster-scanner/main.go new file mode 100644 index 0000000..98254c9 --- /dev/null +++ b/scripts/cluster-scanner/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "log/slog" + "os" + + "github.com/spectrocloud/palette-samples/cluster-scanner/internal" + "github.com/spectrocloud/palette-sdk-go/client" +) + +func main() { + + // Read environment variables required to initialize the Palette client. + host := os.Getenv("PALETTE_HOST") + apiKey := os.Getenv("PALETTE_API_KEY") + projectUid := os.Getenv("PALETTE_PROJECT_UID") + + // Initialize a logger for structured output. + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + // Ensure the required environment variables are provided. + if host == "" || apiKey == "" { + logger.Error("You must specify the PALETTE_HOST and PALETTE_API_KEY environment variables.") + os.Exit(1) + } + + // Initialize a Palette client with the provided host and API key. + paletteClient := client.New( + client.WithPaletteURI(host), + client.WithAPIKey(apiKey), + ) + + // Set the scope for the client based on wether the project UID is provided. + if projectUid != "" { + client.WithScopeProject(projectUid)(paletteClient) + logger.Info("Setting scope to project.") + } else { + client.WithScopeTenant()(paletteClient) + logger.Info("Setting scope to tenant.") + } + + // Search for clusters using the Palette client and the SearchClusters function. + logger.Info("Searching for clusters...") + clusters, err := internal.SearchClusters(paletteClient) + if err != nil { + logger.Error("Failed to search cluster summaries", "error", err) + os.Exit(2) + } + + // Check if any clusters were found. + if len(clusters) == 0 { + logger.Warn("There are no clusters running.") + return + } + + // Check for clusters running for more than 24 hours using the SearchOldClusters function. + messageArray, err := internal.SearchOldClusters(clusters) + if err != nil { + logger.Error("Failed to search for old clusters", "error", err) + os.Exit(2) + } + if len(messageArray) != 0 { + logger.Info("The following clusters have been running for over 24 hours. Please delete them if they're no longer needed:") + for _, message := range messageArray { + logger.Info(message) + } + return + } + logger.Info("There are no clusters running for more than 24 hours.") +}