The API allowed composes to have multiple architectures, image types and repositories. Turns out that's not exactly what we want it is not clear how to combine the lits of each. Each architecture might not combine with each image type, and it is not clear which repositories are need for each image build. Lastly, while allowing different image builds to have different distros in the same compose does not appear immediately useful, there is no particular reason to disallow that. This patch reworks the way composes are specified. The intention remains the same, to be able to submit several image builds as one compose. But rather than taking arrays of image types and architectures, take one array of image builds instead, each of which consists of one distro, one architecture, one image build and an array of repositories. In a follow-up patch they will also each contain an array of upload targets. This means that each image build will have the same sort of structure as a compose request in the weldr API. The reason we want to submit an array of them rather than have them as individual composes, is that in a follow-up patch we will introduce the concept of a "finalizer", or "call-back" or something to that effect, which will be triggered when all the images have been built successfully. The use-case is, as always, koji, which requires this. Signed-off-by: Tom Gundersen <teg@jklm.no>
266 lines
7.8 KiB
Go
266 lines
7.8 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/google/uuid"
|
|
"github.com/julienschmidt/httprouter"
|
|
"github.com/osbuild/osbuild-composer/internal/blueprint"
|
|
"github.com/osbuild/osbuild-composer/internal/distro"
|
|
"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
|
|
// 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, store *store.Store, rpmMetadata rpmmd.RPMMD, distros *distro.Registry) *API {
|
|
api := &API{
|
|
logger: logger,
|
|
store: store,
|
|
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
|
|
}
|
|
|
|
// 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(distro, arch, imageType, composeUUID, &blueprint.Blueprint{}, repoConfigs, packages, buildPackages, 0, 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 = composeUUID
|
|
// 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
|
|
compose, exists := api.store.GetCompose(id)
|
|
if !exists {
|
|
writer.WriteHeader(http.StatusBadRequest)
|
|
errorReason.Error = "Compose UUID does not exist"
|
|
// TODO: handle error
|
|
_ = 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.GetState().ToString()
|
|
// TODO: handle error
|
|
_ = json.NewEncoder(writer).Encode(reply)
|
|
}
|