Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: accept new SLO specs using the filesystem HTTP api #914

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,23 @@ picked up by a Prometheus instance. While running Pyrra on its own works, there
won't be any SLO configured, nor will there be any data from a Prometheus to
work with. It's designed to work alongside a Prometheus.

SLO specs may be published using the `/specs/create` endpoint. This will cause `pyrra` to
generate the corresponding recordingRules, write them out to disk and trigger a reload
of Prometheus.

Example:
```
% curl -i -X POST -H "content-type: multipart/form-data" -F "spec=@service-levels/some-slo.yaml" http://localhost:9444/ingest
HTTP/1.1 200 OK
Date: Fri, 15 Sep 2023 14:21:54 GMT
Content-Length: 2
Content-Type: text/plain; charset=utf-8

ok
```

Note: the specs endpoint does not require authentication and needs to be explicitly enabled using `--specs-api`.

## Tech Stack

**Client:** TypeScript with React, Bootstrap, and uPlot.
Expand Down
209 changes: 208 additions & 1 deletion filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package main

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
Expand Down Expand Up @@ -33,6 +35,16 @@ import (
"github.com/pyrra-dev/pyrra/slo"
)

type SpecsTransformation struct {
Outcome string `json:"outcome"`
Message string `json:"message"`
}

type SpecsList struct {
SpecsAvailable []string `json:"specsAvailable"`
RulesGenerated []string `json:"rulesGenerated"`
}

type Objectives struct {
mu sync.RWMutex
objectives map[string]slo.Objective
Expand Down Expand Up @@ -89,7 +101,192 @@ Objectives:
return objectives
}

func cmdFilesystem(logger log.Logger, reg *prometheus.Registry, promClient api.Client, configFiles, prometheusFolder string, genericRules bool) int {
func listFolderContents(folderPath string) ([]string, error) {
var fileNames []string
err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
fileNames = append(fileNames, filepath.Base(path))
return nil
})
return fileNames, err
}

func listSpecsHandler(logger log.Logger, specsDir, prometheusDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

payload := &SpecsList{
SpecsAvailable: []string{},
RulesGenerated: []string{},
}

level.Info(logger).Log("msg", "listing available specs", "dir", specsDir)

fileNames, err := listFolderContents(specsDir)
if err != nil {
level.Error(logger).Log("msg", "error listing available specs", "err", err)
} else {
payload.SpecsAvailable = append(payload.SpecsAvailable, fileNames...)
}

level.Info(logger).Log("msg", "listing generated rules", "dir", prometheusDir)

fileNames, err = listFolderContents(prometheusDir)
if err != nil {
level.Error(logger).Log("msg", "error listing generated rules", "err", err)
} else {
payload.RulesGenerated = append(payload.RulesGenerated, fileNames...)
}

w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(payload)
if err != nil {
level.Error(logger).Log("msg", "failed to encode payload")
}
}
}

func createSpecHandler(logger log.Logger, dir, prometheusFolder string, reload chan struct{}, genericRules bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

if r.Method != http.MethodPost {
response := SpecsTransformation{Outcome: "error", Message: "Method not allowed"}
responseAsJSON, _ := json.Marshal(response)
http.Error(w, string(responseAsJSON), http.StatusMethodNotAllowed)
return
}

err := r.ParseMultipartForm(32 << 20)
if err != nil {
response := SpecsTransformation{Outcome: "error", Message: "Failed to parse form upload"}
responseAsJSON, _ := json.Marshal(response)
http.Error(w, string(responseAsJSON), http.StatusBadRequest)
return
}

file, handler, err := r.FormFile("spec")
if err != nil {
response := SpecsTransformation{Outcome: "error", Message: "Failed to read spec field from form upload"}
responseAsJSON, _ := json.Marshal(response)
http.Error(w, string(responseAsJSON), http.StatusBadRequest)
return
}

level.Info(logger).Log("msg", "processing SLO spec", "specFile", handler.Filename)

ingestedSpec := dir + "/" + handler.Filename

f, err := os.OpenFile(ingestedSpec, os.O_WRONLY|os.O_CREATE, 0o666)
if err != nil {
level.Error(logger).Log("msg", "failed to write spec to disk", "err", err)
response := SpecsTransformation{Outcome: "error", Message: "There was an error writing the spec to disk"}
responseAsJSON, _ := json.Marshal(response)
http.Error(w, string(responseAsJSON), http.StatusInternalServerError)
return
}

defer f.Close()

_, err = io.Copy(f, file)
if err != nil {
level.Error(logger).Log("msg", "failed to copy contents", "err", err)
response := SpecsTransformation{Outcome: "error", Message: "There was an error copying file contents"}
responseAsJSON, _ := json.Marshal(response)
http.Error(w, string(responseAsJSON), http.StatusInternalServerError)
return
}

level.Info(logger).Log("msg", "attempting to build rules from spec", "location", ingestedSpec)
err = writeRuleFile(logger, ingestedSpec, prometheusFolder, genericRules, false)
if err != nil {
level.Error(logger).Log("msg", "error building rules from spec", "file", ingestedSpec, "err", err)
response := SpecsTransformation{Outcome: "error", Message: "There was an error building rules from this spec"}
responseAsJSON, _ := json.Marshal(response)
http.Error(w, string(responseAsJSON), http.StatusBadRequest)

err := os.Remove(ingestedSpec)
if err != nil {
level.Error(logger).Log("msg", "failed to remove spec", "file", ingestedSpec, "err", err)
}
return
}

level.Info(logger).Log("msg", "signaling Prometheus reload")
reload <- struct{}{}

response := SpecsTransformation{Outcome: "success", Message: "Ok"}
responseAsJSON, _ := json.Marshal(response)
_, err = w.Write(responseAsJSON)
if err != nil {
level.Error(logger).Log("msg", "failed to return http 200", "err", err)
}
}
}

func removeSpecHandler(logger log.Logger, dir, prometheusFolder string, reload chan struct{}) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

if r.Method != http.MethodDelete {
response := SpecsTransformation{Outcome: "error", Message: "Method not allowed"}
responseAsJSON, _ := json.Marshal(response)
http.Error(w, string(responseAsJSON), http.StatusMethodNotAllowed)
return
}

filePath := r.URL.Query().Get("f")
if filePath == "" {
response := SpecsTransformation{Outcome: "error", Message: "Missing 'f' parameter in query"}
responseAsJSON, _ := json.Marshal(response)
http.Error(w, string(responseAsJSON), http.StatusBadRequest)
return
}

level.Info(logger).Log("msg", "removing spec", "file", filePath, "dir", dir)

cleanPath := filepath.Clean(filePath)

err := os.Remove(dir + "/" + cleanPath)
if err != nil {
level.Error(logger).Log("msg", "failed to remove file", "file", filePath, "clean", cleanPath, "err", err)
response := SpecsTransformation{Outcome: "error", Message: "Failed to remove file"}
responseAsJSON, _ := json.Marshal(response)
http.Error(w, string(responseAsJSON), http.StatusInternalServerError)
return
}

level.Debug(logger).Log("msg", "removing generated rules", "file", filePath, "dir", prometheusFolder)
err = os.Remove(prometheusFolder + "/" + cleanPath)
if err != nil {
level.Error(logger).Log("msg", "failed to remove Prometheus rules file", "file", filePath, "clean", cleanPath, "err", err)
response := SpecsTransformation{Outcome: "error", Message: "Failed to delete rules"}
responseAsJSON, _ := json.Marshal(response)
http.Error(w, string(responseAsJSON), http.StatusInternalServerError)
return
}

level.Info(logger).Log("msg", "signaling Prometheus reload")
reload <- struct{}{}

response := SpecsTransformation{Outcome: "success", Message: "Ok"}
responseAsJSON, _ := json.Marshal(response)
_, err = w.Write(responseAsJSON)
if err != nil {
level.Error(logger).Log("msg", "failed to return http 200", "err", err)
}
}
}

func cmdFilesystem(logger log.Logger, reg *prometheus.Registry, promClient api.Client, configFiles, prometheusFolder string, specsAPI, genericRules bool) int {
reconcilesTotal := prometheus.NewCounter(prometheus.CounterOpts{
Name: "pyrra_filesystem_reconciles_total",
Help: "The total amount of reconciles.",
Expand Down Expand Up @@ -242,6 +439,7 @@ func cmdFilesystem(logger log.Logger, reg *prometheus.Registry, promClient api.C
}
{
prometheusInterceptor := connectprometheus.NewInterceptor(reg)
dir := filepath.Dir(configFiles)

router := http.NewServeMux()
router.Handle(objectivesv1alpha1connect.NewObjectiveBackendServiceHandler(
Expand All @@ -252,6 +450,15 @@ func cmdFilesystem(logger log.Logger, reg *prometheus.Registry, promClient api.C
))
router.Handle("/metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))

if specsAPI {
level.Info(logger).Log("msg", "specs API endpoints are enabled")
router.HandleFunc("/specs/remove", removeSpecHandler(logger, dir, prometheusFolder, reload))
router.HandleFunc("/specs/create", createSpecHandler(logger, dir, prometheusFolder, reload, genericRules))
router.HandleFunc("/specs/list", listSpecsHandler(logger, dir, prometheusFolder))
} else {
level.Info(logger).Log("msg", "specs API endpoints are disabled")
}

server := http.Server{
Addr: ":9444",
Handler: h2c.NewHandler(router, &http2.Server{}),
Expand Down
Loading