Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/validate-checksums.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ jobs:
echo
echo "Please refer to the developer docs at docs/developer for more information."
echo "-----------------------------------------------------------------------------"
echo "git status:"
git status -vv
echo "-----------------------------------------------------------------------------"
Comment on lines +63 to +65
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!!

exit 1
fi

Expand Down
30 changes: 30 additions & 0 deletions pkg/flatpak/flatpak.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package flatpak

import (
"fmt"
"strings"

"github.com/osbuild/images/pkg/container"
"github.com/osbuild/images/pkg/ostree"
Expand Down Expand Up @@ -33,11 +34,40 @@ func Resolve(source SourceSpec) (Spec, error) {
}

func ResolveAll(sources map[string][]SourceSpec) (map[string][]Spec, error) {
ociClients := make(map[string]*OCIRegistryIndex)
defer func() {
for _, c := range ociClients {
c.Close()
}
}()

flatpaks := make(map[string][]Spec, len(sources))

for name, srcList := range sources {
specs := make([]Spec, len(srcList))
for i, src := range srcList {
if src.Registry.Type == REGISTRY_TYPE_OCI {
uri, found := strings.CutPrefix(src.Registry.URI, "oci+")
if !found {
return nil, fmt.Errorf("flatpak registry %q: missing oci+ prefix", src.Registry.URI)
}
idx, ok := ociClients[uri]
if !ok {
var err error
idx, err = NewOCIRegistryIndex(uri, "linux", "latest")
if err != nil {
return nil, err
}
ociClients[uri] = idx
}
res, err := src.Registry.queryOCIWithIndex(idx, src.Reference.String())
if err != nil {
return nil, fmt.Errorf("failed to resolve flatpak: %w", err)
}
specs[i] = *res
continue
}

res, err := Resolve(src)
if err != nil {
return nil, fmt.Errorf("failed to resolve flatpak: %w", err)
Expand Down
207 changes: 122 additions & 85 deletions pkg/flatpak/oci_registry_index.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"

"github.com/osbuild/images/pkg/container"
Expand Down Expand Up @@ -44,139 +45,175 @@ type ResponseImage struct {
Labels map[string]string `json:"Labels"`
}

// Deserialization of an OCI Manifest, only defines the fields necessary for us
// to get what we need; which is the digest of the config object.
type OCIManifest struct {
Config *struct {
Digest string `json:"digest,omitempty"`
} `json:"config,omitempty"`
}

type ResponseImageList struct{}

func QueryOCIRegistryIndex(uri, ref, os, tag string) (*container.Spec, error) {
res, err := fetchRegistryIndex(uri, os, tag)
if err != nil {
return nil, err
}
// OCIRegistryIndex is a session for querying one OCI registry's Flatpak static index
// (/index/static) for a fixed os/tag pair. The decoded JSON is cached on this instance
// until [OCIRegistryIndex.Close]. Callers should defer Close() once they are done
// issuing [OCIRegistryIndex.Query] calls.
//
// Manifest and config resolution for the digest-pinned image is delegated to
// [container.Resolver] from pkg/container ([container.NewBlockingResolver]).
type OCIRegistryIndex struct {
baseURI string
os string
tag string

mu sync.Mutex
cacheKey string
root *ResponseRoot
}

for _, result := range res.Results {
for _, img := range result.Images {
if img.Labels["org.flatpak.ref"] == ref {
imageID, err := fetchDockerAPIConfigDigest(res.Registry, result.Name, img.Digest)
if err != nil {
return nil, err
}

reg, _ := strings.CutPrefix(res.Registry, "https://")

return &container.Spec{
Source: fmt.Sprintf("%s%s", reg, result.Name),
Digest: img.Digest,
ImageID: imageID,
LocalName: result.Name,
}, nil
}
}
// NewOCIRegistryIndex constructs an index client for baseURI (https host or full origin
// without the oci+ scheme prefix), Flatpak index os and tag query parameters.
func NewOCIRegistryIndex(baseURI, os, tag string) (*OCIRegistryIndex, error) {
if baseURI == "" {
return nil, fmt.Errorf("flatpak oci registry: empty base URI")
}

return nil, fmt.Errorf("did not find image %q", ref)
return &OCIRegistryIndex{baseURI: baseURI, os: os, tag: tag}, nil
}

func httpClient() (*http.Client, error) {
return &http.Client{
Transport: http.DefaultTransport.(*http.Transport).Clone(),
Timeout: 300 * time.Second,
}, nil
// Close drops the cached decoded index. It must not be called concurrently with Query.
func (q *OCIRegistryIndex) Close() {
q.mu.Lock()
defer q.mu.Unlock()
q.cacheKey = ""
q.root = nil
}

func fetchRegistryIndex(uri, os, tag string) (*ResponseRoot, error) {
client, err := httpClient()
// Query parses flatpakRef with [NewReferenceFromString] for architecture selection,
// loads the registry index (cached on this instance), finds the image by
// org.flatpak.ref, then resolves the digest-pinned image using [container.NewBlockingResolver]
// (pkg/container).
func (q *OCIRegistryIndex) Query(flatpakRef string) (*container.Spec, error) {
parsed, err := NewReferenceFromString(flatpakRef)
if err != nil {
return nil, err
}

uri, err = url.JoinPath(uri, ENDPOINT_STATIC)
res, err := q.getResponseRoot()
if err != nil {
return nil, fmt.Errorf("could not format URI: %w", err)
return nil, err
}

req, err := http.NewRequest(http.MethodGet, uri, nil)
repoName, manifestDigest, err := findFlatpakInIndex(res, flatpakRef)
if err != nil {
return nil, err
}

// The default set of query parameters that are always passed by
// flatpak. See [1].
//
// [1]: https://github.com/flatpak/flatpak-oci-specs/blob/main/registry-index.md#appendix---usage-by-flatpak
q := req.URL.Query()
q.Set("label:org.flatpak.ref:exists", "1")
q.Set("os", os)
q.Set("tag", tag)
req.URL.RawQuery = q.Encode()
imageRef := ociImageRefFromIndexComponents(res.Registry, repoName, manifestDigest)

res, err := client.Do(req)
r := container.NewBlockingResolver(parsed.Arch)
spec, err := r.Resolve(container.SourceSpec{
Source: imageRef,
Name: repoName,
TLSVerify: nil,
Local: false,
})
if err != nil {
return nil, err
return nil, fmt.Errorf("resolve flatpak container %q: %w", imageRef, err)
}
defer res.Body.Close()
return &spec, nil
}

if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("registry index %q returned status: %s", uri, res.Status)
func (q *OCIRegistryIndex) buildIndexGETRequest() (*http.Request, string, error) {
indexURL, err := url.JoinPath(q.baseURI, ENDPOINT_STATIC)
if err != nil {
return nil, "", fmt.Errorf("could not format URI: %w", err)
}

var root ResponseRoot
if err := json.NewDecoder(res.Body).Decode(&root); err != nil {
return nil, err
req, err := http.NewRequest(http.MethodGet, indexURL, nil)
if err != nil {
return nil, "", err
}

return &root, nil
// The default set of query parameters that are always passed by
// flatpak. See [1].
//
// [1]: https://github.com/flatpak/flatpak-oci-specs/blob/main/registry-index.md#appendix---usage-by-flatpak
query := req.URL.Query()
query.Set("label:org.flatpak.ref:exists", "1")
query.Set("os", q.os)
query.Set("tag", q.tag)
req.URL.RawQuery = query.Encode()

return req, req.URL.String(), nil
}

// Use the docker API provided by a registry to fetch the digest of the 'config' section of
// a manifest [1], [2].
// [1]: https://distribution.github.io/distribution/spec/api/#pulling-an-image-manifest
// [2]: https://specs.opencontainers.org/image-spec/manifest/
func fetchDockerAPIConfigDigest(registry, name, digest string) (string, error) {
client, err := httpClient()
func (q *OCIRegistryIndex) getResponseRoot() (*ResponseRoot, error) {
req, cacheKey, err := q.buildIndexGETRequest()
if err != nil {
return "", err
return nil, err
}

uri, err := url.JoinPath(registry, "v2", name, "manifests", digest)
if err != nil {
return "", fmt.Errorf("could not format URI: %w", err)
q.mu.Lock()
if q.cacheKey == cacheKey && q.root != nil {
root := q.root
q.mu.Unlock()
return root, nil
}
q.mu.Unlock()

req, err := http.NewRequest(http.MethodGet, uri, nil)
client, err := httpClient()
if err != nil {
return "", err
return nil, err
}
req.Header.Set("Accept", "application/vnd.oci.image.manifest.v1+json")

res, err := client.Do(req)
if err != nil {
return "", err
return nil, err
}
defer res.Body.Close()

if res.StatusCode != http.StatusOK {
return "", fmt.Errorf("docker api error %d %w %q", res.StatusCode, err, uri)
return nil, fmt.Errorf("registry index %q returned status: %s", cacheKey, res.Status)
}

var manifest OCIManifest
if err := json.NewDecoder(res.Body).Decode(&manifest); err != nil {
return "", err
root := new(ResponseRoot)
if err := json.NewDecoder(res.Body).Decode(root); err != nil {
return nil, err
}

if manifest.Config == nil {
return "", fmt.Errorf("manifest did not contain config")
}
q.mu.Lock()
q.cacheKey = cacheKey
q.root = root
q.mu.Unlock()

return root, nil
}

if manifest.Config.Digest == "" {
return "", fmt.Errorf("config did not contain digest")
// findFlatpakInIndex returns the repository name and manifest digest for the first image
// whose org.flatpak.ref label equals wantRef.
func findFlatpakInIndex(res *ResponseRoot, wantRef string) (repoName, manifestDigest string, err error) {
for _, result := range res.Results {
for _, img := range result.Images {
if img == nil || img.Labels == nil {
continue
}
if img.Labels["org.flatpak.ref"] != wantRef {
continue
}
if res.Registry == "" {
return "", "", fmt.Errorf("registry index: found %q but Registry field was missing", wantRef)
}
return result.Name, img.Digest, nil
}
}
return "", "", fmt.Errorf("did not find image %q", wantRef)
}

return manifest.Config.Digest, nil
// ociImageRefFromIndexComponents builds a docker-style reference host/repo@digest for
// [container.NewClient] / the blocking resolver.
func ociImageRefFromIndexComponents(registryURL, repoName, manifestDigest string) string {
host := strings.TrimPrefix(strings.TrimPrefix(registryURL, "https://"), "http://")
host = strings.TrimSuffix(host, "/")
repoPath := strings.TrimPrefix(repoName, "/")
return fmt.Sprintf("%s/%s@%s", host, repoPath, manifestDigest)
}

func httpClient() (*http.Client, error) {
return &http.Client{
Transport: http.DefaultTransport.(*http.Transport).Clone(),
Timeout: 300 * time.Second,
}, nil
}
Loading
Loading