Previously, the Cloud API endpoint `/v1/compose/{id}` return value's
`image_status.status` for a running worker job was "running", which didn't
comply with the Cloud API specification. Equivalents allowed by the API
specification are "building", "uploading" or "registering".
As a result, the Image Builder API also does not comply in this regard
to its specification, because it currently just copies the status value
string returned by osbuild-composer.
Define the `image_status.status` as a reusable type in the Cloud API
specification. This forces openapi to generate an explicit type for it,
which can be then explicitly used in the code, instead of plain strings.
Return "building", instead of "running" for running compose.
Modify api integration test to check for all valid `image_status.status`
values for a compose.
Add News entry explaining this change.
Signed-off-by: Tomas Hozza <thozza@redhat.com>
418 lines
13 KiB
Go
418 lines
13 KiB
Go
//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen --package=cloudapi --generate types,spec,chi-server,client -o openapi.gen.go openapi.yml
|
|
|
|
package cloudapi
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"math/big"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/osbuild/osbuild-composer/internal/blueprint"
|
|
"github.com/osbuild/osbuild-composer/internal/distro"
|
|
"github.com/osbuild/osbuild-composer/internal/distroregistry"
|
|
"github.com/osbuild/osbuild-composer/internal/rpmmd"
|
|
"github.com/osbuild/osbuild-composer/internal/target"
|
|
"github.com/osbuild/osbuild-composer/internal/worker"
|
|
)
|
|
|
|
// Server represents the state of the cloud Server
|
|
type Server struct {
|
|
workers *worker.Server
|
|
rpmMetadata rpmmd.RPMMD
|
|
distros *distroregistry.Registry
|
|
}
|
|
|
|
// NewServer creates a new cloud server
|
|
func NewServer(workers *worker.Server, rpmMetadata rpmmd.RPMMD, distros *distroregistry.Registry) *Server {
|
|
server := &Server{
|
|
workers: workers,
|
|
rpmMetadata: rpmMetadata,
|
|
distros: distros,
|
|
}
|
|
return server
|
|
}
|
|
|
|
// Create an http.Handler() for this server, that provides the composer API at
|
|
// the given path.
|
|
func (server *Server) Handler(path string) http.Handler {
|
|
r := chi.NewRouter()
|
|
|
|
r.Route(path, func(r chi.Router) {
|
|
HandlerFromMux(server, r)
|
|
})
|
|
|
|
return r
|
|
}
|
|
|
|
// Compose handles a new /compose POST request
|
|
func (server *Server) Compose(w http.ResponseWriter, r *http.Request) {
|
|
contentType := r.Header["Content-Type"]
|
|
if len(contentType) != 1 || contentType[0] != "application/json" {
|
|
http.Error(w, "Only 'application/json' content type is supported", http.StatusUnsupportedMediaType)
|
|
return
|
|
}
|
|
|
|
var request ComposeRequest
|
|
err := json.NewDecoder(r.Body).Decode(&request)
|
|
if err != nil {
|
|
http.Error(w, "Could not parse JSON body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
distribution := server.distros.GetDistro(request.Distribution)
|
|
if distribution == nil {
|
|
http.Error(w, fmt.Sprintf("Unsupported distribution: %s", request.Distribution), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var bp = blueprint.Blueprint{}
|
|
err = bp.Initialize()
|
|
if err != nil {
|
|
http.Error(w, "Unable to initialize blueprint", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if request.Customizations != nil && request.Customizations.Packages != nil {
|
|
for _, p := range *request.Customizations.Packages {
|
|
bp.Packages = append(bp.Packages, blueprint.Package{
|
|
Name: p,
|
|
})
|
|
}
|
|
}
|
|
|
|
type imageRequest struct {
|
|
manifest distro.Manifest
|
|
arch string
|
|
}
|
|
imageRequests := make([]imageRequest, len(request.ImageRequests))
|
|
var targets []*target.Target
|
|
|
|
// use the same seed for all images so we get the same IDs
|
|
bigSeed, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
|
|
if err != nil {
|
|
panic("cannot generate a manifest seed: " + err.Error())
|
|
}
|
|
manifestSeed := bigSeed.Int64()
|
|
|
|
for i, ir := range request.ImageRequests {
|
|
arch, err := distribution.GetArch(ir.Architecture)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Unsupported architecture '%s' for distribution '%s'", ir.Architecture, request.Distribution), http.StatusBadRequest)
|
|
return
|
|
}
|
|
imageType, err := arch.GetImageType(ir.ImageType)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Unsupported image type '%s' for %s/%s", ir.ImageType, ir.Architecture, request.Distribution), http.StatusBadRequest)
|
|
return
|
|
}
|
|
repositories := make([]rpmmd.RepoConfig, len(ir.Repositories))
|
|
for j, repo := range ir.Repositories {
|
|
repositories[j].RHSM = repo.Rhsm
|
|
|
|
if repo.Baseurl != nil {
|
|
repositories[j].BaseURL = *repo.Baseurl
|
|
} else if repo.Mirrorlist != nil {
|
|
repositories[j].MirrorList = *repo.Mirrorlist
|
|
} else if repo.Metalink != nil {
|
|
repositories[j].Metalink = *repo.Metalink
|
|
} else {
|
|
http.Error(w, "Must specify baseurl, mirrorlist, or metalink", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
packageSets := imageType.PackageSets(bp)
|
|
pkgSpecSets := make(map[string][]rpmmd.PackageSpec)
|
|
for name, packages := range packageSets {
|
|
pkgs, _, err := server.rpmMetadata.Depsolve(packages, repositories, distribution.ModulePlatformID(), arch.Name())
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to depsolve base packages for %s/%s/%s: %s", ir.ImageType, ir.Architecture, request.Distribution, err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
pkgSpecSets[name] = pkgs
|
|
}
|
|
|
|
imageOptions := distro.ImageOptions{Size: imageType.Size(0)}
|
|
if request.Customizations != nil && request.Customizations.Subscription != nil {
|
|
imageOptions.Subscription = &distro.SubscriptionImageOptions{
|
|
Organization: request.Customizations.Subscription.Organization,
|
|
ActivationKey: request.Customizations.Subscription.ActivationKey,
|
|
ServerUrl: request.Customizations.Subscription.ServerUrl,
|
|
BaseUrl: request.Customizations.Subscription.BaseUrl,
|
|
Insights: request.Customizations.Subscription.Insights,
|
|
}
|
|
}
|
|
|
|
manifest, err := imageType.Manifest(nil, imageOptions, repositories, pkgSpecSets, manifestSeed)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Failed to get manifest for for %s/%s/%s: %s", ir.ImageType, ir.Architecture, request.Distribution, err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
imageRequests[i].manifest = manifest
|
|
imageRequests[i].arch = arch.Name()
|
|
|
|
uploadRequest := ir.UploadRequest
|
|
/* oneOf is not supported by the openapi generator so marshal and unmarshal the uploadrequest based on the type */
|
|
if uploadRequest.Type == UploadTypes_aws {
|
|
var awsUploadOptions AWSUploadRequestOptions
|
|
jsonUploadOptions, err := json.Marshal(uploadRequest.Options)
|
|
if err != nil {
|
|
http.Error(w, "Unable to marshal aws upload request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
err = json.Unmarshal(jsonUploadOptions, &awsUploadOptions)
|
|
if err != nil {
|
|
http.Error(w, "Unable to unmarshal aws upload request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var share []string
|
|
if awsUploadOptions.Ec2.ShareWithAccounts != nil {
|
|
share = *awsUploadOptions.Ec2.ShareWithAccounts
|
|
}
|
|
key := fmt.Sprintf("composer-api-%s", uuid.New().String())
|
|
t := target.NewAWSTarget(&target.AWSTargetOptions{
|
|
Filename: imageType.Filename(),
|
|
Region: awsUploadOptions.Region,
|
|
AccessKeyID: awsUploadOptions.S3.AccessKeyId,
|
|
SecretAccessKey: awsUploadOptions.S3.SecretAccessKey,
|
|
Bucket: awsUploadOptions.S3.Bucket,
|
|
Key: key,
|
|
ShareWithAccounts: share,
|
|
})
|
|
if awsUploadOptions.Ec2.SnapshotName != nil {
|
|
t.ImageName = *awsUploadOptions.Ec2.SnapshotName
|
|
} else {
|
|
t.ImageName = key
|
|
}
|
|
|
|
targets = append(targets, t)
|
|
} else if uploadRequest.Type == UploadTypes_gcp {
|
|
var gcpUploadOptions GCPUploadRequestOptions
|
|
jsonUploadOptions, err := json.Marshal(uploadRequest.Options)
|
|
if err != nil {
|
|
http.Error(w, "Unable to marshal gcp upload request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
err = json.Unmarshal(jsonUploadOptions, &gcpUploadOptions)
|
|
if err != nil {
|
|
http.Error(w, "Unable to unmarshal gcp upload request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var share []string
|
|
if gcpUploadOptions.ShareWithAccounts != nil {
|
|
share = *gcpUploadOptions.ShareWithAccounts
|
|
}
|
|
var region string
|
|
if gcpUploadOptions.Region != nil {
|
|
region = *gcpUploadOptions.Region
|
|
}
|
|
object := fmt.Sprintf("composer-api-%s", uuid.New().String())
|
|
t := target.NewGCPTarget(&target.GCPTargetOptions{
|
|
Filename: imageType.Filename(),
|
|
Region: region,
|
|
Os: "", // not exposed in cloudapi for now
|
|
Bucket: gcpUploadOptions.Bucket,
|
|
Object: object,
|
|
ShareWithAccounts: share,
|
|
})
|
|
// Import will fail if an image with this name already exists
|
|
if gcpUploadOptions.ImageName != nil {
|
|
t.ImageName = *gcpUploadOptions.ImageName
|
|
} else {
|
|
t.ImageName = object
|
|
}
|
|
|
|
targets = append(targets, t)
|
|
} else if uploadRequest.Type == UploadTypes_azure {
|
|
var azureUploadOptions AzureUploadRequestOptions
|
|
jsonUploadOptions, err := json.Marshal(uploadRequest.Options)
|
|
if err != nil {
|
|
http.Error(w, "Unable to marshal azure upload request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
err = json.Unmarshal(jsonUploadOptions, &azureUploadOptions)
|
|
if err != nil {
|
|
http.Error(w, "Unable to unmarshal azure upload request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
t := target.NewAzureImageTarget(&target.AzureImageTargetOptions{
|
|
Filename: imageType.Filename(),
|
|
TenantID: azureUploadOptions.TenantId,
|
|
Location: azureUploadOptions.Location,
|
|
SubscriptionID: azureUploadOptions.SubscriptionId,
|
|
ResourceGroup: azureUploadOptions.ResourceGroup,
|
|
})
|
|
|
|
if azureUploadOptions.ImageName != nil {
|
|
t.ImageName = *azureUploadOptions.ImageName
|
|
} else {
|
|
// if ImageName wasn't given, generate a random one
|
|
t.ImageName = fmt.Sprintf("composer-api-%s", uuid.New().String())
|
|
}
|
|
|
|
targets = append(targets, t)
|
|
} else {
|
|
http.Error(w, "Unknown upload request type, only 'aws', 'azure' and 'gcp' are supported", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
|
|
var ir imageRequest
|
|
if len(imageRequests) == 1 {
|
|
// NOTE: the store currently does not support multi-image composes
|
|
ir = imageRequests[0]
|
|
} else {
|
|
http.Error(w, "Only single-image composes are currently supported", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
id, err := server.workers.EnqueueOSBuild(ir.arch, &worker.OSBuildJob{
|
|
Manifest: ir.manifest,
|
|
Targets: targets,
|
|
})
|
|
if err != nil {
|
|
http.Error(w, "Failed to enqueue manifest", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var response ComposeResult
|
|
response.Id = id.String()
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(http.StatusCreated)
|
|
err = json.NewEncoder(w).Encode(response)
|
|
if err != nil {
|
|
panic("Failed to write response")
|
|
}
|
|
}
|
|
|
|
// ComposeStatus handles a /compose/{id} GET request
|
|
func (server *Server) ComposeStatus(w http.ResponseWriter, r *http.Request, id string) {
|
|
jobId, err := uuid.Parse(id)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid format for parameter id: %s", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var result worker.OSBuildJobResult
|
|
status, _, err := server.workers.JobStatus(jobId, &result)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Job %s not found: %s", id, err), http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var us *UploadStatus
|
|
if result.TargetResults != nil {
|
|
// Only single upload target is allowed, therefore only a single upload target result is allowed as well
|
|
if len(result.TargetResults) != 1 {
|
|
http.Error(w, fmt.Sprintf("Job %s returned more upload target results than allowed", id), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
tr := *result.TargetResults[0]
|
|
|
|
var uploadType UploadTypes
|
|
var uploadOptions interface{}
|
|
|
|
switch tr.Name {
|
|
case "org.osbuild.aws":
|
|
uploadType = UploadTypes_aws
|
|
awsOptions := tr.Options.(*target.AWSTargetResultOptions)
|
|
uploadOptions = AWSUploadStatus{
|
|
Ami: awsOptions.Ami,
|
|
Region: awsOptions.Region,
|
|
}
|
|
case "org.osbuild.gcp":
|
|
uploadType = UploadTypes_gcp
|
|
gcpOptions := tr.Options.(*target.GCPTargetResultOptions)
|
|
uploadOptions = GCPUploadStatus{
|
|
ImageName: gcpOptions.ImageName,
|
|
ProjectId: gcpOptions.ProjectID,
|
|
}
|
|
case "org.osbuild.azure.image":
|
|
uploadType = UploadTypes_azure
|
|
gcpOptions := tr.Options.(*target.AzureImageTargetResultOptions)
|
|
uploadOptions = AzureUploadStatus{
|
|
ImageName: gcpOptions.ImageName,
|
|
}
|
|
default:
|
|
http.Error(w, fmt.Sprintf("Job %s returned unknown upload target results %s", id, tr.Name), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
us = &UploadStatus{
|
|
Status: result.UploadStatus,
|
|
Type: uploadType,
|
|
Options: uploadOptions,
|
|
}
|
|
}
|
|
|
|
response := ComposeStatus{
|
|
ImageStatus: ImageStatus{
|
|
Status: composeStatusFromJobStatus(status, &result),
|
|
UploadStatus: us,
|
|
},
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
err = json.NewEncoder(w).Encode(response)
|
|
if err != nil {
|
|
panic("Failed to write response")
|
|
}
|
|
}
|
|
|
|
func composeStatusFromJobStatus(js *worker.JobStatus, result *worker.OSBuildJobResult) ImageStatusValue {
|
|
if js.Canceled {
|
|
return ImageStatusValue_failure
|
|
}
|
|
|
|
if js.Started.IsZero() {
|
|
return ImageStatusValue_pending
|
|
}
|
|
|
|
if js.Finished.IsZero() {
|
|
// TODO: handle also ImageStatusValue_uploading
|
|
// TODO: handle also ImageStatusValue_registering
|
|
return ImageStatusValue_building
|
|
}
|
|
|
|
if result.Success {
|
|
return ImageStatusValue_success
|
|
}
|
|
|
|
return ImageStatusValue_failure
|
|
}
|
|
|
|
// GetOpenapiJson handles a /openapi.json GET request
|
|
func (server *Server) GetOpenapiJson(w http.ResponseWriter, r *http.Request) {
|
|
spec, err := GetSwagger()
|
|
if err != nil {
|
|
http.Error(w, "Could not load openapi spec", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
err = json.NewEncoder(w).Encode(spec)
|
|
if err != nil {
|
|
panic("Failed to write response")
|
|
}
|
|
}
|
|
|
|
// GetVersion handles a /version GET request
|
|
func (server *Server) GetVersion(w http.ResponseWriter, r *http.Request) {
|
|
spec, err := GetSwagger()
|
|
if err != nil {
|
|
http.Error(w, "Could not load version", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
version := Version{spec.Info.Version}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
err = json.NewEncoder(w).Encode(version)
|
|
if err != nil {
|
|
panic("Failed to write response")
|
|
}
|
|
}
|