Technically osbuild/osbuild-composer#4564 broke the api spec by marking a required field as non-required. Fix this by using allOf.
1630 lines
47 KiB
Go
1630 lines
47 KiB
Go
//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen --package=v2 --generate types,spec,server -o openapi.v2.gen.go openapi.v2.yml
|
|
package v2
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/labstack/echo/v4"
|
|
|
|
"github.com/osbuild/images/pkg/distro"
|
|
"github.com/osbuild/images/pkg/manifest"
|
|
"github.com/osbuild/images/pkg/osbuild"
|
|
"github.com/osbuild/images/pkg/rpmmd"
|
|
"github.com/osbuild/images/pkg/sbom"
|
|
"github.com/osbuild/osbuild-composer/internal/blueprint"
|
|
"github.com/osbuild/osbuild-composer/internal/common"
|
|
"github.com/osbuild/osbuild-composer/internal/jsondb"
|
|
"github.com/osbuild/osbuild-composer/internal/target"
|
|
"github.com/osbuild/osbuild-composer/internal/worker"
|
|
"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
|
|
)
|
|
|
|
type apiHandlers struct {
|
|
server *Server
|
|
}
|
|
|
|
type binder struct{}
|
|
|
|
func (b binder) Bind(i interface{}, ctx echo.Context) error {
|
|
contentType := ctx.Request().Header["Content-Type"]
|
|
if len(contentType) != 1 || contentType[0] != "application/json" {
|
|
return HTTPError(ErrorUnsupportedMediaType)
|
|
}
|
|
|
|
err := json.NewDecoder(ctx.Request().Body).Decode(i)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorBodyDecodingError, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *apiHandlers) GetOpenapi(ctx echo.Context) error {
|
|
spec, err := GetSwagger()
|
|
if err != nil {
|
|
return HTTPError(ErrorFailedToLoadOpenAPISpec)
|
|
}
|
|
return ctx.JSON(http.StatusOK, spec)
|
|
}
|
|
|
|
func (h *apiHandlers) GetErrorList(ctx echo.Context, params GetErrorListParams) error {
|
|
page := 0
|
|
var err error
|
|
if params.Page != nil {
|
|
page, err = strconv.Atoi(string(*params.Page))
|
|
if err != nil {
|
|
return HTTPError(ErrorInvalidPageParam)
|
|
}
|
|
}
|
|
|
|
size := 100
|
|
if params.Size != nil {
|
|
size, err = strconv.Atoi(string(*params.Size))
|
|
if err != nil {
|
|
return HTTPError(ErrorInvalidSizeParam)
|
|
}
|
|
}
|
|
|
|
return ctx.JSON(http.StatusOK, APIErrorList(page, size, ctx))
|
|
}
|
|
|
|
func (h *apiHandlers) GetError(ctx echo.Context, id string) error {
|
|
errorId, err := strconv.Atoi(id)
|
|
if err != nil {
|
|
return HTTPError(ErrorInvalidErrorId)
|
|
}
|
|
|
|
apiError := APIError(find(ServiceErrorCode(errorId)), ctx, nil)
|
|
// If the service error wasn't found, it's a 404 in this instance
|
|
if apiError.Id == fmt.Sprintf("%d", ErrorServiceErrorNotFound) {
|
|
return HTTPError(ErrorErrorNotFound)
|
|
}
|
|
return ctx.JSON(http.StatusOK, apiError)
|
|
}
|
|
|
|
// splitExtension returns the extension of the given file. If there's
|
|
// a multipart extension (e.g. file.tar.gz), it returns all parts (e.g.
|
|
// .tar.gz). If there's no extension in the input, it returns an empty
|
|
// string. If the filename starts with dot, the part before the second dot
|
|
// is not considered as an extension.
|
|
func splitExtension(filename string) string {
|
|
filenameParts := strings.Split(filename, ".")
|
|
|
|
if len(filenameParts) > 0 && filenameParts[0] == "" {
|
|
filenameParts = filenameParts[1:]
|
|
}
|
|
|
|
if len(filenameParts) <= 1 {
|
|
return ""
|
|
}
|
|
|
|
return "." + strings.Join(filenameParts[1:], ".")
|
|
}
|
|
|
|
type imageRequest struct {
|
|
imageType distro.ImageType
|
|
repositories []rpmmd.RepoConfig
|
|
imageOptions distro.ImageOptions
|
|
targets []*target.Target
|
|
blueprint blueprint.Blueprint
|
|
manifestSeed int64
|
|
}
|
|
|
|
func (h *apiHandlers) PostCompose(ctx echo.Context) error {
|
|
var request ComposeRequest
|
|
err := ctx.Bind(&request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// channel is empty if JWT is not enabled
|
|
channel, err := h.server.getTenantChannel(ctx)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorTenantNotFound, err)
|
|
}
|
|
|
|
irs, err := request.GetImageRequests(h.server.distros, h.server.repos)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var id uuid.UUID
|
|
if request.Koji != nil {
|
|
id, err = h.server.enqueueKojiCompose(uint64(request.Koji.TaskId), request.Koji.Server, request.Koji.Name, request.Koji.Version, request.Koji.Release, irs, channel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
id, err = h.server.enqueueCompose(irs, channel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
ctx.Logger().Infof("Job ID %s enqueued for operationID %s", id, ctx.Get(common.OperationIDKey))
|
|
|
|
// Save the request in the artifacts directory, log errors but continue
|
|
if err := saveComposeRequest(h.server.workers.ArtifactsDir(), id, request); err != nil {
|
|
ctx.Logger().Warnf("Failed to save compose request: %v", err)
|
|
}
|
|
|
|
return ctx.JSON(http.StatusCreated, &ComposeId{
|
|
ObjectReference: ObjectReference{
|
|
Href: "/api/image-builder-composer/v2/compose",
|
|
Id: id.String(),
|
|
Kind: "ComposeId",
|
|
},
|
|
Id: id.String(),
|
|
})
|
|
}
|
|
|
|
func imageTypeFromApiImageType(it ImageTypes, arch distro.Arch) string {
|
|
switch it {
|
|
case ImageTypesAws:
|
|
return "ami"
|
|
case ImageTypesAwsRhui:
|
|
return "ec2"
|
|
case ImageTypesAwsHaRhui:
|
|
return "ec2-ha"
|
|
case ImageTypesAwsSapRhui:
|
|
return "ec2-sap"
|
|
case ImageTypesGcp:
|
|
return "gce"
|
|
case ImageTypesGcpRhui:
|
|
return "gce-rhui"
|
|
case ImageTypesAzure:
|
|
return "vhd"
|
|
case ImageTypesAzureRhui:
|
|
return "azure-rhui"
|
|
case ImageTypesAzureEap7Rhui:
|
|
return "azure-eap7-rhui"
|
|
case ImageTypesAzureSapRhui:
|
|
return "azure-sap-rhui"
|
|
case ImageTypesGuestImage:
|
|
return "qcow2"
|
|
case ImageTypesVsphere:
|
|
return "vmdk"
|
|
case ImageTypesVsphereOva:
|
|
return "ova"
|
|
case ImageTypesImageInstaller:
|
|
return "image-installer"
|
|
case ImageTypesEdgeCommit:
|
|
return "rhel-edge-commit"
|
|
case ImageTypesEdgeContainer:
|
|
return "rhel-edge-container"
|
|
case ImageTypesEdgeInstaller:
|
|
return "rhel-edge-installer"
|
|
case ImageTypesIotBootableContainer:
|
|
return "iot-bootable-container"
|
|
case ImageTypesIotCommit:
|
|
return "iot-commit"
|
|
case ImageTypesIotContainer:
|
|
return "iot-container"
|
|
case ImageTypesIotInstaller:
|
|
return "iot-installer"
|
|
case ImageTypesIotSimplifiedInstaller:
|
|
return "iot-simplified-installer"
|
|
case ImageTypesIotRawImage:
|
|
return "iot-raw-image"
|
|
case ImageTypesLiveInstaller:
|
|
return "live-installer"
|
|
case ImageTypesMinimalRaw:
|
|
return "minimal-raw"
|
|
case ImageTypesOci:
|
|
return "oci"
|
|
case ImageTypesWsl:
|
|
return "wsl"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *apiHandlers) targetResultToUploadStatus(jobId uuid.UUID, t *target.TargetResult) (*UploadStatus, error) {
|
|
var us *UploadStatus
|
|
var uploadType UploadTypes
|
|
var uploadOptions interface{}
|
|
|
|
switch t.Name {
|
|
case target.TargetNameAWS:
|
|
uploadType = UploadTypesAws
|
|
awsOptions := t.Options.(*target.AWSTargetResultOptions)
|
|
uploadOptions = AWSEC2UploadStatus{
|
|
Ami: awsOptions.Ami,
|
|
Region: awsOptions.Region,
|
|
}
|
|
case target.TargetNameAWSS3:
|
|
uploadType = UploadTypesAwsS3
|
|
awsOptions := t.Options.(*target.AWSS3TargetResultOptions)
|
|
uploadOptions = AWSS3UploadStatus{
|
|
Url: awsOptions.URL,
|
|
}
|
|
case target.TargetNameGCP:
|
|
uploadType = UploadTypesGcp
|
|
gcpOptions := t.Options.(*target.GCPTargetResultOptions)
|
|
uploadOptions = GCPUploadStatus{
|
|
ImageName: gcpOptions.ImageName,
|
|
ProjectId: gcpOptions.ProjectID,
|
|
}
|
|
case target.TargetNameAzureImage:
|
|
uploadType = UploadTypesAzure
|
|
gcpOptions := t.Options.(*target.AzureImageTargetResultOptions)
|
|
uploadOptions = AzureUploadStatus{
|
|
ImageName: gcpOptions.ImageName,
|
|
}
|
|
case target.TargetNameContainer:
|
|
uploadType = UploadTypesContainer
|
|
containerOptions := t.Options.(*target.ContainerTargetResultOptions)
|
|
uploadOptions = ContainerUploadStatus{
|
|
Url: containerOptions.URL,
|
|
Digest: containerOptions.Digest,
|
|
}
|
|
case target.TargetNameOCIObjectStorage:
|
|
uploadType = UploadTypesOciObjectstorage
|
|
ociOptions := t.Options.(*target.OCIObjectStorageTargetResultOptions)
|
|
uploadOptions = OCIUploadStatus{
|
|
Url: ociOptions.URL,
|
|
}
|
|
case target.TargetNamePulpOSTree:
|
|
uploadType = UploadTypesPulpOstree
|
|
pulpOSTreeOptions := t.Options.(*target.PulpOSTreeTargetResultOptions)
|
|
uploadOptions = PulpOSTreeUploadStatus{
|
|
RepoUrl: pulpOSTreeOptions.RepoURL,
|
|
}
|
|
case target.TargetNameWorkerServer:
|
|
uploadType = UploadTypesLocal
|
|
workerServerOptions := t.Options.(*target.WorkerServerTargetResultOptions)
|
|
absPath, err := h.server.workers.JobArtifactLocation(jobId, workerServerOptions.ArtifactRelPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to find job artifact: %w", err)
|
|
}
|
|
uploadOptions = LocalUploadStatus{
|
|
ArtifactPath: absPath,
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unknown upload target: %s", t.Name)
|
|
}
|
|
|
|
us = &UploadStatus{
|
|
// TODO: determine upload status based on the target results, not job results
|
|
// Don't set the status here for now, but let it be set by the caller.
|
|
//Status: UploadStatusValue(result.UploadStatus),
|
|
Type: uploadType,
|
|
Options: uploadOptions,
|
|
}
|
|
|
|
return us, nil
|
|
}
|
|
|
|
// GetComposeList returns a list of the root job UUIDs
|
|
func (h *apiHandlers) GetComposeList(ctx echo.Context) error {
|
|
jobs, err := h.server.workers.AllRootJobIDs()
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorGettingComposeList, err)
|
|
}
|
|
|
|
// Gather up the details of each job
|
|
var stats []ComposeStatus
|
|
for _, jid := range jobs {
|
|
s, err := h.getJobIDComposeStatus(jid)
|
|
if err != nil {
|
|
// TODO log this error?
|
|
continue
|
|
}
|
|
stats = append(stats, s)
|
|
}
|
|
slices.SortFunc(stats, func(a, b ComposeStatus) int {
|
|
return cmp.Compare(a.Id, b.Id)
|
|
})
|
|
|
|
return ctx.JSON(http.StatusOK, stats)
|
|
}
|
|
|
|
func (h *apiHandlers) GetComposeStatus(ctx echo.Context, id string) error {
|
|
return h.server.EnsureJobChannel(h.getComposeStatusImpl)(ctx, id)
|
|
}
|
|
|
|
func (h *apiHandlers) getComposeStatusImpl(ctx echo.Context, id string) error {
|
|
jobId, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return HTTPError(ErrorInvalidComposeId)
|
|
}
|
|
|
|
response, err := h.getJobIDComposeStatus(jobId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ctx.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
// getJobIDComposeStatus returns the ComposeStatus for the job
|
|
// or an HTTPError
|
|
func (h *apiHandlers) getJobIDComposeStatus(jobId uuid.UUID) (ComposeStatus, error) {
|
|
jobType, err := h.server.workers.JobType(jobId)
|
|
if err != nil {
|
|
return ComposeStatus{}, HTTPError(ErrorComposeNotFound)
|
|
}
|
|
|
|
if jobType == worker.JobTypeOSBuild {
|
|
var result worker.OSBuildJobResult
|
|
jobInfo, err := h.server.workers.OSBuildJobInfo(jobId, &result)
|
|
if err != nil {
|
|
return ComposeStatus{}, HTTPError(ErrorMalformedOSBuildJobResult)
|
|
}
|
|
|
|
jobError, err := h.server.workers.JobDependencyChainErrors(jobId)
|
|
if err != nil {
|
|
return ComposeStatus{}, HTTPError(ErrorGettingBuildDependencyStatus)
|
|
}
|
|
|
|
var uploadStatuses *[]UploadStatus
|
|
var us0 *UploadStatus
|
|
if result.TargetResults != nil {
|
|
statuses := make([]UploadStatus, len(result.TargetResults))
|
|
for idx := range result.TargetResults {
|
|
tr := result.TargetResults[idx]
|
|
us, err := h.targetResultToUploadStatus(jobId, tr)
|
|
if err != nil {
|
|
return ComposeStatus{}, HTTPErrorWithInternal(ErrorUnknownUploadTarget, err)
|
|
}
|
|
us.Status = uploadStatusFromJobStatus(jobInfo.JobStatus, result.JobError)
|
|
statuses[idx] = *us
|
|
}
|
|
|
|
if len(statuses) > 0 {
|
|
// make sure uploadStatuses remains nil if the array is empty but not nill
|
|
uploadStatuses = &statuses
|
|
// get first upload status if there's at least one
|
|
us0 = &statuses[0]
|
|
}
|
|
}
|
|
|
|
return ComposeStatus{
|
|
ObjectReference: ObjectReference{
|
|
Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", jobId),
|
|
Id: jobId.String(),
|
|
Kind: "ComposeStatus",
|
|
},
|
|
Status: composeStatusFromOSBuildJobStatus(jobInfo.JobStatus, &result),
|
|
ImageStatus: ImageStatus{
|
|
Status: imageStatusFromOSBuildJobStatus(jobInfo.JobStatus, &result),
|
|
Error: composeStatusErrorFromJobError(jobError),
|
|
UploadStatus: us0, // add the first upload status to the old top-level field
|
|
UploadStatuses: uploadStatuses,
|
|
},
|
|
}, nil
|
|
} else if jobType == worker.JobTypeKojiFinalize {
|
|
var result worker.KojiFinalizeJobResult
|
|
finalizeInfo, err := h.server.workers.KojiFinalizeJobInfo(jobId, &result)
|
|
if err != nil {
|
|
return ComposeStatus{}, HTTPError(ErrorMalformedOSBuildJobResult)
|
|
}
|
|
if len(finalizeInfo.Deps) < 2 {
|
|
return ComposeStatus{}, HTTPError(ErrorUnexpectedNumberOfImageBuilds)
|
|
}
|
|
var initResult worker.KojiInitJobResult
|
|
_, err = h.server.workers.KojiInitJobInfo(finalizeInfo.Deps[0], &initResult)
|
|
if err != nil {
|
|
return ComposeStatus{}, HTTPError(ErrorMalformedOSBuildJobResult)
|
|
}
|
|
var buildJobResults []worker.OSBuildJobResult
|
|
var buildJobStatuses []ImageStatus
|
|
for i := 1; i < len(finalizeInfo.Deps); i++ {
|
|
var buildJobResult worker.OSBuildJobResult
|
|
buildInfo, err := h.server.workers.OSBuildJobInfo(finalizeInfo.Deps[i], &buildJobResult)
|
|
if err != nil {
|
|
return ComposeStatus{}, HTTPError(ErrorMalformedOSBuildJobResult)
|
|
}
|
|
buildJobError, err := h.server.workers.JobDependencyChainErrors(finalizeInfo.Deps[i])
|
|
if err != nil {
|
|
return ComposeStatus{}, HTTPError(ErrorGettingBuildDependencyStatus)
|
|
}
|
|
|
|
var uploadStatuses *[]UploadStatus
|
|
var us0 *UploadStatus
|
|
if buildJobResult.TargetResults != nil {
|
|
// can't set the array size because koji targets wont be counted
|
|
statuses := make([]UploadStatus, 0, len(buildJobResult.TargetResults))
|
|
for idx := range buildJobResult.TargetResults {
|
|
tr := buildJobResult.TargetResults[idx]
|
|
if tr.Name != target.TargetNameKoji {
|
|
us, err := h.targetResultToUploadStatus(jobId, tr)
|
|
if err != nil {
|
|
return ComposeStatus{}, HTTPErrorWithInternal(ErrorUnknownUploadTarget, err)
|
|
}
|
|
us.Status = uploadStatusFromJobStatus(buildInfo.JobStatus, result.JobError)
|
|
statuses = append(statuses, *us)
|
|
}
|
|
}
|
|
|
|
if len(statuses) > 0 {
|
|
// make sure uploadStatuses remains nil if the array is empty but not nill
|
|
uploadStatuses = &statuses
|
|
// get first upload status if there's at least one
|
|
us0 = &statuses[0]
|
|
}
|
|
}
|
|
|
|
buildJobResults = append(buildJobResults, buildJobResult)
|
|
buildJobStatuses = append(buildJobStatuses, ImageStatus{
|
|
Status: imageStatusFromKojiJobStatus(buildInfo.JobStatus, &initResult, &buildJobResult),
|
|
Error: composeStatusErrorFromJobError(buildJobError),
|
|
UploadStatus: us0, // add the first upload status to the old top-level field
|
|
UploadStatuses: uploadStatuses,
|
|
})
|
|
}
|
|
response := ComposeStatus{
|
|
ObjectReference: ObjectReference{
|
|
Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", jobId),
|
|
Id: jobId.String(),
|
|
Kind: "ComposeStatus",
|
|
},
|
|
Status: composeStatusFromKojiJobStatus(finalizeInfo.JobStatus, &initResult, buildJobResults, &result),
|
|
ImageStatus: buildJobStatuses[0], // backwards compatibility
|
|
ImageStatuses: &buildJobStatuses,
|
|
KojiStatus: &KojiStatus{},
|
|
}
|
|
/* #nosec G115 */
|
|
buildID := int(initResult.BuildID)
|
|
// Make sure signed integer conversion didn't underflow
|
|
if buildID < 0 {
|
|
err := fmt.Errorf("BuildID integer underflow: %d", initResult.BuildID)
|
|
return ComposeStatus{}, HTTPErrorWithInternal(ErrorMalformedOSBuildJobResult, err)
|
|
}
|
|
if buildID != 0 {
|
|
response.KojiStatus.BuildId = &buildID
|
|
}
|
|
return response, nil
|
|
} else {
|
|
return ComposeStatus{}, HTTPError(ErrorInvalidJobType)
|
|
}
|
|
}
|
|
|
|
func composeStatusErrorFromJobError(jobError *clienterrors.Error) *ComposeStatusError {
|
|
if jobError == nil {
|
|
return nil
|
|
}
|
|
err := &ComposeStatusError{
|
|
Id: int(jobError.ID),
|
|
Reason: jobError.Reason,
|
|
}
|
|
if jobError.Details != nil {
|
|
err.Details = &jobError.Details
|
|
}
|
|
return err
|
|
}
|
|
|
|
func imageStatusFromOSBuildJobStatus(js *worker.JobStatus, result *worker.OSBuildJobResult) ImageStatusValue {
|
|
if js.Canceled {
|
|
return ImageStatusValueFailure
|
|
}
|
|
|
|
if js.Started.IsZero() {
|
|
return ImageStatusValuePending
|
|
}
|
|
|
|
if js.Finished.IsZero() {
|
|
// TODO: handle also ImageStatusValueUploading
|
|
// TODO: handle also ImageStatusValueRegistering
|
|
return ImageStatusValueBuilding
|
|
}
|
|
|
|
if result.Success {
|
|
return ImageStatusValueSuccess
|
|
}
|
|
|
|
return ImageStatusValueFailure
|
|
}
|
|
|
|
func imageStatusFromKojiJobStatus(js *worker.JobStatus, initResult *worker.KojiInitJobResult, buildResult *worker.OSBuildJobResult) ImageStatusValue {
|
|
if js.Canceled {
|
|
return ImageStatusValueFailure
|
|
}
|
|
|
|
if initResult.JobError != nil {
|
|
return ImageStatusValueFailure
|
|
}
|
|
|
|
if js.Started.IsZero() {
|
|
return ImageStatusValuePending
|
|
}
|
|
|
|
if js.Finished.IsZero() {
|
|
return ImageStatusValueBuilding
|
|
}
|
|
|
|
if buildResult.JobError != nil {
|
|
return ImageStatusValueFailure
|
|
}
|
|
|
|
if buildResult.OSBuildOutput != nil && !buildResult.OSBuildOutput.Success {
|
|
return ImageStatusValueFailure
|
|
}
|
|
|
|
return ImageStatusValueSuccess
|
|
}
|
|
|
|
func composeStatusFromOSBuildJobStatus(js *worker.JobStatus, result *worker.OSBuildJobResult) ComposeStatusValue {
|
|
if js.Canceled {
|
|
return ComposeStatusValueFailure
|
|
}
|
|
|
|
if js.Finished.IsZero() {
|
|
return ComposeStatusValuePending
|
|
}
|
|
|
|
if result.Success {
|
|
return ComposeStatusValueSuccess
|
|
}
|
|
|
|
return ComposeStatusValueFailure
|
|
}
|
|
|
|
func composeStatusFromKojiJobStatus(js *worker.JobStatus, initResult *worker.KojiInitJobResult, buildResults []worker.OSBuildJobResult, result *worker.KojiFinalizeJobResult) ComposeStatusValue {
|
|
if js.Canceled {
|
|
return ComposeStatusValueFailure
|
|
}
|
|
|
|
if js.Finished.IsZero() {
|
|
return ComposeStatusValuePending
|
|
}
|
|
|
|
if initResult.JobError != nil {
|
|
return ComposeStatusValueFailure
|
|
}
|
|
|
|
for _, buildResult := range buildResults {
|
|
if buildResult.JobError != nil {
|
|
return ComposeStatusValueFailure
|
|
}
|
|
|
|
if buildResult.OSBuildOutput != nil && !buildResult.OSBuildOutput.Success {
|
|
return ComposeStatusValueFailure
|
|
}
|
|
}
|
|
|
|
if result.JobError != nil {
|
|
return ComposeStatusValueFailure
|
|
}
|
|
|
|
return ComposeStatusValueSuccess
|
|
}
|
|
|
|
// ComposeMetadata handles a /composes/{id}/metadata GET request
|
|
func (h *apiHandlers) GetComposeMetadata(ctx echo.Context, id string) error {
|
|
return h.server.EnsureJobChannel(h.getComposeMetadataImpl)(ctx, id)
|
|
}
|
|
|
|
func (h *apiHandlers) getComposeMetadataImpl(ctx echo.Context, id string) error {
|
|
jobId, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return HTTPError(ErrorInvalidComposeId)
|
|
}
|
|
|
|
jobType, err := h.server.workers.JobType(jobId)
|
|
if err != nil {
|
|
return HTTPError(ErrorComposeNotFound)
|
|
}
|
|
|
|
// TODO: support koji builds
|
|
if jobType != worker.JobTypeOSBuild {
|
|
return HTTPError(ErrorInvalidJobType)
|
|
}
|
|
|
|
var result worker.OSBuildJobResult
|
|
buildInfo, err := h.server.workers.OSBuildJobInfo(jobId, &result)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
var job worker.OSBuildJob
|
|
if err = h.server.workers.OSBuildJob(jobId, &job); err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
// Get the original compose request, if present
|
|
request, err := readComposeRequest(h.server.workers.ArtifactsDir(), jobId)
|
|
if err != nil {
|
|
ctx.Logger().Warnf("Failed to read compose request: %v", err)
|
|
}
|
|
|
|
if buildInfo.JobStatus.Finished.IsZero() {
|
|
// job still running: empty response
|
|
return ctx.JSON(200, ComposeMetadata{
|
|
ObjectReference: ObjectReference{
|
|
Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/metadata", jobId),
|
|
Id: jobId.String(),
|
|
Kind: "ComposeMetadata",
|
|
},
|
|
Request: request,
|
|
})
|
|
}
|
|
|
|
if buildInfo.JobStatus.Canceled || !result.Success {
|
|
// job canceled or failed, empty response
|
|
return ctx.JSON(200, ComposeMetadata{
|
|
ObjectReference: ObjectReference{
|
|
Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/metadata", jobId),
|
|
Id: jobId.String(),
|
|
Kind: "ComposeMetadata",
|
|
},
|
|
Request: request,
|
|
})
|
|
}
|
|
|
|
if result.OSBuildOutput == nil || len(result.OSBuildOutput.Log) == 0 {
|
|
// no osbuild output recorded for job, error
|
|
return HTTPError(ErrorMalformedOSBuildJobResult)
|
|
}
|
|
|
|
var ostreeCommitMetadata *osbuild.OSTreeCommitStageMetadata
|
|
var rpmStagesMd []osbuild.RPMStageMetadata // collect rpm stage metadata from payload pipelines
|
|
for _, plName := range job.PipelineNames.Payload {
|
|
plMd, hasMd := result.OSBuildOutput.Metadata[plName]
|
|
if !hasMd {
|
|
continue
|
|
}
|
|
for _, stageMd := range plMd {
|
|
switch md := stageMd.(type) {
|
|
case *osbuild.RPMStageMetadata:
|
|
rpmStagesMd = append(rpmStagesMd, *md)
|
|
case *osbuild.OSTreeCommitStageMetadata:
|
|
ostreeCommitMetadata = md
|
|
}
|
|
}
|
|
}
|
|
|
|
packages := stagesToPackageMetadata(rpmStagesMd)
|
|
|
|
resp := &ComposeMetadata{
|
|
ObjectReference: ObjectReference{
|
|
Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/metadata", jobId),
|
|
Id: jobId.String(),
|
|
Kind: "ComposeMetadata",
|
|
},
|
|
Packages: &packages,
|
|
Request: request,
|
|
}
|
|
|
|
if ostreeCommitMetadata != nil {
|
|
resp.OstreeCommit = &ostreeCommitMetadata.Compose.OSTreeCommit
|
|
}
|
|
|
|
return ctx.JSON(200, resp)
|
|
}
|
|
|
|
func stagesToPackageMetadata(stages []osbuild.RPMStageMetadata) []PackageMetadata {
|
|
packages := make([]PackageMetadata, 0)
|
|
for _, md := range stages {
|
|
for _, rpm := range md.Packages {
|
|
packages = append(packages,
|
|
PackageMetadata{
|
|
PackageMetadataCommon: PackageMetadataCommon{
|
|
Type: "rpm",
|
|
Name: rpm.Name,
|
|
Version: rpm.Version,
|
|
Release: rpm.Release,
|
|
Epoch: rpm.Epoch,
|
|
Arch: rpm.Arch,
|
|
Signature: osbuild.RPMPackageMetadataToSignature(rpm),
|
|
},
|
|
Sigmd5: rpm.SigMD5,
|
|
},
|
|
)
|
|
}
|
|
}
|
|
return packages
|
|
}
|
|
|
|
// Get logs for a compose
|
|
func (h *apiHandlers) GetComposeLogs(ctx echo.Context, id string) error {
|
|
return h.server.EnsureJobChannel(h.getComposeLogsImpl)(ctx, id)
|
|
}
|
|
|
|
func (h *apiHandlers) getComposeLogsImpl(ctx echo.Context, id string) error {
|
|
|
|
jobId, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return HTTPError(ErrorInvalidComposeId)
|
|
}
|
|
|
|
jobType, err := h.server.workers.JobType(jobId)
|
|
if err != nil {
|
|
return HTTPError(ErrorComposeNotFound)
|
|
}
|
|
|
|
var buildResultBlobs []interface{}
|
|
|
|
resp := &ComposeLogs{
|
|
ObjectReference: ObjectReference{
|
|
Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/logs", jobId),
|
|
Id: jobId.String(),
|
|
Kind: "ComposeLogs",
|
|
},
|
|
}
|
|
|
|
switch jobType {
|
|
case worker.JobTypeKojiFinalize:
|
|
var finalizeResult worker.KojiFinalizeJobResult
|
|
finalizeInfo, err := h.server.workers.KojiFinalizeJobInfo(jobId, &finalizeResult)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
var initResult worker.KojiInitJobResult
|
|
_, err = h.server.workers.KojiInitJobInfo(finalizeInfo.Deps[0], &initResult)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
for i := 1; i < len(finalizeInfo.Deps); i++ {
|
|
buildJobType, err := h.server.workers.JobType(finalizeInfo.Deps[i])
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
switch buildJobType {
|
|
case worker.JobTypeOSBuild:
|
|
var buildResult worker.OSBuildJobResult
|
|
_, err = h.server.workers.OSBuildJobInfo(finalizeInfo.Deps[i], &buildResult)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
buildResultBlobs = append(buildResultBlobs, buildResult)
|
|
|
|
default:
|
|
return HTTPErrorWithInternal(ErrorInvalidJobType,
|
|
fmt.Errorf("unexpected job type in koji compose dependencies: %q", buildJobType))
|
|
}
|
|
}
|
|
|
|
resp.Koji = &KojiLogs{
|
|
Init: initResult,
|
|
Import: finalizeResult,
|
|
}
|
|
|
|
case worker.JobTypeOSBuild:
|
|
var buildResult worker.OSBuildJobResult
|
|
_, err = h.server.workers.OSBuildJobInfo(jobId, &buildResult)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
buildResultBlobs = append(buildResultBlobs, buildResult)
|
|
|
|
default:
|
|
return HTTPError(ErrorInvalidJobType)
|
|
}
|
|
|
|
// Return the OSBuildJobResults as-is for now. The contents of ImageBuilds
|
|
// is not part of the API. It's meant for a human to be able to access
|
|
// the logs, which just happen to be in JSON.
|
|
resp.ImageBuilds = buildResultBlobs
|
|
return ctx.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func manifestJobResultsFromJobDeps(w *worker.Server, deps []uuid.UUID) (*worker.JobInfo, *worker.ManifestJobByIDResult, error) {
|
|
var manifestResult worker.ManifestJobByIDResult
|
|
|
|
for i := 0; i < len(deps); i++ {
|
|
depType, err := w.JobType(deps[i])
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if depType == worker.JobTypeManifestIDOnly {
|
|
manifestJobInfo, err := w.ManifestJobInfo(deps[i], &manifestResult)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return manifestJobInfo, &manifestResult, nil
|
|
}
|
|
}
|
|
|
|
return nil, nil, fmt.Errorf("no %q job found in the dependencies", worker.JobTypeManifestIDOnly)
|
|
}
|
|
|
|
// GetComposeIdManifests returns the Manifests for a given Compose (one for each image).
|
|
func (h *apiHandlers) GetComposeManifests(ctx echo.Context, id string) error {
|
|
return h.server.EnsureJobChannel(h.getComposeManifestsImpl)(ctx, id)
|
|
}
|
|
|
|
func (h *apiHandlers) getComposeManifestsImpl(ctx echo.Context, id string) error {
|
|
jobId, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return HTTPError(ErrorInvalidComposeId)
|
|
}
|
|
|
|
jobType, err := h.server.workers.JobType(jobId)
|
|
if err != nil {
|
|
return HTTPError(ErrorComposeNotFound)
|
|
}
|
|
|
|
var manifestBlobs []interface{}
|
|
|
|
switch jobType {
|
|
case worker.JobTypeKojiFinalize:
|
|
var finalizeResult worker.KojiFinalizeJobResult
|
|
finalizeInfo, err := h.server.workers.KojiFinalizeJobInfo(jobId, &finalizeResult)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
for i := 1; i < len(finalizeInfo.Deps); i++ {
|
|
buildJobType, err := h.server.workers.JobType(finalizeInfo.Deps[i])
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
var mf manifest.OSBuildManifest
|
|
|
|
switch buildJobType {
|
|
case worker.JobTypeOSBuild:
|
|
var buildJob worker.OSBuildJob
|
|
err = h.server.workers.OSBuildJob(finalizeInfo.Deps[i], &buildJob)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
if len(buildJob.Manifest) != 0 {
|
|
mf = buildJob.Manifest
|
|
} else {
|
|
buildInfo, err := h.server.workers.OSBuildJobInfo(finalizeInfo.Deps[i], &worker.OSBuildJobResult{})
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
_, manifestResult, err := manifestJobResultsFromJobDeps(h.server.workers, buildInfo.Deps)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, fmt.Errorf("job %q: %v", jobId, err))
|
|
}
|
|
mf = manifestResult.Manifest
|
|
}
|
|
|
|
default:
|
|
return HTTPErrorWithInternal(ErrorInvalidJobType,
|
|
fmt.Errorf("unexpected job type in koji compose dependencies: %q", buildJobType))
|
|
}
|
|
manifestBlobs = append(manifestBlobs, mf)
|
|
}
|
|
|
|
case worker.JobTypeOSBuild:
|
|
var buildJob worker.OSBuildJob
|
|
err = h.server.workers.OSBuildJob(jobId, &buildJob)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
var mf manifest.OSBuildManifest
|
|
if len(buildJob.Manifest) != 0 {
|
|
mf = buildJob.Manifest
|
|
} else {
|
|
buildInfo, err := h.server.workers.OSBuildJobInfo(jobId, &worker.OSBuildJobResult{})
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
_, manifestResult, err := manifestJobResultsFromJobDeps(h.server.workers, buildInfo.Deps)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, fmt.Errorf("job %q: %v", jobId, err))
|
|
}
|
|
mf = manifestResult.Manifest
|
|
}
|
|
manifestBlobs = append(manifestBlobs, mf)
|
|
|
|
default:
|
|
return HTTPError(ErrorInvalidJobType)
|
|
}
|
|
|
|
resp := &ComposeManifests{
|
|
ObjectReference: ObjectReference{
|
|
Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/manifests", jobId),
|
|
Id: jobId.String(),
|
|
Kind: "ComposeManifests",
|
|
},
|
|
Manifests: manifestBlobs,
|
|
}
|
|
|
|
return ctx.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// sbomsFromOSBuildJob extracts SBOM documents from dependencies of an OSBuild job.
|
|
func sbomsFromOSBuildJob(w *worker.Server, osbuildJobUUID uuid.UUID) ([]ImageSBOM, error) {
|
|
var osbuildJobResult worker.OSBuildJobResult
|
|
osbuildJobInfo, err := w.OSBuildJobInfo(osbuildJobUUID, &osbuildJobResult)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to get results for OSBuild job %q: %v", osbuildJobUUID, err)
|
|
}
|
|
|
|
pipelineNameToPurpose := func(pipelineName string) (ImageSBOMPipelinePurpose, error) {
|
|
if slices.Contains(osbuildJobResult.PipelineNames.Payload, pipelineName) {
|
|
return ImageSBOMPipelinePurposeImage, nil
|
|
}
|
|
if slices.Contains(osbuildJobResult.PipelineNames.Build, pipelineName) {
|
|
return ImageSBOMPipelinePurposeBuildroot, nil
|
|
}
|
|
return "", fmt.Errorf("Pipeline %q is not listed as either a payload or build pipeline", pipelineName)
|
|
}
|
|
|
|
// SBOMs are attached to the depsolve job results.
|
|
// Depsolve jobs are dependencies of Manifest job.
|
|
// Manifest job is a dependency of OSBuild job.
|
|
manifesJobInfo, _, err := manifestJobResultsFromJobDeps(w, osbuildJobInfo.Deps)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to get manifest job info for OSBuild job %q: %v", osbuildJobUUID, err)
|
|
}
|
|
|
|
var imageSBOMs []ImageSBOM
|
|
for _, manifestDepUUID := range manifesJobInfo.Deps {
|
|
depJobType, err := w.JobType(manifestDepUUID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to get job type for dependency %q: %v", manifestDepUUID, err)
|
|
}
|
|
|
|
if depJobType != worker.JobTypeDepsolve {
|
|
continue
|
|
}
|
|
|
|
var depsolveJobResult worker.DepsolveJobResult
|
|
_, err = w.DepsolveJobInfo(manifestDepUUID, &depsolveJobResult)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to get results for depsolve job %q: %v", manifestDepUUID, err)
|
|
}
|
|
|
|
if depsolveJobResult.SbomDocs == nil {
|
|
return nil, fmt.Errorf("depsolve job %q: missing SBOMs", manifestDepUUID)
|
|
}
|
|
|
|
for pipelineName, sbomDoc := range depsolveJobResult.SbomDocs {
|
|
purpose, err := pipelineNameToPurpose(pipelineName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed to determine purpose for pipeline %q: %v", pipelineName, err)
|
|
}
|
|
|
|
var sbomType ImageSBOMSbomType
|
|
switch sbomDoc.DocType {
|
|
case sbom.StandardTypeSpdx:
|
|
sbomType = ImageSBOMSbomTypeSpdx
|
|
default:
|
|
return nil, fmt.Errorf("Unknown SBOM type %q attached to depsolve job %q", sbomDoc.DocType, manifestDepUUID)
|
|
}
|
|
|
|
imageSBOMs = append(imageSBOMs, ImageSBOM{
|
|
PipelineName: pipelineName,
|
|
PipelinePurpose: purpose,
|
|
Sbom: sbomDoc.Document,
|
|
SbomType: sbomType,
|
|
})
|
|
}
|
|
|
|
// There should be only one depsolve job per OSBuild job
|
|
break
|
|
}
|
|
|
|
if len(imageSBOMs) == 0 {
|
|
return nil, fmt.Errorf("OSBuild job %q: manifest job dependency is missing depsolve job dependency", osbuildJobUUID)
|
|
}
|
|
|
|
// Sort the SBOMs by pipeline name to ensure consistent ordering.
|
|
// The SBOM documents are attached to the depsolve job results, in a map where the key is the pipeline name.
|
|
// The order of the keys in the map is not guaranteed to be consistent across different runs.
|
|
sort.Slice(imageSBOMs, func(i, j int) bool {
|
|
return imageSBOMs[i].PipelineName < imageSBOMs[j].PipelineName
|
|
})
|
|
|
|
return imageSBOMs, nil
|
|
}
|
|
|
|
// GetComposeSBOMs returns the SBOM documents for a given Compose (multiple SBOMs for each image).
|
|
func (h *apiHandlers) GetComposeSBOMs(ctx echo.Context, id string) error {
|
|
return h.server.EnsureJobChannel(h.getComposeSBOMsImpl)(ctx, id)
|
|
}
|
|
|
|
func (h *apiHandlers) getComposeSBOMsImpl(ctx echo.Context, id string) error {
|
|
jobId, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return HTTPError(ErrorInvalidComposeId)
|
|
}
|
|
|
|
jobType, err := h.server.workers.JobType(jobId)
|
|
if err != nil {
|
|
return HTTPError(ErrorComposeNotFound)
|
|
}
|
|
|
|
var items [][]ImageSBOM
|
|
|
|
switch jobType {
|
|
// Koji compose
|
|
case worker.JobTypeKojiFinalize:
|
|
var finalizeResult worker.KojiFinalizeJobResult
|
|
finalizeInfo, err := h.server.workers.KojiFinalizeJobInfo(jobId, &finalizeResult)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
for _, kojiFinalizeDepUUID := range finalizeInfo.Deps {
|
|
buildJobType, err := h.server.workers.JobType(kojiFinalizeDepUUID)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
switch buildJobType {
|
|
case worker.JobTypeKojiInit:
|
|
continue
|
|
|
|
case worker.JobTypeOSBuild:
|
|
imageSBOMs, err := sbomsFromOSBuildJob(h.server.workers, kojiFinalizeDepUUID)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound,
|
|
fmt.Errorf("Failed to get SBOMs for OSBuild job %q: %v", kojiFinalizeDepUUID, err))
|
|
}
|
|
items = append(items, imageSBOMs)
|
|
|
|
default:
|
|
return HTTPErrorWithInternal(ErrorInvalidJobType,
|
|
fmt.Errorf("unexpected job type in koji compose dependencies: %q", buildJobType))
|
|
}
|
|
}
|
|
|
|
// non-Koji compose
|
|
case worker.JobTypeOSBuild:
|
|
imageSBOMs, err := sbomsFromOSBuildJob(h.server.workers, jobId)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound,
|
|
fmt.Errorf("Failed to get SBOMs for OSBuild job %q: %v", jobId, err))
|
|
}
|
|
items = append(items, imageSBOMs)
|
|
|
|
default:
|
|
return HTTPError(ErrorInvalidJobType)
|
|
}
|
|
|
|
resp := &ComposeSBOMs{
|
|
ObjectReference: ObjectReference{
|
|
Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/sboms", jobId),
|
|
Id: jobId.String(),
|
|
Kind: "ComposeSBOMs",
|
|
},
|
|
Items: items,
|
|
}
|
|
|
|
return ctx.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// Converts repositories in the request to the internal rpmmd.RepoConfig representation
|
|
func convertRepos(irRepos, payloadRepositories []Repository, payloadPackageSets []string) ([]rpmmd.RepoConfig, error) {
|
|
repos := make([]rpmmd.RepoConfig, 0, len(irRepos)+len(payloadRepositories))
|
|
|
|
for idx := range irRepos {
|
|
r, err := genRepoConfig(irRepos[idx])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
repos = append(repos, *r)
|
|
}
|
|
|
|
for idx := range payloadRepositories {
|
|
// the PackageSets (package_sets) field for these repositories is
|
|
// ignored (see openapi.v2.yml description for payload_repositories)
|
|
// and we replace any value in it with the names of the payload package
|
|
// sets
|
|
r, err := genRepoConfig(payloadRepositories[idx])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r.PackageSets = payloadPackageSets
|
|
repos = append(repos, *r)
|
|
}
|
|
|
|
return repos, nil
|
|
}
|
|
|
|
func genRepoConfig(repo Repository) (*rpmmd.RepoConfig, error) {
|
|
repoConfig := new(rpmmd.RepoConfig)
|
|
|
|
repoConfig.RHSM = repo.Rhsm != nil && *repo.Rhsm
|
|
|
|
if repo.Baseurl != nil && *repo.Baseurl != "" {
|
|
repoConfig.BaseURLs = []string{*repo.Baseurl}
|
|
} else if repo.Mirrorlist != nil {
|
|
repoConfig.MirrorList = *repo.Mirrorlist
|
|
} else if repo.Metalink != nil {
|
|
repoConfig.Metalink = *repo.Metalink
|
|
} else {
|
|
return nil, HTTPError(ErrorInvalidRepository)
|
|
}
|
|
|
|
if repo.Gpgkey != nil && *repo.Gpgkey != "" {
|
|
repoConfig.GPGKeys = []string{*repo.Gpgkey}
|
|
}
|
|
if repo.IgnoreSsl != nil {
|
|
repoConfig.IgnoreSSL = repo.IgnoreSsl
|
|
}
|
|
|
|
if repo.CheckGpg != nil {
|
|
repoConfig.CheckGPG = repo.CheckGpg
|
|
}
|
|
if repo.Gpgkey != nil && *repo.Gpgkey != "" {
|
|
repoConfig.GPGKeys = []string{*repo.Gpgkey}
|
|
}
|
|
if repo.IgnoreSsl != nil {
|
|
repoConfig.IgnoreSSL = repo.IgnoreSsl
|
|
}
|
|
if repo.CheckRepoGpg != nil {
|
|
repoConfig.CheckRepoGPG = repo.CheckRepoGpg
|
|
}
|
|
if repo.ModuleHotfixes != nil {
|
|
repoConfig.ModuleHotfixes = repo.ModuleHotfixes
|
|
}
|
|
|
|
if repoConfig.CheckGPG != nil && *repoConfig.CheckGPG && len(repoConfig.GPGKeys) == 0 {
|
|
return nil, HTTPError(ErrorNoGPGKey)
|
|
}
|
|
|
|
if repo.PackageSets != nil {
|
|
repoConfig.PackageSets = *repo.PackageSets
|
|
}
|
|
|
|
return repoConfig, nil
|
|
}
|
|
|
|
func (h *apiHandlers) PostCloneCompose(ctx echo.Context, id string) error {
|
|
return h.server.EnsureJobChannel(h.postCloneComposeImpl)(ctx, id)
|
|
}
|
|
|
|
func (h *apiHandlers) postCloneComposeImpl(ctx echo.Context, id string) error {
|
|
channel, err := h.server.getTenantChannel(ctx)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorTenantNotFound, err)
|
|
}
|
|
|
|
jobId, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return HTTPError(ErrorInvalidComposeId)
|
|
}
|
|
|
|
jobType, err := h.server.workers.JobType(jobId)
|
|
if err != nil {
|
|
return HTTPError(ErrorComposeNotFound)
|
|
}
|
|
|
|
if jobType != worker.JobTypeOSBuild {
|
|
return HTTPError(ErrorInvalidJobType)
|
|
}
|
|
|
|
var osbuildResult worker.OSBuildJobResult
|
|
osbuildInfo, err := h.server.workers.OSBuildJobInfo(jobId, &osbuildResult)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorGettingOSBuildJobStatus, err)
|
|
}
|
|
|
|
if osbuildInfo.JobStatus.Finished.IsZero() || !osbuildResult.Success {
|
|
return HTTPError(ErrorComposeBadState)
|
|
}
|
|
|
|
if osbuildResult.TargetResults == nil {
|
|
return HTTPError(ErrorMalformedOSBuildJobResult)
|
|
}
|
|
// Only single upload target is allowed, therefore only a single upload target result is allowed as well
|
|
if len(osbuildResult.TargetResults) != 1 {
|
|
return HTTPError(ErrorSeveralUploadTargets)
|
|
}
|
|
var us *UploadStatus
|
|
us, err = h.targetResultToUploadStatus(jobId, osbuildResult.TargetResults[0])
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorUnknownUploadTarget, err)
|
|
}
|
|
|
|
var osbuildJob worker.OSBuildJob
|
|
err = h.server.workers.OSBuildJob(jobId, &osbuildJob)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorComposeNotFound, err)
|
|
}
|
|
|
|
if len(osbuildJob.Targets) != 1 {
|
|
return HTTPError(ErrorSeveralUploadTargets)
|
|
}
|
|
|
|
// the id of the last job in the dependency chain which users should wait on
|
|
finalJob := jobId
|
|
// look at the upload status of the osbuild dependency to decide what to do
|
|
if us.Type == UploadTypesAws {
|
|
options := us.Options.(AWSEC2UploadStatus)
|
|
var img AWSEC2CloneCompose
|
|
err := ctx.Bind(&img)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
shareAmi := options.Ami
|
|
shareRegion := img.Region
|
|
if img.Region != options.Region {
|
|
// Let the share job use dynArgs
|
|
shareAmi = ""
|
|
shareRegion = ""
|
|
|
|
// Check dependents if we need to do a copyjob
|
|
foundDep := false
|
|
for _, d := range osbuildInfo.Dependents {
|
|
jt, err := h.server.workers.JobType(d)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorGettingJobType, err)
|
|
}
|
|
if jt == worker.JobTypeAWSEC2Copy {
|
|
var cjResult worker.AWSEC2CopyJobResult
|
|
_, err := h.server.workers.AWSEC2CopyJobInfo(d, &cjResult)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorGettingAWSEC2JobStatus, err)
|
|
}
|
|
|
|
if cjResult.JobError == nil && options.Region == cjResult.Region {
|
|
finalJob = d
|
|
foundDep = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundDep {
|
|
copyJob := &worker.AWSEC2CopyJob{
|
|
Ami: options.Ami,
|
|
SourceRegion: options.Region,
|
|
TargetRegion: img.Region,
|
|
TargetName: fmt.Sprintf("composer-api-%s", uuid.New().String()),
|
|
}
|
|
finalJob, err = h.server.workers.EnqueueAWSEC2CopyJob(copyJob, finalJob, channel)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorEnqueueingJob, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var shares []string
|
|
awsT, ok := (osbuildJob.Targets[0].Options).(*target.AWSTargetOptions)
|
|
if !ok {
|
|
return HTTPError(ErrorUnknownUploadTarget)
|
|
}
|
|
if len(awsT.ShareWithAccounts) > 0 {
|
|
shares = append(shares, awsT.ShareWithAccounts...)
|
|
}
|
|
if img.ShareWithAccounts != nil && len(*img.ShareWithAccounts) > 0 {
|
|
shares = append(shares, (*img.ShareWithAccounts)...)
|
|
}
|
|
if len(shares) > 0 {
|
|
shareJob := &worker.AWSEC2ShareJob{
|
|
Ami: shareAmi,
|
|
Region: shareRegion,
|
|
ShareWithAccounts: shares,
|
|
}
|
|
finalJob, err = h.server.workers.EnqueueAWSEC2ShareJob(shareJob, finalJob, channel)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorEnqueueingJob, err)
|
|
}
|
|
}
|
|
} else {
|
|
return HTTPError(ErrorUnsupportedImage)
|
|
}
|
|
|
|
return ctx.JSON(http.StatusCreated, CloneComposeResponse{
|
|
ObjectReference: ObjectReference{
|
|
Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/clone", jobId),
|
|
Id: finalJob.String(),
|
|
Kind: "CloneComposeId",
|
|
},
|
|
Id: finalJob.String(),
|
|
})
|
|
}
|
|
|
|
func (h *apiHandlers) GetCloneStatus(ctx echo.Context, id string) error {
|
|
return h.server.EnsureJobChannel(h.getCloneStatus)(ctx, id)
|
|
}
|
|
|
|
func (h *apiHandlers) getCloneStatus(ctx echo.Context, id string) error {
|
|
jobId, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return HTTPError(ErrorInvalidComposeId)
|
|
}
|
|
|
|
jobType, err := h.server.workers.JobType(jobId)
|
|
if err != nil {
|
|
return HTTPError(ErrorComposeNotFound)
|
|
}
|
|
|
|
var us UploadStatus
|
|
switch jobType {
|
|
case worker.JobTypeAWSEC2Copy:
|
|
var result worker.AWSEC2CopyJobResult
|
|
info, err := h.server.workers.AWSEC2CopyJobInfo(jobId, &result)
|
|
if err != nil {
|
|
return HTTPError(ErrorGettingAWSEC2JobStatus)
|
|
}
|
|
|
|
us = UploadStatus{
|
|
Status: uploadStatusFromJobStatus(info.JobStatus, result.JobError),
|
|
Type: UploadTypesAws,
|
|
Options: AWSEC2UploadStatus{
|
|
Ami: result.Ami,
|
|
Region: result.Region,
|
|
},
|
|
}
|
|
case worker.JobTypeAWSEC2Share:
|
|
var result worker.AWSEC2ShareJobResult
|
|
info, err := h.server.workers.AWSEC2ShareJobInfo(jobId, &result)
|
|
if err != nil {
|
|
return HTTPError(ErrorGettingAWSEC2JobStatus)
|
|
}
|
|
|
|
us = UploadStatus{
|
|
Status: uploadStatusFromJobStatus(info.JobStatus, result.JobError),
|
|
Type: UploadTypesAws,
|
|
Options: AWSEC2UploadStatus{
|
|
Ami: result.Ami,
|
|
Region: result.Region,
|
|
},
|
|
}
|
|
default:
|
|
return HTTPError(ErrorInvalidJobType)
|
|
}
|
|
|
|
return ctx.JSON(http.StatusOK, CloneStatus{
|
|
ObjectReference: ObjectReference{
|
|
Href: fmt.Sprintf("/api/image-builder-composer/v2/clones/%v", jobId),
|
|
Id: jobId.String(),
|
|
Kind: "CloneComposeStatus",
|
|
},
|
|
UploadStatus: us,
|
|
})
|
|
}
|
|
|
|
// TODO: determine upload status based on the target results, not job results
|
|
func uploadStatusFromJobStatus(js *worker.JobStatus, je *clienterrors.Error) UploadStatusValue {
|
|
if je != nil || js.Canceled {
|
|
return UploadStatusValueFailure
|
|
}
|
|
|
|
if js.Started.IsZero() {
|
|
return UploadStatusValuePending
|
|
}
|
|
|
|
if js.Finished.IsZero() {
|
|
return UploadStatusValueRunning
|
|
}
|
|
return UploadStatusValueSuccess
|
|
}
|
|
|
|
// PostDepsolveBlueprint depsolves the packages in a blueprint and returns
|
|
// the results as a list of rpmmd.PackageSpecs
|
|
func (h *apiHandlers) PostDepsolveBlueprint(ctx echo.Context) error {
|
|
var request DepsolveRequest
|
|
err := ctx.Bind(&request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Depsolve the requested blueprint
|
|
// Any errors returned are suitable as a response
|
|
deps, err := request.Depsolve(h.server.distros, h.server.repos, h.server.workers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return ctx.JSON(http.StatusOK,
|
|
DepsolveResponse{
|
|
Packages: packageSpecToPackageMetadata(deps),
|
|
})
|
|
}
|
|
|
|
// packageSpecToPackageMetadata converts the rpmmd.PackageSpec to PackageMetadata
|
|
// This is used to return package information from the blueprint depsolve request
|
|
// using the common PackageMetadata format from the openapi schema.
|
|
func packageSpecToPackageMetadata(pkgspecs []rpmmd.PackageSpec) []PackageMetadataCommon {
|
|
packages := make([]PackageMetadataCommon, 0)
|
|
for _, rpm := range pkgspecs {
|
|
// Set epoch if it is not 0
|
|
|
|
var epoch *string
|
|
if rpm.Epoch > 0 {
|
|
epoch = common.ToPtr(strconv.FormatUint(uint64(rpm.Epoch), 10))
|
|
}
|
|
packages = append(packages,
|
|
PackageMetadataCommon{
|
|
Type: "rpm",
|
|
Name: rpm.Name,
|
|
Version: rpm.Version,
|
|
Release: rpm.Release,
|
|
Epoch: epoch,
|
|
Arch: rpm.Arch,
|
|
Checksum: common.ToPtr(rpm.Checksum),
|
|
},
|
|
)
|
|
}
|
|
return packages
|
|
}
|
|
|
|
// packageListToPackageDetails converts the rpmmd.PackageList to PackageDetails
|
|
// This is used to return detailed package information from the package search
|
|
func packageListToPackageDetails(packages rpmmd.PackageList) []PackageDetails {
|
|
details := make([]PackageDetails, 0)
|
|
for _, rpm := range packages {
|
|
d := PackageDetails{
|
|
Name: rpm.Name,
|
|
Version: rpm.Version,
|
|
Release: rpm.Release,
|
|
Arch: rpm.Arch,
|
|
}
|
|
|
|
// Set epoch if it is not 0
|
|
if rpm.Epoch > 0 {
|
|
d.Epoch = common.ToPtr(strconv.FormatUint(uint64(rpm.Epoch), 10))
|
|
}
|
|
|
|
// Set buildtime to a RFC3339 string
|
|
d.Buildtime = common.ToPtr(rpm.BuildTime.Format(time.RFC3339))
|
|
if len(rpm.Summary) > 0 {
|
|
d.Summary = common.ToPtr(rpm.Summary)
|
|
}
|
|
if len(rpm.Description) > 0 {
|
|
d.Description = common.ToPtr(rpm.Description)
|
|
}
|
|
if len(rpm.URL) > 0 {
|
|
d.Url = common.ToPtr(rpm.URL)
|
|
}
|
|
if len(rpm.License) > 0 {
|
|
d.License = common.ToPtr(rpm.License)
|
|
}
|
|
|
|
details = append(details, d)
|
|
}
|
|
|
|
return details
|
|
}
|
|
|
|
// PostSearchPackages searches for packages and returns detailed
|
|
// information about the matches.
|
|
func (h *apiHandlers) PostSearchPackages(ctx echo.Context) error {
|
|
var request SearchPackagesRequest
|
|
err := ctx.Bind(&request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Search for the listed packages
|
|
// Any errors returned are suitable as a response
|
|
packages, err := request.Search(h.server.distros, h.server.repos, h.server.workers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return ctx.JSON(http.StatusOK,
|
|
SearchPackagesResponse{
|
|
Packages: packageListToPackageDetails(packages),
|
|
})
|
|
}
|
|
|
|
// GetDistributionList returns the list of all supported distribution repositories
|
|
// It is arranged by distro name -> architecture -> image type
|
|
func (h *apiHandlers) GetDistributionList(ctx echo.Context) error {
|
|
distros := make(map[string]map[string]map[string][]rpmmd.RepoConfig)
|
|
distroNames := h.server.repos.ListDistros()
|
|
sort.Strings(distroNames)
|
|
for _, distroName := range distroNames {
|
|
distro := h.server.distros.GetDistro(distroName)
|
|
if distro == nil {
|
|
continue
|
|
}
|
|
|
|
for _, archName := range distro.ListArches() {
|
|
arch, _ := distro.GetArch(archName)
|
|
for _, imageType := range arch.ListImageTypes() {
|
|
repos, err := h.server.repos.ReposByImageTypeName(distroName, archName, imageType)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if _, ok := distros[distroName]; !ok {
|
|
distros[distroName] = make(map[string]map[string][]rpmmd.RepoConfig)
|
|
}
|
|
if _, ok := distros[distroName][archName]; !ok {
|
|
distros[distroName][archName] = make(map[string][]rpmmd.RepoConfig)
|
|
}
|
|
|
|
distros[distroName][archName][imageType] = repos
|
|
}
|
|
}
|
|
}
|
|
|
|
return ctx.JSON(http.StatusOK, distros)
|
|
}
|
|
|
|
// GetComposeDownload downloads a compose artifact
|
|
func (h *apiHandlers) GetComposeDownload(ctx echo.Context, id string) error {
|
|
return h.server.EnsureJobChannel(h.getComposeDownloadImpl)(ctx, id)
|
|
}
|
|
|
|
func (h *apiHandlers) getComposeDownloadImpl(ctx echo.Context, id string) error {
|
|
jobId, err := uuid.Parse(id)
|
|
if err != nil {
|
|
return HTTPError(ErrorInvalidComposeId)
|
|
}
|
|
|
|
jobType, err := h.server.workers.JobType(jobId)
|
|
if err != nil {
|
|
return HTTPError(ErrorComposeNotFound)
|
|
}
|
|
if jobType != worker.JobTypeOSBuild {
|
|
return HTTPError(ErrorInvalidJobType)
|
|
}
|
|
|
|
var osbuildResult worker.OSBuildJobResult
|
|
jobInfo, err := h.server.workers.OSBuildJobInfo(jobId, &osbuildResult)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorGettingOSBuildJobStatus, err)
|
|
}
|
|
|
|
// Is it finished?
|
|
if jobInfo.JobStatus.Finished.IsZero() {
|
|
err := fmt.Errorf("Cannot access artifacts before job is finished: %s", jobId)
|
|
return HTTPErrorWithInternal(ErrorArtifactNotFound, err)
|
|
}
|
|
|
|
// Building only supports one target, but that may change, so make sure to check.
|
|
// NOTE: TargetResults isn't populated until it is finished
|
|
if len(osbuildResult.TargetResults) != 1 {
|
|
msg := fmt.Errorf("%#v", osbuildResult.TargetResults)
|
|
//return HTTPError(ErrorSeveralUploadTargets)
|
|
return HTTPErrorWithInternal(ErrorSeveralUploadTargets, msg)
|
|
}
|
|
tr := osbuildResult.TargetResults[0]
|
|
if tr.OsbuildArtifact == nil {
|
|
return HTTPError(ErrorArtifactNotFound)
|
|
}
|
|
|
|
// NOTE: This also returns an error if the job isn't finished or it cannot find the file
|
|
file, err := h.server.workers.JobArtifactLocation(jobId, tr.OsbuildArtifact.ExportFilename)
|
|
if err != nil {
|
|
return HTTPErrorWithInternal(ErrorArtifactNotFound, err)
|
|
}
|
|
return ctx.Attachment(file, fmt.Sprintf("%s-%s", jobId, tr.OsbuildArtifact.ExportFilename))
|
|
}
|
|
|
|
// saveComposeRequest stores the compose request's json on disk
|
|
// This is saved in the ComposeRequest directory of the artifacts directory
|
|
// If no artifacts directory has been configured it saves nothing and silently returns
|
|
func saveComposeRequest(artifactsDir string, id uuid.UUID, request ComposeRequest) error {
|
|
if artifactsDir == "" {
|
|
return nil
|
|
}
|
|
p := path.Join(artifactsDir, "ComposeRequest")
|
|
err := os.MkdirAll(p, 0700)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
db := jsondb.New(p, 0700)
|
|
return db.Write(id.String(), request)
|
|
}
|
|
|
|
// readComposeRequest reads the compose request's json on disk
|
|
// This reads the original compose request json from the ComposeRequest directory of
|
|
// the artifacts directory.
|
|
// If no artifacts directory had been setup it silently returns nothing
|
|
func readComposeRequest(artifactsDir string, id uuid.UUID) (*ComposeRequest, error) {
|
|
if artifactsDir == "" {
|
|
return nil, nil
|
|
}
|
|
p := path.Join(artifactsDir, "ComposeRequest")
|
|
err := os.MkdirAll(p, 0700)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db := jsondb.New(p, 0700)
|
|
var request ComposeRequest
|
|
exists, err := db.Read(id.String(), &request)
|
|
if !exists {
|
|
return nil, err
|
|
}
|
|
return &request, err
|
|
}
|