Repository: osbuild/osbuild-composer
Pull request #78 (2019-11-06 01:34:42)

blueprint: move pipeline generation into its own package
**NOTE** I called the new package `os`, but that clashes with the `os` from the standard library. I noticed that when using the new API in composer itself. I've aliased the name were this happens, but would prefer a different name, I think. Any suggestions?

@ondrejbudai I moved the customizations themselves into the new `fedora30` package, because they might be OS-dependant, but kept the types in the blueprints package. Do you think that makes sense?

---

Introduce the `os` package, which contains an interface for OS
implementations. Its main purpose is to convert a blueprint to a
OS-specific pipeline.

Also introduce the `os/fedora30` package. It is the first implementation
of the OS interface. Most of its code has been copied with minimal
modifications from the blueprint package.

The `blueprint` package is now back to serving a single purpose:
representing a weldr blueprint. It does not depend on the `pipeline`
package anymore.

Change osbuild-composer and osbuild-pipeline to use the new API,
hard-coding "fedora-30". This looks a bit weird now, but is the same
behavior as before.

All test cases now also take an "os" key in the "compose" object.

ondrejbudai: > @ondrejbudai I moved the customizations themselves into the new `fedora30` package, because they might be OS-dependant, but kept the types in the blueprints package. Do you think that makes sense?

Ya, definitely makes sense.

larskarlitski: > Ya, definitely makes sense.

Thanks!

>  I've aliased the name were this happens, but would prefer a different name, I think. Any suggestions?

@teg suggested `distro`. I like that.
ondrejbudai: > @teg suggested `distro`. I like that.

:+1: from me, this also came to my mind.
larskarlitski: Update to rename `os` to `distro` and add the `panic` @ondrejbudai was asking for.

Patch:
diff --git a/cmd/osbuild-pipeline/main.go b/cmd/osbuild-pipeline/main.go
index 8772556af..7ac3de033 100644
--- a/cmd/osbuild-pipeline/main.go
+++ b/cmd/osbuild-pipeline/main.go
@@ -7,6 +7,9 @@ import (
 	"os"
 
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
+
+	"github.com/osbuild/osbuild-composer/internal/distro"
+	_ "github.com/osbuild/osbuild-composer/internal/distro/fedora30"
 )
 
 func main() {
@@ -27,7 +30,9 @@ func main() {
 			panic("Colud not parse blueprint")
 		}
 	}
-	pipeline, err := blueprint.ToPipeline(format)
+
+	f30 := distro.New("fedora-30")
+	pipeline, err := f30.Pipeline(blueprint, format)
 	if err != nil {
 		panic(err.Error())
 	}
diff --git a/internal/blueprint/blueprint.go b/internal/blueprint/blueprint.go
index 123a62461..3d1016666 100644
--- a/internal/blueprint/blueprint.go
+++ b/internal/blueprint/blueprint.go
@@ -1,13 +1,6 @@
-// Package blueprint contains primitives for representing weldr blueprints and
-// translating them to OSBuild pipelines
+// Package blueprint contains primitives for representing weldr blueprints
 package blueprint
 
-import (
-	"sort"
-
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
-)
-
 // An InvalidOutputFormatError is returned when a requested output format is
 // not supported. The requested format is included as the error message.
 type InvalidOutputFormatError struct {
@@ -40,45 +33,7 @@ type Group struct {
 	Name string `json:"name"`
 }
 
-type output interface {
-	translate(b *Blueprint) (*pipeline.Pipeline, error)
-	getName() string
-	getMime() string
-}
-
-var outputs = map[string]output{
-	"ami":              &amiOutput{},
-	"ext4-filesystem":  &ext4Output{},
-	"live-iso":         &liveIsoOutput{},
-	"partitioned-disk": &diskOutput{},
-	"qcow2":            &qcow2Output{},
-	"openstack":        &openstackOutput{},
-	"tar":              &tarOutput{},
-	"vhd":              &vhdOutput{},
-	"vmdk":             &vmdkOutput{},
-}
-
-// ListOutputFormats returns a sorted list of the supported output formats
-func ListOutputFormats() []string {
-	formats := make([]string, 0, len(outputs))
-	for name := range outputs {
-		formats = append(formats, name)
-	}
-	sort.Strings(formats)
-
-	return formats
-}
-
-// ToPipeline converts the blueprint to a pipeline for a given output format.
-func (b *Blueprint) ToPipeline(outputFormat string) (*pipeline.Pipeline, error) {
-	if output, exists := outputs[outputFormat]; exists {
-		return output.translate(b)
-	}
-
-	return nil, &InvalidOutputFormatError{outputFormat}
-}
-
-func (b *Blueprint) getKernelCustomization() *KernelCustomization {
+func (b *Blueprint) GetKernelCustomization() *KernelCustomization {
 	if b.Customizations == nil {
 		return nil
 	}
@@ -86,21 +41,11 @@ func (b *Blueprint) getKernelCustomization() *KernelCustomization {
 	return b.Customizations.Kernel
 }
 
-// FilenameFromType gets the canonical filename and MIME type for a given
-// output format
-func FilenameFromType(outputFormat string) (string, string, error) {
-	if output, exists := outputs[outputFormat]; exists {
-		return output.getName(), output.getMime(), nil
-	}
-
-	return "", "", &InvalidOutputFormatError{outputFormat}
-}
-
 func (p Package) ToNameVersion() string {
-	// Omit version to prevent all packages with prefix of name to be installed
-	if p.Version == "*" {
-		return p.Name
-	}
+       // Omit version to prevent all packages with prefix of name to be installed
+       if p.Version == "*" {
+               return p.Name
+       }
 
-	return p.Name + "-" + p.Version
+       return p.Name + "-" + p.Version
 }
diff --git a/internal/blueprint/customizations.go b/internal/blueprint/customizations.go
index 44243a5db..5d20d49be 100644
--- a/internal/blueprint/customizations.go
+++ b/internal/blueprint/customizations.go
@@ -1,13 +1,5 @@
 package blueprint
 
-import (
-	"github.com/osbuild/osbuild-composer/internal/crypt"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
-	"log"
-	"strconv"
-	"strings"
-)
-
 type Customizations struct {
 	Hostname *string                `json:"hostname,omitempty"`
 	Kernel   *KernelCustomization   `json:"kernel,omitempty"`
@@ -72,260 +64,9 @@ type ServicesCustomization struct {
 }
 
 type CustomizationError struct {
-	message string
+	Message string
 }
 
 func (e *CustomizationError) Error() string {
-	return e.message
-}
-
-func (c *Customizations) customizeAll(p *pipeline.Pipeline) error {
-	c.customizeHostname(p)
-	c.customizeGroup(p)
-	if err := c.customizeUserAndSSHKey(p); err != nil {
-		return err
-	}
-	c.customizeTimezone(p)
-	c.customizeNTPServers(p)
-	c.customizeLanguages(p)
-	c.customizeKeyboard(p)
-	c.customizeFirewall(p)
-	c.customizeServices(p)
-
-	return nil
-}
-
-func (c *Customizations) customizeHostname(p *pipeline.Pipeline) {
-	if c.Hostname == nil {
-		return
-	}
-
-	p.AddStage(
-		pipeline.NewHostnameStage(
-			&pipeline.HostnameStageOptions{Hostname: *c.Hostname},
-		),
-	)
-}
-
-func (c *Customizations) customizeGroup(p *pipeline.Pipeline) {
-	if len(c.Group) == 0 {
-		return
-	}
-
-	groups := make(map[string]pipeline.GroupsStageOptionsGroup)
-	for _, group := range c.Group {
-		if userCustomizationsContainUsername(c.User, group.Name) {
-			log.Println("group with name ", group.Name, " was not created, because user with same name was defined!")
-			continue
-		}
-
-		groupData := pipeline.GroupsStageOptionsGroup{}
-		if group.GID != nil {
-			gid := strconv.Itoa(*group.GID)
-			groupData.GID = &gid
-		}
-
-		groups[group.Name] = groupData
-	}
-
-	p.AddStage(
-		pipeline.NewGroupsStage(
-			&pipeline.GroupsStageOptions{Groups: groups},
-		),
-	)
-}
-
-func (c *Customizations) assertAllUsersExistForSSHCustomizations() error {
-	for _, sshkey := range c.SSHKey {
-		userFound := false
-		for _, user := range c.User {
-			if user.Name == sshkey.User {
-				userFound = true
-			}
-		}
-
-		if !userFound {
-			return &CustomizationError{"Cannot set SSH key for non-existing user " + sshkey.User}
-		}
-	}
-	return nil
-}
-
-func (c *Customizations) customizeUserAndSSHKey(p *pipeline.Pipeline) error {
-	if len(c.User) == 0 {
-		if len(c.SSHKey) > 0 {
-			return &CustomizationError{"SSH key customization defined but no user customizations are defined"}
-		}
-
-		return nil
-	}
-
-	// return error if ssh key customization without user defined in user customization if found
-	if e := c.assertAllUsersExistForSSHCustomizations(); e != nil {
-		return e
-	}
-
-	users := make(map[string]pipeline.UsersStageOptionsUser)
-	for _, user := range c.User {
-
-		if user.Password != nil && !crypt.PasswordIsCrypted(*user.Password) {
-			cryptedPassword, err := crypt.CryptSHA512(*user.Password)
-			if err != nil {
-				return err
-			}
-
-			user.Password = &cryptedPassword
-		}
-
-		userData := pipeline.UsersStageOptionsUser{
-			Groups:      user.Groups,
-			Description: user.Description,
-			Home:        user.Home,
-			Shell:       user.Shell,
-			Password:    user.Password,
-			Key:         user.Key,
-		}
-
-		if user.UID != nil {
-			uid := strconv.Itoa(*user.UID)
-			userData.UID = &uid
-		}
-
-		if user.GID != nil {
-			gid := strconv.Itoa(*user.GID)
-			userData.GID = &gid
-		}
-
-		// process sshkey customizations
-		if additionalKeys := findKeysForUser(c.SSHKey, user.Name); len(additionalKeys) > 0 {
-			joinedKeys := strings.Join(additionalKeys, "\n")
-
-			if userData.Key != nil {
-				*userData.Key += "\n" + joinedKeys
-			} else {
-				userData.Key = &joinedKeys
-			}
-		}
-
-		users[user.Name] = userData
-	}
-
-	p.AddStage(
-		pipeline.NewUsersStage(
-			&pipeline.UsersStageOptions{Users: users},
-		),
-	)
-
-	return nil
-}
-
-func (c *Customizations) customizeTimezone(p *pipeline.Pipeline) {
-	if c.Timezone == nil || c.Timezone.Timezone == nil {
-		return
-	}
-
-	// TODO: lorax (anaconda) automatically installs chrony if timeservers are defined
-	// except for the case when chrony is explicitly removed from installed packages (using -chrony)
-	// this is currently not supported, no checks whether chrony is installed are not performed
-
-	p.AddStage(
-		pipeline.NewTimezoneStage(&pipeline.TimezoneStageOptions{
-			Zone: *c.Timezone.Timezone,
-		}),
-	)
-}
-
-func (c *Customizations) customizeNTPServers(p *pipeline.Pipeline) {
-	if c.Timezone == nil || len(c.Timezone.NTPServers) == 0 {
-		return
-	}
-
-	p.AddStage(
-		pipeline.NewChronyStage(&pipeline.ChronyStageOptions{
-			Timeservers: c.Timezone.NTPServers,
-		}),
-	)
-}
-
-func (c *Customizations) customizeLanguages(p *pipeline.Pipeline) {
-	if c.Locale == nil || len(c.Locale.Languages) == 0 {
-		return
-	}
-
-	// TODO: you can specify more languages in customization
-	// The first one is the primary one, we can set in the locale stage, this should currently work
-	// Also, ALL the listed languages are installed using langpack-* packages
-	// This is currently not implemented!
-	// See anaconda src: pyanaconda/payload/dnfpayload.py:772
-
-	p.AddStage(
-		pipeline.NewLocaleStage(&pipeline.LocaleStageOptions{
-			Language: c.Locale.Languages[0],
-		}),
-	)
-}
-
-func (c *Customizations) customizeKeyboard(p *pipeline.Pipeline) {
-	if c.Locale == nil || c.Locale.Keyboard == nil {
-		return
-	}
-
-	p.AddStage(
-		pipeline.NewKeymapStage(&pipeline.KeymapStageOptions{
-			Keymap: *c.Locale.Keyboard,
-		}),
-	)
-}
-
-func (c *Customizations) customizeFirewall(p *pipeline.Pipeline) {
-	if c.Firewall == nil {
-		return
-	}
-
-	var enabledServices, disabledServices []string
-
-	if c.Firewall.Services != nil {
-		enabledServices = c.Firewall.Services.Enabled
-		disabledServices = c.Firewall.Services.Disabled
-	}
-
-	p.AddStage(
-		pipeline.NewFirewallStage(&pipeline.FirewallStageOptions{
-			Ports:            c.Firewall.Ports,
-			EnabledServices:  enabledServices,
-			DisabledServices: disabledServices,
-		}),
-	)
-}
-
-func (c *Customizations) customizeServices(p *pipeline.Pipeline) {
-	if c.Services == nil {
-		return
-	}
-
-	p.AddStage(
-		pipeline.NewSystemdStage(&pipeline.SystemdStageOptions{
-			EnabledServices:  c.Services.Enabled,
-			DisabledServices: c.Services.Disabled,
-		}),
-	)
-}
-
-func findKeysForUser(sshKeyCustomizations []SSHKeyCustomization, user string) (keys []string) {
-	for _, sshKey := range sshKeyCustomizations {
-		if sshKey.User == user {
-			keys = append(keys, sshKey.Key)
-		}
-	}
-	return
-}
-
-func userCustomizationsContainUsername(userCustomizations []UserCustomization, name string) bool {
-	for _, usr := range userCustomizations {
-		if usr.Name == name {
-			return true
-		}
-	}
-
-	return false
+	return e.Message
 }
diff --git a/internal/distro/distro.go b/internal/distro/distro.go
new file mode 100644
index 000000000..6ba2c385c
--- /dev/null
+++ b/internal/distro/distro.go
@@ -0,0 +1,48 @@
+package distro
+
+import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+)
+
+type Distro interface {
+	// Returns a sorted list of the output formats this distro supports.
+	ListOutputFormats() []string
+
+	// Returns the canonical filename and MIME type for a given output
+	// format
+	FilenameFromType(outputFormat string) (string, string, error)
+
+	// Returns an osbuild pipeline that generates an image in the given
+	// output format with all packages and customizations specified in the
+	// given blueprint. `outputFormat` must be one returned by
+	// ListOutputFormats().
+	Pipeline(b *blueprint.Blueprint, outputFormat string) (*pipeline.Pipeline, error)
+}
+
+// An InvalidOutputFormatError is returned when a requested output format is
+// not supported. The requested format is included as the error message.
+type InvalidOutputFormatError struct {
+	Format string
+}
+
+func (e *InvalidOutputFormatError) Error() string {
+	return e.Format
+}
+
+var registered = map[string]Distro{}
+
+func New(name string) Distro {
+	distro, ok := registered[name]
+	if !ok {
+		panic("unknown distro: " + name)
+	}
+	return distro
+}
+
+func Register(name string, distro Distro) {
+	if _, exists := registered[name]; exists {
+		panic("a distro with this name already exists: " + name)
+	}
+	registered[name] = distro
+}
diff --git a/internal/blueprint/ami_output.go b/internal/distro/fedora30/ami.go
similarity index 73%
rename from internal/blueprint/ami_output.go
rename to internal/distro/fedora30/ami.go
index bdacf1201..ccd7266eb 100644
--- a/internal/blueprint/ami_output.go
+++ b/internal/distro/fedora30/ami.go
@@ -1,10 +1,13 @@
-package blueprint
+package fedora30
 
-import "github.com/osbuild/osbuild-composer/internal/pipeline"
+import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+)
 
 type amiOutput struct{}
 
-func (t *amiOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
+func (t *amiOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error) {
 	packages := [...]string{
 		"@Core",
 		"chrony",
@@ -30,7 +33,7 @@ func (t *amiOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
 	addF30QemuAssembler(p, "qcow2", t.getName())
 
 	if b.Customizations != nil {
-		err := b.Customizations.customizeAll(p)
+		err := customizeAll(p, b.Customizations)
 		if err != nil {
 			return nil, err
 		}
diff --git a/internal/distro/fedora30/customizations.go b/internal/distro/fedora30/customizations.go
new file mode 100644
index 000000000..7d0277d55
--- /dev/null
+++ b/internal/distro/fedora30/customizations.go
@@ -0,0 +1,261 @@
+package fedora30
+
+import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/crypt"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+	"log"
+	"strconv"
+	"strings"
+)
+
+func customizeAll(p *pipeline.Pipeline, c *blueprint.Customizations) error {
+	customizeHostname(p, c)
+	customizeGroup(p, c)
+	if err := customizeUserAndSSHKey(p, c); err != nil {
+		return err
+	}
+	customizeTimezone(p, c)
+	customizeNTPServers(p, c)
+	customizeLanguages(p, c)
+	customizeKeyboard(p, c)
+	customizeFirewall(p, c)
+	customizeServices(p, c)
+
+	return nil
+}
+
+func customizeHostname(p *pipeline.Pipeline, c *blueprint.Customizations) {
+	if c.Hostname == nil {
+		return
+	}
+
+	p.AddStage(
+		pipeline.NewHostnameStage(
+			&pipeline.HostnameStageOptions{Hostname: *c.Hostname},
+		),
+	)
+}
+
+func customizeGroup(p *pipeline.Pipeline, c *blueprint.Customizations) {
+	if len(c.Group) == 0 {
+		return
+	}
+
+	groups := make(map[string]pipeline.GroupsStageOptionsGroup)
+	for _, group := range c.Group {
+		if userCustomizationsContainUsername(c.User, group.Name) {
+			log.Println("group with name ", group.Name, " was not created, because user with same name was defined!")
+			continue
+		}
+
+		groupData := pipeline.GroupsStageOptionsGroup{}
+		if group.GID != nil {
+			gid := strconv.Itoa(*group.GID)
+			groupData.GID = &gid
+		}
+
+		groups[group.Name] = groupData
+	}
+
+	p.AddStage(
+		pipeline.NewGroupsStage(
+			&pipeline.GroupsStageOptions{Groups: groups},
+		),
+	)
+}
+
+func assertAllUsersExistForSSHCustomizations(c *blueprint.Customizations) error {
+	for _, sshkey := range c.SSHKey {
+		userFound := false
+		for _, user := range c.User {
+			if user.Name == sshkey.User {
+				userFound = true
+			}
+		}
+
+		if !userFound {
+			return &blueprint.CustomizationError{"Cannot set SSH key for non-existing user " + sshkey.User}
+		}
+	}
+	return nil
+}
+
+func customizeUserAndSSHKey(p *pipeline.Pipeline, c *blueprint.Customizations) error {
+	if len(c.User) == 0 {
+		if len(c.SSHKey) > 0 {
+			return &blueprint.CustomizationError{"SSH key customization defined but no user customizations are defined"}
+		}
+
+		return nil
+	}
+
+	// return error if ssh key customization without user defined in user customization if found
+	if e := assertAllUsersExistForSSHCustomizations(c); e != nil {
+		return e
+	}
+
+	users := make(map[string]pipeline.UsersStageOptionsUser)
+	for _, user := range c.User {
+
+		if user.Password != nil && !crypt.PasswordIsCrypted(*user.Password) {
+			cryptedPassword, err := crypt.CryptSHA512(*user.Password)
+			if err != nil {
+				return err
+			}
+
+			user.Password = &cryptedPassword
+		}
+
+		userData := pipeline.UsersStageOptionsUser{
+			Groups:      user.Groups,
+			Description: user.Description,
+			Home:        user.Home,
+			Shell:       user.Shell,
+			Password:    user.Password,
+			Key:         user.Key,
+		}
+
+		if user.UID != nil {
+			uid := strconv.Itoa(*user.UID)
+			userData.UID = &uid
+		}
+
+		if user.GID != nil {
+			gid := strconv.Itoa(*user.GID)
+			userData.GID = &gid
+		}
+
+		// process sshkey customizations
+		if additionalKeys := findKeysForUser(c.SSHKey, user.Name); len(additionalKeys) > 0 {
+			joinedKeys := strings.Join(additionalKeys, "\n")
+
+			if userData.Key != nil {
+				*userData.Key += "\n" + joinedKeys
+			} else {
+				userData.Key = &joinedKeys
+			}
+		}
+
+		users[user.Name] = userData
+	}
+
+	p.AddStage(
+		pipeline.NewUsersStage(
+			&pipeline.UsersStageOptions{Users: users},
+		),
+	)
+
+	return nil
+}
+
+func customizeTimezone(p *pipeline.Pipeline, c *blueprint.Customizations) {
+	if c.Timezone == nil || c.Timezone.Timezone == nil {
+		return
+	}
+
+	// TODO: lorax (anaconda) automatically installs chrony if timeservers are defined
+	// except for the case when chrony is explicitly removed from installed packages (using -chrony)
+	// this is currently not supported, no checks whether chrony is installed are not performed
+
+	p.AddStage(
+		pipeline.NewTimezoneStage(&pipeline.TimezoneStageOptions{
+			Zone: *c.Timezone.Timezone,
+		}),
+	)
+}
+
+func customizeNTPServers(p *pipeline.Pipeline, c *blueprint.Customizations) {
+	if c.Timezone == nil || len(c.Timezone.NTPServers) == 0 {
+		return
+	}
+
+	p.AddStage(
+		pipeline.NewChronyStage(&pipeline.ChronyStageOptions{
+			Timeservers: c.Timezone.NTPServers,
+		}),
+	)
+}
+
+func customizeLanguages(p *pipeline.Pipeline, c *blueprint.Customizations) {
+	if c.Locale == nil || len(c.Locale.Languages) == 0 {
+		return
+	}
+
+	// TODO: you can specify more languages in customization
+	// The first one is the primary one, we can set in the locale stage, this should currently work
+	// Also, ALL the listed languages are installed using langpack-* packages
+	// This is currently not implemented!
+	// See anaconda src: pyanaconda/payload/dnfpayload.py:772
+
+	p.AddStage(
+		pipeline.NewLocaleStage(&pipeline.LocaleStageOptions{
+			Language: c.Locale.Languages[0],
+		}),
+	)
+}
+
+func customizeKeyboard(p *pipeline.Pipeline, c *blueprint.Customizations) {
+	if c.Locale == nil || c.Locale.Keyboard == nil {
+		return
+	}
+
+	p.AddStage(
+		pipeline.NewKeymapStage(&pipeline.KeymapStageOptions{
+			Keymap: *c.Locale.Keyboard,
+		}),
+	)
+}
+
+func customizeFirewall(p *pipeline.Pipeline, c *blueprint.Customizations) {
+	if c.Firewall == nil {
+		return
+	}
+
+	var enabledServices, disabledServices []string
+
+	if c.Firewall.Services != nil {
+		enabledServices = c.Firewall.Services.Enabled
+		disabledServices = c.Firewall.Services.Disabled
+	}
+
+	p.AddStage(
+		pipeline.NewFirewallStage(&pipeline.FirewallStageOptions{
+			Ports:            c.Firewall.Ports,
+			EnabledServices:  enabledServices,
+			DisabledServices: disabledServices,
+		}),
+	)
+}
+
+func customizeServices(p *pipeline.Pipeline, c *blueprint.Customizations) {
+	if c.Services == nil {
+		return
+	}
+
+	p.AddStage(
+		pipeline.NewSystemdStage(&pipeline.SystemdStageOptions{
+			EnabledServices:  c.Services.Enabled,
+			DisabledServices: c.Services.Disabled,
+		}),
+	)
+}
+
+func findKeysForUser(sshKeyCustomizations []blueprint.SSHKeyCustomization, user string) (keys []string) {
+	for _, sshKey := range sshKeyCustomizations {
+		if sshKey.User == user {
+			keys = append(keys, sshKey.Key)
+		}
+	}
+	return
+}
+
+func userCustomizationsContainUsername(userCustomizations []blueprint.UserCustomization, name string) bool {
+	for _, usr := range userCustomizations {
+		if usr.Name == name {
+			return true
+		}
+	}
+
+	return false
+}
diff --git a/internal/blueprint/disk_output.go b/internal/distro/fedora30/disk.go
similarity index 67%
rename from internal/blueprint/disk_output.go
rename to internal/distro/fedora30/disk.go
index 713a24b85..d3a87bc84 100644
--- a/internal/blueprint/disk_output.go
+++ b/internal/distro/fedora30/disk.go
@@ -1,10 +1,13 @@
-package blueprint
+package fedora30
 
-import "github.com/osbuild/osbuild-composer/internal/pipeline"
+import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+)
 
 type diskOutput struct{}
 
-func (t *diskOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
+func (t *diskOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error) {
 	packages := [...]string{
 		"@core",
 		"chrony",
@@ -20,13 +23,13 @@ func (t *diskOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
 	p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
 	addF30LocaleStage(p)
 	addF30FSTabStage(p)
-	addF30GRUB2Stage(p, b.getKernelCustomization())
+	addF30GRUB2Stage(p, b.GetKernelCustomization())
 	addF30FixBlsStage(p)
 	addF30SELinuxStage(p)
 	addF30QemuAssembler(p, "raw", t.getName())
 
 	if b.Customizations != nil {
-		err := b.Customizations.customizeAll(p)
+		err := customizeAll(p, b.Customizations)
 		if err != nil {
 			return nil, err
 		}
diff --git a/internal/blueprint/ext4_output.go b/internal/distro/fedora30/ext4.go
similarity index 66%
rename from internal/blueprint/ext4_output.go
rename to internal/distro/fedora30/ext4.go
index 2f27cdcb9..b9e728d14 100644
--- a/internal/blueprint/ext4_output.go
+++ b/internal/distro/fedora30/ext4.go
@@ -1,10 +1,13 @@
-package blueprint
+package fedora30
 
-import "github.com/osbuild/osbuild-composer/internal/pipeline"
+import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+)
 
 type ext4Output struct{}
 
-func (t *ext4Output) translate(b *Blueprint) (*pipeline.Pipeline, error) {
+func (t *ext4Output) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error) {
 	packages := [...]string{
 		"policycoreutils",
 		"selinux-policy-targeted",
@@ -18,13 +21,13 @@ func (t *ext4Output) translate(b *Blueprint) (*pipeline.Pipeline, error) {
 	}
 	p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
 	addF30LocaleStage(p)
-	addF30GRUB2Stage(p, b.getKernelCustomization())
+	addF30GRUB2Stage(p, b.GetKernelCustomization())
 	addF30FixBlsStage(p)
 	addF30SELinuxStage(p)
 	addF30RawFSAssembler(p, t.getName())
 
 	if b.Customizations != nil {
-		err := b.Customizations.customizeAll(p)
+		err := customizeAll(p, b.Customizations)
 		if err != nil {
 			return nil, err
 		}
diff --git a/internal/blueprint/f30_helpers.go b/internal/distro/fedora30/helpers.go
similarity index 97%
rename from internal/blueprint/f30_helpers.go
rename to internal/distro/fedora30/helpers.go
index ae73e9d9b..2d9810754 100644
--- a/internal/blueprint/f30_helpers.go
+++ b/internal/distro/fedora30/helpers.go
@@ -1,6 +1,7 @@
-package blueprint
+package fedora30
 
 import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/pipeline"
 
 	"github.com/google/uuid"
@@ -89,7 +90,7 @@ func getF30Pipeline() *pipeline.Pipeline {
 	return p
 }
 
-func getCustomF30PackageSet(packages []string, excludedPackages []string, blueprint *Blueprint) *pipeline.Pipeline {
+func getCustomF30PackageSet(packages []string, excludedPackages []string, blueprint *blueprint.Blueprint) *pipeline.Pipeline {
 	p := &pipeline.Pipeline{
 		BuildPipeline: getF30BuildPipeline(),
 	}
@@ -122,7 +123,7 @@ func getCustomF30PackageSet(packages []string, excludedPackages []string, bluepr
 	return p
 }
 
-func addF30GRUB2Stage(p *pipeline.Pipeline, kernelCustomization *KernelCustomization) {
+func addF30GRUB2Stage(p *pipeline.Pipeline, kernelCustomization *blueprint.KernelCustomization) {
 	id, err := uuid.Parse("76a22bf4-f153-4541-b6c7-0332c0dfaeac")
 	if err != nil {
 		panic("invalid UUID")
diff --git a/internal/blueprint/liveiso_output.go b/internal/distro/fedora30/liveiso.go
similarity index 57%
rename from internal/blueprint/liveiso_output.go
rename to internal/distro/fedora30/liveiso.go
index b3c241d74..b4ac01c4b 100644
--- a/internal/blueprint/liveiso_output.go
+++ b/internal/distro/fedora30/liveiso.go
@@ -1,17 +1,20 @@
-package blueprint
+package fedora30
 
-import "github.com/osbuild/osbuild-composer/internal/pipeline"
+import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+)
 
 type liveIsoOutput struct{}
 
-func (t *liveIsoOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
+func (t *liveIsoOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error) {
 	// TODO!
 	p := getF30Pipeline()
 	addF30SELinuxStage(p)
 	addF30QemuAssembler(p, "raw", t.getName())
 
 	if b.Customizations != nil {
-		err := b.Customizations.customizeAll(p)
+		err := customizeAll(p, b.Customizations)
 		if err != nil {
 			return nil, err
 		}
diff --git a/internal/blueprint/openstack_output.go b/internal/distro/fedora30/openstack.go
similarity index 69%
rename from internal/blueprint/openstack_output.go
rename to internal/distro/fedora30/openstack.go
index 816a5e7be..876503817 100644
--- a/internal/blueprint/openstack_output.go
+++ b/internal/distro/fedora30/openstack.go
@@ -1,10 +1,13 @@
-package blueprint
+package fedora30
 
-import "github.com/osbuild/osbuild-composer/internal/pipeline"
+import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+)
 
 type openstackOutput struct{}
 
-func (t *openstackOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
+func (t *openstackOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error) {
 	packages := [...]string{
 		"@Core",
 		"chrony",
@@ -24,13 +27,13 @@ func (t *openstackOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
 	p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
 	addF30LocaleStage(p)
 	addF30FSTabStage(p)
-	addF30GRUB2Stage(p, b.getKernelCustomization())
+	addF30GRUB2Stage(p, b.GetKernelCustomization())
 	addF30FixBlsStage(p)
 	addF30SELinuxStage(p)
 	addF30QemuAssembler(p, "qcow2", t.getName())
 
 	if b.Customizations != nil {
-		err := b.Customizations.customizeAll(p)
+		err := customizeAll(p, b.Customizations)
 		if err != nil {
 			return nil, err
 		}
diff --git a/internal/distro/fedora30/os.go b/internal/distro/fedora30/os.go
new file mode 100644
index 000000000..e834035b3
--- /dev/null
+++ b/internal/distro/fedora30/os.go
@@ -0,0 +1,59 @@
+package fedora30
+
+import (
+	"sort"
+
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/distro"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+)
+
+type Fedora30 struct {
+	outputs map[string]output
+}
+
+type output interface {
+	translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error)
+	getName() string
+	getMime() string
+}
+
+func init() {
+	distro.Register("fedora-30", &Fedora30{
+		outputs: map[string]output {
+			"ami": &amiOutput{},
+			"ext4-filesystem":  &ext4Output{},
+			"live-iso":         &liveIsoOutput{},
+			"partitioned-disk": &diskOutput{},
+			"qcow2":            &qcow2Output{},
+			"openstack":        &openstackOutput{},
+			"tar":              &tarOutput{},
+			"vhd":              &vhdOutput{},
+			"vmdk":             &vmdkOutput{},
+		},
+	})
+}
+
+// ListOutputFormats returns a sorted list of the supported output formats
+func (f *Fedora30) ListOutputFormats() []string {
+	formats := make([]string, 0, len(f.outputs))
+	for name := range f.outputs {
+		formats = append(formats, name)
+	}
+	sort.Strings(formats)
+	return formats
+}
+
+func (f *Fedora30) FilenameFromType(outputFormat string) (string, string, error) {
+	if output, exists := f.outputs[outputFormat]; exists {
+		return output.getName(), output.getMime(), nil
+	}
+	return "", "", &distro.InvalidOutputFormatError{outputFormat}
+}
+
+func (f *Fedora30) Pipeline(b *blueprint.Blueprint, outputFormat string) (*pipeline.Pipeline, error) {
+	if output, exists := f.outputs[outputFormat]; exists {
+		return output.translate(b)
+	}
+	return nil, &distro.InvalidOutputFormatError{outputFormat}
+}
diff --git a/internal/blueprint/qcow2_output.go b/internal/distro/fedora30/qcow2.go
similarity index 70%
rename from internal/blueprint/qcow2_output.go
rename to internal/distro/fedora30/qcow2.go
index aeef47391..0aacf8409 100644
--- a/internal/blueprint/qcow2_output.go
+++ b/internal/distro/fedora30/qcow2.go
@@ -1,10 +1,13 @@
-package blueprint
+package fedora30
 
-import "github.com/osbuild/osbuild-composer/internal/pipeline"
+import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+)
 
 type qcow2Output struct{}
 
-func (t *qcow2Output) translate(b *Blueprint) (*pipeline.Pipeline, error) {
+func (t *qcow2Output) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error) {
 	packages := [...]string{
 		"kernel-core",
 		"@Fedora Cloud Server",
@@ -25,13 +28,13 @@ func (t *qcow2Output) translate(b *Blueprint) (*pipeline.Pipeline, error) {
 	p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
 	addF30LocaleStage(p)
 	addF30FSTabStage(p)
-	addF30GRUB2Stage(p, b.getKernelCustomization())
+	addF30GRUB2Stage(p, b.GetKernelCustomization())
 	addF30FixBlsStage(p)
 	addF30SELinuxStage(p)
 	addF30QemuAssembler(p, "qcow2", t.getName())
 
 	if b.Customizations != nil {
-		err := b.Customizations.customizeAll(p)
+		err := customizeAll(p, b.Customizations)
 		if err != nil {
 			return nil, err
 		}
diff --git a/internal/blueprint/tar_output.go b/internal/distro/fedora30/tar.go
similarity index 66%
rename from internal/blueprint/tar_output.go
rename to internal/distro/fedora30/tar.go
index adab45be8..145d278fb 100644
--- a/internal/blueprint/tar_output.go
+++ b/internal/distro/fedora30/tar.go
@@ -1,10 +1,13 @@
-package blueprint
+package fedora30
 
-import "github.com/osbuild/osbuild-composer/internal/pipeline"
+import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+)
 
 type tarOutput struct{}
 
-func (t *tarOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
+func (t *tarOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error) {
 	packages := [...]string{
 		"policycoreutils",
 		"selinux-policy-targeted",
@@ -18,13 +21,13 @@ func (t *tarOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
 	}
 	p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
 	addF30LocaleStage(p)
-	addF30GRUB2Stage(p, b.getKernelCustomization())
+	addF30GRUB2Stage(p, b.GetKernelCustomization())
 	addF30FixBlsStage(p)
 	addF30SELinuxStage(p)
 	addF30TarAssembler(p, t.getName(), "xz")
 
 	if b.Customizations != nil {
-		err := b.Customizations.customizeAll(p)
+		err := customizeAll(p, b.Customizations)
 		if err != nil {
 			return nil, err
 		}
diff --git a/internal/blueprint/vhd_output.go b/internal/distro/fedora30/vhd.go
similarity index 68%
rename from internal/blueprint/vhd_output.go
rename to internal/distro/fedora30/vhd.go
index c033914cc..8837846a4 100644
--- a/internal/blueprint/vhd_output.go
+++ b/internal/distro/fedora30/vhd.go
@@ -1,10 +1,13 @@
-package blueprint
+package fedora30
 
-import "github.com/osbuild/osbuild-composer/internal/pipeline"
+import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+)
 
 type vhdOutput struct{}
 
-func (t *vhdOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
+func (t *vhdOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error) {
 	packages := [...]string{
 		"@Core",
 		"chrony",
@@ -23,13 +26,13 @@ func (t *vhdOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
 	p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
 	addF30LocaleStage(p)
 	addF30FSTabStage(p)
-	addF30GRUB2Stage(p, b.getKernelCustomization())
+	addF30GRUB2Stage(p, b.GetKernelCustomization())
 	addF30FixBlsStage(p)
 	addF30SELinuxStage(p)
 	addF30QemuAssembler(p, "qcow2", t.getName())
 
 	if b.Customizations != nil {
-		err := b.Customizations.customizeAll(p)
+		err := customizeAll(p, b.Customizations)
 		if err != nil {
 			return nil, err
 		}
diff --git a/internal/blueprint/vmdk_output.go b/internal/distro/fedora30/vmdk.go
similarity index 67%
rename from internal/blueprint/vmdk_output.go
rename to internal/distro/fedora30/vmdk.go
index b0e98535a..dec148da1 100644
--- a/internal/blueprint/vmdk_output.go
+++ b/internal/distro/fedora30/vmdk.go
@@ -1,10 +1,13 @@
-package blueprint
+package fedora30
 
-import "github.com/osbuild/osbuild-composer/internal/pipeline"
+import (
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+)
 
 type vmdkOutput struct{}
 
-func (t *vmdkOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
+func (t *vmdkOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error) {
 	packages := [...]string{
 		"@core",
 		"chrony",
@@ -21,13 +24,13 @@ func (t *vmdkOutput) translate(b *Blueprint) (*pipeline.Pipeline, error) {
 	p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
 	addF30LocaleStage(p)
 	addF30FSTabStage(p)
-	addF30GRUB2Stage(p, b.getKernelCustomization())
+	addF30GRUB2Stage(p, b.GetKernelCustomization())
 	addF30FixBlsStage(p)
 	addF30SELinuxStage(p)
 	addF30QemuAssembler(p, "vmdk", t.getName())
 
 	if b.Customizations != nil {
-		err := b.Customizations.customizeAll(p)
+		err := customizeAll(p, b.Customizations)
 		if err != nil {
 			return nil, err
 		}
diff --git a/internal/store/store.go b/internal/store/store.go
index 0a24d1bdc..102dcd301 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -16,6 +16,9 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/pipeline"
 	"github.com/osbuild/osbuild-composer/internal/target"
 
+	"github.com/osbuild/osbuild-composer/internal/distro"
+	_ "github.com/osbuild/osbuild-composer/internal/distro/fedora30"
+
 	"github.com/google/uuid"
 )
 
@@ -25,6 +28,7 @@ type Store struct {
 	Blueprints map[string]blueprint.Blueprint `json:"blueprints"`
 	Workspace  map[string]blueprint.Blueprint `json:"workspace"`
 	Composes   map[uuid.UUID]Compose          `json:"composes"`
+	Sources    map[string]SourceConfig        `json:"sources"`
 
 	mu           sync.RWMutex // protects all fields
 	pendingJobs  chan Job
@@ -58,6 +62,15 @@ type Image struct {
 	Size int64
 }
 
+type SourceConfig struct {
+	Name     string `json:"name"`
+	Type     string `json:"type"`
+	URL      string `json:"url"`
+	CheckGPG bool   `json:"check_gpg"`
+	CheckSSL bool   `json:"check_ssl"`
+	System   bool   `json:"system"`
+}
+
 type NotFoundError struct {
 	message string
 }
@@ -126,6 +139,9 @@ func New(stateFile *string) *Store {
 		// TODO: push waiting/running composes to workers again
 		s.Composes = make(map[uuid.UUID]Compose)
 	}
+	if s.Sources == nil {
+		s.Sources = make(map[string]SourceConfig)
+	}
 	s.pendingJobs = make(chan Job, 200)
 
 	return &s
@@ -380,7 +396,8 @@ func (s *Store) PushCompose(composeID uuid.UUID, bp *blueprint.Blueprint, compos
 	targets := []*target.Target{
 		target.NewLocalTarget(target.NewLocalTargetOptions("/var/lib/osbuild-composer/outputs/" + composeID.String())),
 	}
-	pipeline, err := bp.ToPipeline(composeType)
+	f30 := distro.New("fedora-30")
+	pipeline, err := f30.Pipeline(bp, composeType)
 	if err != nil {
 		return err
 	}
@@ -458,7 +475,8 @@ func (s *Store) GetImage(composeID uuid.UUID) (*Image, error) {
 		if compose.QueueStatus != "FINISHED" {
 			return nil, &InvalidRequestError{"compose was not finished"}
 		}
-		name, mime, err := blueprint.FilenameFromType(compose.OutputType)
+		f30 := distro.New("fedora-30")
+		name, mime, err := f30.FilenameFromType(compose.OutputType)
 		if err != nil {
 			panic("invalid output type")
 		}
@@ -487,3 +505,53 @@ func (s *Store) GetImage(composeID uuid.UUID) (*Image, error) {
 
 	return nil, &NotFoundError{"compose could not be found"}
 }
+
+func (s *Store) PushSource(source SourceConfig) {
+	s.change(func() error {
+		s.Sources[source.Name] = source
+		return nil
+	})
+}
+
+func (s *Store) DeleteSource(name string) {
+	s.change(func() error {
+		delete(s.Sources, name)
+		return nil
+	})
+}
+
+func (s *Store) ListSources() []string {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+	names := make([]string, 0, len(s.Sources))
+	for name := range s.Sources {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+
+	return names
+}
+
+func (s *Store) GetSource(name string) *SourceConfig {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	source, ok := s.Sources[name]
+	if !ok {
+		return nil
+	}
+	return &source
+}
+
+func (s *Store) GetAllSources() map[string]SourceConfig {
+	s.mu.RLock()
+	defer s.mu.RUnlock()
+
+	sources := make(map[string]SourceConfig)
+
+	for k, v := range s.Sources {
+		sources[k] = v
+	}
+
+	return sources
+}
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index b70858578..fb451547a 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -16,6 +16,9 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/store"
+
+	"github.com/osbuild/osbuild-composer/internal/distro"
+	_ "github.com/osbuild/osbuild-composer/internal/distro/fedora30"
 )
 
 type API struct {
@@ -47,6 +50,8 @@ func New(repo rpmmd.RepoConfig, packages rpmmd.PackageList, logger *log.Logger,
 	api.router.GET("/api/status", api.statusHandler)
 	api.router.GET("/api/v0/projects/source/list", api.sourceListHandler)
 	api.router.GET("/api/v0/projects/source/info/*sources", api.sourceInfoHandler)
+	api.router.POST("/api/v0/projects/source/new", api.sourceNewHandler)
+	api.router.DELETE("/api/v0/projects/source/delete/*source", api.sourceDeleteHandler)
 
 	api.router.GET("/api/v0/modules/list", api.modulesListAllHandler)
 	api.router.GET("/api/v0/modules/list/:modules", api.modulesListHandler)
@@ -155,28 +160,22 @@ func (api *API) sourceListHandler(writer http.ResponseWriter, request *http.Requ
 		Sources []string `json:"sources"`
 	}
 
+	names := api.store.ListSources()
+	names = append(names, api.repo.Id)
+
 	json.NewEncoder(writer).Encode(reply{
-		Sources: []string{api.repo.Id},
+		Sources: names,
 	})
 }
 
 func (api *API) sourceInfoHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
 	// weldr uses a slightly different format than dnf to store repository
 	// configuration
-	type sourceConfig struct {
-		ID       string `json:"id"`
-		Name     string `json:"name"`
-		Type     string `json:"type"`
-		URL      string `json:"url"`
-		CheckGPG bool   `json:"check_gpg"`
-		CheckSSL bool   `json:"check_ssl"`
-		System   bool   `json:"system"`
-	}
 	type reply struct {
-		Sources map[string]sourceConfig `json:"sources"`
-		Errors  []responseError         `json:"errors"`
+		Sources map[string]store.SourceConfig `json:"sources"`
+		Errors  []responseError               `json:"errors"`
 	}
-	// we only have one repository
+
 	names := strings.Split(params.ByName("sources"), ",")
 
 	if names[0] == "/" {
@@ -189,40 +188,99 @@ func (api *API) sourceInfoHandler(writer http.ResponseWriter, request *http.Requ
 		return
 	}
 
-	// Remove the leading / from the names
-	sourceName := names[0][1:]
-	if sourceName != api.repo.Id && sourceName != "*" {
+	sources := map[string]store.SourceConfig{}
+	errors := []responseError{}
+	for i, name := range names {
+		// remove leading / from first name
+		if i == 0 {
+			name = name[1:]
+		}
+		// if name is "*" we want all sources from the store and the base repo
+		if name == "*" {
+			sources = api.store.GetAllSources()
+		}
+		// check if the source is in the base repo
+		if name == api.repo.Id || name == "*" {
+			cfg := store.SourceConfig{
+				Name:     api.repo.Name,
+				CheckGPG: true,
+				CheckSSL: true,
+				System:   true,
+			}
+
+			if api.repo.BaseURL != "" {
+				cfg.URL = api.repo.BaseURL
+				cfg.Type = "yum-baseurl"
+			} else if api.repo.Metalink != "" {
+				cfg.URL = api.repo.Metalink
+				cfg.Type = "yum-metalink"
+			} else if api.repo.MirrorList != "" {
+				cfg.URL = api.repo.MirrorList
+				cfg.Type = "yum-mirrorlist"
+			}
+			sources[cfg.Name] = cfg
+			// check if the source is in the store
+		} else if source := api.store.GetSource(name); source != nil {
+			sources[source.Name] = *source
+		} else {
+			error := responseError{
+				ID:  "UnknownSource",
+				Msg: fmt.Sprintf("%s is not a valid source", name),
+			}
+			errors = append(errors, error)
+		}
+	}
+
+	json.NewEncoder(writer).Encode(reply{
+		Sources: sources,
+		Errors:  errors,
+	})
+}
+
+func (api *API) sourceNewHandler(writer http.ResponseWriter, request *http.Request, _ httprouter.Params) {
+	contentType := request.Header["Content-Type"]
+	if len(contentType) != 1 || contentType[0] != "application/json" {
+		errors := responseError{
+			ID:  "HTTPError",
+			Msg: "Internal Server Error",
+		}
+		statusResponseError(writer, http.StatusInternalServerError, errors)
+		return
+	}
+
+	var source store.SourceConfig
+	err := json.NewDecoder(request.Body).Decode(&source)
+	if err != nil {
 		errors := responseError{
-			ID:  "UnknownSource",
-			Msg: fmt.Sprintf("%s is not a valid source", sourceName),
+			Code: http.StatusBadRequest,
+			ID:   "HTTPError",
+			Msg:  "Bad Request",
 		}
 		statusResponseError(writer, http.StatusBadRequest, errors)
 		return
 	}
 
-	cfg := sourceConfig{
-		ID:       api.repo.Id,
-		Name:     api.repo.Name,
-		CheckGPG: true,
-		CheckSSL: true,
-		System:   true,
-	}
+	api.store.PushSource(source)
+
+	statusResponseOK(writer)
+}
+
+func (api *API) sourceDeleteHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
+	name := strings.Split(params.ByName("source"), ",")
 
-	if api.repo.BaseURL != "" {
-		cfg.URL = api.repo.BaseURL
-		cfg.Type = "yum-baseurl"
-	} else if api.repo.Metalink != "" {
-		cfg.URL = api.repo.Metalink
-		cfg.Type = "yum-metalink"
-	} else if api.repo.MirrorList != "" {
-		cfg.URL = api.repo.MirrorList
-		cfg.Type = "yum-mirrorlist"
+	if name[0] == "/" {
+		errors := responseError{
+			Code: http.StatusNotFound,
+			ID:   "HTTPError",
+			Msg:  "Not Found",
+		}
+		statusResponseError(writer, http.StatusNotFound, errors)
+		return
 	}
 
-	json.NewEncoder(writer).Encode(reply{
-		Sources: map[string]sourceConfig{cfg.ID: cfg},
-		Errors:  []responseError{},
-	})
+	api.store.DeleteSource(name[0])
+
+	statusResponseOK(writer)
 }
 
 type modulesListModule struct {
@@ -792,7 +850,8 @@ func (api *API) composeTypesHandler(writer http.ResponseWriter, request *http.Re
 		Types []composeType `json:"types"`
 	}
 
-	for _, format := range blueprint.ListOutputFormats() {
+	f30 := distro.New("fedora-30")
+	for _, format := range f30.ListOutputFormats() {
 		reply.Types = append(reply.Types, composeType{format, true})
 	}
 


Test patch:
diff --git a/internal/blueprint/blueprint_test.go b/internal/blueprint/blueprint_test.go
index 9484e975e..2d81790f0 100644
--- a/internal/blueprint/blueprint_test.go
+++ b/internal/blueprint/blueprint_test.go
@@ -1,13 +1,9 @@
 package blueprint_test
 
 import (
-	"encoding/json"
-	"io/ioutil"
-	"reflect"
 	"testing"
 
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
-	"github.com/osbuild/osbuild-composer/internal/pipeline"
 )
 
 func TestInvalidOutputFormatError_Error(t *testing.T) {
@@ -29,167 +25,3 @@ func TestInvalidOutputFormatError_Error(t *testing.T) {
 		})
 	}
 }
-
-func TestListOutputFormats(t *testing.T) {
-	tests := []struct {
-		name string
-		want []string
-	}{
-		{
-			name: "basic",
-			want: []string{
-				"ami",
-				"ext4-filesystem",
-				"live-iso",
-				"openstack",
-				"partitioned-disk",
-				"qcow2",
-				"tar",
-				"vhd",
-				"vmdk",
-			},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			if got := blueprint.ListOutputFormats(); !reflect.DeepEqual(got, tt.want) {
-				t.Errorf("ListOutputFormats() = %v, want %v", got, tt.want)
-			}
-		})
-	}
-}
-
-func TestBlueprint_ToPipeline(t *testing.T) {
-	pipelinePath := "../../test/cases/"
-	fileInfos, err := ioutil.ReadDir(pipelinePath)
-	if err != nil {
-		t.Errorf("Could not read pipelines directory '%s': %v", pipelinePath, err)
-	}
-	for _, fileInfo := range fileInfos {
-		type compose struct {
-			OutputFormat string               `json:"output-format"`
-			Blueprint    *blueprint.Blueprint `json:"blueprint"`
-		}
-		var tt struct {
-			Compose  *compose           `json:"compose"`
-			Pipeline *pipeline.Pipeline `json:"pipeline,omitempty"`
-		}
-		file, err := ioutil.ReadFile(pipelinePath + fileInfo.Name())
-		if err != nil {
-			t.Errorf("Colud not read test-case '%s': %v", fileInfo.Name(), err)
-		}
-		err = json.Unmarshal([]byte(file), &tt)
-		if err != nil {
-			t.Errorf("Colud not parse test-case '%s': %v", fileInfo.Name(), err)
-		}
-		if tt.Compose == nil || tt.Compose.Blueprint == nil {
-			t.Logf("Skipping '%s'.", fileInfo.Name())
-			continue
-		}
-		t.Run(tt.Compose.OutputFormat, func(t *testing.T) {
-			got, err := tt.Compose.Blueprint.ToPipeline(tt.Compose.OutputFormat)
-			if (err != nil) != (tt.Pipeline == nil) {
-				t.Errorf("Blueprint.ToPipeline() error = %v", err)
-				return
-			}
-			if tt.Pipeline != nil {
-				if !reflect.DeepEqual(got, tt.Pipeline) {
-					// Without this the "difference" is just a list of pointers.
-					gotJson, _ := json.Marshal(got)
-					fileJson, _ := json.Marshal(tt.Pipeline)
-					t.Errorf("Blueprint.ToPipeline() =\n%v,\nwant =\n%v", string(gotJson), string(fileJson))
-				}
-			}
-		})
-	}
-}
-
-func TestFilenameFromType(t *testing.T) {
-	type args struct {
-		outputFormat string
-	}
-	tests := []struct {
-		name    string
-		args    args
-		want    string
-		want1   string
-		wantErr bool
-	}{
-		{
-			name:  "ami",
-			args:  args{"ami"},
-			want:  "image.ami",
-			want1: "application/x-qemu-disk",
-		},
-		{
-			name:  "ext4",
-			args:  args{"ext4-filesystem"},
-			want:  "filesystem.img",
-			want1: "application/octet-stream",
-		},
-		{
-			name:  "live-iso",
-			args:  args{"live-iso"},
-			want:  "image.iso",
-			want1: "application/x-iso9660-image",
-		},
-		{
-			name:  "openstack",
-			args:  args{"openstack"},
-			want:  "image.qcow2",
-			want1: "application/x-qemu-disk",
-		},
-		{
-			name:  "partitioned-disk",
-			args:  args{"partitioned-disk"},
-			want:  "disk.img",
-			want1: "application/octet-stream",
-		},
-		{
-			name:  "qcow2",
-			args:  args{"qcow2"},
-			want:  "image.qcow2",
-			want1: "application/x-qemu-disk",
-		},
-		{
-			name:  "tar",
-			args:  args{"tar"},
-			want:  "root.tar.xz",
-			want1: "application/x-tar",
-		},
-		{
-			name:  "vhd",
-			args:  args{"vhd"},
-			want:  "image.vhd",
-			want1: "application/x-vhd",
-		},
-		{
-			name:  "vmdk",
-			args:  args{"vmdk"},
-			want:  "disk.vmdk",
-			want1: "application/x-vmdk",
-		},
-		{
-			name:    "invalid-output-type",
-			args:    args{"foobar"},
-			wantErr: true,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got, got1, err := blueprint.FilenameFromType(tt.args.outputFormat)
-			if (err != nil) != tt.wantErr {
-				t.Errorf("FilenameFromType() error = %v, wantErr %v", err, tt.wantErr)
-				return
-			}
-			if !tt.wantErr {
-				if got != tt.want {
-					t.Errorf("FilenameFromType() got = %v, want %v", got, tt.want)
-				}
-				if got1 != tt.want1 {
-					t.Errorf("FilenameFromType() got1 = %v, want %v", got1, tt.want1)
-				}
-			}
-		})
-	}
-}
diff --git a/internal/distro/distro_test.go b/internal/distro/distro_test.go
new file mode 100644
index 000000000..851bed893
--- /dev/null
+++ b/internal/distro/distro_test.go
@@ -0,0 +1,66 @@
+package distro_test
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"reflect"
+	"testing"
+
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/distro"
+	"github.com/osbuild/osbuild-composer/internal/pipeline"
+
+	_ "github.com/osbuild/osbuild-composer/internal/distro/fedora30"
+)
+
+func TestDistro_Pipeline(t *testing.T) {
+	pipelinePath := "../../test/cases/"
+	fileInfos, err := ioutil.ReadDir(pipelinePath)
+	if err != nil {
+		t.Errorf("Could not read pipelines directory '%s': %v", pipelinePath, err)
+	}
+	for _, fileInfo := range fileInfos {
+		type compose struct {
+			Distro       string		  `json:"distro"`
+			OutputFormat string               `json:"output-format"`
+			Blueprint    *blueprint.Blueprint `json:"blueprint"`
+		}
+		var tt struct {
+			Compose  *compose           `json:"compose"`
+			Pipeline *pipeline.Pipeline `json:"pipeline,omitempty"`
+		}
+		file, err := ioutil.ReadFile(pipelinePath + fileInfo.Name())
+		if err != nil {
+			t.Errorf("Colud not read test-case '%s': %v", fileInfo.Name(), err)
+		}
+		err = json.Unmarshal([]byte(file), &tt)
+		if err != nil {
+			t.Errorf("Colud not parse test-case '%s': %v", fileInfo.Name(), err)
+		}
+		if tt.Compose == nil || tt.Compose.Blueprint == nil {
+			t.Logf("Skipping '%s'.", fileInfo.Name())
+			continue
+		}
+		t.Run(tt.Compose.OutputFormat, func(t *testing.T) {
+			d := distro.New(tt.Compose.Distro)
+			if d == nil {
+				t.Errorf("unknown distro: %v", tt.Compose.Distro)
+				return
+			}
+			got, err := d.Pipeline(tt.Compose.Blueprint, tt.Compose.OutputFormat)
+			if (err != nil) != (tt.Pipeline == nil) {
+				t.Errorf("distro.Pipeline() error = %v", err)
+				return
+			}
+			if tt.Pipeline != nil {
+				if !reflect.DeepEqual(got, tt.Pipeline) {
+					// Without this the "difference" is just a list of pointers.
+					gotJson, _ := json.Marshal(got)
+					fileJson, _ := json.Marshal(tt.Pipeline)
+					t.Errorf("d.Pipeline() =\n%v,\nwant =\n%v", string(gotJson), string(fileJson))
+				}
+			}
+		})
+	}
+}
+
diff --git a/internal/distro/fedora30/os_test.go b/internal/distro/fedora30/os_test.go
new file mode 100644
index 000000000..e19576dc6
--- /dev/null
+++ b/internal/distro/fedora30/os_test.go
@@ -0,0 +1,119 @@
+package fedora30_test
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/osbuild/osbuild-composer/internal/distro"
+	_ "github.com/osbuild/osbuild-composer/internal/distro/fedora30"
+)
+
+func TestListOutputFormats(t *testing.T) {
+	want := []string{
+		"ami",
+		"ext4-filesystem",
+		"live-iso",
+		"openstack",
+		"partitioned-disk",
+		"qcow2",
+		"tar",
+		"vhd",
+		"vmdk",
+	}
+
+	f30 := distro.New("fedora-30")
+	if got := f30.ListOutputFormats(); !reflect.DeepEqual(got, want) {
+		t.Errorf("ListOutputFormats() = %v, want %v", got, want)
+	}
+}
+
+func TestFilenameFromType(t *testing.T) {
+	type args struct {
+		outputFormat string
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    string
+		want1   string
+		wantErr bool
+	}{
+		{
+			name:  "ami",
+			args:  args{"ami"},
+			want:  "image.ami",
+			want1: "application/x-qemu-disk",
+		},
+		{
+			name:  "ext4",
+			args:  args{"ext4-filesystem"},
+			want:  "filesystem.img",
+			want1: "application/octet-stream",
+		},
+		{
+			name:  "live-iso",
+			args:  args{"live-iso"},
+			want:  "image.iso",
+			want1: "application/x-iso9660-image",
+		},
+		{
+			name:  "openstack",
+			args:  args{"openstack"},
+			want:  "image.qcow2",
+			want1: "application/x-qemu-disk",
+		},
+		{
+			name:  "partitioned-disk",
+			args:  args{"partitioned-disk"},
+			want:  "disk.img",
+			want1: "application/octet-stream",
+		},
+		{
+			name:  "qcow2",
+			args:  args{"qcow2"},
+			want:  "image.qcow2",
+			want1: "application/x-qemu-disk",
+		},
+		{
+			name:  "tar",
+			args:  args{"tar"},
+			want:  "root.tar.xz",
+			want1: "application/x-tar",
+		},
+		{
+			name:  "vhd",
+			args:  args{"vhd"},
+			want:  "image.vhd",
+			want1: "application/x-vhd",
+		},
+		{
+			name:  "vmdk",
+			args:  args{"vmdk"},
+			want:  "disk.vmdk",
+			want1: "application/x-vmdk",
+		},
+		{
+			name:    "invalid-output-type",
+			args:    args{"foobar"},
+			wantErr: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			f30 := distro.New("fedora-30")
+			got, got1, err := f30.FilenameFromType(tt.args.outputFormat)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("FilenameFromType() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !tt.wantErr {
+				if got != tt.want {
+					t.Errorf("FilenameFromType() got = %v, want %v", got, tt.want)
+				}
+				if got1 != tt.want1 {
+					t.Errorf("FilenameFromType() got1 = %v, want %v", got1, tt.want1)
+				}
+			}
+		})
+	}
+}
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index c786a1a26..230683ab1 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -170,9 +170,9 @@ func TestBasic(t *testing.T) {
 
 		{"/api/v0/projects/source/info", http.StatusNotFound, ``},
 		{"/api/v0/projects/source/info/", http.StatusNotFound, `{"errors":[{"code":404,"id":"HTTPError","msg":"Not Found"}],"status":false}`},
-		{"/api/v0/projects/source/info/foo", http.StatusBadRequest, `{"errors":[{"id":"UnknownSource","msg":"foo is not a valid source"}],"status":false}`},
-		{"/api/v0/projects/source/info/test", http.StatusOK, `{"sources":{"test":{"id":"test","name":"Test","type":"yum-baseurl","url":"http://example.com/test/os","check_gpg":true,"check_ssl":true,"system":true}},"errors":[]}`},
-		{"/api/v0/projects/source/info/*", http.StatusOK, `{"sources":{"test":{"id":"test","name":"Test","type":"yum-baseurl","url":"http://example.com/test/os","check_gpg":true,"check_ssl":true,"system":true}},"errors":[]}`},
+		{"/api/v0/projects/source/info/foo", http.StatusOK, `{"errors":[{"id":"UnknownSource","msg":"foo is not a valid source"}],"sources":{}}`},
+		{"/api/v0/projects/source/info/test", http.StatusOK, `{"sources":{"Test":{"name":"Test","type":"yum-baseurl","url":"http://example.com/test/os","check_gpg":true,"check_ssl":true,"system":true}},"errors":[]}`},
+		{"/api/v0/projects/source/info/*", http.StatusOK, `{"sources":{"Test":{"name":"Test","type":"yum-baseurl","url":"http://example.com/test/os","check_gpg":true,"check_ssl":true,"system":true}},"errors":[]}`},
 
 		{"/api/v0/modules/list", http.StatusOK, `{"total":2,"offset":0,"limit":20,"modules":[{"name":"package1","group_type":"rpm"},{"name":"package2","group_type":"rpm"}]}`},
 		{"/api/v0/modules/list/*", http.StatusOK, `{"total":2,"offset":0,"limit":20,"modules":[{"name":"package1","group_type":"rpm"},{"name":"package2","group_type":"rpm"}]}`},
@@ -358,3 +358,42 @@ func TestComposeQueue(t *testing.T) {
 		sendHTTP(api, false, "DELETE", "/api/v0/blueprints/delete/test", ``)
 	}
 }
+
+func TestSourcesNew(t *testing.T) {
+	var cases = []struct {
+		Method         string
+		Path           string
+		Body           string
+		ExpectedStatus int
+		ExpectedJSON   string
+	}{
+		{"POST", "/api/v0/projects/source/new", ``, http.StatusBadRequest, `{"errors":[{"code":400,"id":"HTTPError","msg":"Bad Request"}],"status":false}`},
+		{"POST", "/api/v0/projects/source/new", `{"name": "fish","url": "https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedora_29/","type": "yum-baseurl","check_ssl": false,"check_gpg": false}`, http.StatusOK, `{"status":true}`},
+	}
+
+	for _, c := range cases {
+		api := weldr.New(repo, packages, nil, store.New(nil))
+		testRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
+		sendHTTP(api, true, "DELETE", "/api/v0/projects/source/delete/fish", ``)
+	}
+}
+
+func TestSourcesDelete(t *testing.T) {
+	var cases = []struct {
+		Method         string
+		Path           string
+		Body           string
+		ExpectedStatus int
+		ExpectedJSON   string
+	}{
+		{"DELETE", "/api/v0/projects/source/delete/", ``, http.StatusNotFound, `{"status":false,"errors":[{"code":404,"id":"HTTPError","msg":"Not Found"}]}`},
+		{"DELETE", "/api/v0/projects/source/delete/fish", ``, http.StatusOK, `{"status":true}`},
+	}
+
+	for _, c := range cases {
+		api := weldr.New(repo, packages, nil, store.New(nil))
+		sendHTTP(api, true, "POST", "/api/v0/projects/source/new", `{"name": "fish","url": "https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedora_29/","type": "yum-baseurl","check_ssl": false,"check_gpg": false}`)
+		testRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
+		sendHTTP(api, true, "DELETE", "/api/v0/projects/source/delete/fish", ``)
+	}
+}
diff --git a/test/cases/ami_empty_blueprint.json b/test/cases/ami_empty_blueprint.json
index 47ac638cf..1f6c46154 100644
--- a/test/cases/ami_empty_blueprint.json
+++ b/test/cases/ami_empty_blueprint.json
@@ -1,5 +1,6 @@
 {
   "compose": {
+    "distro": "fedora-30",
     "filename": "image.ami",
     "output-format": "ami",
     "blueprint": {}
diff --git a/test/cases/disk_empty_blueprint.json b/test/cases/disk_empty_blueprint.json
index 4f8702ed4..9cc82c68e 100644
--- a/test/cases/disk_empty_blueprint.json
+++ b/test/cases/disk_empty_blueprint.json
@@ -1,5 +1,6 @@
 {
   "compose": {
+    "distro": "fedora-30",
     "output-format": "partitioned-disk",
     "filename": "disk.img",
     "blueprint": {}
diff --git a/test/cases/ext4_empty_blueprint.json b/test/cases/ext4_empty_blueprint.json
index ff0cf858e..a4b14df13 100644
--- a/test/cases/ext4_empty_blueprint.json
+++ b/test/cases/ext4_empty_blueprint.json
@@ -1,5 +1,6 @@
 {
   "compose": {
+    "distro": "fedora-30",
     "output-format": "ext4-filesystem",
     "filename": "filesystem.img",
     "blueprint": {}
diff --git a/test/cases/openstack_empty_blueprint.json b/test/cases/openstack_empty_blueprint.json
index c17a10549..2aadc8078 100644
--- a/test/cases/openstack_empty_blueprint.json
+++ b/test/cases/openstack_empty_blueprint.json
@@ -1,5 +1,6 @@
 {
   "compose": {
+    "distro": "fedora-30",
     "output-format": "openstack",
     "filename": "image.qcow2",
     "blueprint": {}
diff --git a/test/cases/qcow2_empty_blueprint.json b/test/cases/qcow2_empty_blueprint.json
index 96a52f5c9..fc5b518f5 100644
--- a/test/cases/qcow2_empty_blueprint.json
+++ b/test/cases/qcow2_empty_blueprint.json
@@ -1,5 +1,6 @@
 {
   "compose": {
+    "distro": "fedora-30",
     "output-format": "qcow2",
     "filename": "image.qcow2",
     "blueprint": {}
diff --git a/test/cases/vhd_empty_blueprint.json b/test/cases/vhd_empty_blueprint.json
index 1bcfb436e..f068221b8 100644
--- a/test/cases/vhd_empty_blueprint.json
+++ b/test/cases/vhd_empty_blueprint.json
@@ -1,5 +1,6 @@
 {
   "compose": {
+    "distro": "fedora-30",
     "output-format": "vhd",
     "filename": "image.vhd",
     "blueprint": {}
diff --git a/test/cases/vmdk_empty_blueprint.json b/test/cases/vmdk_empty_blueprint.json
index 894ab1451..84e923924 100644
--- a/test/cases/vmdk_empty_blueprint.json
+++ b/test/cases/vmdk_empty_blueprint.json
@@ -1,5 +1,6 @@
 {
   "compose": {
+    "distro": "fedora-30",
     "output-format": "vmdk",
     "filename": "disk.vmdk",
     "blueprint": {}