omni/internal/backend/factory/factory.go
Artem Chernyshev ccd55cc8fb
Some checks are pending
default / default (push) Waiting to run
default / e2e-backups (push) Blocked by required conditions
default / e2e-forced-removal (push) Blocked by required conditions
default / e2e-scaling (push) Blocked by required conditions
default / e2e-short (push) Blocked by required conditions
default / e2e-short-secureboot (push) Blocked by required conditions
default / e2e-templates (push) Blocked by required conditions
default / e2e-upgrades (push) Blocked by required conditions
default / e2e-workload-proxy (push) Blocked by required conditions
feat: rewrite Omni config management
Omni can now be configured via a config file instead of the command line
flags.
The flags `--config-path` will now read the config provided in the YAML
format.
The config structure was completely changed. It was not public before,
so it's fine to ignore backward compatibility.
The command line flags were not changed.

Signed-off-by: Artem Chernyshev <artem.chernyshev@talos-systems.com>
2025-06-09 14:44:29 +03:00

207 lines
5.0 KiB
Go

// Copyright (c) 2025 Sidero Labs, Inc.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
// Package factory provides the code to call Talos image factory.
package factory
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/cosi-project/runtime/pkg/resource"
"github.com/cosi-project/runtime/pkg/safe"
"github.com/cosi-project/runtime/pkg/state"
"github.com/siderolabs/talos/pkg/machinery/imager/quirks"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/siderolabs/omni/client/pkg/constants"
"github.com/siderolabs/omni/client/pkg/omni/resources"
"github.com/siderolabs/omni/client/pkg/omni/resources/omni"
"github.com/siderolabs/omni/internal/pkg/config"
)
// Handler of image requests.
type Handler struct {
state state.State
logger *zap.Logger
config *config.Registries
}
// NewHandler creates a new factory proxy handler.
func NewHandler(state state.State, logger *zap.Logger, config *config.Registries) *Handler {
return &Handler{
state: state,
logger: logger,
config: config,
}
}
func setContentHeaders(w http.ResponseWriter, contentType, filename string) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "attachment; filename="+filename)
w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
}
func httpNotFound(w http.ResponseWriter) {
http.Error(w, "Not found", http.StatusNotFound)
}
func (handler *Handler) handleError(msg string, w http.ResponseWriter, err error) {
handler.logger.Error(msg, zap.Error(err))
switch status.Code(err) { //nolint:exhaustive
case codes.Unauthenticated:
http.Error(w, "Unauthorized", http.StatusUnauthorized)
case codes.PermissionDenied:
http.Error(w, "Permission denied", http.StatusForbidden)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// ServeHTTP handles image requests.
func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
io.Copy(io.Discard, r.Body) //nolint:errcheck
r.Body.Close() //nolint:errcheck
switch r.Method {
case http.MethodGet, http.MethodHead:
// supported
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
params, err := parseRequest(r, handler.state, handler.config)
if err != nil {
if errors.Is(err, errNotFound) {
httpNotFound(w)
return
}
handler.handleError("failed to parse request", w, err)
return
}
handler.logger.Info("proxy request", zap.String("url", params.ProxyURL))
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, params.ProxyURL, nil)
if err != nil {
handler.handleError("failed to call image factory", w, err)
return
}
setContentHeaders(w, params.ContentType, params.DestinationFilename)
for name, values := range r.Header {
for _, value := range values {
proxyReq.Header.Add(name, value)
}
}
client := http.Client{}
resp, err := client.Do(proxyReq)
if err != nil {
handler.handleError("failed to call image factory", w, err)
return
}
defer resp.Body.Close() //nolint:errcheck
for name, values := range resp.Header {
// ignore content disposition header from the image factory
// as we override it here
if name == "Content-Disposition" {
continue
}
for _, value := range values {
w.Header().Add(name, value)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body) //nolint:errcheck
}
var errNotFound = errors.New("not found")
// ProxyParams is exposed for the unit tests.
type ProxyParams struct {
ProxyURL string
ContentType string
DestinationFilename string
}
func parseRequest(r *http.Request, st state.State, config *config.Registries) (*ProxyParams, error) {
segments := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(segments) < 4 {
return nil, errNotFound
}
if segments[0] != "image" {
return nil, errNotFound
}
ctx := r.Context()
media, err := safe.ReaderGet[*omni.InstallationMedia](ctx, st, resource.NewMetadata(
resources.EphemeralNamespace, omni.InstallationMediaType, segments[3], resource.VersionUndefined,
))
if err != nil {
if state.IsNotFoundError(err) {
return nil, errNotFound
}
return nil, err
}
secureBoot := r.URL.Query().Get(constants.SecureBoot) == "true"
talosVersion := segments[2]
srcFilename := talosVersion
if secureBoot {
srcFilename += "-secureboot"
}
p := &ProxyParams{
ContentType: media.TypedSpec().Value.ContentType,
DestinationFilename: fmt.Sprintf("%s-%s.%s", media.TypedSpec().Value.DestFilePrefix, srcFilename, media.TypedSpec().Value.Extension),
}
proxyURL, err := url.Parse(config.ImageFactoryBaseURL)
if err != nil {
return nil, err
}
filename := media.TypedSpec().Value.GenerateFilename(!quirks.New(talosVersion).SupportsOverlay(), secureBoot, true)
// strip the last element from the URL
// replace it with the generated filename
segments = append(segments[:3], filename)
proxyURL = proxyURL.JoinPath(segments...)
p.ProxyURL = proxyURL.String()
return p, nil
}