debian-forge-composer/internal/weldr/api.go
Tom Gundersen 7625d26ff5 pipeline/target: implement as variant types
Go doesn't really do variants, so we must somehow emulate it. The
json objects we use are essentially tagged unions, with a `name`
field in reverse domain name notation identifying the type and a
type specific 'options' object.

In Go we represent this by having an BarOptions interface, which
implements a private method `isBarOptions()`, making sure that only
types in the same package are able to implement it. Each type FooBar
that should belong to the variant implements the interface, and a
constructor `NewFooBar(options *FooBarOptions) *Bar` that makes sure
the `name` field is set correctly.

This would be enough to represent our types and marshal them into
JSON, but unmarshalling would not work (json does not know about
our tags, so would not know what concrete types to demarshal to).
We therefore must also implement the Unmarshall interface for Bar,
to select the right types for the Options field.

We implement his logic for Target, Stage and Assembler. A handful
of concrete types are also implemented, matching what osbuild
supports.

Signed-off-by: Tom Gundersen <teg@jklm.no>
2019-09-28 17:49:07 +02:00

684 lines
20 KiB
Go

package weldr
import (
"encoding/json"
"log"
"net"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"osbuild-composer/internal/job"
"osbuild-composer/internal/rpmmd"
)
type API struct {
store *store
repo rpmmd.RepoConfig
packages rpmmd.PackageList
logger *log.Logger
router *httprouter.Router
}
func New(repo rpmmd.RepoConfig, packages rpmmd.PackageList, logger *log.Logger, initialState []byte, stateChannel chan<- []byte, jobs chan<- job.Job, jobStatus <-chan job.Status) *API {
// This needs to be shared with the worker API so that they can communicate with each other
// builds := make(chan queue.Build, 200)
api := &API{
store: newStore(initialState, stateChannel, jobs, jobStatus),
repo: repo,
packages: packages,
logger: logger,
}
// sample blueprint on first run
if initialState == nil {
api.store.pushBlueprint(blueprint{
Name: "example",
Description: "An Example",
Version: "1",
Packages: []blueprintPackage{{"httpd", "2.*"}},
Modules: []blueprintPackage{},
})
}
api.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.GET("/api/status", api.statusHandler)
api.router.GET("/api/v0/projects/source/list", api.sourceListHandler)
api.router.GET("/api/v0/projects/source/info/:sources", api.sourceInfoHandler)
api.router.GET("/api/v0/modules/list", api.modulesListAllHandler)
api.router.GET("/api/v0/modules/list/:modules", api.modulesListHandler)
// these are the same, except that modules/info also includes dependencies
api.router.GET("/api/v0/modules/info/:modules", api.modulesInfoHandler)
api.router.GET("/api/v0/projects/info/:modules", api.modulesInfoHandler)
api.router.GET("/api/v0/blueprints/list", api.blueprintsListHandler)
api.router.GET("/api/v0/blueprints/info/:blueprints", api.blueprintsInfoHandler)
api.router.GET("/api/v0/blueprints/depsolve/:blueprints", api.blueprintsDepsolveHandler)
api.router.GET("/api/v0/blueprints/diff/:blueprint/:from/:to", api.blueprintsDiffHandler)
api.router.POST("/api/v0/blueprints/new", api.blueprintsNewHandler)
api.router.POST("/api/v0/blueprints/workspace", api.blueprintsWorkspaceHandler)
api.router.DELETE("/api/v0/blueprints/delete/:blueprint", api.blueprintDeleteHandler)
api.router.DELETE("/api/v0/blueprints/workspace/:blueprint", api.blueprintDeleteWorkspaceHandler)
api.router.POST("/api/v0/compose", api.composeHandler)
api.router.GET("/api/v0/compose/types", api.composeTypesHandler)
api.router.GET("/api/v0/compose/queue", api.composeQueueHandler)
api.router.GET("/api/v0/compose/finished", api.composeFinishedHandler)
api.router.GET("/api/v0/compose/failed", api.composeFailedHandler)
return api
}
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
}
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 statusResponseOK(writer http.ResponseWriter) {
type reply struct {
Status bool `json:"status"`
}
writer.WriteHeader(http.StatusOK)
json.NewEncoder(writer).Encode(reply{true})
}
func statusResponseError(writer http.ResponseWriter, code int, errors ...string) {
type reply struct {
Status bool `json:"status"`
Errors []string `json:"errors,omitempty"`
}
writer.WriteHeader(code)
json.NewEncoder(writer).Encode(reply{false, errors})
}
func (api *API) statusHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
type reply struct {
API uint `json:"api"`
DBSupported bool `json:"db_supported"`
DBVersion string `json:"db_version"`
SchemaVersion string `json:"schema_version"`
Backend string `json:"backend"`
Build string `json:"build"`
Messages []string `json:"messages"`
}
json.NewEncoder(writer).Encode(reply{
API: 1,
DBSupported: true,
DBVersion: "0",
SchemaVersion: "0",
Backend: "osbuild-composer",
Build: "devel",
Messages: make([]string, 0),
})
}
func (api *API) sourceListHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
type reply struct {
Sources []string `json:"sources"`
}
json.NewEncoder(writer).Encode(reply{
Sources: []string{api.repo.Id},
})
}
func (api *API) sourceInfoHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
// weldr uses a slightly different format than dnf to store repository
// configuration
type sourceConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url"`
CheckGPG bool `json:"check_gpg"`
CheckSSL bool `json:"check_ssl"`
System bool `json:"system"`
}
type reply struct {
Sources map[string]sourceConfig `json:"sources"`
}
// we only have one repository
names := strings.Split(params.ByName("sources"), ",")
if names[0] != api.repo.Id && names[0] != "*" {
statusResponseError(writer, http.StatusBadRequest, "repository not found: "+names[0])
return
}
cfg := sourceConfig{
ID: api.repo.Id,
Name: api.repo.Name,
CheckGPG: true,
CheckSSL: true,
System: true,
}
if api.repo.BaseURL != "" {
cfg.URL = api.repo.BaseURL
cfg.Type = "yum-baseurl"
} else if api.repo.Metalink != "" {
cfg.URL = api.repo.Metalink
cfg.Type = "yum-metalink"
} else if api.repo.MirrorList != "" {
cfg.URL = api.repo.MirrorList
cfg.Type = "yum-mirrorlist"
}
json.NewEncoder(writer).Encode(reply{
Sources: map[string]sourceConfig{cfg.ID: cfg},
})
}
type modulesListModule struct {
Name string `json:"name"`
GroupType string `json:"group_type"`
}
type modulesListReply struct {
Total uint `json:"total"`
Offset uint `json:"offset"`
Limit uint `json:"limit"`
Modules []modulesListModule `json:"modules"`
}
func (api *API) modulesListAllHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
offset, limit, err := parseOffsetAndLimit(request.URL.Query())
if err != nil {
statusResponseError(writer, http.StatusBadRequest, "BadRequest: "+err.Error())
return
}
total := uint(len(api.packages))
start := min(offset, total)
n := min(limit, total-start)
modules := make([]modulesListModule, n)
for i := uint(0); i < n; i++ {
modules[i] = modulesListModule{api.packages[start+i].Name, "rpm"}
}
json.NewEncoder(writer).Encode(modulesListReply{
Total: total,
Offset: offset,
Limit: limit,
Modules: modules,
})
}
func (api *API) modulesListHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
names := strings.Split(params.ByName("modules"), ",")
if names[0] == "" || names[0] == "*" {
api.modulesListAllHandler(writer, request, params)
return
}
offset, limit, err := parseOffsetAndLimit(request.URL.Query())
if err != nil {
statusResponseError(writer, http.StatusBadRequest, "BadRequest: "+err.Error())
return
}
// we don't support glob-matching, but cockpit-composer surrounds some
// queries with asterisks; this is crude, but solves that case
for i := range names {
names[i] = strings.ReplaceAll(names[i], "*", "")
}
modules := make([]modulesListModule, 0)
total := uint(0)
end := offset + limit
for _, pkg := range api.packages {
for _, name := range names {
if strings.Contains(pkg.Name, name) {
total++
if total > offset && total < end {
modules = append(modules, modulesListModule{pkg.Name, "rpm"})
}
}
}
}
json.NewEncoder(writer).Encode(modulesListReply{
Total: total,
Offset: offset,
Limit: limit,
Modules: modules,
})
}
func (api *API) modulesInfoHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
type source struct {
License string `json:"license"`
Version string `json:"version"`
}
type build struct {
Arch string `json:"arch"`
BuildTime time.Time `json:"build_time"`
Epoch uint `json:"epoch"`
Release string `json:"release"`
Source source `json:"source"`
}
type project struct {
Name string `json:"name"`
Summary string `json:"summary"`
Description string `json:"description"`
Homepage string `json:"homepage"`
Builds []build `json:"builds"`
Dependencies []rpmmd.PackageSpec `json:"dependencies,omitempty"`
}
type projectsReply struct {
Projects []project `json:"projects"`
}
type modulesReply struct {
Modules []project `json:"modules"`
}
// handle both projects/info and modules/info, the latter includes dependencies
modulesRequested := strings.HasPrefix(request.URL.Path, "/api/v0/modules")
names := strings.Split(params.ByName("modules"), ",")
if names[0] == "" {
statusResponseError(writer, http.StatusNotFound)
return
}
projects := make([]project, 0)
for _, name := range names {
first, n := api.packages.Search(name)
if n == 0 {
statusResponseError(writer, http.StatusNotFound)
return
}
// get basic info from the first package, but collect build
// information from all that have the same name
pkg := api.packages[first]
project := project{
Name: pkg.Name,
Summary: pkg.Summary,
Description: pkg.Description,
Homepage: pkg.URL,
}
project.Builds = make([]build, n)
for i, pkg := range api.packages[first : first+n] {
project.Builds[i] = build{
Arch: pkg.Arch,
BuildTime: pkg.BuildTime,
Epoch: pkg.Epoch,
Release: pkg.Release,
Source: source{pkg.License, pkg.Version},
}
}
if modulesRequested {
project.Dependencies, _ = rpmmd.Depsolve(pkg.Name)
}
projects = append(projects, project)
}
if modulesRequested {
json.NewEncoder(writer).Encode(modulesReply{projects})
} else {
json.NewEncoder(writer).Encode(projectsReply{projects})
}
}
func (api *API) blueprintsListHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
type reply struct {
Total uint `json:"total"`
Offset uint `json:"offset"`
Limit uint `json:"limit"`
Blueprints []string `json:"blueprints"`
}
offset, limit, err := parseOffsetAndLimit(request.URL.Query())
if err != nil {
statusResponseError(writer, http.StatusBadRequest, "BadRequest: "+err.Error())
return
}
names := api.store.listBlueprints()
total := uint(len(names))
offset = min(offset, total)
limit = min(limit, total-offset)
json.NewEncoder(writer).Encode(reply{
Total: total,
Offset: offset,
Limit: limit,
Blueprints: names[offset : offset+limit],
})
}
func (api *API) blueprintsInfoHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
type change struct {
Changed bool `json:"changed"`
Name string `json:"name"`
}
type reply struct {
Blueprints []blueprint `json:"blueprints"`
Changes []change `json:"changes"`
Errors []string `json:"errors"`
}
names := strings.Split(params.ByName("blueprints"), ",")
if names[0] == "" {
statusResponseError(writer, http.StatusNotFound)
return
}
blueprints := []blueprint{}
changes := []change{}
for _, name := range names {
var blueprint blueprint
var changed bool
if !api.store.getBlueprint(name, &blueprint, &changed) {
statusResponseError(writer, http.StatusNotFound)
return
}
blueprints = append(blueprints, blueprint)
changes = append(changes, change{changed, blueprint.Name})
}
json.NewEncoder(writer).Encode(reply{
Blueprints: blueprints,
Changes: changes,
Errors: []string{},
})
}
func (api *API) blueprintsDepsolveHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
type entry struct {
Blueprint blueprint `json:"blueprint"`
Dependencies []rpmmd.PackageSpec `json:"dependencies"`
}
type reply struct {
Blueprints []entry `json:"blueprints"`
Errors []string `json:"errors"`
}
names := strings.Split(params.ByName("blueprints"), ",")
if names[0] == "" {
statusResponseError(writer, http.StatusNotFound)
return
}
blueprints := []entry{}
for _, name := range names {
var blueprint blueprint
if !api.store.getBlueprint(name, &blueprint, nil) {
statusResponseError(writer, http.StatusNotFound)
return
}
specs := make([]string, len(blueprint.Packages))
for i, pkg := range blueprint.Packages {
specs[i] = pkg.Name
// If a package has version "*" the package name suffix must be equal to "-*-*.*"
// Using just "-*" would find any other package containing the package name
if pkg.Version != "" && pkg.Version != "*" {
specs[i] += "-" + pkg.Version
} else if pkg.Version == "*" {
specs[i] += "-*-*.*"
}
}
dependencies, _ := rpmmd.Depsolve(specs...)
blueprints = append(blueprints, entry{blueprint, dependencies})
}
json.NewEncoder(writer).Encode(reply{
Blueprints: blueprints,
Errors: []string{},
})
}
func (api *API) blueprintsDiffHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
type pack struct {
Package blueprintPackage `json:"Package"`
}
type diff struct {
New *pack `json:"new"`
Old *pack `json:"old"`
}
type reply struct {
Diffs []diff `json:"diff"`
}
name := params.ByName("blueprint")
if len(name) == 0 {
statusResponseError(writer, http.StatusNotFound, "no blueprint name given")
return
}
fromCommit := params.ByName("from")
if len(fromCommit) == 0 || fromCommit != "NEWEST" {
statusResponseError(writer, http.StatusNotFound, "invalid from commit ID given")
return
}
toCommit := params.ByName("to")
if len(toCommit) == 0 || toCommit != "WORKSPACE" {
statusResponseError(writer, http.StatusNotFound, "invalid to commit ID given")
return
}
// Fetch old and new blueprint details from store and return error if not found
var oldBlueprint, newBlueprint blueprint
if !api.store.getBlueprintCommitted(name, &oldBlueprint) || !api.store.getBlueprint(name, &newBlueprint, nil) {
statusResponseError(writer, http.StatusNotFound)
return
}
newSlice := newBlueprint.Packages
oldMap := make(map[string]blueprintPackage)
diffs := []diff{}
for _, oldPackage := range oldBlueprint.Packages {
oldMap[oldPackage.Name] = oldPackage
}
// For each package in new blueprint check if the old one contains it
for _, newPackage := range newSlice {
oldPackage, found := oldMap[newPackage.Name]
// If found remove from old packages map but otherwise create a diff with the added package
if found {
delete(oldMap, oldPackage.Name)
// Create a diff if the versions changed
if oldPackage.Version != newPackage.Version {
diffs = append(diffs, diff{Old: &pack{oldPackage}, New: &pack{newPackage}})
}
} else {
diffs = append(diffs, diff{Old: nil, New: &pack{newPackage}})
}
}
// All packages remaining in the old packages map have been removed in the new blueprint so create a diff
for _, oldPackage := range oldMap {
diffs = append(diffs, diff{Old: &pack{oldPackage}, New: nil})
}
json.NewEncoder(writer).Encode(reply{diffs})
}
func (api *API) blueprintsNewHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
contentType := request.Header["Content-Type"]
if len(contentType) != 1 || contentType[0] != "application/json" {
statusResponseError(writer, http.StatusUnsupportedMediaType, "blueprint must be json")
return
}
var blueprint blueprint
err := json.NewDecoder(request.Body).Decode(&blueprint)
if err != nil {
statusResponseError(writer, http.StatusBadRequest, "invalid blueprint: "+err.Error())
return
}
api.store.pushBlueprint(blueprint)
statusResponseOK(writer)
}
func (api *API) blueprintsWorkspaceHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
contentType := request.Header["Content-Type"]
if len(contentType) != 1 || contentType[0] != "application/json" {
statusResponseError(writer, http.StatusUnsupportedMediaType, "blueprint must be json")
return
}
var blueprint blueprint
err := json.NewDecoder(request.Body).Decode(&blueprint)
if err != nil {
statusResponseError(writer, http.StatusBadRequest, "invalid blueprint: "+err.Error())
return
}
api.store.pushBlueprintToWorkspace(blueprint)
statusResponseOK(writer)
}
func (api *API) blueprintDeleteHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
api.store.deleteBlueprint(params.ByName("blueprint"))
statusResponseOK(writer)
}
func (api *API) blueprintDeleteWorkspaceHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
api.store.deleteBlueprintFromWorkspace(params.ByName("blueprint"))
statusResponseOK(writer)
}
// Schedule new compose by first translating the appropriate blueprint into a pipeline and then
// pushing it into the channel for waiting builds.
func (api *API) composeHandler(writer http.ResponseWriter, httpRequest *http.Request, _ httprouter.Params) {
// https://weldr.io/lorax/pylorax.api.html#pylorax.api.v0.v0_compose_start
type ComposeRequest struct {
BlueprintName string `json:"blueprint_name"`
ComposeType string `json:"compose_type"`
Branch string `json:"branch"`
}
type ComposeReply struct {
BuildID uuid.UUID `json:"build_id"`
Status bool `json:"status"`
}
contentType := httpRequest.Header["Content-Type"]
if len(contentType) != 1 || contentType[0] != "application/json" {
statusResponseError(writer, http.StatusUnsupportedMediaType, "blueprint must be json")
return
}
var cr ComposeRequest
err := json.NewDecoder(httpRequest.Body).Decode(&cr)
if err != nil {
statusResponseError(writer, http.StatusBadRequest, "invalid request format: "+err.Error())
return
}
reply := ComposeReply{
BuildID: uuid.New(),
Status: true,
}
bp := blueprint{}
changed := false
found := api.store.getBlueprint(cr.BlueprintName, &bp, &changed) // TODO: what to do with changed?
if found {
api.store.addCompose(reply.BuildID, &bp, cr.ComposeType)
} else {
statusResponseError(writer, http.StatusBadRequest, "blueprint does not exist")
return
}
json.NewEncoder(writer).Encode(reply)
}
func (api *API) composeTypesHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
type composeType struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
}
var reply struct {
Types []composeType `json:"types"`
}
reply.Types = append(reply.Types, composeType{"tar", true})
json.NewEncoder(writer).Encode(reply)
}
func (api *API) composeQueueHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
var reply struct {
New []interface{} `json:"new"`
Run []interface{} `json:"run"`
}
reply.New = make([]interface{}, 0)
reply.Run = make([]interface{}, 0)
json.NewEncoder(writer).Encode(reply)
}
func (api *API) composeFinishedHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
var reply struct {
Finished []interface{} `json:"finished"`
}
reply.Finished = make([]interface{}, 0)
json.NewEncoder(writer).Encode(reply)
}
func (api *API) composeFailedHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
var reply struct {
Failed []interface{} `json:"failed"`
}
reply.Failed = make([]interface{}, 0)
json.NewEncoder(writer).Encode(reply)
}