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:
parent
02a194f612
commit
4f63b54a16
3 changed files with 301 additions and 2 deletions
|
|
@ -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
183
internal/rcm/api.go
Normal 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
107
internal/rcm/api_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue