Skip to content

Commit

Permalink
feat: add status service
Browse files Browse the repository at this point in the history
This service is in charge of monitoring the remote status for applications
  • Loading branch information
leonsteinhaeuser committed Nov 20, 2024
1 parent a767e9c commit 08d16e2
Show file tree
Hide file tree
Showing 8 changed files with 340 additions and 15 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/container_build.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Docker
name: build

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
Expand Down Expand Up @@ -35,6 +35,7 @@ jobs:
include:
- service_name: view
- service_name: number
- service_name: status
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand Down
10 changes: 10 additions & 0 deletions services/status/Containerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FROM golang:alpine AS builder
RUN mkdir -p /go/src/app
WORKDIR /go/src/app
ADD --chown=65535:65535 . .
RUN go build -o bin/status

FROM alpine:latest
COPY --chown=65535:65535 --from=builder /go/src/app/bin/status /status
USER 65535
ENTRYPOINT /status
19 changes: 19 additions & 0 deletions services/status/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# status service

This service takes a comma separated list of key=value pairs and returns the status of the services in the list via the `/status` endpoint.

## Configuration

| Environment variable | Type | Default | Description |
| -------------------- | ---- | ------- | ----------- |
| `LISTEN_ADDRESS` | string | `:8082` | Address to listen on for HTTP requests. |
| `EXTERNAL_SERVICES_TO_WATCH` | []string | `redhat=https://www.redhat.com/en` | Comma separated list of key=value pairs of services to watch |
| `CHECK_INTERVAL` | duration | `5s` | Interval parsed as duration, to check the upstream service. |
| `REQUEST_TIMEOUT` | duration | `2s` | Timeout parsed as duration, for the HTTP request to the upstream service. |

## API

| Path | Method | Description |
| ---- | ------ | ----------- |
| `/status` | GET | Returns the status of the services in the list. |
| `/healthz` | GET | Returns the health of the service. |
3 changes: 3 additions & 0 deletions services/status/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/leonsteinhaeuser/rh-ocp-examples/services/status

go 1.23.2
159 changes: 159 additions & 0 deletions services/status/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package main

import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"sync"
"time"
)

var (
// envExternalServicesToWatch is a comma-separated list of key=value services to watch (HTTP GET).
// The key is the name of the service, and the value is the URL to check.
envExternalServicesToWatch = os.Getenv("EXTERNAL_SERVICES_TO_WATCH")
envCheckInterval = os.Getenv("CHECK_INTERVAL")
envRequestTimeout = os.Getenv("REQUEST_TIMEOUT")
envListenAddress = os.Getenv("LISTEN_ADDRESS")

externalServicesToWatch = map[string]string{}
checkInterval = 15 * time.Second
requestTimeout = 5 * time.Second

mlock sync.Mutex = sync.Mutex{}
externalServicesStatus = map[string]ServiceStatus{}
)

type ServiceStatus struct {
LastChecked time.Time `json:"last_checked"`
Status Status `json:"status"`
URL string `json:"url"`
}

type Status string

const (
Unknown Status = "unknown"
Available Status = "available"
Unavailable Status = "unavailable"
)

func init() {
// Parse the envExternalServicesToWatch and populate the externalServicesToWatch map.
// If the envExternalServicesToWatch is empty, then we should watch the default services.
if envExternalServicesToWatch == "" {
externalServicesToWatch = map[string]string{
"redhat": "https://www.redhat.com/en",
}
} else {
services := strings.Split(envExternalServicesToWatch, ",")
for _, service := range services {
parts := strings.Split(service, "=")
if len(parts) != 2 {
// this is a fatal error, so we exit the program.
slog.Error("invalid external service format, expected key=value format", "service", service)
os.Exit(1)
}
externalServicesToWatch[parts[0]] = parts[1]
}
}

// parse the envCheckInterval and set the checkInterval.
if envCheckInterval != "" {
d, err := time.ParseDuration(envCheckInterval)
if err != nil {
slog.Error("failed to parse the check interval", "interval", envCheckInterval, "error", err)
os.Exit(1)
}
checkInterval = d
}

// parse the envRequestTimeout and set the requestTimeout.
if envRequestTimeout != "" {
d, err := time.ParseDuration(envRequestTimeout)
if err != nil {
slog.Error("failed to parse the request timeout", "timeout", envRequestTimeout, "error", err)
os.Exit(1)
}
requestTimeout = d
}
http.DefaultClient.Timeout = requestTimeout
}

func main() {
http.HandleFunc("GET /status", statusHandler)
http.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})

ctx, cf := context.WithCancel(context.Background())
defer cf()
// check the availability of the external services.
watcher(ctx)

slog.Info("starting the server", "address", envListenAddress)
err := http.ListenAndServe(envListenAddress, nil)
if err != nil {
slog.Error("failed to start the server", "error", err)
os.Exit(1)
}
}

func watcher(ctx context.Context) {
for service, url := range externalServicesToWatch {
go func(ctx context.Context, service, url string) {
ticker := time.NewTicker(checkInterval)
for {
select {
case <-ctx.Done():
slog.Warn("received a signal to stop the watcher", "service", service)
return
case <-ticker.C:
mlock.Lock()
svc := ServiceStatus{
LastChecked: time.Now(),
Status: Unknown,
URL: url,
}
err := checkExternalServiceAvailability(service, url)
if err != nil {
svc.Status = Unavailable
externalServicesStatus[service] = svc
mlock.Unlock()
slog.Error("service is not available", "service", service, "error", err)
continue
}
svc.Status = Available
externalServicesStatus[service] = svc
mlock.Unlock()
}
}
}(ctx, service, url)
}
}

// checkExternalServicesAvailability checks the availability of the external services.
// It sends an HTTP GET request to the URL of the service and checks the status code.
func checkExternalServiceAvailability(service string, url string) error {
rsp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to check the availability of the service %s: %w", service, err)
}
if rsp.StatusCode != http.StatusOK {
return fmt.Errorf("service %s is not available: %s", service, rsp.Status)
}
return nil
}

func statusHandler(w http.ResponseWriter, r *http.Request) {
err := json.NewEncoder(w).Encode(externalServicesStatus)
if err != nil {
slog.Error("failed to encode the response", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
4 changes: 3 additions & 1 deletion services/view/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ This service talks to the number service and displays the number in a web page.
| Endpoint | Method | Payload Request | Payload Response | Description |
| --- | --- | --- | --- | --- |
| / | GET | - | html | Get a random number and display it in a web page |
| /api/v1/status

## Configuration

The view service requires the following environment variables:

- `NUMBER_SERVICE_URL`: The URL of the number service
- `NUMBER_SERVICE_URL`: The URL of the number service
- `STATUS_SERVICE_URL`: The URL of the status service (including the endpoint)
56 changes: 43 additions & 13 deletions services/view/main.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
package main

import (
_ "embed"
"encoding/json"
"html/template"
"io"
"log/slog"
"net/http"
"os"
)

var (
envNumberServiceURL = os.Getenv("NUMBER_SERVICE_URL")
envStatusServiceURL = os.Getenv("STATUS_SERVICE_URL")

//go:embed view.html
websiteHTML string
)

func init() {
Expand All @@ -25,24 +31,48 @@ func main() {
http.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
http.HandleFunc("GET /api/v1/status", proxyEndpoint)
http.HandleFunc("GET /", logMiddleware(getMain))
slog.Info("starting server on port 8080")
http.ListenAndServe(":8080", nil)
}

const (
websiteHTML = `<!DOCTYPE html>
<html>
<head>
<title>View Service</title>
</head>
<body>
<h1>Hello, World!</h1>
<p>This is the view service.</p>
<h5>The number you requested is:</h5><p>{{.number}}</p>
</body>
</html>`
)
func proxyEndpoint(w http.ResponseWriter, r *http.Request) {
req, err := http.NewRequest(r.Method, envStatusServiceURL, r.Body)
if err != nil {
slog.Error("could not create proxy request", "error", err)
http.Error(w, "could not create proxy request", http.StatusInternalServerError)
return
}

// Copy headers from the original request
for key, values := range r.Header {
for _, value := range values {
req.Header.Add(key, value)
}
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Error("could not forward request", "error", err)
http.Error(w, "could not forward request", http.StatusInternalServerError)
return
}
defer resp.Body.Close()

// Copy headers from the response
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}

w.WriteHeader(resp.StatusCode)
_, err = io.Copy(w, resp.Body)
if err != nil {
slog.Error("could not copy response body", "error", err)
}
}

var (
websiteTemplate = template.Must(template.New("website").Parse(websiteHTML))
Expand Down
Loading

0 comments on commit 08d16e2

Please sign in to comment.