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
2 changes: 1 addition & 1 deletion cmd/image-builder/bib_main.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ func bibManifestFromCobra(cmd *cobra.Command, args []string, pbar progress.Progr
// XXX: hack to skip repo loading for the bootc image.
// We need to add a SkipRepositories or similar to
// manifestgen instead to make this clean
OverrideRepos: []rpmmd.RepoConfig{
ForceRepos: []rpmmd.RepoConfig{
{
BaseURLs: []string{"https://example.com/not-used"},
},
Expand Down
168 changes: 168 additions & 0 deletions cmd/image-builder/copr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package main

import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"

"github.com/osbuild/image-builder/pkg/rpmmd"
)

var coprBaseURL = "https://copr.fedorainfracloud.org"

type coprProject struct {
FullName string
Owner string
Name string
ChrootRepos map[string]string
GPGKeyURL string
}

type coprAPIResponse struct {
FullName string `json:"full_name"`
Ownername string `json:"ownername"`
Name string `json:"name"`
ChrootRepos map[string]string `json:"chroot_repos"`
}

var coprHTTPClient = http.DefaultClient

// parseCoprURL extracts the owner and project from a parsed
// copr:// URL. The expected form is copr://owner/project.
func parseCoprURL(u *url.URL) (owner, project string, err error) {
// TODO: do we want to require hostnames or something? This
// TODO: all seems to be a bit iffy as we're working around the
// TODO: url.URL kinda?

// url.Parse("copr://@osbuild/osbuild") gives:
// Opaque: "//@osbuild/osbuild"
// url.Parse("copr://user/project") gives:
// Host: "user", Path: "/project"

// Rebuild the path from the raw form to handle both uniformly.
raw := strings.TrimPrefix(u.String(), "copr://")

parts := strings.SplitN(raw, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("invalid copr URL %q, expected copr://owner/project (e.g. copr://@osbuild/osbuild)", u)
}

return parts[0], parts[1], nil
}

func fetchCoprProject(owner, project string) (*coprProject, error) {
apiURL := fmt.Sprintf("%s/api_3/project?ownername=%s&projectname=%s",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Where is the API documented? Should be included in a comment somewhere in this file.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Will do!

coprBaseURL,
url.QueryEscape(owner),
url.QueryEscape(project),
)

resp, err := coprHTTPClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("cannot fetch copr project %s/%s: %w", owner, project, err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("copr project %s/%s not found", owner, project)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("copr API returned status %d for project %s/%s", resp.StatusCode, owner, project)
}

var apiResp coprAPIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, fmt.Errorf("cannot decode copr API response for %s/%s: %w", owner, project, err)
}

if len(apiResp.ChrootRepos) == 0 {
return nil, fmt.Errorf("copr project %s/%s has no chroot repos", owner, project)
}

gpgKeyURL := fmt.Sprintf("https://download.copr.fedorainfracloud.org/results/%s/%s/pubkey.gpg", owner, project)

return &coprProject{
FullName: apiResp.FullName,
Owner: owner,
Name: project,
ChrootRepos: apiResp.ChrootRepos,
GPGKeyURL: gpgKeyURL,
}, nil
}

func (cp *coprProject) repoConfig(chroot, what string, idx int) *rpmmd.RepoConfig {
baseURL, ok := cp.ChrootRepos[chroot]
if !ok {
return nil
}

checkGPG := true
checkRepoGPG := false
return &rpmmd.RepoConfig{
Id: fmt.Sprintf("%s-copr-%v", what, idx),
Name: fmt.Sprintf("Copr repo for %s owned by %s", cp.Name, cp.Owner),
BaseURLs: []string{baseURL},
GPGKeys: []string{cp.GPGKeyURL},
CheckGPG: &checkGPG,
CheckRepoGPG: &checkRepoGPG,
}
}

// distroToCoprChroots returns candidate COPR chroot prefixes for a
// given distro name. The arch suffix is not included, callers append
// it themselves (see resolveCoprRepo).
func distroToCoprChroots(distroName string) []string {
// centos-10 → centos-stream-10
if strings.HasPrefix(distroName, "centos-") {
ver := strings.TrimPrefix(distroName, "centos-")
return []string{
"centos-stream-" + ver,
distroName,
}
}

// rhel-10.2, rhel-10, then epel-10.2, epel-10 is our order
// of preference. in the future we might allow for explicit
// chroot selection through the URL
if strings.HasPrefix(distroName, "rhel-") {
ver := strings.TrimPrefix(distroName, "rhel-")
major, _, hasDot := strings.Cut(ver, ".")
candidates := []string{distroName}
if hasDot {
candidates = append(candidates, "rhel-"+major)
}
candidates = append(candidates, "epel-"+ver)
if hasDot {
candidates = append(candidates, "epel-"+major)
}
return candidates
}

return []string{distroName}
}

// resolveCoprRepo fetches the COPR project and returns a RepoConfig
// for the given distro/arch combination. nil if
// the project has no matching chroot.
func resolveCoprRepo(u *url.URL, what string, idx int, distroName, archName string) (*rpmmd.RepoConfig, error) {
owner, project, err := parseCoprURL(u)
if err != nil {
return nil, err
}

cp, err := fetchCoprProject(owner, project)
if err != nil {
return nil, err
}

for _, prefix := range distroToCoprChroots(distroName) {
chroot := prefix + "-" + archName
if rc := cp.repoConfig(chroot, what, idx); rc != nil {
return rc, nil
}
}

return nil, nil
}
211 changes: 211 additions & 0 deletions cmd/image-builder/copr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
require.NoError(t, err)
return u
}

func TestParseCoprURL(t *testing.T) {
tests := []struct {
spec string
owner string
project string
err string
}{
{"copr://@osbuild/osbuild", "@osbuild", "osbuild", ""},
{"copr://daan/myproject", "daan", "myproject", ""},
{"copr://@group/my-project", "@group", "my-project", ""},
{"copr://", "", "", "invalid copr URL"},
{"copr://noslash", "", "", "invalid copr URL"},
{"copr:///project", "", "", "invalid copr URL"},
{"copr://owner/", "", "", "invalid copr URL"},
}

for _, tt := range tests {
t.Run(tt.spec, func(t *testing.T) {
u := mustParseURL(t, tt.spec)
owner, project, err := parseCoprURL(u)
if tt.err != "" {
assert.ErrorContains(t, err, tt.err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.owner, owner)
assert.Equal(t, tt.project, project)
}
})
}
}

func TestDistroToCoprChroots(t *testing.T) {
tests := []struct {
distro string
expected []string
}{
{"fedora-44", []string{"fedora-44"}},
{"fedora-43", []string{"fedora-43"}},
{"centos-10", []string{"centos-stream-10", "centos-10"}},
{"centos-9", []string{"centos-stream-9", "centos-9"}},
{"rhel-10.2", []string{"rhel-10.2", "rhel-10", "epel-10.2", "epel-10"}},
{"rhel-10", []string{"rhel-10", "epel-10"}},
{"rhel-9.6", []string{"rhel-9.6", "rhel-9", "epel-9.6", "epel-9"}},
{"almalinux-9.4", []string{"almalinux-9.4"}},
}

for _, tt := range tests {
t.Run(tt.distro, func(t *testing.T) {
assert.Equal(t, tt.expected, distroToCoprChroots(tt.distro))
})
}
}

func mockCoprServer(t *testing.T, response coprAPIResponse, statusCode int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
err := json.NewEncoder(w).Encode(response)
require.NoError(t, err)
}))
}

func TestCoprProjectRepoConfig(t *testing.T) {
cp := &coprProject{
FullName: "@osbuild/osbuild",
Owner: "@osbuild",
Name: "osbuild",
ChrootRepos: map[string]string{
"fedora-44-x86_64": "https://download.copr.fedorainfracloud.org/results/@osbuild/osbuild/fedora-44-x86_64/",
"fedora-44-aarch64": "https://download.copr.fedorainfracloud.org/results/@osbuild/osbuild/fedora-44-aarch64/",
"centos-stream-10-x86_64": "https://download.copr.fedorainfracloud.org/results/@osbuild/osbuild/centos-stream-10-x86_64/",
},
GPGKeyURL: "https://download.copr.fedorainfracloud.org/results/@osbuild/osbuild/pubkey.gpg",
}

t.Run("matching chroot", func(t *testing.T) {
rc := cp.repoConfig("fedora-44-x86_64", "extra", 0)
require.NotNil(t, rc)
assert.Equal(t, "extra-copr-0", rc.Id)
assert.Equal(t, []string{"https://download.copr.fedorainfracloud.org/results/@osbuild/osbuild/fedora-44-x86_64/"}, rc.BaseURLs)
assert.Equal(t, []string{"https://download.copr.fedorainfracloud.org/results/@osbuild/osbuild/pubkey.gpg"}, rc.GPGKeys)
assert.True(t, *rc.CheckGPG)
assert.False(t, *rc.CheckRepoGPG)
})

t.Run("non-matching chroot", func(t *testing.T) {
rc := cp.repoConfig("fedora-99-x86_64", "extra", 0)
assert.Nil(t, rc)
})
}

func TestFetchCoprProjectNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()

origClient := coprHTTPClient
origURL := coprBaseURL
t.Cleanup(func() {
coprHTTPClient = origClient
coprBaseURL = origURL
})
coprHTTPClient = srv.Client()
coprBaseURL = srv.URL

_, err := fetchCoprProject("@noone", "noproject")
assert.ErrorContains(t, err, "not found")
}

func TestFetchCoprProjectHappy(t *testing.T) {
response := coprAPIResponse{
FullName: "@test/testproject",
Ownername: "@test",
Name: "testproject",
ChrootRepos: map[string]string{
"fedora-44-x86_64": "https://download.copr.fedorainfracloud.org/results/@test/testproject/fedora-44-x86_64/",
},
}
srv := mockCoprServer(t, response, http.StatusOK)
defer srv.Close()

origClient := coprHTTPClient
origURL := coprBaseURL
t.Cleanup(func() {
coprHTTPClient = origClient
coprBaseURL = origURL
})
coprHTTPClient = srv.Client()
coprBaseURL = srv.URL

cp, err := fetchCoprProject("@test", "testproject")
require.NoError(t, err)
assert.Equal(t, "@test/testproject", cp.FullName)
assert.Equal(t, "@test", cp.Owner)
assert.Equal(t, "testproject", cp.Name)
assert.Contains(t, cp.ChrootRepos, "fedora-44-x86_64")
assert.Contains(t, cp.GPGKeyURL, "pubkey.gpg")
}

func TestResolveCoprRepo(t *testing.T) {
response := coprAPIResponse{
FullName: "@osbuild/osbuild",
Ownername: "@osbuild",
Name: "osbuild",
ChrootRepos: map[string]string{
"fedora-44-x86_64": "https://example.com/fedora-44-x86_64/",
"centos-stream-10-x86_64": "https://example.com/centos-stream-10-x86_64/",
"epel-9-x86_64": "https://example.com/epel-9-x86_64/",
},
}
srv := mockCoprServer(t, response, http.StatusOK)
defer srv.Close()

origClient := coprHTTPClient
origURL := coprBaseURL
t.Cleanup(func() {
coprHTTPClient = origClient
coprBaseURL = origURL
})
coprHTTPClient = srv.Client()
coprBaseURL = srv.URL

t.Run("fedora direct match", func(t *testing.T) {
rc, err := resolveCoprRepo(mustParseURL(t, "copr://@osbuild/osbuild"), "extra", 0, "fedora-44", "x86_64")
require.NoError(t, err)
require.NotNil(t, rc)
assert.Equal(t, []string{"https://example.com/fedora-44-x86_64/"}, rc.BaseURLs)
})

t.Run("centos maps to centos-stream", func(t *testing.T) {
rc, err := resolveCoprRepo(mustParseURL(t, "copr://@osbuild/osbuild"), "extra", 0, "centos-10", "x86_64")
require.NoError(t, err)
require.NotNil(t, rc)
assert.Equal(t, []string{"https://example.com/centos-stream-10-x86_64/"}, rc.BaseURLs)
})

t.Run("rhel falls back to epel", func(t *testing.T) {
rc, err := resolveCoprRepo(mustParseURL(t, "copr://@osbuild/osbuild"), "extra", 0, "rhel-9.6", "x86_64")
require.NoError(t, err)
require.NotNil(t, rc)
assert.Equal(t, []string{"https://example.com/epel-9-x86_64/"}, rc.BaseURLs)
})

t.Run("no matching chroot returns nil", func(t *testing.T) {
rc, err := resolveCoprRepo(mustParseURL(t, "copr://@osbuild/osbuild"), "extra", 0, "fedora-99", "x86_64")
require.NoError(t, err)
assert.Nil(t, rc)
})
}
Loading
Loading