feat: Added Prime Monitor Go Service for monitoring, health check and feature flagging (#411)

* feat: added build meta data to monitor

* feat: added versioning to prime-monitor

* feat: added logger package for prime-monitor

* feat: implemented logging in cmd

* feat: implemented moduled monorepo architecture

* feat: added cron package for healthcheck

* feat: added constants and modules for healthcheck

* feat: added environment service getter method

* feat: added environment service parser for parsing the recieved keys

* feat: added environment service provider method

* feat: added retry based executer for the test methods

* feat: added http health check method and placeholders for redis & pg

* feat: added tests for healthcheck methods

* feat: added PerformHealthCheck Controller Method for execution of healthcheck controller

* feat: added healthcheck test file for testing the healthcheck

* feat: added healthcheck to work file

* feat: added readme for healthcheck package

* feat: created healthcheck register for prime scheduler

* feat: added cron command for running cron subpart of monitor

* feat: added prime schedule handler for cron

* feat: added start command for starting up prime scheduler

* feat: added build utility files for monitor

* fix: goreleaser builds

* feat: removed Redis method from healthcheck methods

* feat: added docker file for building prime-monitor

* feat: added flag for adding healthcheck duration for cron start

* chore: removed redis as a healthcheck method

* chore: modified actions to build monitor on branch build and docker images

* feat: added monitor service in docker compose caddy

* feat: added description for HealthCheckJob

* fix: status code issue for reachable and non reachable

* feat: added api package for connecting with prime api

* feat: modified cmd package to report healthcheck status to prime

* feat: added api types inside prime-monitor

* feat: added message field for meta while status reporting

* fix: modified constants for environment variables

* feat: added monitor readme

* fix: build-branch monitor content shift to build-branch-ee

* chore: added build args on release

* feat: remove version meta data from cli

* fix: docker file changed to fix the build to default

* fix: removed build flags from github branch build

* fix: moved cron start to root level start cmd

* fix: passed the recieved machine signature to api headers

* feat: optimised docker build for monitor
This commit is contained in:
Henit Chobisa
2024-06-20 12:14:46 +05:30
committed by GitHub
parent 8494699792
commit a93467b95b
41 changed files with 2296 additions and 0 deletions

View File

@@ -34,6 +34,7 @@ jobs:
build_space: ${{ steps.changed_files.outputs.space_any_changed }}
build_apiserver: ${{ steps.changed_files.outputs.apiserver_any_changed }}
build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }}
build_monitor: ${{ steps.changed_files.outputs.monitor_any_changed }}
artifact_upload_to_s3: ${{ steps.set_env_variables.outputs.artifact_upload_to_s3 }}
artifact_s3_suffix: ${{ steps.set_env_variables.outputs.artifact_s3_suffix }}
@@ -103,6 +104,8 @@ jobs:
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
monitor:
- monitor/**
branch_build_push_admin:
if: ${{ needs.branch_build_setup.outputs.build_admin== 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
@@ -429,6 +432,71 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_monitor:
if: ${{ needs.branch_build_setup.outputs.build_monitor == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }}
name: Build-Push Monitor Docker Image
runs-on: ${{vars.ACTION_RUNS_ON}}
needs: [branch_build_setup]
env:
MONITOR_TAG: makeplane/monitor-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }}
TARGET_BRANCH: ${{ needs.branch_build_setup.outputs.gh_branch_name }}
BUILDX_DRIVER: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
BUILDX_VERSION: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
BUILDX_PLATFORMS: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
BUILDX_ENDPOINT: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
steps:
- name: Set Monitor Docker Tag
run: |
if [ "${{ github.event_name }}" == "release" ]; then
TAG=makeplane/monitor-enterprise:stable
TAG=${TAG},makeplane/monitor-enterprise:${{ github.event.release.tag_name }}
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/monitor-enterprise:stable
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/monitor-enterprise:${{ github.event.release.tag_name }}
elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then
TAG=makeplane/monitor-enterprise:latest
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/monitor-enterprise:latest
else
TAG=${{ env.MONITOR_TAG }}
TAG=${TAG},${{ vars.HARBOR_REGISTRY }}/${{ vars.HARBOR_PROJECT }}/monitor-enterprise:${{ needs.branch_build_setup.outputs.gh_branch_name }}
fi
echo "MONITOR_TAG=${TAG}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Harbor
uses: docker/login-action@v3
with:
username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.HARBOR_TOKEN }}
registry: ${{ vars.HARBOR_REGISTRY }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: ${{ env.BUILDX_DRIVER }}
version: ${{ env.BUILDX_VERSION }}
endpoint: ${{ env.BUILDX_ENDPOINT }}
- name: Check out the repo
uses: actions/checkout@v4
- name: Build and Push Monitor to Docker Container Registry
uses: docker/build-push-action@v5.1.0
with:
context: ./monitor
file: ./monitor/Dockerfile
platforms: ${{ env.BUILDX_PLATFORMS }}
tags: ${{ env.MONITOR_TAG }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
upload_artifacts_s3:
if: ${{ needs.branch_build_setup.outputs.artifact_upload_to_s3 == 'true' }}
name: Upload artifacts to S3 Bucket
@@ -464,3 +532,4 @@ jobs:
aws s3 cp ~/${{ env.ARTIFACT_SUFFIX }} s3://${{ vars.SELF_HOST_BUCKET_NAME }}/plane-enterprise/${{ env.ARTIFACT_SUFFIX }} --recursive
rm -rf ~/${{ env.ARTIFACT_SUFFIX }}

View File

@@ -0,0 +1,162 @@
name: Build and Lint on Pull Request EE
on:
workflow_dispatch:
pull_request:
types: ["opened", "synchronize", "ready_for_review"]
jobs:
get-changed-files:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
outputs:
apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }}
admin_changed: ${{ steps.changed-files.outputs.admin_any_changed }}
space_changed: ${{ steps.changed-files.outputs.space_any_changed }}
web_changed: ${{ steps.changed-files.outputs.web_any_changed }}
monitor_changed: ${{ steps.changed-files.outputs.monitor_any_changed }}
steps:
- uses: actions/checkout@v4
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@v44
with:
files_yaml: |
apiserver:
- apiserver/**
admin:
- admin/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
space:
- space/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
web:
- web/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
monitor:
- monitor/**
lint-apiserver:
needs: get-changed-files
runs-on: ubuntu-latest
if: needs.get-changed-files.outputs.apiserver_changed == 'true'
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x" # Specify the Python version you need
- name: Install Pylint
run: python -m pip install ruff
- name: Install Apiserver Dependencies
run: cd apiserver && pip install -r requirements.txt
- name: Lint apiserver
run: ruff check --fix apiserver
lint-admin:
needs: get-changed-files
if: needs.get-changed-files.outputs.admin_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=admin
lint-space:
needs: get-changed-files
if: needs.get-changed-files.outputs.space_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=space
lint-web:
needs: get-changed-files
if: needs.get-changed-files.outputs.web_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- run: yarn lint --filter=web
test-monitor:
needs: get-changed-files
if: needs.get-changed-files.outputs.monitor_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22.2'
- run: cd ./monitor && make test
build-admin:
needs: lint-admin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- run: yarn build --filter=admin
build-space:
needs: lint-space
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- run: yarn build --filter=space
build-web:
needs: lint-web
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
- run: yarn install
- run: yarn build --filter=web
build-monitor:
needs: test-monitor
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22.2'
- run: cd ./monitor && make build

View File

@@ -1,3 +1,12 @@
x-monitor-env: &monitor-env
environment:
- SERVICE_HTTP_WEB=web:3000
- SERVICE_HTTP_API=api:8000
- SERVICE_HTTP_PROXY=proxy:80
- SERVICE_HTTP_MINIO=plane-minio:9090
- SERVICE_TCP_REDIS=plane-redis:6379
- SERVICE_TCP_POSTGRES=plane-db:5432
x-proxy-env: &proxy-env
environment:
- SITE_ADDRESS=${SITE_ADDRESS:-localhost:80}
@@ -67,6 +76,11 @@ services:
- api
- worker
monitor:
<<: *monitor-env
image: registry.plane.tools/plane/monitor-enterprise:${APP_RELEASE_VERSION}
restart: unless-stopped
space:
<<: *app-env
image: registry.plane.tools/plane/space-enterprise:${APP_RELEASE_VERSION}

2
monitor/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/

31
monitor/.goreleaser.yaml Normal file
View File

@@ -0,0 +1,31 @@
version: 2
builds:
- main: ./main.go
id: "prime-monitor"
dir: ./cli
binary: prime-monitor
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
archives:
- format: tar.gz
id: "prime-monitor"
wrap_in_directory: false
builds:
- "prime-monitor"
name_template: >-
prime-monitor_{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
changelog:
sort: asc
filters:
exclude:
- "^docs:"

19
monitor/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM --platform=$BUILDPLATFORM tonistiigi/binfmt as binfmt
FROM golang:alpine3.20 as Build
WORKDIR /app
RUN apk update && \
apk add git && \
go install github.com/goreleaser/goreleaser/v2@latest
COPY . .
RUN goreleaser build --snapshot --clean --single-target --output=./prime-monitor
FROM alpine:latest as Run
WORKDIR /app
COPY --from=Build /app/prime-monitor /usr/local/bin/prime-monitor
CMD [ "prime-monitor", "start" ]

14
monitor/Makefile Normal file
View File

@@ -0,0 +1,14 @@
help::
cd ./cli && go run .
start-cron::
cd ./cli && go run . cron start
build::
cd ./cli && go build
test::
cd ./lib/healthcheck/ && go test . -v -cover
.PHONY: help, start-cron, test

34
monitor/README.md Normal file
View File

@@ -0,0 +1,34 @@
# Monitor
Monitor a package written in go, aims to provide services responsible for
healthcheck, feature flagging and validation of entities with respect to
deployments. The services of Monitor are encapsulated in a command line
interface inside the `./cli` folder and all the features are encapsulated
inside packages inside the lib folder `./lib` folder.
## Convention and Adding New Features
- Each feature lies on a seprate module, and for every module, there
must be it's individual tests and readme associated with it, with the
function signatures associated with it.
- Examples must be provided for each of the functions that are present inside
the package that, it can be used as a reference manual.
- Every function should be written in such a way such that it can be consumed by
any external host, maybe used with cron, http, cli or a seprate package on
itself. Passing callback functions are sometimes best for such scenarios.
## Running and Usage of Monitor
Monitor relies on 4 different environment variables of execution, which are
listed below,
- `PRIME_HOST` : The host for the prime service, defaults to
`https://prime.plane.so`.
- `LICENSE_KEY`: The client's license key, required for validation purposes of
the api requests to the prime server.
- `LICENSE_VERSION`: The currently used version by the client, it's generally
the plane app version, but it's required and needed to be passed.
- `MACHINE_SIGNATURE`: Machine signature field indicates the machine signature
of the host machine. Say you're using monitor as a docker image on a mac, then
we would require the `MACHINE_SIGNATURE` of mac, assuming mac's machine
signature is associated with the license.
You can build monitor, with `make build` and test it with `make test`. Monitor
can be run with `--help` command, to check the capabilites of it.

0
monitor/cli/LICENSE Normal file
View File

65
monitor/cli/cmd/root.go Normal file
View File

@@ -0,0 +1,65 @@
/*
Copyright © 2024 plane.so engineering@plane.so
*/
package cmd
import (
"fmt"
"os"
"github.com/makeplane/plane-ee/monitor/lib/logger"
"github.com/makeplane/plane-ee/monitor/pkg/constants"
"github.com/makeplane/plane-ee/monitor/pkg/constants/descriptors"
error_msgs "github.com/makeplane/plane-ee/monitor/pkg/constants/errors"
"github.com/spf13/cobra"
)
var CmdLogger = logger.NewHandler(nil)
var LICENSE_KEY = ""
var MACHINE_SIGNATURE = ""
var LICENSE_VERSION = ""
var HOST = "https://prime.plane.so"
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: descriptors.PRIME_MONITOR_USAGE,
Short: descriptors.PRIME_MONITOR_USAGE_DESC,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if host := os.Getenv(constants.PRIME_HOST); host != "" {
HOST = host
}
if licenseKey := os.Getenv(constants.LICENSE_KEY); licenseKey == "" {
return fmt.Errorf(error_msgs.LICENSE_ABSENT)
} else {
LICENSE_KEY = licenseKey
}
if licenseVersion := os.Getenv(constants.LICENSE_VERSION); licenseVersion == "" {
return fmt.Errorf(error_msgs.LICENSE_VERSION_ABSENT)
} else {
LICENSE_VERSION = licenseVersion
}
if machineSignature := os.Getenv(constants.MACHINE_SIGNATURE); machineSignature == "" {
return fmt.Errorf(error_msgs.MACHINE_SIG_ABSENT)
} else {
MACHINE_SIGNATURE = machineSignature
}
return nil
},
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

102
monitor/cli/cmd/start.go Normal file
View File

@@ -0,0 +1,102 @@
/*
Copyright © 2024 plane.so engineering@plane.so
*/
package cmd
import (
"context"
"fmt"
"strings"
"time"
"github.com/go-co-op/gocron/v2"
prime_api "github.com/makeplane/plane-ee/monitor/lib/api"
prime_cron "github.com/makeplane/plane-ee/monitor/lib/cron"
"github.com/makeplane/plane-ee/monitor/lib/healthcheck"
"github.com/makeplane/plane-ee/monitor/pkg/constants/descriptors"
"github.com/makeplane/plane-ee/monitor/pkg/handlers"
"github.com/spf13/cobra"
)
var StartCmd = &cobra.Command{
Use: descriptors.CMD_START_USAGE,
Short: descriptors.CMD_START_USAGE_DESC,
RunE: func(cmd *cobra.Command, args []string) error {
healthCheckInterval, err := cmd.Flags().GetInt(descriptors.FLAG_INTERVAL_HEALTHCHECK)
if err != nil {
return err
}
primeSchedulerHandler := handlers.NewPrimeScheduleHandler()
primeScheduler, err := prime_cron.NewPrimeScheduler(primeSchedulerHandler)
if err != nil {
return err
}
primeScheduler.RegisterNewHealthCheckJob(
context.Background(),
gocron.DurationJob(time.Duration(healthCheckInterval)*time.Minute),
func(statuses []*healthcheck.HealthCheckStatus, errors []*error) {
if len(errors) != 0 {
CmdLogger.Error(context.Background(), fmt.Sprintf("Health Check Job returned not nil error message (%v)", errors[0]))
return
}
statusMap := map[string]string{}
metaMap := map[string]prime_api.StatusMeta{}
msg := ""
for _, status := range statuses {
normServiceName := "service_" + strings.ToLower(status.ServiceName)
// If the service status is inside the ok status range
if status.StatusCode >= 200 && status.StatusCode <= 227 {
statusMap[normServiceName] = descriptors.HEALTHY
msg = fmt.Sprintf("Recieved Service (%v) status code (%d)", status.ServiceName, status.StatusCode)
CmdLogger.Info(context.Background(), msg)
} else {
statusMap[normServiceName] = descriptors.UNHEALTHY
var code = prime_api.NotReachable
var statusCode = status.StatusCode
reachable := 1
if status.Status == healthcheck.SERVICE_STATUS_REACHABLE {
code = prime_api.ReachableWithNotOkStatus
msg = fmt.Sprintf("Recieved Non Healthy Status Code (%d) from Service (%v), Unhealthy", status.StatusCode, status.ServiceName)
CmdLogger.Error(context.Background(), msg)
} else {
code = prime_api.NotReachable
reachable = 0
msg = fmt.Sprintf("Recieved Non Healthy Status Code (%d) from Service (%v), Not Reachable", status.StatusCode, status.ServiceName)
CmdLogger.Error(context.Background(), msg)
}
metaMap[normServiceName] = prime_api.StatusMeta{
Message: msg,
Code: code,
StatusCode: statusCode,
Reachable: reachable,
}
}
}
monitorApi := prime_api.NewMonitorApi(HOST, LICENSE_KEY, LICENSE_VERSION, MACHINE_SIGNATURE)
errorCode := monitorApi.PostServiceStatus(prime_api.StatusPayload{
Status: statusMap,
Meta: metaMap,
Version: LICENSE_VERSION,
})
if errorCode != 0 {
CmdLogger.Error(context.Background(), fmt.Sprintf("Recived Error while reporting health status, %v", errorCode))
}
},
)
primeScheduler.StartWithBlocker()
return nil
},
}
func init() {
// interval is used as healthcheck for added consistency
StartCmd.Flags().Int(descriptors.FLAG_INTERVAL_HEALTHCHECK, 5, descriptors.FLAG_INTERVAL_HEALTHCHECK_USE)
rootCmd.AddCommand(StartCmd)
}

9
monitor/cli/go.mod Normal file
View File

@@ -0,0 +1,9 @@
module github.com/makeplane/plane-ee/monitor
go 1.22.4
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/cobra v1.8.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)

10
monitor/cli/go.sum Normal file
View File

@@ -0,0 +1,10 @@
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

12
monitor/cli/main.go Normal file
View File

@@ -0,0 +1,12 @@
/*
Copyright © 2024 NAME HERE <EMAIL ADDRESS>
*/
package main
import (
"github.com/makeplane/plane-ee/monitor/cmd"
)
func main() {
cmd.Execute()
}

View File

@@ -0,0 +1,18 @@
package constants
// Keys
type key int
const (
META_KEY key = iota
)
// -------------- Env variables constants -------------------
const (
LICENSE_KEY = "LICENSE_KEY"
MACHINE_SIGNATURE = "MACHINE_SIGNATURE"
PRIME_HOST = "PRIME_HOST"
LICENSE_DOMAIN = "LICENSE_DOMAIN"
LICENSE_VERSION = "LICENSE_VERSION"
LICENSE_CUID = "LICENSE_DOMAIN"
)

View File

@@ -0,0 +1,21 @@
package descriptors
var (
PRIME_MONITOR_USAGE = "prime-client"
PRIME_MONITOR_USAGE_DESC = "Prime Client is a solution to handle monitoring tasks for plane services ecosystem"
CMD_CRON_USAGE = "cron"
CMD_CRON_USAGE_DESC = "Cron command facilitates you with the cron jobs available as part of monitor"
CMD_START_USAGE = "start"
CMD_START_USAGE_DESC = "Start registers and starts the existing jobs such as cron, http etc."
HEALTHY = "healthy"
UNHEALTHY = "unhealthy"
)
// --------------------- Cmd Flags --------------------------
var (
FLAG_INTERVAL_HEALTHCHECK = "interval-healthcheck"
FLAG_INTERVAL_HEALTHCHECK_USE = "Interval (in minutes) to run health check cron job"
)

View File

@@ -0,0 +1,8 @@
package error_msgs
// ------------------ CMD Errors ----------------------
const (
LICENSE_ABSENT = "expecting a license to be available in OS Env under 'LICENSE_KEY', none found"
MACHINE_SIG_ABSENT = "expecting a signature to be available in OS Env under 'MACHINE_SIGNATURE', none found"
LICENSE_VERSION_ABSENT = "expecting a version to be available in OS Env under 'LICENSE_VERSION', none found"
)

View File

@@ -0,0 +1,29 @@
package handlers
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/makeplane/plane-ee/monitor/lib/logger"
)
type PrimeScheduleHandler struct{}
var primeLogger = logger.NewHandler(nil)
func NewPrimeScheduleHandler() *PrimeScheduleHandler {
return &PrimeScheduleHandler{}
}
func (h *PrimeScheduleHandler) PreRun(id uuid.UUID, job string) {
primeLogger.Info(context.Background(), fmt.Sprintf("Started Job with UUID %v, and name %v", id, job))
}
func (h *PrimeScheduleHandler) PostRunE(id uuid.UUID, job string, err error) {
primeLogger.Info(context.Background(), fmt.Sprintf("Job (%v, %v) finished with error %v", id, job, err))
}
func (h *PrimeScheduleHandler) PostRun(id uuid.UUID, job string) {
primeLogger.Info(context.Background(), fmt.Sprintf("Job (%v, %v) finished successful execution", id, job))
}

9
monitor/go.work Normal file
View File

@@ -0,0 +1,9 @@
go 1.22.4
use (
./cli
./lib/cron
./lib/healthcheck
./lib/logger
./lib/api
)

10
monitor/go.work.sum Normal file
View File

@@ -0,0 +1,10 @@
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=

185
monitor/lib/api/api.go Normal file
View File

@@ -0,0 +1,185 @@
package prime_api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)
type IPrimeMonitorApi interface {
PostServiceStatus(StatusPayload) ErrorCode
}
type PrimeMonitorApi struct {
host string
apiKey string
client string
version string
machineSignature string
}
func NewMonitorApi(host, apiKey, version, machineSignature string) IPrimeMonitorApi {
return &PrimeMonitorApi{
host: host,
apiKey: apiKey,
client: "Prime-Monitor",
version: version,
machineSignature: machineSignature,
}
}
func (api *PrimeMonitorApi) SetClient(client string) {
api.client = client
}
var (
API_PREFIX = "/api"
MONITOR_ENDPOINT = API_PREFIX + "/monitor/"
)
// ----------------------- Controller Methods ------------------------------
// Posts the status of the services given, to the prime server, returns error if
// hinderer, else doesn't return anything
func (api *PrimeMonitorApi) PostServiceStatus(payload StatusPayload) ErrorCode {
_, err := api.post(api.host+MONITOR_ENDPOINT, payload)
if err != nil {
fmt.Println(err)
return UNABLE_TO_POST_SERVICE_STATUS
}
return 0
}
// ------------------------ Helper Methods ----------------------------------
/*
prepareRequest prepares an HTTP request with the necessary headers and parameters.
Parameters:
- method: string specifying the HTTP method (e.g., "GET", "POST").
- urlStr: string specifying the URL for the request.
- body: io.Reader containing the request body.
- params: map[string]string containing the query parameters.
Returns:
- *http.Request: The prepared HTTP request.
- error: An error if any occurs during the preparation.
*/
func (api *PrimeMonitorApi) prepareRequest(method, urlStr string, body io.Reader, params map[string]string) (*http.Request, error) {
if method == "GET" && params != nil {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return nil, fmt.Errorf("error parsing URL: %v", err)
}
query := parsedURL.Query()
for key, value := range params {
query.Set(key, value)
}
parsedURL.RawQuery = query.Encode()
urlStr = parsedURL.String()
}
req, err := http.NewRequest(method, urlStr, body)
if err != nil {
return nil, fmt.Errorf("error creating request: %v", err)
}
headers := map[string]string{
"X-Api-Key": api.apiKey,
"X-Machine-Signature": api.machineSignature,
"X-Client": api.client,
"X-License-Version": api.version,
"Content-Type": "application/json",
}
for key, value := range headers {
req.Header.Add(key, value)
}
return req, nil
}
/*
doRequest executes the HTTP request and handles common response scenarios.
Parameters:
- req: *http.Request specifying the HTTP request to execute.
Returns:
- []byte: The response body.
- error: An error if any occurs during the request execution.
*/
func (api *PrimeMonitorApi) doRequest(req *http.Request) ([]byte, error) {
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error making request: %v", err)
}
defer resp.Body.Close()
switch {
case resp.StatusCode >= 200 && resp.StatusCode <= 227:
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %v", err)
}
return body, nil
case resp.StatusCode >= 300 && resp.StatusCode <= 308:
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %v", err)
}
return body, nil
case resp.StatusCode >= 400 && resp.StatusCode <= 451:
return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode)
default:
return nil, fmt.Errorf("unexpected status code: %v", resp.StatusCode)
}
}
/*
get performs a GET request.
Parameters:
- baseURL: string specifying the base URL for the request.
- params: map[string]string containing the query parameters.
Returns:
- []byte: The response body.
- error: An error if any occurs during the request.
*/
func (api *PrimeMonitorApi) get(baseURL string, params map[string]string) ([]byte, error) {
req, err := api.prepareRequest("GET", baseURL, nil, params)
if err != nil {
return nil, err
}
return api.doRequest(req)
}
/*
post performs a POST request with JSON body.
Parameters:
- baseURL: string specifying the base URL for the request.
- data: interface{} containing the data to be sent in the request body.
Returns:
- []byte: The response body.
- error: An error if any occurs during the request.
*/
func (api *PrimeMonitorApi) post(baseURL string, data interface{}) ([]byte, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("error marshaling data: %v", err)
}
req, err := api.prepareRequest("POST", baseURL, bytes.NewBuffer(jsonData), nil)
if err != nil {
return nil, err
}
return api.doRequest(req)
}

View File

@@ -0,0 +1,19 @@
package prime_api
type ErrorCode int
const (
NO_ERROR ErrorCode = iota
UNABLE_TO_POST_SERVICE_STATUS
)
var errorCodeMap = map[ErrorCode]string{
NO_ERROR: "",
UNABLE_TO_POST_SERVICE_STATUS: "Unable to post service status",
}
// Converting the Error Code type to confirm to the stringer interface to be
// used in error messages and print statements, ;)
func (code ErrorCode) String() string {
return errorCodeMap[code]
}

8
monitor/lib/api/go.mod Normal file
View File

@@ -0,0 +1,8 @@
module github.com/makeplane/plane-ee/monitor/lib/api
go 1.22.4
require (
github.com/denisbrodbeck/machineid v1.0.1 // indirect
golang.org/x/sys v0.21.0 // indirect
)

4
monitor/lib/api/go.sum Normal file
View File

@@ -0,0 +1,4 @@
github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

21
monitor/lib/api/types.go Normal file
View File

@@ -0,0 +1,21 @@
package prime_api
type StatusErrorCode int
const (
NotReachable StatusErrorCode = iota
ReachableWithNotOkStatus
)
type StatusMeta struct {
Message string `json:"message"`
Code StatusErrorCode `json:"code"`
StatusCode int `json:"status_code"`
Reachable int `json:"reachable"`
}
type StatusPayload struct {
Version string `json:"version"`
Status map[string]string `json:"status"`
Meta map[string]StatusMeta `json:"meta"`
}

View File

66
monitor/lib/cron/cron.go Normal file
View File

@@ -0,0 +1,66 @@
package prime_cron
import (
"sync"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
)
type PrimeLifeCycleHandler interface {
PreRun(uuid.UUID, string)
PostRunE(uuid.UUID, string, error)
PostRun(uuid.UUID, string)
}
type PrimeScheduler struct {
Scheduler gocron.Scheduler
Handler PrimeLifeCycleHandler
}
func NewPrimeScheduler(handler PrimeLifeCycleHandler) (*PrimeScheduler, error) {
// TODO: add a logger to scheduler
scheduler, err := gocron.NewScheduler()
if err != nil {
return nil, err
}
return &PrimeScheduler{
Scheduler: scheduler,
Handler: handler,
}, nil
}
// Lists all the jobs for the current scheduler
func (p *PrimeScheduler) GetJobs() []gocron.Job {
return p.Scheduler.Jobs()
}
// Create Job creates a new job for the PrimeScheduler
func (p *PrimeScheduler) RegisterJob(defination gocron.JobDefinition, task gocron.Task, options ...gocron.JobOption) (gocron.Job, error) {
options = append(options, gocron.WithEventListeners(
gocron.AfterJobRuns(p.Handler.PostRun),
gocron.BeforeJobRuns(p.Handler.PreRun),
gocron.AfterJobRunsWithError(p.Handler.PostRunE),
))
return p.Scheduler.NewJob(defination, task, options...)
}
// Add Job to the Prime Scheduler
func (p *PrimeScheduler) GetJobsWaiting() int {
return p.Scheduler.JobsWaitingInQueue()
}
func (p *PrimeScheduler) Start() {
p.Scheduler.Start()
}
func (p *PrimeScheduler) StartWithBlocker() {
var wg = sync.WaitGroup{}
wg.Add(1)
p.Scheduler.Start()
wg.Wait()
}
func (p *PrimeScheduler) Shutdown() error {
return p.Scheduler.Shutdown()
}

12
monitor/lib/cron/go.mod Normal file
View File

@@ -0,0 +1,12 @@
module github.com/makeplane/plane-ee/monitor/lib/cron
go 1.22.4
require (
github.com/go-co-op/gocron/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
go.uber.org/atomic v1.9.0 // indirect
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect
)

42
monitor/lib/cron/go.sum Normal file
View File

@@ -0,0 +1,42 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0=
github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY=
github.com/go-co-op/gocron/v2 v2.5.0 h1:ff/TJX9GdTJBDL1il9cyd/Sj3WnS+BB7ZzwHKSNL5p8=
github.com/go-co-op/gocron/v2 v2.5.0/go.mod h1:ckPQw96ZuZLRUGu88vVpd9a6d9HakI14KWahFZtGvNw=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY=
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

57
monitor/lib/cron/jobs.go Normal file
View File

@@ -0,0 +1,57 @@
package prime_cron
import (
"context"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/makeplane/plane-ee/monitor/lib/healthcheck"
)
type HealthCheckCallback func(serviceStatuses []*healthcheck.HealthCheckStatus, err []*error)
// Registers a new healthcheck job in the prime scheduler, the function takes
// the context of the parent process, defination provided by the gocron and also
// takes in a healthcheck callback, which will be fired when all the status have
// been accumilated and are ready to be sent.
func (s *PrimeScheduler) RegisterNewHealthCheckJob(ctx context.Context, defination gocron.JobDefinition, healthCheckCallback HealthCheckCallback) {
healthCheckTask := gocron.NewTask(func() {
healthCheckHandler := healthcheck.NewHealthCheckHandler()
healthCheckCtx, cancel := context.WithCancel(ctx)
defer cancel()
statusChannel, errorChannel := healthCheckHandler.PerformHealthCheck(healthCheckCtx, healthcheck.HealthCheckOptions{
MaxRetries: 5,
ConfirmTries: 3,
TimeoutDuration: 5 * time.Second,
RetryDuration: 2 * time.Second,
})
statuses := make([]*healthcheck.HealthCheckStatus, 0)
errors := make([]*error, 0)
for {
select {
case status, ok := <-statusChannel:
if !ok {
statusChannel = nil
} else {
statuses = append(statuses, status)
}
case err, ok := <-errorChannel:
if !ok {
errorChannel = nil
} else {
cancel()
errors = append(errors, err)
}
}
if statusChannel == nil && errorChannel == nil {
break
}
}
healthCheckCallback(statuses, errors)
})
s.RegisterJob(defination, healthCheckTask, gocron.WithName("Service Health Check"))
}

View File

@@ -0,0 +1,110 @@
# Prime Healthcheck
Prime Healthcheck is a service that take care of checking for all the running
services in the user's system. Internally the Prime Healthcheck makes network
calls to each of the services that are provided in the environment variables.
## Usage
### `PerformHealthCheck`
`PerformHealthCheck` is the primary method that is responsible for perfoming the
health check over the services provided. Essentially PerformHealthCheck looks up
for environment variables provided with prefix `SERVICE_`, the format is along
the lines of `SERVICE_TESTMETHOD_NAME` where name is the name of the service,
the corresponding value to this would contain, the exact url to be called for
that service, without the protocol. The function returns two channel, which can
be used to recieve updates for healthchecks.
***Function Signature***
```go
func (h *HealthCheckHandler) PerformHealthCheck(ctx context.Context, options HealthCheckOptions) (chan *HealthCheckStatus, chan *error)
```
```go
package main
func main() {
// If you have to pass custom test methods
handler := HealthCheckHandler{
TestIdMethodMap: T_TestIdMethodMap,
TestIdStringMap: T_TestIdStringMap,
}
// If you want to use default test methods
handler := NewHealthCheckHandler()
t.Setenv(tt.ServiceName, tt.ServiceValue)
options := HealthCheckOptions{
ConfirmTries: 3,
MaxRetries: 1,
TimeoutDuration: 5 * time.Second,
RetryDuration: 1 * time.Second,
}
statusChannel, errorChannel := handler.PerformHealthCheck(ctx, options)
statuses := make([]HealthCheckStatus, 0)
errors := make([]error, 0)
for {
select {
case status, ok := <-statusChannel:
if !ok {
statusChannel = nil
} else {
statuses = append(statuses, *status)
}
case err, ok := <-errorChannel:
if !ok {
errorChannel = nil
} else {
errors = append(errors, *err)
cancel()
}
}
if statusChannel == nil && errorChannel == nil {
break
}
}
}
```
### `ExecuteHealthCheckWithRetries`
`ExecuteHealthCheckWithRetries` is a method that performs the health check with retries.
It uses the provided test method and options to repeatedly check the health of a service
until it either succeeds or exhausts the retry attempts.
***Function Signature***
```go
func (h *HealthCheckHandler) ExecuteHealthCheckWithRetries(ctx context.Context, testMethod HealthCheckMethod, options HealthCheckMethodOptions, wg *sync.WaitGroup, statusChannel chan *HealthCheckStatus, errorChannel chan *error)
```
### `GetServiceFromEnvironment`
`GetServiceFromEnvironment` retrieves a map of services from the environment variables.
It filters the environment variables to only include those with the prefix `SERVICE_` and
parses them into a map of service names to `ServiceData`.
***Function Signature***
```go
func (h *HealthCheckHandler) GetServiceFromEnvironment() (map[string]*ServiceData, error)
```
### `ParseKeyValue`
`ParseKeyValue` parses a key-value pair from the environment variables into a service
name and `ServiceData`. It splits the key and value into their respective components
and validates them.
***Function Signature***
```go
func (h *HealthCheckHandler) ParseKeyValue(key string, value string) (string, *ServiceData, error)
```
### `GetEnvironmentVarMap`
`GetEnvironmentVarMap` retrieves all environment variables and returns them as a map
of key-value pairs. It filters out any empty or commented-out variables.
***Function Signature***
```go
func GetEnvironmentVarMap() map[string]string
```
... For understanding more, please read healthcheck.go and healthcheck_method.go

View File

@@ -0,0 +1,45 @@
package healthcheck
// --------------------- CONSTANTS ---------------------
// PREFIX_METHOD_NAME
var (
BLOCK_PART_LENGTH = 3
PARSE_DELIMITER = "_"
)
type TestMethodId int
// TEST METHODS
const (
HTTP_TEST_METHOD TestMethodId = iota
TCP_TEST_METHOD
)
// Provides a method to parse the TestMethodId from Strings
var TestIdStringMap = map[string]TestMethodId{
"HTTP": HTTP_TEST_METHOD,
"TCP": TCP_TEST_METHOD,
}
type ServiceStatus int
const (
SERVICE_STATUS_REACHABLE ServiceStatus = iota
SERVICE_STATUS_NOT_REACHABLE ServiceStatus = iota
)
var TestIdMethodMap = map[TestMethodId]HealthCheckMethod{
HTTP_TEST_METHOD: HttpHealthCheckMethod,
TCP_TEST_METHOD: TcpHealthCheckMethod,
}
// ----------------------- ERRORS -----------------------
var (
INVALID_KEY_BLOCK_LEN = "The key passed is not valid, as there are lesser blocks than required."
F1_INVALID_VALUE_BLOCK_LEN = "The value passed for key (%s) is invalid, as there are lesser blocks than required."
F1_INVALID_TEST_ID = "The test Id (%s) does not match any available test strategy yet."
F2_TEST_METHOD_DOESNOT_EXIST = "The test strategy (%d) for the particular service (%s) doesnot exist yet."
HOSTNAME_ABSENT = "Expecting a hostname, but none provided"
)

View File

@@ -0,0 +1,11 @@
module github.com/makeplane/plane-ee/monitor/lib/healthcheck
go 1.22.4
require github.com/stretchr/testify v1.9.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -0,0 +1,10 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,254 @@
package healthcheck
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
)
// Struct Exposed for interacting with the Healthcheck services
type HealthCheckHandler struct {
TestIdMethodMap map[TestMethodId]HealthCheckMethod
TestIdStringMap map[string]TestMethodId
}
func NewHealthCheckHandler() *HealthCheckHandler {
return &HealthCheckHandler{
TestIdMethodMap: TestIdMethodMap,
TestIdStringMap: TestIdStringMap,
}
}
// --------------------Utility Types---------------------------
type HealthCheckOptions struct {
// Max Retries is the number of retries after one iteration of the
// confirmation tries
MaxRetries int
// Confirm Tries is the number of tries made to confirm if a service can be
// considered healthy
ConfirmTries int
RetryDuration time.Duration
TimeoutDuration time.Duration
}
type HealthCheckStatus struct {
ServiceName string
Status ServiceStatus
StatusCode int
}
type ServiceData struct {
HostName string
Port string
Path string
TestMethod TestMethodId
}
// ------------------ Controller Methods ----------------------
func (h *HealthCheckHandler) PerformHealthCheck(ctx context.Context, options HealthCheckOptions) (chan *HealthCheckStatus, chan *error) {
select {
// return the function in case the context is cancelled
case <-ctx.Done():
return nil, nil
default:
_, cancel := context.WithCancel(ctx)
// channels responsible for transmitting the final status of the services
statusChannel := make(chan *HealthCheckStatus)
errorChannel := make(chan *error)
go func() {
// Get the Service data from the environment
serviceMap, err := h.GetServiceFromEnvironment()
if err != nil {
errorChannel <- &err
}
// Implement the Test Methods for each of the service recieved
wg := &sync.WaitGroup{}
for serviceName, props := range serviceMap {
testMethod, ok := h.TestIdMethodMap[props.TestMethod]
if !ok {
cancel()
err := fmt.Errorf(F2_TEST_METHOD_DOESNOT_EXIST, props.TestMethod, serviceName)
errorChannel <- &err
} else {
wg.Add(1)
// only implement wait group when the test method exist
healthCheckOptions := HealthCheckMethodOptions{
ServiceName: serviceName,
ServiceData: props,
MaxRetries: options.MaxRetries,
ConfirmTries: options.ConfirmTries,
RetryDuration: options.RetryDuration,
TimeoutDuration: options.TimeoutDuration,
}
// Added 1 for waiting for each HealthCheckExecution
go h.ExecuteHealthCheckWithRetries(ctx, testMethod, healthCheckOptions, wg, statusChannel, errorChannel)
}
}
wg.Wait()
close(statusChannel)
close(errorChannel)
}()
return statusChannel, errorChannel
}
}
func (h *HealthCheckHandler) ExecuteHealthCheckWithRetries(
ctx context.Context,
testMethod HealthCheckMethod,
options HealthCheckMethodOptions,
wg *sync.WaitGroup, statusChannel chan *HealthCheckStatus, errorChannel chan *error) {
healthy := false
defer wg.Done()
// channels for recieving the frequent updates from the healthcheck channels,
// we will run the operations over these channels, and decide weather we
// should send a final status to user or not
methodStatusChannel := make(chan *HealthCheckStatus)
methodErrorChannel := make(chan *error)
options.StatusChannel = methodStatusChannel
options.ErrorChannel = methodErrorChannel
failureStatus := 0
for retry := 0; retry < options.MaxRetries; retry++ {
testResults := make([]bool, 0)
// iterating through a boolean array b
for b := 0; b < options.ConfirmTries; b++ {
go testMethod(ctx, options)
select {
case status := <-methodStatusChannel:
testResults = append(testResults, status.Status == SERVICE_STATUS_REACHABLE)
if status.Status != SERVICE_STATUS_REACHABLE {
failureStatus = status.StatusCode
}
case <-methodErrorChannel:
testResults = append(testResults, false)
}
time.Sleep(options.RetryDuration)
}
healthy = !ShouldRetry(testResults)
if healthy {
break
}
}
if healthy {
statusChannel <- &HealthCheckStatus{
ServiceName: options.ServiceName,
Status: SERVICE_STATUS_REACHABLE,
StatusCode: 200,
}
} else {
if failureStatus == 0 {
failureStatus = 500
}
statusChannel <- &HealthCheckStatus{
ServiceName: options.ServiceName,
Status: SERVICE_STATUS_NOT_REACHABLE,
StatusCode: 500,
}
}
}
// -------------------- Helper Methods ------------------------
// Provides a map of service corresponding to the data corresponing to that
func (h *HealthCheckHandler) GetServiceFromEnvironment() (map[string]*ServiceData, error) {
serviceMap := make(map[string]*ServiceData)
envVars := GetEnvironmentVarMap()
for key, value := range envVars {
// Only take the services which has SERVICE_ prefix in front of them
if strings.HasPrefix(key, "SERVICE_") {
key, value, err := h.ParseKeyValue(key, value)
if err != nil {
return nil, err
}
serviceMap[key] = value
}
}
return serviceMap, nil
}
// Parse the key and the value and returns the parsed blocks
func (h *HealthCheckHandler) ParseKeyValue(key string, value string) (string, *ServiceData, error) {
blocks := strings.Split(key, PARSE_DELIMITER)
if len(blocks) < BLOCK_PART_LENGTH {
return "", nil, fmt.Errorf(INVALID_KEY_BLOCK_LEN)
}
// ------------ Parsing the Key ------------------------
serviceName := blocks[BLOCK_PART_LENGTH-1]
serviceTestMethod := strings.ToUpper(blocks[1])
serviceTestMethodId, ok := h.TestIdStringMap[serviceTestMethod]
if !ok {
return "", nil, fmt.Errorf(F1_INVALID_TEST_ID, serviceTestMethod)
}
// --------------- Parsing the Value --------------------
urlComponents := strings.Split(value, "/")
// parsing the hostname component, with port and hostname
valueBlocks := strings.Split(urlComponents[0], ":")
hostName := ""
port := ""
// Precondition: web:9000/test
if len(valueBlocks) >= 1 {
hostName = valueBlocks[0]
}
if hostName == "" {
return "", nil, fmt.Errorf(HOSTNAME_ABSENT)
}
if len(valueBlocks) >= 2 {
port = valueBlocks[1]
}
path := "/"
if len(urlComponents) > 1 {
path += urlComponents[1]
}
return serviceName, &ServiceData{
HostName: hostName,
Port: port,
Path: path,
TestMethod: serviceTestMethodId,
}, nil
}
// Gets a map of environment variables present
func GetEnvironmentVarMap() map[string]string {
envVars := os.Environ()
envMap := make(map[string]string)
for _, env := range envVars {
// Just to be sure, was not required
if env != "" && !strings.HasPrefix(env, "#") {
keyValue := strings.Split(env, "=")
envMap[keyValue[0]] = keyValue[1]
}
}
return envMap
}

View File

@@ -0,0 +1,147 @@
package healthcheck
import (
"context"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
)
type HealthCheckMethodOptions struct {
StatusChannel chan *HealthCheckStatus
ErrorChannel chan *error
ServiceName string
ServiceData *ServiceData
MaxRetries int
ConfirmTries int
TimeoutDuration time.Duration
RetryDuration time.Duration
}
type HealthCheckMethod func(context.Context, HealthCheckMethodOptions)
// ----------------------- Methods ------------------------------
// Performs healthcheck on the service provided in healthcheckoptions and
// streams the response over the given HealthCheckStatus Channel.
func HttpHealthCheckMethod(ctx context.Context, options HealthCheckMethodOptions) {
select {
case <-ctx.Done():
return
default:
reachable, statusCode, err := ReportHttpCallStatus(options)
if err != nil {
options.ErrorChannel <- &err
}
health := &HealthCheckStatus{
ServiceName: options.ServiceName,
}
if reachable {
health.Status = SERVICE_STATUS_REACHABLE
health.StatusCode = statusCode
} else {
health.Status = SERVICE_STATUS_NOT_REACHABLE
}
options.StatusChannel <- health
}
}
// @Unimplemented
// Performs a healthcheck over services based on TCP and returns the response via a channel
// provided in the options parameter
func TcpHealthCheckMethod(ctx context.Context, options HealthCheckMethodOptions) {
select {
case <-ctx.Done():
return
default:
// Attempt to establish a TCP connection to the Redis server
conn, err := net.DialTimeout(
"tcp",
fmt.Sprintf("%s:%s", options.ServiceData.HostName, options.ServiceData.Port),
options.TimeoutDuration,
)
defer conn.Close()
if err != nil {
return
}
options.StatusChannel <- &HealthCheckStatus{
ServiceName: options.ServiceName,
}
}
}
// ----------------------- Helpers -----------------------------
// Generates an http string from the HealthCheckMethodOptions and generates a
// url and returns the url as a string
func GetHttpUrlString(options HealthCheckMethodOptions) string {
url := "http://" + options.ServiceData.HostName
if options.ServiceData.Port != "" {
url += ":" + options.ServiceData.Port
}
if !strings.HasPrefix(options.ServiceData.Path, "/") {
options.ServiceData.Path = "/" + options.ServiceData.Path
}
url += options.ServiceData.Path
return url
}
// Check if a status is valid or not, from 200 to 399, every status is
// considered as valid, others would be considered invalid
func IsValidStatus(statusCode int) bool {
switch {
case statusCode >= 200 && statusCode < 399:
return true
default:
return false
}
}
// Makes an API Call and Reports the status of the call
func ReportHttpCallStatus(options HealthCheckMethodOptions) (bool, int, error) {
urlString := GetHttpUrlString(options)
client := http.Client{
Timeout: options.TimeoutDuration,
}
resp, err := client.Get(urlString)
if err != nil {
// If it's a timeout error we send the err as nil, as we want to represent
// that the service is not reachable
if os.IsTimeout(err) {
return false, 0, nil
}
return false, 0, err
}
statusValid := IsValidStatus(resp.StatusCode)
return statusValid, resp.StatusCode, nil
}
// Checks if a health check method should retry or not, based on the results of
// reachable (true) & not-reachable(false) statuses
func ShouldRetry(tryResults []bool) bool {
trueCounts := 0
for _, result := range tryResults {
if result {
trueCounts++
}
}
lastVal := tryResults[len(tryResults)-1]
if !lastVal {
return true
}
if len(tryResults) > 1 {
if tryResults[len(tryResults)-2] {
return trueCounts < len(tryResults)/2
} else {
return true
}
}
return false
}

View File

@@ -0,0 +1,152 @@
package healthcheck
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestHttpHealthCheckMethod(t *testing.T) {
tests := []struct {
name string
responseStatus int
expectedStatus ServiceStatus
}{
{"Service Reachable", http.StatusOK, SERVICE_STATUS_REACHABLE},
{"Service Not Reachable", http.StatusInternalServerError, SERVICE_STATUS_NOT_REACHABLE},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a local HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.responseStatus)
}))
defer server.Close()
// Set up channels and wait group
statusChannel := make(chan *HealthCheckStatus, 1)
errorChannel := make(chan *error, 1)
serverURL := server.URL
serverHostPort := strings.TrimPrefix(serverURL, "http://")
hostPort := strings.Split(serverHostPort, ":")
// Set up options
options := HealthCheckMethodOptions{
StatusChannel: statusChannel,
ErrorChannel: errorChannel,
ServiceName: "TestService",
ServiceData: &ServiceData{
HostName: hostPort[0],
Port: hostPort[1],
Path: "/",
TestMethod: HTTP_TEST_METHOD, // Assuming a default TestMethodId
}}
// Create a context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Call the health check method
go HttpHealthCheckMethod(ctx, options)
// Verify the results
select {
case health := <-statusChannel:
if health.Status != tt.expectedStatus {
t.Errorf("%s:expected status %d, got %d", tt.name, tt.expectedStatus, health.Status)
}
case err := <-errorChannel:
t.Errorf("%s: Unexpected error: %v", tt.name, *err)
case <-time.After(5 * time.Second):
t.Errorf("%s:test timed out", tt.name)
}
})
}
}
func TestIsValidStatus(t *testing.T) {
tests := []struct {
Name string
Status int
Expected bool
}{
{
Name: "Status OK",
Status: 200,
Expected: true,
},
{
Name: "Status Redirect",
Status: 300,
Expected: true,
},
{
Name: "Status Not Reachable",
Status: 400,
Expected: false,
},
{
Name: "Status Server Error",
Status: 500,
Expected: false,
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
status := IsValidStatus(tt.Status)
if status != tt.Expected {
t.Errorf("%s: For Status %d, expected %v but recieved %v", tt.Name, tt.Status, tt.Expected, status)
}
})
}
}
func TestShouldRetry(t *testing.T) {
tests := []struct {
TryResults []bool
ExpectedResult bool
}{
{
TryResults: []bool{false, false, true},
ExpectedResult: true,
},
{
TryResults: []bool{true, true, false},
ExpectedResult: true,
},
{
TryResults: []bool{true},
ExpectedResult: false,
},
{
TryResults: []bool{false},
ExpectedResult: true,
},
{
TryResults: []bool{true, true, false, true},
ExpectedResult: true,
},
{
TryResults: []bool{false, true, true, true},
ExpectedResult: false,
},
{
TryResults: []bool{true, true, true, false},
ExpectedResult: true,
},
}
for index, tt := range tests {
result := ShouldRetry(tt.TryResults)
if result != tt.ExpectedResult {
t.Errorf("For case %d, expected result %v but got %v", index, tt.ExpectedResult, result)
}
}
}

View File

@@ -0,0 +1,281 @@
package healthcheck
import (
"context"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestMain(m *testing.M) {
os.Exit(m.Run())
}
var T_TestIdMethodMap = map[TestMethodId]HealthCheckMethod{
1: mockTestMethod,
2: mockTestMethodFail,
3: mockTestMethodError,
}
var T_TestIdStringMap = map[string]TestMethodId{
"TEST": 1,
"TESTF": 2,
"TESTE": 3,
}
func mockTestMethod(ctx context.Context, options HealthCheckMethodOptions) {
options.StatusChannel <- &HealthCheckStatus{
ServiceName: options.ServiceName,
Status: SERVICE_STATUS_REACHABLE,
StatusCode: 200,
}
}
func mockTestMethodFail(ctx context.Context, options HealthCheckMethodOptions) {
options.StatusChannel <- &HealthCheckStatus{
ServiceName: options.ServiceName,
Status: SERVICE_STATUS_NOT_REACHABLE,
StatusCode: 500,
}
}
func mockTestMethodError(ctx context.Context, options HealthCheckMethodOptions) {
err := fmt.Errorf("Some Error Occured")
options.ErrorChannel <- &err
}
func TestPerformHealthCheck(t *testing.T) {
handler := HealthCheckHandler{
TestIdMethodMap: T_TestIdMethodMap,
TestIdStringMap: T_TestIdStringMap,
}
tests := []struct {
Name string
ServiceName string
ServiceValue string
ExpectError bool
ExpectedResponse HealthCheckStatus
}{
{
Name: "Perform HealthCheck With Service Reachable",
ExpectError: false,
ServiceName: "SERVICE_TEST_WEB",
ServiceValue: "web:9000",
ExpectedResponse: HealthCheckStatus{
ServiceName: "WEB",
Status: SERVICE_STATUS_REACHABLE,
StatusCode: 200,
},
},
{
Name: "Perform HealthCheck With Service Not Reachable",
ExpectError: false,
ServiceName: "SERVICE_TESTF_WEB",
ServiceValue: "web:9000",
ExpectedResponse: HealthCheckStatus{
ServiceName: "WEB",
Status: SERVICE_STATUS_NOT_REACHABLE,
StatusCode: 500,
},
},
{
Name: "Perform HealthCheck With Service with Method Error",
ExpectError: false,
ServiceName: "SERVICE_TESTE_WEB",
ServiceValue: "web:9000",
ExpectedResponse: HealthCheckStatus{
ServiceName: "WEB",
Status: SERVICE_STATUS_NOT_REACHABLE,
StatusCode: 500,
},
},
{
Name: "PerformHealthCheck With Undefined Test Method",
ExpectError: true,
ServiceName: "SERVICE_TESTC_WEB",
},
}
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
t.Setenv(tt.ServiceName, tt.ServiceValue)
options := HealthCheckOptions{
ConfirmTries: 3,
MaxRetries: 1,
TimeoutDuration: 5 * time.Second,
RetryDuration: 1 * time.Second,
}
statusChannel, errorChannel := handler.PerformHealthCheck(ctx, options)
statuses := make([]HealthCheckStatus, 0)
errors := make([]error, 0)
for {
select {
case status, ok := <-statusChannel:
if !ok {
statusChannel = nil
} else {
statuses = append(statuses, *status)
}
case err, ok := <-errorChannel:
if !ok {
errorChannel = nil
} else {
errors = append(errors, *err)
cancel()
}
}
if statusChannel == nil && errorChannel == nil {
break
}
}
if tt.ExpectError {
if len(errorChannel) != 1 {
assert.NotNil(t, errors[0])
}
} else {
if len(statuses) != 1 {
t.Fatalf("%s: expected statuses length 1, got %d", tt.Name, len(statuses))
}
if statuses[0] != tt.ExpectedResponse {
t.Fatalf("%s:expected status %v, got %v", tt.Name, tt.ExpectedResponse, statuses[0])
}
}
})
}
}
func TestExecuteHealthCheckWithRetries(t *testing.T) {
handler := &HealthCheckHandler{
TestIdStringMap: T_TestIdStringMap,
TestIdMethodMap: T_TestIdMethodMap,
}
healthCheckOptions := HealthCheckMethodOptions{
ServiceName: "web",
ServiceData: &ServiceData{
HostName: "localhost",
Port: "5000",
Path: "/",
TestMethod: HTTP_TEST_METHOD,
},
MaxRetries: 2,
ConfirmTries: 3,
RetryDuration: 1 * time.Second,
TimeoutDuration: 2 * time.Second,
}
ctx := context.Background()
var wg = sync.WaitGroup{}
statusChannel := make(chan *HealthCheckStatus)
errorChannel := make(chan *error)
wg.Add(1)
go handler.ExecuteHealthCheckWithRetries(ctx, HttpHealthCheckMethod, healthCheckOptions, &wg, statusChannel, errorChannel)
select {
case status := <-statusChannel:
t.Logf("Got result %v", status)
case error := <-errorChannel:
t.Errorf("Recieved error message (%v) from channel that is not expected", error)
}
}
// ------------- Test Helper Functions ---------------
func TestGetServiceFromEnvironment(t *testing.T) {
handler := NewHealthCheckHandler()
testServiceMap := map[string]string{
"SERVICE_HTTP_WEB": "http://web:3000",
"SERVICE_HTTP_API": "http://api:9000",
}
expectedServiceMap := make(map[string]string)
for key, value := range testServiceMap {
t.Setenv(key, value)
key = strings.ReplaceAll(key, "SERVICE_", "")
expectedServiceMap[key] = value
}
// Execute the GetServiceFromEnvironment to get the services
actualServiceMap, err := handler.GetServiceFromEnvironment()
assert.NoError(t, err)
assert.ObjectsAreEqual(actualServiceMap, expectedServiceMap)
}
func TestParseKeyValue(t *testing.T) {
handler := NewHealthCheckHandler()
tests := []struct {
Name string
ServiceKey string
ServiceUrl string
ExpectError bool
Expected *ServiceData
}{
{
Name: "Http Service Method",
ServiceKey: "SERVICE_HTTP_WEB",
ServiceUrl: "web:9000",
Expected: &ServiceData{
HostName: "web",
Port: "9000",
Path: "/",
TestMethod: HTTP_TEST_METHOD,
},
},
{
Name: "TCP Service with path but without port",
ServiceKey: "SERVICE_TCP_POSTGRES",
ServiceUrl: "postgres/test",
Expected: &ServiceData{
HostName: "postgres",
Port: "",
Path: "/test",
TestMethod: TCP_TEST_METHOD,
},
},
{
Name: "Incorrect Test Method Specified",
ServiceKey: "SERVICE_WRONG_WEB",
ServiceUrl: "web:9000",
ExpectError: true,
},
{
Name: "Key without Service Name",
ServiceKey: "SERVICE_HTTP",
ServiceUrl: "web:9000",
ExpectError: true,
},
{
Name: "No hostname provided",
ServiceKey: "SERVICE_HTTP_WEB",
ServiceUrl: ":9000",
ExpectError: true,
},
}
for _, tt := range tests {
_, serviceData, err := handler.ParseKeyValue(tt.ServiceKey, tt.ServiceUrl)
if tt.ExpectError {
if err == nil {
t.Errorf("%s: Expected Error but got nil with struct %+v", tt.Name, serviceData)
}
} else {
if ok := assert.Nil(t, err); !ok {
t.Errorf("%s: Expected Nil error but recived %v", tt.Name, err)
}
if ok := assert.ObjectsAreEqual(tt.Expected, serviceData); !ok {
t.Errorf("%s: Expected %+v but recieved %+v", tt.Name, tt.Expected, serviceData)
}
}
}
}

View File

@@ -0,0 +1,5 @@
module github.com/makeplane/plane-ee/monitor/lib/logger
go 1.22.4
require golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect

View File

@@ -0,0 +1,2 @@
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM=
golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=

View File

@@ -0,0 +1,227 @@
package logger
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
"log/slog"
)
const (
timeFormat = "[15:04:05.000]"
reset = "\033[0m"
black = 30
red = 31
green = 32
yellow = 33
blue = 34
magenta = 35
cyan = 36
lightGray = 37
darkGray = 90
lightRed = 91
lightGreen = 92
lightYellow = 93
lightBlue = 94
lightMagenta = 95
lightCyan = 96
white = 97
)
func colorize(colorCode int, v string) string {
return fmt.Sprintf("\033[%sm%s%s", strconv.Itoa(colorCode), v, reset)
}
type Handler struct {
h slog.Handler
r func([]string, slog.Attr) slog.Attr
b *bytes.Buffer
m *sync.Mutex
}
func (h *Handler) Enabled(ctx context.Context, level slog.Level) bool {
return h.h.Enabled(ctx, level)
}
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &Handler{h: h.h.WithAttrs(attrs), b: h.b, r: h.r, m: h.m}
}
func (h *Handler) WithGroup(name string) slog.Handler {
return &Handler{h: h.h.WithGroup(name), b: h.b, r: h.r, m: h.m}
}
func (h *Handler) computeAttrs(
ctx context.Context,
r slog.Record,
) (map[string]any, error) {
h.m.Lock()
defer func() {
h.b.Reset()
h.m.Unlock()
}()
if err := h.h.Handle(ctx, r); err != nil {
return nil, fmt.Errorf("error when calling inner handler's Handle: %w", err)
}
var attrs map[string]any
err := json.Unmarshal(h.b.Bytes(), &attrs)
if err != nil {
return nil, fmt.Errorf("error when unmarshaling inner handler's Handle result: %w", err)
}
return attrs, nil
}
func (h *Handler) Handle(ctx context.Context, r slog.Record) error {
var level string
levelAttr := slog.Attr{
Key: slog.LevelKey,
Value: slog.AnyValue(r.Level),
}
if h.r != nil {
levelAttr = h.r([]string{}, levelAttr)
}
if !levelAttr.Equal(slog.Attr{}) {
level = levelAttr.Value.String() + ":"
if r.Level <= slog.LevelDebug {
level = colorize(lightGray, level)
} else if r.Level <= slog.LevelInfo {
level = colorize(cyan, level)
} else if r.Level < slog.LevelWarn {
level = colorize(lightBlue, level)
} else if r.Level < slog.LevelError {
level = colorize(lightYellow, level)
} else if r.Level <= slog.LevelError+1 {
level = colorize(lightRed, level)
} else if r.Level > slog.LevelError+1 {
level = colorize(lightMagenta, level)
}
}
var timestamp string
timeAttr := slog.Attr{
Key: slog.TimeKey,
Value: slog.StringValue(r.Time.Format(timeFormat)),
}
if h.r != nil {
timeAttr = h.r([]string{}, timeAttr)
}
if !timeAttr.Equal(slog.Attr{}) {
timestamp = colorize(lightGray, timeAttr.Value.String())
}
var msg string
msgAttr := slog.Attr{
Key: slog.MessageKey,
Value: slog.StringValue(r.Message),
}
if h.r != nil {
msgAttr = h.r([]string{}, msgAttr)
}
if !msgAttr.Equal(slog.Attr{}) {
msg = colorize(white, msgAttr.Value.String())
}
attrs, err := h.computeAttrs(ctx, r)
if err != nil {
return err
}
bytes, err := json.MarshalIndent(attrs, "", " ")
if err != nil {
return fmt.Errorf("error when marshaling attrs: %w", err)
}
out := strings.Builder{}
if len(timestamp) > 0 {
out.WriteString(timestamp)
out.WriteString(" ")
}
if len(level) > 0 {
out.WriteString(level)
out.WriteString(" ")
}
if len(msg) > 0 {
out.WriteString(msg)
out.WriteString(" ")
}
if len(bytes) > 0 {
out.WriteString(colorize(darkGray, string(bytes)))
}
fmt.Println(out.String())
return nil
}
func suppressDefaults(
next func([]string, slog.Attr) slog.Attr,
) func([]string, slog.Attr) slog.Attr {
return func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey ||
a.Key == slog.LevelKey ||
a.Key == slog.MessageKey {
return slog.Attr{}
}
if next == nil {
return a
}
return next(groups, a)
}
}
func NewHandler(opts *slog.HandlerOptions) *Handler {
if opts == nil {
opts = &slog.HandlerOptions{}
}
b := &bytes.Buffer{}
return &Handler{
b: b,
h: slog.NewJSONHandler(b, &slog.HandlerOptions{
Level: opts.Level,
AddSource: opts.AddSource,
ReplaceAttr: suppressDefaults(opts.ReplaceAttr),
}),
r: opts.ReplaceAttr,
m: &sync.Mutex{},
}
}
func (h *Handler) GetSlogHandler() slog.Handler {
return h.h
}
// New methods for simpler logging API
func (h *Handler) log(ctx context.Context, level slog.Level, msg string) {
r := slog.Record{
Time: time.Now(),
Level: level,
Message: msg,
}
h.Handle(ctx, r)
}
func (h *Handler) Info(ctx context.Context, msg string) {
h.log(ctx, slog.LevelInfo, msg)
}
func (h *Handler) Error(ctx context.Context, msg string, attrs ...slog.Attr) {
h.log(ctx, slog.LevelError, msg)
}
func (h *Handler) Warning(ctx context.Context, msg string, attrs ...slog.Attr) {
h.log(ctx, slog.LevelWarn, msg)
}
func (h *Handler) Debug(ctx context.Context, msg string, attrs ...slog.Attr) {
h.log(ctx, slog.LevelDebug, msg)
}