Repository: osbuild/osbuild-composer (go)

Issue:
RCM API
Introduce a limited API for RCM to use to push composes to osbuild-composer.

Acceptance criteria:

- [x] Introduce a dedicated TCP socket for this API.
- [x] Implement a method to push a new compose, specifying the architectures to build for and the repositories to use. It would be ok to silently drop requests for unsupported (non-native) architecture, until we have support for more. For the time being assume the qcow2 output type and the local upload target.
- [x] Implement a method to get the status of a compose (waiting/running/finished/failed)
- [x] Make the new API optional (ship the systemd socket unit in a sub-package and gracefully handle when not installed, possibly needing further discussion?)
- [ ] Add support for the koji upload target, and use that exclusively in stead of the local one. This requires changes both to the input to the endpoint pushing a new compose, and the output of the status endpoint in finished state.
- [ ] Return per-job status from GET /v1/compose/

Optional:
- [ ] Support kerberos authentication in the same way brew etc does.
- [ ] Extend the status API to optionally block until the compose is no longer waiting/running.

Pull request description:
Rcm api
This issue fixes #192 

Patch:
diff --git a/cmd/osbuild-composer/main.go b/cmd/osbuild-composer/main.go
index cbeda673b..f43a351f3 100644
--- a/cmd/osbuild-composer/main.go
+++ b/cmd/osbuild-composer/main.go
@@ -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)
 }
diff --git a/internal/rcm/api.go b/internal/rcm/api.go
new file mode 100644
index 000000000..3ca9cc6b4
--- /dev/null
+++ b/internal/rcm/api.go
@@ -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)
+}


Test patch:
diff --git a/internal/rcm/api_test.go b/internal/rcm/api_test.go
new file mode 100644
index 000000000..8b1437714
--- /dev/null
+++ b/internal/rcm/api_test.go
@@ -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)
+	}
+}