Merge pull request #64 from capcom6/feature/health-api

Introduce`/health` endpoint
This commit is contained in:
Aleksandr 2024-05-24 14:00:37 +07:00 committed by GitHub
commit 50f4df4100
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 660 additions and 19 deletions

View File

@ -4,7 +4,7 @@ tmp_dir = "tmp"
[build]
bin = "tmp/main.exe"
cmd = "go build -o ./tmp/main.exe ./cmd/sms-gateway"
cmd = "go build -ldflags='-X github.com/capcom6/sms-gateway/internal/version.AppVersion=dev -X github.com/capcom6/sms-gateway/internal/version.AppRelease=1' -o ./tmp/main.exe ./cmd/sms-gateway"
delay = 1000
exclude_dir = ["api", "assets", "tmp", "vendor", "testdata", "tmp", "web"]
exclude_file = []

View File

@ -44,4 +44,8 @@ LICENSE
.dockerignore
# Ignore the Go build directory
tmp/
tmp/
# Ignore build and deployments directories
deployments/
build/

View File

@ -19,6 +19,7 @@ jobs:
build:
name: Docker image
runs-on: ubuntu-latest
if: github.actor != 'dependabot[bot]' # skip on dependabot because it's not allowed to access secrets
permissions:
contents: read
outputs:
@ -45,11 +46,19 @@ jobs:
username: ${{ secrets.username }}
password: ${{ secrets.password }}
- name: Set APP_VERSION env
run: echo APP_VERSION=$(echo ${GITHUB_REF} | rev | cut -d'/' -f 1 | rev ) >> ${GITHUB_ENV}
- name: Set APP_RELEASE env
run: echo APP_RELEASE=$(( ($(date +%s) - $(date -d "2022-06-15" +%s)) / 86400 )) >> ${GITHUB_ENV}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
file: build/package/Dockerfile
build-args: APP=${{ inputs.app-name }}
build-args: |
APP=${{ inputs.app-name }}
APP_VERSION=${{ env.APP_VERSION }}
APP_RELEASE_ID=${{ env.APP_RELEASE }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -6,6 +6,8 @@ ifeq ($(OS),Windows_NT)
extension = .exe
endif
.DEFAULT_GOAL := build
init:
go mod download

View File

@ -10,7 +10,7 @@ GET {{localUrl}}/device HTTP/1.1
Authorization: Basic {{localCredentials}}
###
POST {{localUrl}}/message?skipPhoneValidation=true HTTP/1.1
POST {{localUrl}}/message?skipPhoneValidation=false HTTP/1.1
Content-Type: application/json
Authorization: Basic {{localCredentials}}

View File

@ -3,6 +3,12 @@
@mobileToken={{$dotenv MOBILE__TOKEN}}
@phone={{$dotenv PHONE}}
###
GET {{baseUrl}}/health HTTP/1.1
###
GET {{baseUrl}}/api/3rdparty/v1/health HTTP/1.1
###
POST {{baseUrl}}/api/mobile/v1/device HTTP/1.1
Authorization: Bearer 123456789

View File

@ -182,6 +182,58 @@
}
}
},
"/api/3rdparty/v1/health": {
"get": {
"description": "Checks if service is healthy",
"produces": [
"application/json"
],
"tags": [
"System"
],
"summary": "Health check",
"responses": {
"200": {
"description": "Health check result",
"schema": {
"$ref": "#/definitions/smsgateway.HealthResponse"
}
},
"500": {
"description": "Service is unhealthy",
"schema": {
"$ref": "#/definitions/smsgateway.HealthResponse"
}
}
}
}
},
"/health": {
"get": {
"description": "Checks if service is healthy",
"produces": [
"application/json"
],
"tags": [
"System"
],
"summary": "Health check",
"responses": {
"200": {
"description": "Health check result",
"schema": {
"$ref": "#/definitions/smsgateway.HealthResponse"
}
},
"500": {
"description": "Service is unhealthy",
"schema": {
"$ref": "#/definitions/smsgateway.HealthResponse"
}
}
}
}
},
"/mobile/v1/device": {
"post": {
"description": "Registers new device and returns credentials",
@ -387,7 +439,6 @@
"application/json"
],
"tags": [
"Device",
"Upstream"
],
"summary": "Send push notifications",
@ -484,6 +535,79 @@
}
}
},
"smsgateway.HealthCheck": {
"type": "object",
"properties": {
"description": {
"description": "A human-readable description of the check.",
"type": "string"
},
"observedUnit": {
"description": "Unit of measurement for the observed value.",
"type": "string"
},
"observedValue": {
"description": "Observed value of the check.",
"type": "integer"
},
"status": {
"description": "Status of the check.\nIt can be one of the following values: \"pass\", \"warn\", or \"fail\".",
"allOf": [
{
"$ref": "#/definitions/smsgateway.HealthStatus"
}
]
}
}
},
"smsgateway.HealthChecks": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/smsgateway.HealthCheck"
}
},
"smsgateway.HealthResponse": {
"type": "object",
"properties": {
"checks": {
"description": "A map of check names to their respective details.",
"allOf": [
{
"$ref": "#/definitions/smsgateway.HealthChecks"
}
]
},
"releaseId": {
"description": "Release ID of the application.\nIt is used to identify the version of the application.",
"type": "integer"
},
"status": {
"description": "Overall status of the application.\nIt can be one of the following values: \"pass\", \"warn\", or \"fail\".",
"allOf": [
{
"$ref": "#/definitions/smsgateway.HealthStatus"
}
]
},
"version": {
"description": "Version of the application.",
"type": "string"
}
}
},
"smsgateway.HealthStatus": {
"type": "string",
"enum": [
"pass",
"warn",
"fail"
],
"x-enum-varnames": [
"HealthStatusPass",
"HealthStatusWarn",
"HealthStatusFail"
]
},
"smsgateway.Message": {
"type": "object",
"required": [

View File

@ -39,6 +39,59 @@ definitions:
example: An error occurred
type: string
type: object
smsgateway.HealthCheck:
properties:
description:
description: A human-readable description of the check.
type: string
observedUnit:
description: Unit of measurement for the observed value.
type: string
observedValue:
description: Observed value of the check.
type: integer
status:
allOf:
- $ref: '#/definitions/smsgateway.HealthStatus'
description: |-
Status of the check.
It can be one of the following values: "pass", "warn", or "fail".
type: object
smsgateway.HealthChecks:
additionalProperties:
$ref: '#/definitions/smsgateway.HealthCheck'
type: object
smsgateway.HealthResponse:
properties:
checks:
allOf:
- $ref: '#/definitions/smsgateway.HealthChecks'
description: A map of check names to their respective details.
releaseId:
description: |-
Release ID of the application.
It is used to identify the version of the application.
type: integer
status:
allOf:
- $ref: '#/definitions/smsgateway.HealthStatus'
description: |-
Overall status of the application.
It can be one of the following values: "pass", "warn", or "fail".
version:
description: Version of the application.
type: string
type: object
smsgateway.HealthStatus:
enum:
- pass
- warn
- fail
type: string
x-enum-varnames:
- HealthStatusPass
- HealthStatusWarn
- HealthStatusFail
smsgateway.Message:
properties:
id:
@ -332,6 +385,40 @@ paths:
tags:
- User
- Messages
/api/3rdparty/v1/health:
get:
description: Checks if service is healthy
produces:
- application/json
responses:
"200":
description: Health check result
schema:
$ref: '#/definitions/smsgateway.HealthResponse'
"500":
description: Service is unhealthy
schema:
$ref: '#/definitions/smsgateway.HealthResponse'
summary: Health check
tags:
- System
/health:
get:
description: Checks if service is healthy
produces:
- application/json
responses:
"200":
description: Health check result
schema:
$ref: '#/definitions/smsgateway.HealthResponse'
"500":
description: Service is unhealthy
schema:
$ref: '#/definitions/smsgateway.HealthResponse'
summary: Health check
tags:
- System
/mobile/v1/device:
patch:
consumes:
@ -490,7 +577,6 @@ paths:
$ref: '#/definitions/smsgateway.ErrorResponse'
summary: Send push notifications
tags:
- Device
- Upstream
schemes:
- https

View File

@ -2,6 +2,8 @@
FROM golang:1.22-alpine AS build
ARG APP
ARG APP_VERSION=1.0.0
ARG APP_RELEASE_ID=1
WORKDIR /go/src
# Copy go.mod and go.sum
@ -15,7 +17,7 @@ COPY . .
# Builds the application as a staticly linked one, to allow it to run on alpine
# RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o app .
RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o app ./cmd/${APP}/main.go
RUN CGO_ENABLED=0 go build -a -installsuffix cgo -ldflags="-w -s -X github.com/capcom6/${APP}/internal/version.AppVersion=${APP_VERSION} -X github.com/capcom6/${APP}/internal/version.AppRelease=${APP_RELEASE_ID}" -o app ./cmd/${APP}/main.go
# Build MkDocs
FROM squidfunk/mkdocs-material:9.5.15 AS mkdocs
@ -46,5 +48,6 @@ COPY --from=build /go/src/app /app
EXPOSE 3000
USER guest
HEALTHCHECK --interval=10s --timeout=3s --retries=3 --start-period=5s CMD /app/app health || exit 1
ENTRYPOINT ["/docker-entrypoint.sh"]

View File

@ -1,4 +1,3 @@
version: '3'
services:
backend:
image: capcom6/sms-gateway
@ -8,7 +7,7 @@ services:
args:
- APP=sms-gateway
environment:
- DEBUG=1
- DEBUG=
- CONFIG_PATH=config.yml
- GOOSE_DBSTRING=sms:sms@tcp(db:3306)/sms
- HTTP__LISTEN=0.0.0.0:3000
@ -21,21 +20,22 @@ services:
- "3000:3000"
volumes:
- ../../configs/config.yml:/app/config.yml:ro
restart: 'no'
restart: 'unless-stopped'
depends_on:
db:
condition: service_healthy
db:
image: mariadb:10.6
image: mariadb:10.11
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=sms
- MYSQL_USER=sms
- MYSQL_PASSWORD=sms
- MARIADB_AUTO_UPGRADE=1
volumes:
- mariadb-data:/var/lib/mysql
restart: 'no'
restart: 'unless-stopped'
healthcheck:
test:
[

View File

@ -12,6 +12,7 @@ import (
appconfig "github.com/capcom6/sms-gateway/internal/config"
"github.com/capcom6/sms-gateway/internal/sms-gateway/handlers"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/auth"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/health"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/messages"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/push"
"github.com/capcom6/sms-gateway/internal/sms-gateway/repositories"
@ -35,6 +36,7 @@ var Module = fx.Module(
repositories.Module,
db.Module,
messages.Module,
health.Module,
)
func Run() {

View File

@ -25,6 +25,8 @@ const (
type ThirdPartyHandlerParams struct {
fx.In
HealthHandler *healthHandler
AuthSvc *auth.Service
MessagesSvc *messages.Service
DevicesSvc *services.DevicesService
@ -36,6 +38,8 @@ type ThirdPartyHandlerParams struct {
type thirdPartyHandler struct {
Handler
healthHandler *healthHandler
authSvc *auth.Service
messagesSvc *messages.Service
devicesSvc *services.DevicesService
@ -173,6 +177,8 @@ func (h *thirdPartyHandler) authorize(handler func(models.User, *fiber.Ctx) erro
return fiber.ErrUnauthorized
}
c.Locals("user", user)
return handler(user, c)
}
}
@ -180,6 +186,8 @@ func (h *thirdPartyHandler) authorize(handler func(models.User, *fiber.Ctx) erro
func (h *thirdPartyHandler) Register(router fiber.Router) {
router = router.Group("/3rdparty/v1")
h.healthHandler.Register(router)
router.Use(basicauth.New(basicauth.Config{
Authorizer: func(username string, password string) bool {
return len(username) > 0 && len(password) > 0
@ -194,9 +202,10 @@ func (h *thirdPartyHandler) Register(router fiber.Router) {
func newThirdPartyHandler(params ThirdPartyHandlerParams) *thirdPartyHandler {
return &thirdPartyHandler{
Handler: Handler{Logger: params.Logger.Named("ThirdPartyHandler"), Validator: params.Validator},
authSvc: params.AuthSvc,
messagesSvc: params.MessagesSvc,
devicesSvc: params.DevicesSvc,
Handler: Handler{Logger: params.Logger.Named("ThirdPartyHandler"), Validator: params.Validator},
healthHandler: params.HealthHandler,
authSvc: params.AuthSvc,
messagesSvc: params.MessagesSvc,
devicesSvc: params.DevicesSvc,
}
}

View File

@ -0,0 +1,79 @@
package handlers
import (
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/health"
"github.com/capcom6/sms-gateway/internal/version"
"github.com/capcom6/sms-gateway/pkg/maps"
"github.com/capcom6/sms-gateway/pkg/smsgateway"
"github.com/gofiber/fiber/v2"
"go.uber.org/fx"
"go.uber.org/zap"
)
type healthHanlderParams struct {
fx.In
HealthSvc *health.Service
Logger *zap.Logger
}
type healthHandler struct {
Handler
healthSvc *health.Service
logger *zap.Logger
}
// @Summary Health check
// @Description Checks if service is healthy
// @Tags System
// @Produce json
// @Success 200 {object} smsgateway.HealthResponse "Health check result"
// @Failure 500 {object} smsgateway.HealthResponse "Service is unhealthy"
// @Router /health [get]
// @Router /api/3rdparty/v1/health [get]
//
// Health check
func (h *healthHandler) getHealth(c *fiber.Ctx) error {
check, err := h.healthSvc.HealthCheck(c.Context())
if err != nil {
return err
}
res := smsgateway.HealthResponse{
Status: smsgateway.HealthStatus(check.Status),
Version: version.AppVersion,
ReleaseID: version.AppReleaseID(),
Checks: maps.MapValues(
check.Checks,
func(c health.CheckDetail) smsgateway.HealthCheck {
return smsgateway.HealthCheck{
Description: c.Description,
ObservedUnit: c.ObservedUnit,
ObservedValue: c.ObservedValue,
Status: smsgateway.HealthStatus(c.Status),
}
},
),
}
if check.Status == health.StatusFail {
return c.Status(fiber.StatusInternalServerError).JSON(res)
}
return c.Status(fiber.StatusOK).JSON(res)
}
func (h *healthHandler) Register(router fiber.Router) {
router.Get("/health", h.getHealth)
}
func newHealthHandler(params healthHanlderParams) *healthHandler {
return &healthHandler{
Handler: Handler{Logger: params.Logger.Named("HealthHandler"), Validator: nil},
healthSvc: params.HealthSvc,
logger: params.Logger,
}
}

View File

@ -17,4 +17,8 @@ var Module = fx.Module(
http.AsApiHandler(newMobileHandler),
http.AsApiHandler(newUpstreamHandler),
),
fx.Provide(
newHealthHandler,
fx.Private,
),
)

View File

@ -5,12 +5,16 @@ import (
)
type rootHandler struct {
healthHandler *healthHandler
}
func (h *rootHandler) Register(app *fiber.App) {
h.healthHandler.Register(app)
app.Static("/", "static")
}
func newRootHandler() *rootHandler {
return &rootHandler{}
func newRootHandler(healthHandler *healthHandler) *rootHandler {
return &rootHandler{
healthHandler: healthHandler,
}
}

View File

@ -39,7 +39,7 @@ func newUpstreamHandler(params upstreamHandlerParams) *upstreamHandler {
// @Summary Send push notifications
// @Description Enqueues notifications for sending to devices
// @Tags Device, Upstream
// @Tags Upstream
// @Accept json
// @Produce json
// @Param request body smsgateway.UpstreamPushRequest true "Push request"

View File

@ -0,0 +1,45 @@
package health
import (
"io"
httpclient "net/http"
"time"
"github.com/capcom6/go-infra-fx/http"
"go.uber.org/fx"
"go.uber.org/zap"
)
func testHealth(shutdowner fx.Shutdowner, logger *zap.Logger, config http.Config) {
client := httpclient.Client{
Timeout: 1 * time.Second,
}
res, err := client.Get("http://" + config.Listen + "/health")
if err != nil {
logger.Error("Failed to send request", zap.Error(err))
if err := shutdowner.Shutdown(fx.ExitCode(1)); err != nil {
logger.Error("Failed to shutdown", zap.Error(err))
}
return
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Error("Failed to read body", zap.Error(err))
}
logger.Info(string(body))
if res.StatusCode >= 400 {
if err := shutdowner.Shutdown(fx.ExitCode(1)); err != nil {
logger.Error("Failed to shutdown", zap.Error(err))
}
return
}
if err := shutdowner.Shutdown(); err != nil {
logger.Error("Failed to shutdown", zap.Error(err))
}
}

View File

@ -0,0 +1,52 @@
package health
import (
"context"
"database/sql"
"sync/atomic"
"go.uber.org/fx"
)
type DBProviderParams struct {
fx.In
DB *sql.DB
}
type DBProvider struct {
db *sql.DB
counter atomic.Int32
}
func (p *DBProvider) Name() string {
return "db"
}
func (p *DBProvider) HealthCheck(ctx context.Context) (Checks, error) {
status := StatusPass
err := p.db.PingContext(ctx)
if err != nil {
p.counter.Add(1)
status = StatusFail
} else {
p.counter.Store(0)
}
return Checks{
"ping": {
Description: "Failed sequential pings count",
ObservedUnit: "",
ObservedValue: int(p.counter.Load()),
Status: status,
},
}, err
}
func NewDBProvider(params DBProviderParams) *DBProvider {
return &DBProvider{
db: params.DB,
}
}

View File

@ -0,0 +1,25 @@
package health
import (
"github.com/capcom6/go-infra-fx/cli"
"go.uber.org/fx"
"go.uber.org/zap"
)
var Module = fx.Module(
"health",
fx.Decorate(func(log *zap.Logger) *zap.Logger {
return log.Named("health")
}),
fx.Provide(
AsHealthProvider(NewDBProvider),
fx.Private,
),
fx.Provide(
NewService,
),
)
func init() {
cli.Register("health", testHealth)
}

View File

@ -0,0 +1,70 @@
package health
import (
"context"
"go.uber.org/fx"
"go.uber.org/zap"
)
type ServiceParams struct {
fx.In
HealthProviders []HealthProvider `group:"health-providers"`
Logger *zap.Logger
}
type Service struct {
healthProviders []HealthProvider
logger *zap.Logger
}
func NewService(params ServiceParams) *Service {
return &Service{
healthProviders: params.HealthProviders,
logger: params.Logger,
}
}
func (s *Service) HealthCheck(ctx context.Context) (Check, error) {
check := Check{
Status: StatusPass,
Checks: map[string]CheckDetail{},
}
level := levelPass
for _, p := range s.healthProviders {
healthChecks, err := p.HealthCheck(ctx)
if err != nil {
s.logger.Error("Error getting health check", zap.String("provider", p.Name()), zap.Error(err))
}
if len(healthChecks) == 0 {
continue
}
for name, detail := range healthChecks {
check.Checks[p.Name()+":"+name] = detail
if detail.Status == StatusFail {
level = max(level, levelFail)
} else if detail.Status == StatusWarn {
level = max(level, levelWarn)
}
}
}
check.Status = statusLevels[level]
return check, nil
}
func AsHealthProvider(f any) any {
return fx.Annotate(
f,
fx.As(new(HealthProvider)),
fx.ResultTags(`group:"health-providers"`),
)
}

View File

@ -0,0 +1,52 @@
package health
import "context"
type Status string
type statusLevel int
const (
StatusPass Status = "pass"
StatusWarn Status = "warn"
StatusFail Status = "fail"
levelPass statusLevel = 0
levelWarn statusLevel = 1
levelFail statusLevel = 2
)
var statusLevels = map[statusLevel]Status{
levelPass: StatusPass,
levelWarn: StatusWarn,
levelFail: StatusFail,
}
// Health status of the application.
type Check struct {
// Overall status of the application.
// It can be one of the following values: "pass", "warn", or "fail".
Status Status
// A map of check names to their respective details.
Checks Checks
}
// Details of a health check.
type CheckDetail struct {
// A human-readable description of the check.
Description string
// Unit of measurement for the observed value.
ObservedUnit string
// Observed value of the check.
ObservedValue int
// Status of the check.
// It can be one of the following values: "pass", "warn", or "fail".
Status Status
}
// Map of check names to their respective details.
type Checks map[string]CheckDetail
type HealthProvider interface {
Name() string
HealthCheck(ctx context.Context) (Checks, error)
}

View File

@ -0,0 +1,17 @@
package version
import "strconv"
const notSet string = "not set"
// these information will be collected when build, by `-ldflags "-X main.appVersion=0.1"`
var (
AppVersion = notSet
AppRelease = notSet
)
func AppReleaseID() int {
id, _ := strconv.Atoi(AppRelease)
return id
}

9
pkg/maps/map_values.go Normal file
View File

@ -0,0 +1,9 @@
package maps
func MapValues[K comparable, V any, R any](m map[K]V, f func(V) R) map[K]R {
result := make(map[K]R, len(m))
for k, v := range m {
result[k] = f(v)
}
return result
}

View File

@ -0,0 +1,39 @@
package smsgateway
type HealthStatus string
const (
HealthStatusPass HealthStatus = "pass"
HealthStatusWarn HealthStatus = "warn"
HealthStatusFail HealthStatus = "fail"
)
// Details of a health check.
type HealthCheck struct {
// A human-readable description of the check.
Description string `json:"description,omitempty"`
// Unit of measurement for the observed value.
ObservedUnit string `json:"observedUnit,omitempty"`
// Observed value of the check.
ObservedValue int `json:"observedValue"`
// Status of the check.
// It can be one of the following values: "pass", "warn", or "fail".
Status HealthStatus `json:"status"`
}
// Map of check names to their respective details.
type HealthChecks map[string]HealthCheck
// Health status of the application.
type HealthResponse struct {
// Overall status of the application.
// It can be one of the following values: "pass", "warn", or "fail".
Status HealthStatus `json:"status"`
// Version of the application.
Version string `json:"version,omitempty"`
// Release ID of the application.
// It is used to identify the version of the application.
ReleaseID int `json:"releaseId,omitempty"`
// A map of check names to their respective details.
Checks HealthChecks `json:"checks,omitempty"`
}