From dacbfd7332434b09d364fb6c62adfd9eeb35791c Mon Sep 17 00:00:00 2001 From: mac641 Date: Thu, 22 Jan 2026 10:15:08 +0100 Subject: [PATCH 1/7] feat(service): implement checkOciImageURI in order to support pulling OCI images --- .../internal/service/image-service.go | 54 ++++++++++++++++--- .../internal/service/partition-service.go | 47 ++++++++++++---- go.mod | 17 +++++- go.sum | 14 +++++ 4 files changed, 114 insertions(+), 18 deletions(-) diff --git a/cmd/metal-api/internal/service/image-service.go b/cmd/metal-api/internal/service/image-service.go index a647d569..5041c500 100644 --- a/cmd/metal-api/internal/service/image-service.go +++ b/cmd/metal-api/internal/service/image-service.go @@ -16,6 +16,9 @@ import ( restfulspec "github.com/emicklei/go-restful-openapi/v2" restful "github.com/emicklei/go-restful/v3" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/metal-stack/metal-lib/httperrors" ) @@ -317,10 +320,16 @@ func (r *imageResource) createImage(request *restful.Request, response *restful. } } + var errs []error err = checkImageURL(requestPayload.ID, requestPayload.URL) if err != nil { - r.sendError(request, response, httperrors.BadRequest(err)) - return + errs = append(errs, err) + err = checkOciImageURI(requestPayload.ID, "", "", requestPayload.URL) + if err != nil { + errs = append(errs, err) + r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) + return + } } img := &metal.Image{ @@ -346,15 +355,42 @@ func (r *imageResource) createImage(request *restful.Request, response *restful. r.send(request, response, http.StatusCreated, v1.NewImageResponse(img)) } +func checkOciImageURI(id, username, password, uri string) error { + ref, err := name.ParseReference(uri) + if err != nil { + return fmt.Errorf("image reference:%s could not be parsed. error:%w", uri, err) + } + + var auth = authn.Anonymous + if username != "" || password != "" { + auth = &authn.Basic{ + Username: username, + Password: password, + } + } + + _, err = remote.Head(ref, remote.WithAuth(auth)) + if err != nil { + return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, uri, err) + } + + return nil +} + func checkImageURL(id, url string) error { - // nolint - res, err := http.Head(url) + var ( + err error + res *http.Response + ) + + res, err = http.Head(url) if err != nil { return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, url, err) } if res.StatusCode >= 400 { return fmt.Errorf("image:%s is not accessible under:%s status:%s", id, url, res.Status) } + return nil } @@ -411,10 +447,16 @@ func (r *imageResource) updateImage(request *restful.Request, response *restful. newImage.Description = *requestPayload.Description } if requestPayload.URL != nil { + var errs []error err = checkImageURL(requestPayload.ID, *requestPayload.URL) if err != nil { - r.sendError(request, response, httperrors.BadRequest(err)) - return + errs = append(errs, err) + err = checkOciImageURI(requestPayload.ID, "", "", *requestPayload.URL) + if err != nil { + errs = append(errs, err) + r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) + return + } } newImage.URL = *requestPayload.URL diff --git a/cmd/metal-api/internal/service/partition-service.go b/cmd/metal-api/internal/service/partition-service.go index 6d1cddfb..4cc23f09 100644 --- a/cmd/metal-api/internal/service/partition-service.go +++ b/cmd/metal-api/internal/service/partition-service.go @@ -175,10 +175,16 @@ func (r *partitionResource) createPartition(request *restful.Request, response * imageURL = *requestPayload.PartitionBootConfiguration.ImageURL } + var errs []error err = checkImageURL("image", imageURL) if err != nil { - r.sendError(request, response, httperrors.BadRequest(err)) - return + errs = append(errs, err) + err = checkOciImageURI("image", "", "", imageURL) + if err != nil { + errs = append(errs, err) + r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) + return + } } var kernelURL string @@ -186,10 +192,16 @@ func (r *partitionResource) createPartition(request *restful.Request, response * kernelURL = *requestPayload.PartitionBootConfiguration.KernelURL } - err = checkImageURL("kernel", kernelURL) + errs = []error{} + err = checkImageURL("kernel", imageURL) if err != nil { - r.sendError(request, response, httperrors.BadRequest(err)) - return + errs = append(errs, err) + err = checkOciImageURI("kernel", "", "", imageURL) + if err != nil { + errs = append(errs, err) + r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) + return + } } var commandLine string @@ -275,7 +287,10 @@ func (r *partitionResource) deletePartition(request *restful.Request, response * } func (r *partitionResource) updatePartition(request *restful.Request, response *restful.Response) { - var requestPayload v1.PartitionUpdateRequest + var ( + requestPayload v1.PartitionUpdateRequest + errs []error + ) err := request.ReadEntity(&requestPayload) if err != nil { r.sendError(request, response, httperrors.BadRequest(err)) @@ -305,19 +320,31 @@ func (r *partitionResource) updatePartition(request *restful.Request, response * if requestPayload.PartitionBootConfiguration.ImageURL != nil { err = checkImageURL("image", *requestPayload.PartitionBootConfiguration.ImageURL) if err != nil { - r.sendError(request, response, httperrors.BadRequest(err)) - return + errs = append(errs, err) + err = checkOciImageURI("image", "", "", *requestPayload.PartitionBootConfiguration.ImageURL) + if err != nil { + errs = append(errs, err) + r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) + return + } } newPartition.BootConfiguration.ImageURL = *requestPayload.PartitionBootConfiguration.ImageURL } if requestPayload.PartitionBootConfiguration.KernelURL != nil { + errs = []error{} err = checkImageURL("kernel", *requestPayload.PartitionBootConfiguration.KernelURL) if err != nil { - r.sendError(request, response, httperrors.BadRequest(err)) - return + errs = append(errs, err) + err = checkOciImageURI("kernel", "", "", *requestPayload.PartitionBootConfiguration.KernelURL) + if err != nil { + errs = append(errs, err) + r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) + return + } } + newPartition.BootConfiguration.KernelURL = *requestPayload.PartitionBootConfiguration.KernelURL } if requestPayload.PartitionBootConfiguration.CommandLine != nil { diff --git a/go.mod b/go.mod index 13451df1..ce7bafa9 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,21 @@ require ( gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2 ) +require ( + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect +) + +require ( + github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect + github.com/docker/cli v29.0.3+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect + github.com/google/go-containerregistry v0.20.7 + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/vbatts/tar-split v0.12.2 // indirect +) + // Newer versions do not export base entities which are used to composite other entities. // This breaks metalctl and friends replace github.com/emicklei/go-restful-openapi/v2 => github.com/emicklei/go-restful-openapi/v2 v2.9.1 @@ -51,8 +66,6 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/containerd/errdefs v1.0.0 // indirect - github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect diff --git a/go.sum b/go.sum index 62541928..e56f2514 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/containerd/stargz-snapshotter/estargz v0.18.1 h1:cy2/lpgBXDA3cDKSyEfNOFMA/c10O1axL69EU7iirO8= +github.com/containerd/stargz-snapshotter/estargz v0.18.1/go.mod h1:ALIEqa7B6oVDsrF37GkGN20SuvG/pIMm7FwP7ZmRb0Q= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= @@ -71,8 +73,14 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v29.0.3+incompatible h1:8J+PZIcF2xLd6h5sHPsp5pvvJA+Sr2wGQxHkRl53a1E= +github.com/docker/cli v29.0.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -180,6 +188,8 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= +github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20240829160300-da1f7e9f2b25 h1:sEDPKUw6iPjczdu33njxFjO6tYa9bfc0z/QyB/zSsBw= github.com/google/pprof v0.0.0-20240829160300-da1f7e9f2b25/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= @@ -301,6 +311,8 @@ github.com/metal-stack/v v1.0.3 h1:Sh2oBlnxrCUD+mVpzfC8HiqL045YWkxs0gpTvkjppqs= github.com/metal-stack/v v1.0.3/go.mod h1:YTahEu7/ishwpYKnp/VaW/7nf8+PInogkfGwLcGPdXg= github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= @@ -429,6 +441,8 @@ github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfj github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= +github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= From b72964aa4d8c93bbb17c93bd2fa195c3774a032b Mon Sep 17 00:00:00 2001 From: mac641 Date: Thu, 29 Jan 2026 13:26:02 +0100 Subject: [PATCH 2/7] refactor(service): merge checkOciImageURI into checkImageURL in order to reduce code duplication - fix previous copy-paste errors --- .../internal/service/image-service.go | 80 +++++++------------ .../internal/service/partition-service.go | 49 +++--------- 2 files changed, 44 insertions(+), 85 deletions(-) diff --git a/cmd/metal-api/internal/service/image-service.go b/cmd/metal-api/internal/service/image-service.go index 5041c500..08fe542f 100644 --- a/cmd/metal-api/internal/service/image-service.go +++ b/cmd/metal-api/internal/service/image-service.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "strconv" + "strings" "time" "github.com/metal-stack/metal-api/cmd/metal-api/internal/datastore" @@ -320,16 +321,10 @@ func (r *imageResource) createImage(request *restful.Request, response *restful. } } - var errs []error - err = checkImageURL(requestPayload.ID, requestPayload.URL) + err = checkImageURL(requestPayload.ID, requestPayload.URL, "", "") if err != nil { - errs = append(errs, err) - err = checkOciImageURI(requestPayload.ID, "", "", requestPayload.URL) - if err != nil { - errs = append(errs, err) - r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) - return - } + r.sendError(request, response, httperrors.BadRequest(err)) + return } img := &metal.Image{ @@ -355,40 +350,33 @@ func (r *imageResource) createImage(request *restful.Request, response *restful. r.send(request, response, http.StatusCreated, v1.NewImageResponse(img)) } -func checkOciImageURI(id, username, password, uri string) error { - ref, err := name.ParseReference(uri) - if err != nil { - return fmt.Errorf("image reference:%s could not be parsed. error:%w", uri, err) - } - - var auth = authn.Anonymous - if username != "" || password != "" { - auth = &authn.Basic{ - Username: username, - Password: password, +func checkImageURL(id, url, username, password string) error { + if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { + res, err := http.Head(url) + if err != nil { + return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, url, err) + } + if res.StatusCode >= 400 { + return fmt.Errorf("image:%s is not accessible under:%s status:%s", id, url, res.Status) + } + } else { + ref, err := name.ParseReference(url) + if err != nil { + return fmt.Errorf("image reference:%s could not be parsed. error:%w", url, err) } - } - - _, err = remote.Head(ref, remote.WithAuth(auth)) - if err != nil { - return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, uri, err) - } - - return nil -} -func checkImageURL(id, url string) error { - var ( - err error - res *http.Response - ) + var auth = authn.Anonymous + if username != "" || password != "" { + auth = &authn.Basic{ + Username: username, + Password: password, + } + } - res, err = http.Head(url) - if err != nil { - return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, url, err) - } - if res.StatusCode >= 400 { - return fmt.Errorf("image:%s is not accessible under:%s status:%s", id, url, res.Status) + _, err = remote.Head(ref, remote.WithAuth(auth)) + if err != nil { + return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, url, err) + } } return nil @@ -447,16 +435,10 @@ func (r *imageResource) updateImage(request *restful.Request, response *restful. newImage.Description = *requestPayload.Description } if requestPayload.URL != nil { - var errs []error - err = checkImageURL(requestPayload.ID, *requestPayload.URL) + err = checkImageURL(requestPayload.ID, *requestPayload.URL, "", "") if err != nil { - errs = append(errs, err) - err = checkOciImageURI(requestPayload.ID, "", "", *requestPayload.URL) - if err != nil { - errs = append(errs, err) - r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) - return - } + r.sendError(request, response, httperrors.BadRequest(err)) + return } newImage.URL = *requestPayload.URL diff --git a/cmd/metal-api/internal/service/partition-service.go b/cmd/metal-api/internal/service/partition-service.go index 4cc23f09..16a53e26 100644 --- a/cmd/metal-api/internal/service/partition-service.go +++ b/cmd/metal-api/internal/service/partition-service.go @@ -175,16 +175,10 @@ func (r *partitionResource) createPartition(request *restful.Request, response * imageURL = *requestPayload.PartitionBootConfiguration.ImageURL } - var errs []error - err = checkImageURL("image", imageURL) + err = checkImageURL("image", imageURL, "", "") if err != nil { - errs = append(errs, err) - err = checkOciImageURI("image", "", "", imageURL) - if err != nil { - errs = append(errs, err) - r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) - return - } + r.sendError(request, response, httperrors.BadRequest(err)) + return } var kernelURL string @@ -192,16 +186,10 @@ func (r *partitionResource) createPartition(request *restful.Request, response * kernelURL = *requestPayload.PartitionBootConfiguration.KernelURL } - errs = []error{} - err = checkImageURL("kernel", imageURL) + err = checkImageURL("kernel", kernelURL, "", "") if err != nil { - errs = append(errs, err) - err = checkOciImageURI("kernel", "", "", imageURL) - if err != nil { - errs = append(errs, err) - r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) - return - } + r.sendError(request, response, httperrors.BadRequest(err)) + return } var commandLine string @@ -289,8 +277,8 @@ func (r *partitionResource) deletePartition(request *restful.Request, response * func (r *partitionResource) updatePartition(request *restful.Request, response *restful.Response) { var ( requestPayload v1.PartitionUpdateRequest - errs []error ) + err := request.ReadEntity(&requestPayload) if err != nil { r.sendError(request, response, httperrors.BadRequest(err)) @@ -318,31 +306,20 @@ func (r *partitionResource) updatePartition(request *restful.Request, response * newPartition.Labels = requestPayload.Labels } if requestPayload.PartitionBootConfiguration.ImageURL != nil { - err = checkImageURL("image", *requestPayload.PartitionBootConfiguration.ImageURL) + err = checkImageURL("image", *requestPayload.PartitionBootConfiguration.ImageURL, "", "") if err != nil { - errs = append(errs, err) - err = checkOciImageURI("image", "", "", *requestPayload.PartitionBootConfiguration.ImageURL) - if err != nil { - errs = append(errs, err) - r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) - return - } + r.sendError(request, response, httperrors.BadRequest(err)) + return } newPartition.BootConfiguration.ImageURL = *requestPayload.PartitionBootConfiguration.ImageURL } if requestPayload.PartitionBootConfiguration.KernelURL != nil { - errs = []error{} - err = checkImageURL("kernel", *requestPayload.PartitionBootConfiguration.KernelURL) + err = checkImageURL("image", *requestPayload.PartitionBootConfiguration.KernelURL, "", "") if err != nil { - errs = append(errs, err) - err = checkOciImageURI("kernel", "", "", *requestPayload.PartitionBootConfiguration.KernelURL) - if err != nil { - errs = append(errs, err) - r.sendError(request, response, httperrors.BadRequest(errors.Join(errs...))) - return - } + r.sendError(request, response, httperrors.BadRequest(err)) + return } newPartition.BootConfiguration.KernelURL = *requestPayload.PartitionBootConfiguration.KernelURL From 78341c26ebd5347d277a6a3f0674595321caca1a Mon Sep 17 00:00:00 2001 From: mac641 Date: Thu, 29 Jan 2026 15:11:12 +0100 Subject: [PATCH 3/7] chore: format go.mod properly --- go.mod | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index ce7bafa9..1b4630a0 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 github.com/go-openapi/spec v0.22.3 github.com/google/go-cmp v0.7.0 + github.com/google/go-containerregistry v0.20.7 github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 @@ -38,21 +39,6 @@ require ( gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.2 ) -require ( - github.com/containerd/errdefs v1.0.0 // indirect - github.com/containerd/errdefs/pkg v0.3.0 // indirect -) - -require ( - github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect - github.com/docker/cli v29.0.3+incompatible // indirect - github.com/docker/distribution v2.8.3+incompatible // indirect - github.com/docker/docker-credential-helpers v0.9.3 // indirect - github.com/google/go-containerregistry v0.20.7 - github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/vbatts/tar-split v0.12.2 // indirect -) - // Newer versions do not export base entities which are used to composite other entities. // This breaks metalctl and friends replace github.com/emicklei/go-restful-openapi/v2 => github.com/emicklei/go-restful-openapi/v2 v2.9.1 @@ -66,8 +52,11 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.18.1 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect @@ -76,7 +65,10 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/docker/cli v29.0.3+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.8.4 // indirect @@ -136,6 +128,7 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/minlz v1.0.1 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect @@ -178,6 +171,7 @@ require ( github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect + github.com/vbatts/tar-split v0.12.2 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect From ea8bd2bf9a9c8fec606fad1323a052d201a62c8b Mon Sep 17 00:00:00 2001 From: mac641 Date: Thu, 29 Jan 2026 15:56:28 +0100 Subject: [PATCH 4/7] refactor: address changes requested by @majst01 --- .../internal/service/image-service.go | 42 ++++++++++--------- .../internal/service/partition-service.go | 8 ++-- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/cmd/metal-api/internal/service/image-service.go b/cmd/metal-api/internal/service/image-service.go index 08fe542f..a26f81f1 100644 --- a/cmd/metal-api/internal/service/image-service.go +++ b/cmd/metal-api/internal/service/image-service.go @@ -5,8 +5,8 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "strconv" - "strings" "time" "github.com/metal-stack/metal-api/cmd/metal-api/internal/datastore" @@ -321,7 +321,7 @@ func (r *imageResource) createImage(request *restful.Request, response *restful. } } - err = checkImageURL(requestPayload.ID, requestPayload.URL, "", "") + err = checkImageURL(requestPayload.ID, requestPayload.URL, nil) if err != nil { r.sendError(request, response, httperrors.BadRequest(err)) return @@ -350,33 +350,37 @@ func (r *imageResource) createImage(request *restful.Request, response *restful. r.send(request, response, http.StatusCreated, v1.NewImageResponse(img)) } -func checkImageURL(id, url, username, password string) error { - if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { - res, err := http.Head(url) +func checkImageURL(id, inputURL string, ociCredentials authn.Authenticator) error { + parsedURL, err := url.Parse(inputURL) + if err != nil { + return fmt.Errorf("image:%s with url:%s could not be parsed. error:%w", id, inputURL, err) + } + + switch parsedURL.Scheme { + case "http", "https": + res, err := http.Head(inputURL) if err != nil { - return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, url, err) + return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, inputURL, err) } if res.StatusCode >= 400 { - return fmt.Errorf("image:%s is not accessible under:%s status:%s", id, url, res.Status) + return fmt.Errorf("image:%s is not accessible under:%s status:%s", id, inputURL, res.Status) } - } else { - ref, err := name.ParseReference(url) + case "oci": + ref, err := name.ParseReference(inputURL) if err != nil { - return fmt.Errorf("image reference:%s could not be parsed. error:%w", url, err) + return fmt.Errorf("image reference:%s could not be parsed. error:%w", inputURL, err) } - var auth = authn.Anonymous - if username != "" || password != "" { - auth = &authn.Basic{ - Username: username, - Password: password, - } + if ociCredentials == nil { + ociCredentials = authn.Anonymous } - _, err = remote.Head(ref, remote.WithAuth(auth)) + _, err = remote.Head(ref, remote.WithAuth(ociCredentials)) if err != nil { - return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, url, err) + return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, inputURL, err) } + default: + return fmt.Errorf("image:%s with url:%s has unkown protocol. error:%w", id, inputURL, err) } return nil @@ -435,7 +439,7 @@ func (r *imageResource) updateImage(request *restful.Request, response *restful. newImage.Description = *requestPayload.Description } if requestPayload.URL != nil { - err = checkImageURL(requestPayload.ID, *requestPayload.URL, "", "") + err = checkImageURL(requestPayload.ID, *requestPayload.URL, nil) if err != nil { r.sendError(request, response, httperrors.BadRequest(err)) return diff --git a/cmd/metal-api/internal/service/partition-service.go b/cmd/metal-api/internal/service/partition-service.go index 16a53e26..5a37f005 100644 --- a/cmd/metal-api/internal/service/partition-service.go +++ b/cmd/metal-api/internal/service/partition-service.go @@ -175,7 +175,7 @@ func (r *partitionResource) createPartition(request *restful.Request, response * imageURL = *requestPayload.PartitionBootConfiguration.ImageURL } - err = checkImageURL("image", imageURL, "", "") + err = checkImageURL("image", imageURL, nil) if err != nil { r.sendError(request, response, httperrors.BadRequest(err)) return @@ -186,7 +186,7 @@ func (r *partitionResource) createPartition(request *restful.Request, response * kernelURL = *requestPayload.PartitionBootConfiguration.KernelURL } - err = checkImageURL("kernel", kernelURL, "", "") + err = checkImageURL("kernel", kernelURL, nil) if err != nil { r.sendError(request, response, httperrors.BadRequest(err)) return @@ -306,7 +306,7 @@ func (r *partitionResource) updatePartition(request *restful.Request, response * newPartition.Labels = requestPayload.Labels } if requestPayload.PartitionBootConfiguration.ImageURL != nil { - err = checkImageURL("image", *requestPayload.PartitionBootConfiguration.ImageURL, "", "") + err = checkImageURL("image", *requestPayload.PartitionBootConfiguration.ImageURL, nil) if err != nil { r.sendError(request, response, httperrors.BadRequest(err)) return @@ -316,7 +316,7 @@ func (r *partitionResource) updatePartition(request *restful.Request, response * } if requestPayload.PartitionBootConfiguration.KernelURL != nil { - err = checkImageURL("image", *requestPayload.PartitionBootConfiguration.KernelURL, "", "") + err = checkImageURL("kernel", *requestPayload.PartitionBootConfiguration.KernelURL, nil) if err != nil { r.sendError(request, response, httperrors.BadRequest(err)) return From f9486a4ae379aea087dbfc81e27fea0154d9de2d Mon Sep 17 00:00:00 2001 From: mac641 Date: Tue, 17 Feb 2026 12:58:38 +0100 Subject: [PATCH 5/7] test(image-service): add tests for checkImageURL and fix issues encountered on the way --- .../internal/service/image-service.go | 15 +++---- .../internal/service/image-service_test.go | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/cmd/metal-api/internal/service/image-service.go b/cmd/metal-api/internal/service/image-service.go index a26f81f1..4ccafa09 100644 --- a/cmd/metal-api/internal/service/image-service.go +++ b/cmd/metal-api/internal/service/image-service.go @@ -5,8 +5,8 @@ import ( "fmt" "log/slog" "net/http" - "net/url" "strconv" + "strings" "time" "github.com/metal-stack/metal-api/cmd/metal-api/internal/datastore" @@ -351,12 +351,9 @@ func (r *imageResource) createImage(request *restful.Request, response *restful. } func checkImageURL(id, inputURL string, ociCredentials authn.Authenticator) error { - parsedURL, err := url.Parse(inputURL) - if err != nil { - return fmt.Errorf("image:%s with url:%s could not be parsed. error:%w", id, inputURL, err) - } + parsedURL := strings.Split(inputURL, "://") - switch parsedURL.Scheme { + switch parsedURL[0] { case "http", "https": res, err := http.Head(inputURL) if err != nil { @@ -366,7 +363,7 @@ func checkImageURL(id, inputURL string, ociCredentials authn.Authenticator) erro return fmt.Errorf("image:%s is not accessible under:%s status:%s", id, inputURL, res.Status) } case "oci": - ref, err := name.ParseReference(inputURL) + ref, err := name.ParseReference(parsedURL[1]) if err != nil { return fmt.Errorf("image reference:%s could not be parsed. error:%w", inputURL, err) } @@ -375,12 +372,12 @@ func checkImageURL(id, inputURL string, ociCredentials authn.Authenticator) erro ociCredentials = authn.Anonymous } - _, err = remote.Head(ref, remote.WithAuth(ociCredentials)) + _, err = remote.Image(ref, remote.WithAuth(ociCredentials)) if err != nil { return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, inputURL, err) } default: - return fmt.Errorf("image:%s with url:%s has unkown protocol. error:%w", id, inputURL, err) + return fmt.Errorf("image:%s with url:%s has unkown protocol", id, inputURL) } return nil diff --git a/cmd/metal-api/internal/service/image-service_test.go b/cmd/metal-api/internal/service/image-service_test.go index 54a2d7a1..ca58b770 100644 --- a/cmd/metal-api/internal/service/image-service_test.go +++ b/cmd/metal-api/internal/service/image-service_test.go @@ -108,6 +108,48 @@ func TestDeleteImage(t *testing.T) { require.Equal(t, testdata.Img3.Name, *result.Name) } +func TestCheckImageURL(t *testing.T) { + t.Run("Invalid URL", func(t *testing.T) { + err := checkImageURL("testID", "http://invalid-ürl", nil) + require.EqualError(t, err, "image:testID is not accessible under:http://invalid-ürl error:Head \"http://invalid-%C3%BCrl\": dial tcp: lookup xn--invalid-rl-heb: no such host") + }) + + t.Run("HTTP URL with successful HEAD request", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := checkImageURL("testID", server.URL, nil) + require.NoError(t, err) + }) + + t.Run("HTTP URL with unsuccessful HEAD request", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + err := checkImageURL("testID", server.URL, nil) + require.EqualError(t, err, "image:testID is not accessible under:"+server.URL+" status:404 Not Found") + }) + + t.Run("Unsupported scheme", func(t *testing.T) { + err := checkImageURL("testID", "ftp://unsupported.url", nil) + require.EqualError(t, err, "image:testID with url:ftp://unsupported.url has unkown protocol") + }) + + t.Run("valid OCI URL", func(t *testing.T) { + err := checkImageURL("testID", "oci://ghcr.io/metal-stack/debian:latest", nil) + require.NoError(t, err) + }) + + t.Run("OCI URL with invalid reference", func(t *testing.T) { + err := checkImageURL("testID", "oci://inva lid", nil) + require.EqualError(t, err, "image reference:oci://inva lid could not be parsed. error:could not parse reference: inva lid") + }) +} + func TestCreateImage(t *testing.T) { ds, mock := datastore.InitMockDB(t) testdata.InitMockDBData(mock) From cfe5e206d35b12f2b114d43ed09e67f9b550989f Mon Sep 17 00:00:00 2001 From: mac641 Date: Thu, 19 Feb 2026 09:40:58 +0100 Subject: [PATCH 6/7] fix(image-service): add separate url parsing check for http-based images --- cmd/metal-api/internal/service/image-service.go | 6 ++++++ cmd/metal-api/internal/service/image-service_test.go | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cmd/metal-api/internal/service/image-service.go b/cmd/metal-api/internal/service/image-service.go index 4ccafa09..da06f905 100644 --- a/cmd/metal-api/internal/service/image-service.go +++ b/cmd/metal-api/internal/service/image-service.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "strconv" "strings" "time" @@ -355,6 +356,11 @@ func checkImageURL(id, inputURL string, ociCredentials authn.Authenticator) erro switch parsedURL[0] { case "http", "https": + _, err := url.ParseRequestURI(inputURL) + if err != nil { + return fmt.Errorf("image:%s could not be parsed. error:%w", inputURL, err) + } + res, err := http.Head(inputURL) if err != nil { return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, inputURL, err) diff --git a/cmd/metal-api/internal/service/image-service_test.go b/cmd/metal-api/internal/service/image-service_test.go index ca58b770..5a2ef942 100644 --- a/cmd/metal-api/internal/service/image-service_test.go +++ b/cmd/metal-api/internal/service/image-service_test.go @@ -110,8 +110,8 @@ func TestDeleteImage(t *testing.T) { func TestCheckImageURL(t *testing.T) { t.Run("Invalid URL", func(t *testing.T) { - err := checkImageURL("testID", "http://invalid-ürl", nil) - require.EqualError(t, err, "image:testID is not accessible under:http://invalid-ürl error:Head \"http://invalid-%C3%BCrl\": dial tcp: lookup xn--invalid-rl-heb: no such host") + err := checkImageURL("testID", "http://invalid url", nil) + require.EqualError(t, err, "image:http://invalid url could not be parsed. error:parse \"http://invalid url\": invalid character \" \" in host name") }) t.Run("HTTP URL with successful HEAD request", func(t *testing.T) { From bdefed1d1578916c5956da5740f7dcdd378eb5c8 Mon Sep 17 00:00:00 2001 From: mac641 Date: Wed, 8 Apr 2026 15:30:06 +0200 Subject: [PATCH 7/7] fix: address changes requested by @vknabel --- .../internal/service/image-service.go | 27 +++++++++---------- .../internal/service/image-service_test.go | 10 +++---- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/cmd/metal-api/internal/service/image-service.go b/cmd/metal-api/internal/service/image-service.go index da06f905..6588b276 100644 --- a/cmd/metal-api/internal/service/image-service.go +++ b/cmd/metal-api/internal/service/image-service.go @@ -352,26 +352,25 @@ func (r *imageResource) createImage(request *restful.Request, response *restful. } func checkImageURL(id, inputURL string, ociCredentials authn.Authenticator) error { - parsedURL := strings.Split(inputURL, "://") + parsedURL, err := url.Parse(inputURL) + if err != nil { + return fmt.Errorf("inputURL: %q could not be parsed, error: %w", inputURL, err) + } - switch parsedURL[0] { + switch parsedURL.Scheme { case "http", "https": - _, err := url.ParseRequestURI(inputURL) - if err != nil { - return fmt.Errorf("image:%s could not be parsed. error:%w", inputURL, err) - } - - res, err := http.Head(inputURL) + res, err := http.Head(parsedURL.String()) if err != nil { - return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, inputURL, err) + return fmt.Errorf("image: %q is not accessible under:%s, error: %w", id, inputURL, err) } if res.StatusCode >= 400 { - return fmt.Errorf("image:%s is not accessible under:%s status:%s", id, inputURL, res.Status) + return fmt.Errorf("image:%q is not accessible under:%s, status:%s", id, inputURL, res.Status) } case "oci": - ref, err := name.ParseReference(parsedURL[1]) + parsedURLWithoutScheme := strings.TrimPrefix(parsedURL.String(), "oci://") + ref, err := name.ParseReference(parsedURLWithoutScheme) if err != nil { - return fmt.Errorf("image reference:%s could not be parsed. error:%w", inputURL, err) + return fmt.Errorf("image reference: %q could not be parsed, error: %w", inputURL, err) } if ociCredentials == nil { @@ -380,10 +379,10 @@ func checkImageURL(id, inputURL string, ociCredentials authn.Authenticator) erro _, err = remote.Image(ref, remote.WithAuth(ociCredentials)) if err != nil { - return fmt.Errorf("image:%s is not accessible under:%s error:%w", id, inputURL, err) + return fmt.Errorf("image: %q is not accessible under: %s, error: %w", id, inputURL, err) } default: - return fmt.Errorf("image:%s with url:%s has unkown protocol", id, inputURL) + return fmt.Errorf("image: %q with url: %s has unknown protocol", id, inputURL) } return nil diff --git a/cmd/metal-api/internal/service/image-service_test.go b/cmd/metal-api/internal/service/image-service_test.go index 5a2ef942..a836b707 100644 --- a/cmd/metal-api/internal/service/image-service_test.go +++ b/cmd/metal-api/internal/service/image-service_test.go @@ -111,7 +111,7 @@ func TestDeleteImage(t *testing.T) { func TestCheckImageURL(t *testing.T) { t.Run("Invalid URL", func(t *testing.T) { err := checkImageURL("testID", "http://invalid url", nil) - require.EqualError(t, err, "image:http://invalid url could not be parsed. error:parse \"http://invalid url\": invalid character \" \" in host name") + require.EqualError(t, err, "inputURL: \"http://invalid url\" could not be parsed, error: parse \"http://invalid url\": invalid character \" \" in host name") }) t.Run("HTTP URL with successful HEAD request", func(t *testing.T) { @@ -131,12 +131,12 @@ func TestCheckImageURL(t *testing.T) { defer server.Close() err := checkImageURL("testID", server.URL, nil) - require.EqualError(t, err, "image:testID is not accessible under:"+server.URL+" status:404 Not Found") + require.EqualError(t, err, "image:\"testID\" is not accessible under:"+server.URL+", status:404 Not Found") }) t.Run("Unsupported scheme", func(t *testing.T) { err := checkImageURL("testID", "ftp://unsupported.url", nil) - require.EqualError(t, err, "image:testID with url:ftp://unsupported.url has unkown protocol") + require.EqualError(t, err, "image: \"testID\" with url: ftp://unsupported.url has unknown protocol") }) t.Run("valid OCI URL", func(t *testing.T) { @@ -146,7 +146,7 @@ func TestCheckImageURL(t *testing.T) { t.Run("OCI URL with invalid reference", func(t *testing.T) { err := checkImageURL("testID", "oci://inva lid", nil) - require.EqualError(t, err, "image reference:oci://inva lid could not be parsed. error:could not parse reference: inva lid") + require.EqualError(t, err, "inputURL: \"oci://inva lid\" could not be parsed, error: parse \"oci://inva lid\": invalid character \" \" in host name") }) } @@ -244,7 +244,7 @@ func TestCreateImageWithBrokenURL(t *testing.T) { err = json.NewDecoder(resp.Body).Decode(&result) require.NoError(t, err) - require.Equal(t, "image:image-1 is not accessible under:http://images.metal-stack.io/this-file-does-not-exist status:404 Not Found", result.Message) + require.Equal(t, "image:\"image-1\" is not accessible under:http://images.metal-stack.io/this-file-does-not-exist, status:404 Not Found", result.Message) } func TestCreateImageWithClassification(t *testing.T) {