osbuild2: support org.osbuild.yum.repos stage

Add support for the new `org.osbuild.yum.repos` stage for creating DNF /
YUM repository configuration files.

Add appropriate new unit tests for the stage implementation and modify
necessary existing unit tests.

Related to https://github.com/osbuild/osbuild/pull/932

Signed-off-by: Tomas Hozza <thozza@redhat.com>
This commit is contained in:
Tomas Hozza 2021-12-14 14:08:19 +01:00 committed by Tomáš Hozza
parent 37a39743bc
commit 97ef7fbf28
4 changed files with 312 additions and 0 deletions

View file

@ -154,6 +154,8 @@ func (stage *Stage) UnmarshalJSON(data []byte) error {
options = new(PwqualityConfStageOptions)
case "org.osbuild.yum.config":
options = new(YumConfigStageOptions)
case "org.osbuild.yum.repos":
options = new(YumReposStageOptions)
default:
return fmt.Errorf("unexpected stage type: %s", rawStage.Type)
}

View file

@ -728,6 +728,24 @@ func TestStage_UnmarshalJSON(t *testing.T) {
data: []byte(`{"type":"org.osbuild.yum.config","options":{}}`),
},
},
{
name: "yum.repos",
fields: fields{
Type: "org.osbuild.yum.repos",
Options: &YumReposStageOptions{
Filename: "test.repo",
Repos: []YumRepository{
{
Id: "my-repo",
BaseURL: []string{"http://example.org/repo"},
},
},
},
},
args: args{
data: []byte(`{"type":"org.osbuild.yum.repos","options":{"filename":"test.repo","repos":[{"id":"my-repo","baseurl":["http://example.org/repo"]}]}}`),
},
},
}
for idx, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -0,0 +1,106 @@
package osbuild2
import (
"fmt"
"regexp"
)
const repoFilenameRegex = "^[\\w.-]{1,250}\\.repo$"
const repoIDRegex = "^[\\w.\\-:]+$"
// YumRepository represents a single DNF / YUM repository.
type YumRepository struct {
Id string `json:"id"`
BaseURL []string `json:"baseurl,omitempty"`
Cost *int `json:"cost,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
GPGKey []string `json:"gpgkey,omitempty"`
Metalink string `json:"metalink,omitempty"`
Mirrorlist string `json:"mirrorlist,omitempty"`
ModuleHotfixes *bool `json:"module_hotfixes,omitempty"`
Name string `json:"name,omitempty"`
GPGCheck *bool `json:"gpgcheck,omitempty"`
RepoGPGCheck *bool `json:"repo_gpgcheck,omitempty"`
}
func (r YumRepository) validate() error {
// Plain string values which can not be empty strings as mandated by
// the stage schema are not validated. The reason is that if they
// are empty, they will be omitted from the resulting JSON, therefore
// they will be never passed to osbuild in the stage options.
// The same logic is applied to slices of strings, which must have
// at least one item if defined. These won't appear in the resulting
// JSON if the slice it empty.
idRegex := regexp.MustCompile(repoIDRegex)
if !idRegex.MatchString(r.Id) {
return fmt.Errorf("repo ID %q doesn't conform to schema (%s)", r.Id, repoIDRegex)
}
// at least one of baseurl, metalink or mirrorlist must be provided
if len(r.BaseURL) == 0 && r.Metalink == "" && r.Mirrorlist == "" {
return fmt.Errorf("at least one of baseurl, metalink or mirrorlist values must be provided")
}
for idx, url := range r.BaseURL {
if url == "" {
return fmt.Errorf("baseurl must not be an empty string (idx %d)", idx)
}
}
for idx, gpgkey := range r.GPGKey {
if gpgkey == "" {
return fmt.Errorf("gpgkey must not be an empty string (idx %d)", idx)
}
}
return nil
}
// YumReposStageOptions represents a single DNF / YUM repo configuration file.
type YumReposStageOptions struct {
// Filename of the configuration file to be created. Must end with '.repo'.
Filename string `json:"filename"`
// List of repositories. The list must contain at least one item.
Repos []YumRepository `json:"repos"`
}
func (YumReposStageOptions) isStageOptions() {}
// NewYumReposStageOptions creates a new YumRepos Stage options object.
func NewYumReposStageOptions(filename string, repos []YumRepository) *YumReposStageOptions {
return &YumReposStageOptions{
Filename: filename,
Repos: repos,
}
}
func (o YumReposStageOptions) validate() error {
filenameRegex := regexp.MustCompile(repoFilenameRegex)
if !filenameRegex.MatchString(o.Filename) {
return fmt.Errorf("filename %q doesn't conform to schema (%s)", o.Filename, repoFilenameRegex)
}
if len(o.Repos) == 0 {
return fmt.Errorf("at least one repository must be defined")
}
for idx, repo := range o.Repos {
if err := repo.validate(); err != nil {
return fmt.Errorf("validation of repository #%d failed: %s", idx, err)
}
}
return nil
}
func NewYumReposStage(options *YumReposStageOptions) *Stage {
if err := options.validate(); err != nil {
panic(err)
}
return &Stage{
Type: "org.osbuild.yum.repos",
Options: options,
}
}

View file

@ -0,0 +1,186 @@
package osbuild2
import (
"testing"
"github.com/osbuild/osbuild-composer/internal/common"
"github.com/stretchr/testify/assert"
)
func TestNewYumReposStage(t *testing.T) {
stageOptions := NewYumReposStageOptions("testing.repo", []YumRepository{
{
Id: "cool-id",
BaseURL: []string{"http://example.org/repo"},
},
})
expectedStage := &Stage{
Type: "org.osbuild.yum.repos",
Options: stageOptions,
}
actualStage := NewYumReposStage(stageOptions)
assert.Equal(t, expectedStage, actualStage)
}
func TestYumReposStageOptionsValidate(t *testing.T) {
tests := []struct {
name string
options YumReposStageOptions
err bool
}{
{
name: "empty-options",
options: YumReposStageOptions{},
err: true,
},
{
name: "no-repos",
options: YumReposStageOptions{
Filename: "test.repo",
Repos: []YumRepository{},
},
err: true,
},
{
name: "invalid-filename",
options: YumReposStageOptions{
Filename: "@#$%^&.rap",
Repos: []YumRepository{
{
Id: "cool-id",
BaseURL: []string{"http://example.org/repo"},
},
},
},
err: true,
},
{
name: "no-filename",
options: YumReposStageOptions{
Repos: []YumRepository{
{
Id: "cool-id",
BaseURL: []string{"http://example.org/repo"},
},
},
},
err: true,
},
{
name: "no-baseurl-mirrorlist-metalink",
options: YumReposStageOptions{
Filename: "test.repo",
Repos: []YumRepository{
{
Id: "cool-id",
},
},
},
err: true,
},
{
name: "baseurl-empty-string",
options: YumReposStageOptions{
Filename: "test.repo",
Repos: []YumRepository{
{
Id: "cool-id",
BaseURL: []string{""},
},
},
},
err: true,
},
{
name: "gpgkey-empty-string",
options: YumReposStageOptions{
Filename: "test.repo",
Repos: []YumRepository{
{
Id: "cool-id",
BaseURL: []string{"http://example.org/repo"},
GPGKey: []string{""},
},
},
},
err: true,
},
{
name: "invalid-repo-id",
options: YumReposStageOptions{
Filename: "test.repo",
Repos: []YumRepository{
{
Id: "c@@l-id",
BaseURL: []string{"http://example.org/repo"},
},
},
},
err: true,
},
{
name: "good-options-baseurl",
options: YumReposStageOptions{
Filename: "test.repo",
Repos: []YumRepository{
{
Id: "cool-id",
Cost: common.IntToPtr(0),
Enabled: common.BoolToPtr(false),
ModuleHotfixes: common.BoolToPtr(false),
Name: "c@@l-name",
GPGCheck: common.BoolToPtr(true),
RepoGPGCheck: common.BoolToPtr(true),
BaseURL: []string{"http://example.org/repo"},
GPGKey: []string{"secretkey"},
},
},
},
err: false,
},
{
name: "good-options-mirrorlist",
options: YumReposStageOptions{
Filename: "test.repo",
Repos: []YumRepository{
{
Id: "cool-id",
Cost: common.IntToPtr(200),
Enabled: common.BoolToPtr(true),
ModuleHotfixes: common.BoolToPtr(true),
Name: "c@@l-name",
GPGCheck: common.BoolToPtr(false),
RepoGPGCheck: common.BoolToPtr(false),
Mirrorlist: "http://example.org/mirrorlist",
GPGKey: []string{"secretkey"},
},
},
},
err: false,
},
{
name: "good-options-metalink",
options: YumReposStageOptions{
Filename: "test.repo",
Repos: []YumRepository{
{
Id: "cool-id",
Metalink: "http://example.org/metalink",
},
},
},
err: false,
},
}
for idx, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.err {
assert.Errorf(t, tt.options.validate(), "%q didn't return an error [idx: %d]", tt.name, idx)
assert.Panics(t, func() { NewYumReposStage(&tt.options) })
} else {
assert.NoErrorf(t, tt.options.validate(), "%q returned an error [idx: %d]", tt.name, idx)
assert.NotPanics(t, func() { NewYumReposStage(&tt.options) })
}
})
}
}