Introduce RCM API

It contains two basic endpoints:
 * POST /v1/compose
 * GET /v1/compose/<uuid>
It passes all the tests, but cannot be used for the intended use case
because the store API does not (yet) support distributions and
architectures as a parameters.
This commit is contained in:
Martin Sehnoutka 2020-01-09 14:19:17 +01:00 committed by msehnout
parent 02a194f612
commit 4f63b54a16
3 changed files with 301 additions and 2 deletions

View file

@ -8,6 +8,7 @@ import (
"github.com/osbuild/osbuild-composer/internal/distro"
"github.com/osbuild/osbuild-composer/internal/jobqueue"
"github.com/osbuild/osbuild-composer/internal/rcm"
"github.com/osbuild/osbuild-composer/internal/rpmmd"
"github.com/osbuild/osbuild-composer/internal/store"
"github.com/osbuild/osbuild-composer/internal/weldr"
@ -41,8 +42,8 @@ func main() {
log.Fatalf("Could not get listening sockets: " + err.Error())
}
if len(listeners) != 2 {
log.Fatalf("Unexpected number of listening sockets (%d), expected 2", len(listeners))
if len(listeners) != 2 || len(listeners) != 3 {
log.Fatalf("Unexpected number of listening sockets (%d), expected 2 or 3", len(listeners))
}
weldrListener := listeners[0]
@ -67,5 +68,13 @@ func main() {
weldrAPI := weldr.New(rpm, currentArch(), distribution, logger, store)
go jobAPI.Serve(jobListener)
// Optionally run RCM API as well as Weldr API
if len(listeners) == 3 {
rcmListener := listeners[2]
rcmAPI := rcm.New(logger, store)
go rcmAPI.Serve(rcmListener)
}
weldrAPI.Serve(weldrListener)
}

183
internal/rcm/api.go Normal file
View file

@ -0,0 +1,183 @@
// Package rcm provides alternative HTTP API to Weldr.
// It's primary use case is for the RCM team. As such it is driven solely by their requirements.
package rcm
import (
"encoding/json"
"log"
"net"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"github.com/osbuild/osbuild-composer/internal/blueprint"
"github.com/osbuild/osbuild-composer/internal/store"
)
// API encapsulates RCM-specific API that is exposed over a separate TCP socket
type API struct {
logger *log.Logger
store *store.Store
router *httprouter.Router
}
// New creates new RCM API
func New(logger *log.Logger, store *store.Store) *API {
api := &API{
logger: logger,
store: store,
router: httprouter.New(),
}
api.router.RedirectTrailingSlash = false
api.router.RedirectFixedPath = false
api.router.MethodNotAllowed = http.HandlerFunc(methodNotAllowedHandler)
api.router.NotFound = http.HandlerFunc(notFoundHandler)
api.router.POST("/v1/compose", api.submit)
api.router.GET("/v1/compose/:uuid", api.status)
return api
}
// Serve serves the RCM API over the provided listener socket
func (api *API) Serve(listener net.Listener) error {
server := http.Server{Handler: api}
err := server.Serve(listener)
if err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
// ServeHTTP logs the request, sets content-type, and forwards the request to appropriate handler
func (api *API) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
if api.logger != nil {
log.Println(request.Method, request.URL.Path)
}
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
api.router.ServeHTTP(writer, request)
}
func methodNotAllowedHandler(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusMethodNotAllowed)
}
func notFoundHandler(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNotFound)
}
func (api *API) submit(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
// Check some basic HTTP parameters
contentType := request.Header["Content-Type"]
if len(contentType) != 1 || contentType[0] != "application/json" {
writer.WriteHeader(http.StatusBadRequest)
return
}
type Repository struct {
URL string `json:"url"`
Checksum string `json:"checksum"`
}
// JSON structure expected from the client
var composeRequest struct {
Distribution string `json:"distribution"`
ImageTypes []string `json:"image_types"`
Architectures []string `json:"architectures"`
Repositories []Repository `json:"repositories"`
}
// JSON structure with error message
var errorReason struct {
Error string `json:"error_reason"`
}
// Parse and verify the structure
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&composeRequest)
if err != nil || composeRequest.Distribution == "" || len(composeRequest.Architectures) == 0 || len(composeRequest.Repositories) == 0 {
writer.WriteHeader(http.StatusBadRequest)
errors := []string{}
if err != nil {
errors = append(errors, err.Error())
}
if composeRequest.Distribution == "" {
errors = append(errors, "input must specify a distribution")
}
if len(composeRequest.ImageTypes) == 0 {
errors = append(errors, "input must specify an image type")
} else if len(composeRequest.ImageTypes) != 1 {
errors = append(errors, "multiple image types are not yet supported")
}
if len(composeRequest.Architectures) == 0 {
errors = append(errors, "input must specify an architecture")
} else if len(composeRequest.Architectures) != 1 {
errors = append(errors, "multiple architectures are not yet supported")
}
if len(composeRequest.Repositories) == 0 {
errors = append(errors, "input must specify repositories")
}
errorReason.Error = strings.Join(errors, ", ")
json.NewEncoder(writer).Encode(errorReason)
return
}
// Push the requested compose to the store
composeUUID := uuid.New()
// nil is used as an upload target, because LocalTarget is already used in the PushCompose function
err = api.store.PushCompose(composeUUID, &blueprint.Blueprint{}, make(map[string]string), composeRequest.Architectures[0], composeRequest.ImageTypes[0], nil)
if err != nil {
if api.logger != nil {
api.logger.Println("RCM API failed to push compose:", err)
}
writer.WriteHeader(http.StatusInternalServerError)
errorReason.Error = "failed to push compose: " + err.Error()
json.NewEncoder(writer).Encode(errorReason)
return
}
// Create the response JSON structure
var reply struct {
UUID uuid.UUID `json:"compose_id"`
}
reply.UUID = composeUUID
json.NewEncoder(writer).Encode(reply)
}
func (api *API) status(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
// JSON structure in case of error
var errorReason struct {
Error string `json:"error_reason"`
}
// Check that the input is a valid UUID
uuidParam := params.ByName("uuid")
id, err := uuid.Parse(uuidParam)
if err != nil {
writer.WriteHeader(http.StatusBadRequest)
errorReason.Error = "Malformed UUID"
json.NewEncoder(writer).Encode(errorReason)
return
}
// Check that the compose exists
compose, exists := api.store.GetCompose(id)
if !exists {
writer.WriteHeader(http.StatusBadRequest)
errorReason.Error = "Compose UUID does not exist"
json.NewEncoder(writer).Encode(errorReason)
return
}
// JSON structure with success response
var reply struct {
Status string `json:"status"`
}
// TODO: return per-job status like Koji does (requires changes in the store)
reply.Status = compose.QueueStatus
json.NewEncoder(writer).Encode(reply)
}

107
internal/rcm/api_test.go Normal file
View file

@ -0,0 +1,107 @@
package rcm_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"github.com/google/uuid"
test_distro "github.com/osbuild/osbuild-composer/internal/distro/test"
"github.com/osbuild/osbuild-composer/internal/rcm"
"github.com/osbuild/osbuild-composer/internal/store"
)
type API interface {
ServeHTTP(writer http.ResponseWriter, request *http.Request)
}
func internalRequest(api API, method, path, body, contentType string) *http.Response {
req := httptest.NewRequest(method, path, bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", contentType)
resp := httptest.NewRecorder()
api.ServeHTTP(resp, req)
return resp.Result()
}
func TestBasicRcmAPI(t *testing.T) {
// Test the HTTP API responses
// This test mainly focuses on HTTP status codes and JSON structures, not necessarily on their content
var cases = []struct {
Method string
Path string
Body string
ContentType string
ExpectedStatus int
ExpectedBodyRegex string
}{
{"GET", "/v1/compose", ``, "", http.StatusMethodNotAllowed, ``},
{"POST", "/v1/compose", `{"status":"RUNNING"}`, "application/json", http.StatusBadRequest, ``},
{"POST", "/v1/compose", `{"status":"RUNNING"}`, "text/plain", http.StatusBadRequest, ``},
{"POST", "/v1/compose", `{"distribution": "fedora-30", "image_types": ["test_output"], "architectures":["test_arch"], "repositories": [{"url": "aaa", "checksum": "bbb"}]}`, "application/json", http.StatusOK, `{"compose_id":".*"}`},
{"POST", "/v1/compose/111-222-333", `{"status":"RUNNING"}`, "application/json", http.StatusMethodNotAllowed, ``},
{"GET", "/v1/compose/7802c476-9cd1-41b7-ba81-43c1906bce73", `{"status":"RUNNING"}`, "application/json", http.StatusBadRequest, `{"error_reason":"Compose UUID does not exist"}`},
}
distro := test_distro.New()
api := rcm.New(nil, store.New(nil, distro))
for _, c := range cases {
resp := internalRequest(api, c.Method, c.Path, c.Body, c.ContentType)
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
response_body := buf.String()
if resp.StatusCode != c.ExpectedStatus {
t.Errorf("%s request to %s should return status code %d but returns %d, response: %s", c.Method, c.Path, c.ExpectedStatus, resp.StatusCode, response_body)
}
matched, err := regexp.Match(c.ExpectedBodyRegex, []byte(response_body))
if err != nil {
t.Fatalf("Failed to match regex, correct the test definition!")
}
if !matched {
t.Errorf("The response to %s request to %s should match this regex %s but returns %s", c.Method, c.Path, c.ExpectedBodyRegex, response_body)
}
}
}
func TestSubmitCompose(t *testing.T) {
// Test the most basic use case: Submit a new job and get its status.
distro := test_distro.New()
api := rcm.New(nil, store.New(nil, distro))
var submit_reply struct {
UUID uuid.UUID `json:"compose_id"`
}
var status_reply struct {
Status string `json:"status,omitempty"`
ErrorReason string `json:"error_reason,omitempty"`
}
// Submit job
t.Log("RCM API submit compose")
resp := internalRequest(api, "POST", "/v1/compose", `{"distribution": "fedora-30", "image_types": ["test_output"], "architectures":["test_arch"], "repositories": [{"url": "aaa", "checksum": "bbb"}]}`, "application/json")
if resp.StatusCode != http.StatusOK {
t.Fatal("Failed to call /v1/compose, HTTP status code:", resp.StatusCode)
}
decoder := json.NewDecoder(resp.Body)
decoder.DisallowUnknownFields()
err := decoder.Decode(&submit_reply)
if err != nil {
t.Fatal("Failed to decode response to /v1/compose:", err)
}
// Get the status
t.Log("RCM API get status")
resp = internalRequest(api, "GET", "/v1/compose/"+submit_reply.UUID.String(), "", "")
decoder = json.NewDecoder(resp.Body)
decoder.DisallowUnknownFields()
err = decoder.Decode(&status_reply)
if err != nil {
t.Fatal("Failed to decode response to /v1/compose/UUID:", err)
}
if status_reply.ErrorReason != "" {
t.Error("Failed to get compose status, reason:", status_reply.ErrorReason)
}
}