Mateusz Urbanek 7c6d26184c
fix: set content-disposition on S3
S3 does not set proper Content-Disposition, and this header might be
ignored by e.g. browsers if set before redirect. We need to include it
when adding new object to the cache.

Additionally, if the object was previously cached, we need to ensure
that Content-Disposition header will be properly updated.

Signed-off-by: Mateusz Urbanek <mateusz.urbanek@siderolabs.com>
2025-08-12 08:55:45 +02:00

324 lines
10 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
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/blang/semver/v4"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/tarball"
"github.com/julienschmidt/httprouter"
"github.com/sigstore/cosign/v2/pkg/cosign"
"go.uber.org/zap"
"golang.org/x/sync/singleflight"
"github.com/siderolabs/image-factory/internal/artifacts"
"github.com/siderolabs/image-factory/internal/asset"
"github.com/siderolabs/image-factory/internal/profile"
"github.com/siderolabs/image-factory/internal/regtransport"
"github.com/siderolabs/image-factory/pkg/schematic"
)
// handleHealth handles registry health and auth.
func (f *Frontend) handleHealth(_ context.Context, _ http.ResponseWriter, _ *http.Request, _ httprouter.Params) error {
// always healthy, yay!
return nil
}
type requestedImage struct {
imageName string
platform string
secureboot bool
}
func getRequestedImage(p httprouter.Params) (requestedImage, error) {
image := p.ByName("image")
switch image {
case "installer":
// defaults to metal image
return requestedImage{
imageName: image,
secureboot: false,
}, nil
case "installer-secureboot":
return requestedImage{
imageName: image,
secureboot: true,
}, nil
default:
// newer installer has `-installer` as suffix
// Eg: metal-installer, metal-installer-secureboot, digital-ocean-installer etc
// first try `-installer-secureboot` and then `-installer`
platform, ok := strings.CutSuffix(image, "-installer-secureboot")
if ok {
return requestedImage{imageName: image, platform: platform, secureboot: true}, nil
}
if platform, ok = strings.CutSuffix(image, "-installer"); ok {
return requestedImage{imageName: image, platform: platform, secureboot: false}, nil
}
return requestedImage{}, fmt.Errorf("invalid image: %s", image)
}
}
func (img requestedImage) Name() string {
return img.imageName
}
// handleBlob handles image blob download.
//
// We always redirect to the external registry, as we assume the image has already been pushed.
func (f *Frontend) handleBlob(ctx context.Context, w http.ResponseWriter, _ *http.Request, p httprouter.Params) error {
// verify that schematic exists
schematicID := p.ByName("schematic")
_, err := f.schematicFactory.Get(ctx, schematicID)
if err != nil {
return err
}
img, err := getRequestedImage(p)
if err != nil {
return err
}
digest := p.ByName("digest")
var redirectURL url.URL
redirectURL.Scheme = f.options.InstallerExternalRepository.Scheme()
redirectURL.Host = f.options.InstallerExternalRepository.Registry.Name()
location := redirectURL.JoinPath("v2", f.options.InstallerExternalRepository.RepositoryStr(), img.Name(), schematicID, "blobs", digest).String()
f.logger.Info("redirecting blob", zap.String("location", location))
w.Header().Add("Location", location)
w.WriteHeader(http.StatusTemporaryRedirect)
return nil
}
func (f *Frontend) redirectToExternalRegistry(w http.ResponseWriter, imageName, schematicID, tagOrDigest string) error {
var redirectURL url.URL
redirectURL.Scheme = f.options.InstallerExternalRepository.Scheme()
redirectURL.Host = f.options.InstallerExternalRepository.Registry.Name()
location := redirectURL.JoinPath("v2", f.options.InstallerExternalRepository.RepositoryStr(), imageName, schematicID, "manifests", tagOrDigest).String()
f.logger.Info("redirecting manifest", zap.String("location", location))
w.Header().Add("Location", location)
w.WriteHeader(http.StatusTemporaryRedirect)
return nil
}
// handleManifest handles image manifest download.
//
// If the manifest is for the tag, we check if the image already exists, and either redirect, or build, push and redirect.
func (f *Frontend) handleManifest(ctx context.Context, w http.ResponseWriter, _ *http.Request, p httprouter.Params) error {
schematicID := p.ByName("schematic")
schematic, err := f.schematicFactory.Get(ctx, schematicID)
if err != nil {
return err
}
versionTag := p.ByName("tag")
img, err := getRequestedImage(p)
if err != nil {
return err
}
// if the tag is the digest, or it doesn't look like the version, we just redirect to the external registry
if strings.HasPrefix(versionTag, "sha256:") || !strings.HasPrefix(versionTag, "v") {
return f.redirectToExternalRegistry(w, img.Name(), schematicID, versionTag)
}
imageRepository := f.options.InstallerInternalRepository.Repo(
f.options.InstallerInternalRepository.RepositoryStr(),
img.Name(),
schematicID,
)
// check if the asset has already been built
f.logger.Info("heading installer image",
zap.String("image", img.Name()),
zap.String("schematic", schematicID),
zap.String("version", versionTag),
zap.Stringer("ref", imageRepository.Tag(versionTag)),
)
extDesc, err := f.puller.Head(
ctx,
imageRepository.Tag(versionTag),
)
if err == nil {
// the asset has already been built, so check the signature
f.logger.Info("verifying cached installer image signature",
zap.String("image", img.Name()),
zap.String("schematic", schematicID),
zap.String("version", versionTag),
zap.Stringer("ref", imageRepository.Digest(extDesc.Digest.String())),
)
_, _, signatureErr := cosign.VerifyImageSignatures(
ctx,
imageRepository.Digest(extDesc.Digest.String()),
f.imageSigner.GetCheckOpts(),
)
if signatureErr == nil {
// redirect to the external registry, but use the digest directly to avoid tag changes
return f.redirectToExternalRegistry(w, img.Name(), schematicID, extDesc.Digest.String())
}
// log the signature verification error, but continue to build the image
f.logger.Error("error verifying cached image signature", zap.String("image", img.Name()), zap.String("schematic", schematicID), zap.String("version", versionTag), zap.Error(signatureErr))
}
if regtransport.IsStatusCodeError(err, http.StatusNotFound, http.StatusForbidden) {
// ignore 404/403, it means the image hasn't been built yet
err = nil
}
if err != nil {
// something is wrong
return err
}
// installer image is not built yet, build it and push it
version, err := semver.Parse(versionTag[1:])
if err != nil {
return fmt.Errorf("error parsing version: %w", err)
}
// build installer images for each architecture, combine them into a single index and push it
key := fmt.Sprintf("%s-%s-%s", img.Name(), schematicID, versionTag)
resultCh := f.sf.DoChan(key, func() (any, error) { //nolint:contextcheck
// we use here detached context to make sure image is built no matter if the request is canceled
return f.buildInstallImage(context.Background(), img, schematic, version, schematicID, versionTag)
})
var res singleflight.Result
select {
case res = <-resultCh:
if res.Err != nil {
return res.Err
}
case <-ctx.Done():
return ctx.Err()
}
manifestHash, ok := res.Val.(v1.Hash)
if !ok {
// unexpected
return fmt.Errorf("unexpected result type: %T", res.Val)
}
// now we can redirect to the external registry
return f.redirectToExternalRegistry(w, img.Name(), schematicID, manifestHash.String())
}
func (f *Frontend) buildInstallImage(ctx context.Context, img requestedImage, schematic *schematic.Schematic, version semver.Version, schematicID, versionTag string) (v1.Hash, error) {
f.logger.Info("building installer image", zap.String("image", img.Name()), zap.String("schematic", schematicID), zap.String("version", versionTag))
var imageIndex v1.ImageIndex = empty.Index
for _, arch := range []artifacts.Arch{artifacts.ArchAmd64, artifacts.ArchArm64} {
prof := profile.InstallerProfile(img.secureboot, arch, img.platform)
prof, err := profile.EnhanceFromSchematic(ctx, prof, schematic, f.artifactsManager, f.secureBootService, versionTag)
if err != nil {
return v1.Hash{}, fmt.Errorf("error enhancing profile from schematic: %w", err)
}
if err = prof.Validate(); err != nil {
return v1.Hash{}, fmt.Errorf("error validating profile: %w", err)
}
var asset asset.BootAsset
asset, err = f.assetBuilder.Build(ctx, prof, version.String(), img.Name())
if err != nil {
return v1.Hash{}, err
}
var archImage v1.Image
archImage, err = tarball.Image(asset.Reader, nil)
if err != nil {
return v1.Hash{}, fmt.Errorf("error creating image from asset: %w", err)
}
imageIndex = mutate.AppendManifests(imageIndex,
mutate.IndexAddendum{
Add: archImage,
Descriptor: v1.Descriptor{
Platform: &v1.Platform{
Architecture: prof.Arch,
OS: "linux",
},
},
})
}
f.logger.Info("pushing installer image", zap.String("image", img.Name()), zap.String("schematic", schematicID), zap.String("version", versionTag))
installerRepo := f.options.InstallerInternalRepository.Repo(
f.options.InstallerInternalRepository.RepositoryStr(),
img.Name(),
schematicID,
)
if err := f.pusher.Push(
ctx,
installerRepo.Tag(versionTag),
imageIndex,
); err != nil {
return v1.Hash{}, fmt.Errorf("error pushing index: %w", err)
}
digest, err := imageIndex.Digest()
if err != nil {
return v1.Hash{}, fmt.Errorf("error getting index digest: %w", err)
}
f.logger.Info("signing installer image", zap.String("image", img.Name()), zap.String("schematic", schematicID), zap.String("version", versionTag), zap.Stringer("digest", digest))
if err := f.imageSigner.SignImage(
ctx,
installerRepo.Digest(digest.String()),
f.pusher,
); err != nil {
return v1.Hash{}, fmt.Errorf("error signing image: %w", err)
}
return digest, nil
}
// handleCosignSigningKeyPub returns cosign public key in PEM format.
func (f *Frontend) handleCosignSigningKeyPub(_ context.Context, w http.ResponseWriter, _ *http.Request, _ httprouter.Params) error {
w.Header().Set("Content-Type", "application/x-pem-file")
w.WriteHeader(http.StatusOK)
_, err := w.Write(f.imageSigner.GetPublicKeyPEM())
return err
}