From 08d16e2a3998ee00a04be2af89e3bd79a4973cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20Steinh=C3=A4user?= Date: Wed, 20 Nov 2024 11:19:29 +0100 Subject: [PATCH] feat: add status service This service is in charge of monitoring the remote status for applications --- .github/workflows/container_build.yml | 3 +- services/status/Containerfile | 10 ++ services/status/README.md | 19 +++ services/status/go.mod | 3 + services/status/main.go | 159 ++++++++++++++++++++++++++ services/view/README.md | 4 +- services/view/main.go | 56 ++++++--- services/view/view.html | 101 ++++++++++++++++ 8 files changed, 340 insertions(+), 15 deletions(-) create mode 100644 services/status/Containerfile create mode 100644 services/status/README.md create mode 100644 services/status/go.mod create mode 100644 services/status/main.go create mode 100644 services/view/view.html diff --git a/.github/workflows/container_build.yml b/.github/workflows/container_build.yml index 519f2d8..552c091 100644 --- a/.github/workflows/container_build.yml +++ b/.github/workflows/container_build.yml @@ -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 @@ -35,6 +35,7 @@ jobs: include: - service_name: view - service_name: number + - service_name: status steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/services/status/Containerfile b/services/status/Containerfile new file mode 100644 index 0000000..8925536 --- /dev/null +++ b/services/status/Containerfile @@ -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 diff --git a/services/status/README.md b/services/status/README.md new file mode 100644 index 0000000..a25f851 --- /dev/null +++ b/services/status/README.md @@ -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. | diff --git a/services/status/go.mod b/services/status/go.mod new file mode 100644 index 0000000..fc2ca09 --- /dev/null +++ b/services/status/go.mod @@ -0,0 +1,3 @@ +module github.com/leonsteinhaeuser/rh-ocp-examples/services/status + +go 1.23.2 diff --git a/services/status/main.go b/services/status/main.go new file mode 100644 index 0000000..15f2fdb --- /dev/null +++ b/services/status/main.go @@ -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 + } +} diff --git a/services/view/README.md b/services/view/README.md index 8b962e2..19bcfc7 100644 --- a/services/view/README.md +++ b/services/view/README.md @@ -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 \ No newline at end of file +- `NUMBER_SERVICE_URL`: The URL of the number service +- `STATUS_SERVICE_URL`: The URL of the status service (including the endpoint) diff --git a/services/view/main.go b/services/view/main.go index d8ef72b..61391da 100644 --- a/services/view/main.go +++ b/services/view/main.go @@ -1,8 +1,10 @@ package main import ( + _ "embed" "encoding/json" "html/template" + "io" "log/slog" "net/http" "os" @@ -10,6 +12,10 @@ import ( var ( envNumberServiceURL = os.Getenv("NUMBER_SERVICE_URL") + envStatusServiceURL = os.Getenv("STATUS_SERVICE_URL") + + //go:embed view.html + websiteHTML string ) func init() { @@ -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 = ` - - - View Service - - -

Hello, World!

-

This is the view service.

-
The number you requested is:

{{.number}}

- -` -) +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)) diff --git a/services/view/view.html b/services/view/view.html new file mode 100644 index 0000000..606774b --- /dev/null +++ b/services/view/view.html @@ -0,0 +1,101 @@ + + + + + + + View + + + + +

View Dashboard

+

The number generated by the number service is:

{{ .number }}

+

Services:

+ + + + + + + + + + + + +
NameURLLast checkStatus
+ + + + \ No newline at end of file