diff --git a/internal/osbuild2/stage.go b/internal/osbuild2/stage.go index a5fbd6c30..30d2e45cd 100644 --- a/internal/osbuild2/stage.go +++ b/internal/osbuild2/stage.go @@ -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) } diff --git a/internal/osbuild2/stage_test.go b/internal/osbuild2/stage_test.go index 2b4db7628..be0f46b71 100644 --- a/internal/osbuild2/stage_test.go +++ b/internal/osbuild2/stage_test.go @@ -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) { diff --git a/internal/osbuild2/yum_repos_stage.go b/internal/osbuild2/yum_repos_stage.go new file mode 100644 index 000000000..3720f0f9f --- /dev/null +++ b/internal/osbuild2/yum_repos_stage.go @@ -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, + } +} diff --git a/internal/osbuild2/yum_repos_stage_test.go b/internal/osbuild2/yum_repos_stage_test.go new file mode 100644 index 000000000..06fed221a --- /dev/null +++ b/internal/osbuild2/yum_repos_stage_test.go @@ -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) }) + } + }) + } +}