The store is responsible for two things: user state and the compose queue. This is problematic, because the rcm API has slightly different semantics from weldr and only used the queue part of the store. Also, the store is simply too complex. This commit splits the queue part out, using the new jobqueue package in both the weldr and the rcm package. The queue is saved to a new directory `queue/`. The weldr package now also has access to a worker server to enqueue and list jobs. Its store continues to track composes, but the `QueueStatus` for each compose (and image build) is deprecated. The field in `ImageBuild` is kept for backwards compatibility for composes which finished before this change, but a lot of code dealing with it in package compose is dropped. store.PushCompose() is degraded to storing a new compose. It should probably be renamed in the future. store.PopJob() is removed. Job ids are now independent of compose ids. Because of that, the local target gains ComposeId and ImageBuildId fields, because a worker cannot infer those from a job anymore. This also necessitates a change in the worker API: the job routes are changed to expect that instead of a (compose id, image build id) pair. The route that accepts built images keeps that pair, because it reports the image back to weldr. worker.Server() now interacts with a job queue instead of the store. It gains public functions that allow enqueuing an osbuild job and getting its status, because only it knows about the specific argument and result types in the job queue (OSBuildJob and OSBuildJobResult). One oddity remains: it needs to report an uploaded image to weldr. Do this with a function that's passed in for now, so that the dependency to the store can be dropped completely. The rcm API drops its dependencies to package blueprint and store, because it too interacts only with the worker server now. Fixes #342
271 lines
7.7 KiB
Go
271 lines
7.7 KiB
Go
// 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"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
|
|
"github.com/osbuild/osbuild-composer/internal/rpmmd"
|
|
"github.com/osbuild/osbuild-composer/internal/worker"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/julienschmidt/httprouter"
|
|
"github.com/osbuild/osbuild-composer/internal/distro"
|
|
)
|
|
|
|
// API encapsulates RCM-specific API that is exposed over a separate TCP socket
|
|
type API struct {
|
|
logger *log.Logger
|
|
workers *worker.Server
|
|
router *httprouter.Router
|
|
// rpmMetadata is an interface to dnf-json and we include it here so that we can
|
|
// mock it in the unit tests
|
|
rpmMetadata rpmmd.RPMMD
|
|
distros *distro.Registry
|
|
}
|
|
|
|
// New creates new RCM API
|
|
func New(logger *log.Logger, workers *worker.Server, rpmMetadata rpmmd.RPMMD, distros *distro.Registry) *API {
|
|
api := &API{
|
|
logger: logger,
|
|
workers: workers,
|
|
router: httprouter.New(),
|
|
rpmMetadata: rpmMetadata,
|
|
distros: distros,
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Depsolves packages and build packages for building an image for a given
|
|
// distro, in the given architecture
|
|
func depsolve(rpmmd rpmmd.RPMMD, distro distro.Distro, imageType distro.ImageType, repos []rpmmd.RepoConfig, arch distro.Arch) ([]rpmmd.PackageSpec, []rpmmd.PackageSpec, error) {
|
|
specs, excludeSpecs := imageType.BasePackages()
|
|
packages, _, err := rpmmd.Depsolve(specs, excludeSpecs, repos, distro.ModulePlatformID(), arch.Name())
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("RPMMD.Depsolve: %v", err)
|
|
}
|
|
|
|
specs = imageType.BuildPackages()
|
|
buildPackages, _, err := rpmmd.Depsolve(specs, nil, repos, distro.ModulePlatformID(), arch.Name())
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("RPMMD.Depsolve: %v", err)
|
|
}
|
|
|
|
return packages, buildPackages, err
|
|
}
|
|
|
|
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 {
|
|
BaseURL string `json:"baseurl,omitempty"`
|
|
Metalink string `json:"metalink,omitempty"`
|
|
MirrorList string `json:"mirrorlist,omitempty"`
|
|
GPGKey string `json:"gpgkey,omitempty"`
|
|
}
|
|
|
|
type imageBuild struct {
|
|
Distribution string `json:"distribution"`
|
|
Architecture string `json:"architecture"`
|
|
ImageType string `json:"image_type"`
|
|
Repositories []repository `json:"repositories"`
|
|
}
|
|
|
|
// JSON structure expected from the client
|
|
var composeRequest struct {
|
|
ImageBuilds []imageBuild `json:"image_builds"`
|
|
}
|
|
// 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 {
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
err = json.NewEncoder(writer).Encode(err.Error())
|
|
if err != nil {
|
|
panic("Failed to write response")
|
|
}
|
|
return
|
|
}
|
|
|
|
if len(composeRequest.ImageBuilds) != 1 {
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
_, err := writer.Write([]byte("unsupported number of image builds"))
|
|
if err != nil {
|
|
panic("Failed to write response")
|
|
}
|
|
return
|
|
}
|
|
|
|
buildRequest := composeRequest.ImageBuilds[0]
|
|
|
|
distro := api.distros.GetDistro(buildRequest.Distribution)
|
|
if distro == nil {
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
_, err := writer.Write([]byte("unknown distro"))
|
|
if err != nil {
|
|
panic("Failed to write response")
|
|
}
|
|
return
|
|
}
|
|
|
|
arch, err := distro.GetArch(buildRequest.Architecture)
|
|
if err != nil {
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
_, err := writer.Write([]byte("unknown architecture for distro"))
|
|
if err != nil {
|
|
panic("Failed to write response")
|
|
}
|
|
return
|
|
}
|
|
|
|
imageType, err := arch.GetImageType(buildRequest.ImageType)
|
|
if err != nil {
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
_, err := writer.Write([]byte("unknown image type for distro and architecture"))
|
|
if err != nil {
|
|
panic("Failed to write response")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Create repo configurations from the URLs in the request.
|
|
repoConfigs := []rpmmd.RepoConfig{}
|
|
for n, repo := range buildRequest.Repositories {
|
|
repoConfigs = append(repoConfigs, rpmmd.RepoConfig{
|
|
Id: fmt.Sprintf("repo-%d", n),
|
|
BaseURL: repo.BaseURL,
|
|
Metalink: repo.Metalink,
|
|
MirrorList: repo.MirrorList,
|
|
GPGKey: repo.GPGKey,
|
|
})
|
|
}
|
|
|
|
packages, buildPackages, err := depsolve(api.rpmMetadata, distro, imageType, repoConfigs, arch)
|
|
if err != nil {
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
_, err := writer.Write([]byte(err.Error()))
|
|
if err != nil {
|
|
panic("Failed to write response")
|
|
}
|
|
return
|
|
}
|
|
|
|
size := imageType.Size(0)
|
|
manifest, err := imageType.Manifest(nil, repoConfigs, packages, buildPackages, size)
|
|
if err != nil {
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
_, err := writer.Write([]byte(err.Error()))
|
|
if err != nil {
|
|
panic("Failed to write response")
|
|
}
|
|
return
|
|
}
|
|
|
|
composeID, err := api.workers.Enqueue(manifest, nil)
|
|
if err != nil {
|
|
if api.logger != nil {
|
|
api.logger.Println("RCM API failed to push compose:", err)
|
|
}
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
errorReason.Error = "failed to push compose: " + err.Error()
|
|
// TODO: handle error
|
|
_ = json.NewEncoder(writer).Encode(errorReason)
|
|
return
|
|
}
|
|
|
|
// Create the response JSON structure
|
|
var reply struct {
|
|
UUID uuid.UUID `json:"compose_id"`
|
|
}
|
|
reply.UUID = composeID
|
|
// TODO: handle error
|
|
_ = 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"
|
|
// TODO: handle error
|
|
_ = json.NewEncoder(writer).Encode(errorReason)
|
|
return
|
|
}
|
|
|
|
// Check that the compose exists
|
|
status, _, err := api.workers.JobResult(id)
|
|
if err != nil {
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
errorReason.Error = err.Error()
|
|
// TODO: handle error
|
|
_ = json.NewEncoder(writer).Encode(errorReason)
|
|
return
|
|
}
|
|
|
|
// JSON structure with success response
|
|
type reply struct {
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
// TODO: handle error
|
|
_ = json.NewEncoder(writer).Encode(reply{Status: status.ToString()})
|
|
}
|