mirror of
https://github.com/siderolabs/image-factory.git
synced 2025-09-26 16:21:11 +02:00
Initial version of the image service. Implements a basic configuration service, and HTTP frontend for assets. Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
182 lines
4.5 KiB
Go
182 lines
4.5 KiB
Go
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
// Package http implements the HTTP frontend.
|
|
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/blang/semver/v4"
|
|
"github.com/julienschmidt/httprouter"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/siderolabs/image-service/internal/asset"
|
|
cfg "github.com/siderolabs/image-service/internal/configuration"
|
|
"github.com/siderolabs/image-service/internal/configuration/storage"
|
|
"github.com/siderolabs/image-service/internal/profile"
|
|
"github.com/siderolabs/image-service/pkg/configuration"
|
|
)
|
|
|
|
// Frontend is the HTTP frontend.
|
|
type Frontend struct {
|
|
router *httprouter.Router
|
|
configService *cfg.Service
|
|
assetBuilder *asset.Builder
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewFrontend creates a new HTTP frontend.
|
|
func NewFrontend(logger *zap.Logger, configService *cfg.Service, assetBuilder *asset.Builder) *Frontend {
|
|
frontend := &Frontend{
|
|
router: httprouter.New(),
|
|
configService: configService,
|
|
assetBuilder: assetBuilder,
|
|
logger: logger.With(zap.String("frontend", "http")),
|
|
}
|
|
|
|
// assets
|
|
frontend.router.GET("/image/:configuration/:version/:path", frontend.wrapper(frontend.handleAsset))
|
|
frontend.router.HEAD("/image/:configuration/:version/:path", frontend.wrapper(frontend.handleAsset))
|
|
|
|
// configuration
|
|
frontend.router.POST("/configuration", frontend.wrapper(frontend.handleConfigurationCreate))
|
|
|
|
return frontend
|
|
}
|
|
|
|
// Handler returns the HTTP handler.
|
|
func (f *Frontend) Handler() http.Handler {
|
|
return f.router
|
|
}
|
|
|
|
func (f *Frontend) wrapper(h func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error) httprouter.Handle {
|
|
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
|
ctx := r.Context()
|
|
|
|
err := h(ctx, w, r, p)
|
|
|
|
f.logger.Info("request", zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.Error(err))
|
|
|
|
switch {
|
|
case err == nil:
|
|
// happy case
|
|
case errors.Is(err, storage.ErrNotFound):
|
|
http.Error(w, "configuration not found", http.StatusNotFound)
|
|
case errors.Is(err, context.Canceled):
|
|
// client closed connection
|
|
default:
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleAsset handles downloading of boot assets.
|
|
func (f *Frontend) handleAsset(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
|
|
configurationID := p.ByName("configuration")
|
|
|
|
configuration, err := f.configService.Get(ctx, configurationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
versionTag := p.ByName("version")
|
|
if !strings.HasPrefix(versionTag, "v") {
|
|
versionTag = "v" + versionTag
|
|
}
|
|
|
|
version, err := semver.Parse(versionTag[1:])
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing version: %w", err)
|
|
}
|
|
|
|
path := p.ByName("path")
|
|
|
|
prof, err := profile.ParseFromPath(path)
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing profile from path: %w", err)
|
|
}
|
|
|
|
prof, err = profile.EnhanceFromConfiguration(prof, configuration, versionTag)
|
|
if err != nil {
|
|
return fmt.Errorf("error enhancing profile from configuration: %w", err)
|
|
}
|
|
|
|
if err = prof.Validate(); err != nil {
|
|
return fmt.Errorf("error validating profile: %w", err)
|
|
}
|
|
|
|
asset, err := f.assetBuilder.Build(ctx, prof, version.String())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer asset.Release() //nolint:errcheck
|
|
|
|
w.Header().Set("Content-Length", strconv.FormatInt(asset.Size(), 10))
|
|
|
|
if ext := filepath.Ext(path); ext != "" {
|
|
w.Header().Set("Content-Type", mime.TypeByExtension(ext))
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
if r.Method == http.MethodHead {
|
|
return nil
|
|
}
|
|
|
|
reader, err := asset.Reader()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer reader.Close() //nolint:errcheck
|
|
|
|
_, err = io.Copy(w, reader)
|
|
|
|
return err
|
|
}
|
|
|
|
// handleConfigurationCreate handles downloading of boot assets.
|
|
func (f *Frontend) handleConfigurationCreate(ctx context.Context, w http.ResponseWriter, r *http.Request, _ httprouter.Params) error {
|
|
data, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = r.Body.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg, err := configuration.Unmarshal(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
id, err := f.configService.Put(ctx, cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
w.Header().Add("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
|
|
resp := struct {
|
|
ID string `json:"id"`
|
|
}{
|
|
ID: id,
|
|
}
|
|
|
|
return json.NewEncoder(w).Encode(resp)
|
|
}
|