distro/f30: rework customizations
This slightly changes the customizations logic. We now make sure that each stage is appended exactly once. customizations.go are now responsible only for the things that are completely generic, and not per-ouput-type. helpers.go contain more high-level helpers that combine customziations and per-output-type defaults. This does not change the behaviour, though some pipelines are slightly reordered to make them consistent. Signed-off-by: Tom Gundersen <teg@jklm.no>
This commit is contained in:
parent
c6e73e65a5
commit
de93ddc757
19 changed files with 222 additions and 247 deletions
|
|
@ -24,20 +24,17 @@ func (t *amiOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error
|
|||
excludedPackages := [...]string{
|
||||
"dracut-config-rescue",
|
||||
}
|
||||
p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
|
||||
addF30FixBlsStage(p)
|
||||
addF30LocaleStage(p)
|
||||
addF30FSTabStage(p)
|
||||
addF30GRUB2Stage(p, nil)
|
||||
addF30SELinuxStage(p)
|
||||
addF30QemuAssembler(p, "raw", t.getName())
|
||||
|
||||
if b.Customizations != nil {
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := newF30Pipeline(packages[:], excludedPackages[:], b)
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setFilesystems(p)
|
||||
setBootloader(p, "ro biosdevname=0 net.ifnames=0", b)
|
||||
setFirewall(p, nil, nil, b)
|
||||
setServices(p, nil, nil, b)
|
||||
setQemuAssembler(p, "raw", t.getName())
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
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"
|
||||
|
||||
"github.com/osbuild/osbuild-composer/internal/blueprint"
|
||||
"github.com/osbuild/osbuild-composer/internal/crypt"
|
||||
"github.com/osbuild/osbuild-composer/internal/pipeline"
|
||||
)
|
||||
|
||||
func customizeAll(p *pipeline.Pipeline, c *blueprint.Customizations) error {
|
||||
if c == nil {
|
||||
c = &blueprint.Customizations{}
|
||||
}
|
||||
customizeHostname(p, c)
|
||||
customizeGroup(p, c)
|
||||
if err := customizeUserAndSSHKey(p, c); err != nil {
|
||||
|
|
@ -17,10 +21,8 @@ func customizeAll(p *pipeline.Pipeline, c *blueprint.Customizations) error {
|
|||
}
|
||||
customizeTimezone(p, c)
|
||||
customizeNTPServers(p, c)
|
||||
customizeLanguages(p, c)
|
||||
customizeLocale(p, c)
|
||||
customizeKeyboard(p, c)
|
||||
customizeFirewall(p, c)
|
||||
customizeServices(p, c)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -177,9 +179,14 @@ func customizeNTPServers(p *pipeline.Pipeline, c *blueprint.Customizations) {
|
|||
)
|
||||
}
|
||||
|
||||
func customizeLanguages(p *pipeline.Pipeline, c *blueprint.Customizations) {
|
||||
if c.Locale == nil || len(c.Locale.Languages) == 0 {
|
||||
return
|
||||
func customizeLocale(p *pipeline.Pipeline, c *blueprint.Customizations) {
|
||||
locale := c.Locale
|
||||
if locale == nil {
|
||||
locale = &blueprint.LocaleCustomization{
|
||||
Languages: []string{
|
||||
"en_US",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: you can specify more languages in customization
|
||||
|
|
@ -190,7 +197,7 @@ func customizeLanguages(p *pipeline.Pipeline, c *blueprint.Customizations) {
|
|||
|
||||
p.AddStage(
|
||||
pipeline.NewLocaleStage(&pipeline.LocaleStageOptions{
|
||||
Language: c.Locale.Languages[0],
|
||||
Language: locale.Languages[0],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -207,40 +214,6 @@ func customizeKeyboard(p *pipeline.Pipeline, c *blueprint.Customizations) {
|
|||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -20,20 +20,17 @@ func (t *diskOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, erro
|
|||
excludedPackages := [...]string{
|
||||
"dracut-config-rescue",
|
||||
}
|
||||
p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
|
||||
addF30LocaleStage(p)
|
||||
addF30FSTabStage(p)
|
||||
addF30GRUB2Stage(p, b.GetKernelCustomization())
|
||||
addF30FixBlsStage(p)
|
||||
addF30SELinuxStage(p)
|
||||
addF30QemuAssembler(p, "raw", t.getName())
|
||||
|
||||
if b.Customizations != nil {
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := newF30Pipeline(packages[:], excludedPackages[:], b)
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setFilesystems(p)
|
||||
setBootloader(p, "ro biosdevname=0 net.ifnames=0", b)
|
||||
setFirewall(p, nil, nil, b)
|
||||
setServices(p, nil, nil, b)
|
||||
setQemuAssembler(p, "raw", t.getName())
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,19 +19,16 @@ func (t *ext4Output) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, erro
|
|||
excludedPackages := [...]string{
|
||||
"dracut-config-rescue",
|
||||
}
|
||||
p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
|
||||
addF30LocaleStage(p)
|
||||
addF30GRUB2Stage(p, b.GetKernelCustomization())
|
||||
addF30FixBlsStage(p)
|
||||
addF30SELinuxStage(p)
|
||||
addF30RawFSAssembler(p, t.getName())
|
||||
|
||||
if b.Customizations != nil {
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := newF30Pipeline(packages[:], excludedPackages[:], b)
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setBootloader(p, "ro biosdevname=0 net.ifnames=0", b)
|
||||
setFirewall(p, nil, nil, b)
|
||||
setServices(p, nil, nil, b)
|
||||
setRawFSAssembler(p, t.getName())
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,35 +62,7 @@ func getF30BuildPipeline() *pipeline.Pipeline {
|
|||
return p
|
||||
}
|
||||
|
||||
func getF30Pipeline() *pipeline.Pipeline {
|
||||
p := &pipeline.Pipeline{
|
||||
BuildPipeline: getF30BuildPipeline(),
|
||||
}
|
||||
options := &pipeline.DNFStageOptions{
|
||||
ReleaseVersion: "30",
|
||||
BaseArchitecture: "x86_64",
|
||||
}
|
||||
options.AddRepository(getF30Repository())
|
||||
options.AddPackage("@Core")
|
||||
options.AddPackage("chrony")
|
||||
options.AddPackage("kernel")
|
||||
options.AddPackage("selinux-policy-targeted")
|
||||
options.AddPackage("grub2-pc")
|
||||
options.AddPackage("spice-vdagent")
|
||||
options.AddPackage("qemu-guest-agent")
|
||||
options.AddPackage("xen-libs")
|
||||
options.AddPackage("langpacks-en")
|
||||
p.AddStage(pipeline.NewDNFStage(options))
|
||||
p.AddStage(pipeline.NewFixBLSStage())
|
||||
p.AddStage(pipeline.NewLocaleStage(
|
||||
&pipeline.LocaleStageOptions{
|
||||
Language: "en_US",
|
||||
}))
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func getCustomF30PackageSet(packages []string, excludedPackages []string, blueprint *blueprint.Blueprint) *pipeline.Pipeline {
|
||||
func newF30Pipeline(packages []string, excludedPackages []string, blueprint *blueprint.Blueprint) *pipeline.Pipeline {
|
||||
p := &pipeline.Pipeline{
|
||||
BuildPipeline: getF30BuildPipeline(),
|
||||
}
|
||||
|
|
@ -106,7 +78,6 @@ func getCustomF30PackageSet(packages []string, excludedPackages []string, bluepr
|
|||
options.ExcludePackage(pkg)
|
||||
}
|
||||
|
||||
// handle extra packages, modules (currently synonym to packages) and groups
|
||||
for _, pkg := range blueprint.Packages {
|
||||
options.AddPackage(pkg.ToNameVersion())
|
||||
}
|
||||
|
|
@ -120,16 +91,20 @@ func getCustomF30PackageSet(packages []string, excludedPackages []string, bluepr
|
|||
}
|
||||
|
||||
p.AddStage(pipeline.NewDNFStage(options))
|
||||
|
||||
/* grub2 mangles the BLS snippets, we must fix them manually */
|
||||
p.AddStage(pipeline.NewFixBLSStage())
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func addF30GRUB2Stage(p *pipeline.Pipeline, kernelCustomization *blueprint.KernelCustomization) {
|
||||
func setBootloader(p *pipeline.Pipeline, kernelOptions string, blueprint *blueprint.Blueprint) {
|
||||
id, err := uuid.Parse("76a22bf4-f153-4541-b6c7-0332c0dfaeac")
|
||||
if err != nil {
|
||||
panic("invalid UUID")
|
||||
}
|
||||
kernelOptions := "ro biosdevname=0 net.ifnames=0"
|
||||
|
||||
kernelCustomization := blueprint.GetKernelCustomization()
|
||||
if kernelCustomization != nil {
|
||||
kernelOptions += " " + kernelCustomization.Append
|
||||
}
|
||||
|
|
@ -142,7 +117,7 @@ func addF30GRUB2Stage(p *pipeline.Pipeline, kernelCustomization *blueprint.Kerne
|
|||
))
|
||||
}
|
||||
|
||||
func addF30FSTabStage(p *pipeline.Pipeline) {
|
||||
func setFilesystems(p *pipeline.Pipeline) {
|
||||
id, err := uuid.Parse("76a22bf4-f153-4541-b6c7-0332c0dfaeac")
|
||||
if err != nil {
|
||||
panic("invalid UUID")
|
||||
|
|
@ -152,25 +127,56 @@ func addF30FSTabStage(p *pipeline.Pipeline) {
|
|||
p.AddStage(pipeline.NewFSTabStage(options))
|
||||
}
|
||||
|
||||
func addF30SELinuxStage(p *pipeline.Pipeline) {
|
||||
func setFirewall(p *pipeline.Pipeline, enabledServices []string, disabledServices []string, b *blueprint.Blueprint) {
|
||||
f := b.GetFirewallCustomization()
|
||||
ports := []string{}
|
||||
|
||||
if f != nil {
|
||||
if f.Services != nil {
|
||||
enabledServices = append(enabledServices, f.Services.Enabled...)
|
||||
disabledServices = append(disabledServices, f.Services.Disabled...)
|
||||
}
|
||||
ports = f.Ports
|
||||
}
|
||||
|
||||
if len(enabledServices) == 0 && len(disabledServices) == 0 && len(ports) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
p.AddStage(
|
||||
pipeline.NewFirewallStage(&pipeline.FirewallStageOptions{
|
||||
Ports: ports,
|
||||
EnabledServices: enabledServices,
|
||||
DisabledServices: disabledServices,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func setServices(p *pipeline.Pipeline, enabledServices []string, disabledServices []string, b *blueprint.Blueprint) {
|
||||
s := b.GetServicesCustomization()
|
||||
|
||||
if s != nil {
|
||||
enabledServices = append(enabledServices, s.Enabled...)
|
||||
disabledServices = append(disabledServices, s.Enabled...)
|
||||
}
|
||||
|
||||
if len(enabledServices) == 0 && len(disabledServices) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
p.AddStage(
|
||||
pipeline.NewSystemdStage(&pipeline.SystemdStageOptions{
|
||||
EnabledServices: enabledServices,
|
||||
DisabledServices: disabledServices,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func setQemuAssembler(p *pipeline.Pipeline, format string, filename string) {
|
||||
p.AddStage(pipeline.NewSELinuxStage(
|
||||
&pipeline.SELinuxStageOptions{
|
||||
FileContexts: "etc/selinux/targeted/contexts/files/file_contexts",
|
||||
}))
|
||||
}
|
||||
|
||||
func addF30LocaleStage(p *pipeline.Pipeline) {
|
||||
p.AddStage(pipeline.NewLocaleStage(
|
||||
&pipeline.LocaleStageOptions{
|
||||
Language: "en_US",
|
||||
}))
|
||||
}
|
||||
|
||||
func addF30FixBlsStage(p *pipeline.Pipeline) {
|
||||
p.AddStage(pipeline.NewFixBLSStage())
|
||||
}
|
||||
|
||||
func addF30QemuAssembler(p *pipeline.Pipeline, format string, filename string) {
|
||||
id, err := uuid.Parse("76a22bf4-f153-4541-b6c7-0332c0dfaeac")
|
||||
if err != nil {
|
||||
panic("invalid UUID")
|
||||
|
|
@ -187,14 +193,22 @@ func addF30QemuAssembler(p *pipeline.Pipeline, format string, filename string) {
|
|||
})
|
||||
}
|
||||
|
||||
func addF30TarAssembler(p *pipeline.Pipeline, filename, compression string) {
|
||||
func setTarAssembler(p *pipeline.Pipeline, filename, compression string) {
|
||||
p.AddStage(pipeline.NewSELinuxStage(
|
||||
&pipeline.SELinuxStageOptions{
|
||||
FileContexts: "etc/selinux/targeted/contexts/files/file_contexts",
|
||||
}))
|
||||
p.Assembler = pipeline.NewTarAssembler(
|
||||
&pipeline.TarAssemblerOptions{
|
||||
Filename: filename,
|
||||
})
|
||||
}
|
||||
|
||||
func addF30RawFSAssembler(p *pipeline.Pipeline, filename string) {
|
||||
func setRawFSAssembler(p *pipeline.Pipeline, filename string) {
|
||||
p.AddStage(pipeline.NewSELinuxStage(
|
||||
&pipeline.SELinuxStageOptions{
|
||||
FileContexts: "etc/selinux/targeted/contexts/files/file_contexts",
|
||||
}))
|
||||
id, err := uuid.Parse("76a22bf4-f153-4541-b6c7-0332c0dfaeac")
|
||||
if err != nil {
|
||||
panic("invalid UUID")
|
||||
|
|
|
|||
|
|
@ -24,20 +24,17 @@ func (t *openstackOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline,
|
|||
excludedPackages := [...]string{
|
||||
"dracut-config-rescue",
|
||||
}
|
||||
p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
|
||||
addF30LocaleStage(p)
|
||||
addF30FSTabStage(p)
|
||||
addF30GRUB2Stage(p, b.GetKernelCustomization())
|
||||
addF30FixBlsStage(p)
|
||||
addF30SELinuxStage(p)
|
||||
addF30QemuAssembler(p, "qcow2", t.getName())
|
||||
|
||||
if b.Customizations != nil {
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := newF30Pipeline(packages[:], excludedPackages[:], b)
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setFilesystems(p)
|
||||
setBootloader(p, "ro biosdevname=0 net.ifnames=0", b)
|
||||
setFirewall(p, nil, nil, b)
|
||||
setServices(p, nil, nil, b)
|
||||
setQemuAssembler(p, "qcow2", t.getName())
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,20 +25,17 @@ func (t *qcow2Output) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, err
|
|||
"gobject-introspection",
|
||||
"plymouth",
|
||||
}
|
||||
p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
|
||||
addF30LocaleStage(p)
|
||||
addF30FSTabStage(p)
|
||||
addF30GRUB2Stage(p, b.GetKernelCustomization())
|
||||
addF30FixBlsStage(p)
|
||||
addF30SELinuxStage(p)
|
||||
addF30QemuAssembler(p, "qcow2", t.getName())
|
||||
|
||||
if b.Customizations != nil {
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := newF30Pipeline(packages[:], excludedPackages[:], b)
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setFilesystems(p)
|
||||
setBootloader(p, "ro biosdevname=0 net.ifnames=0", b)
|
||||
setFirewall(p, nil, nil, b)
|
||||
setServices(p, nil, nil, b)
|
||||
setQemuAssembler(p, "qcow2", t.getName())
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,19 +19,16 @@ func (t *tarOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error
|
|||
excludedPackages := [...]string{
|
||||
"dracut-config-rescue",
|
||||
}
|
||||
p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
|
||||
addF30LocaleStage(p)
|
||||
addF30GRUB2Stage(p, b.GetKernelCustomization())
|
||||
addF30FixBlsStage(p)
|
||||
addF30SELinuxStage(p)
|
||||
addF30TarAssembler(p, t.getName(), "xz")
|
||||
|
||||
if b.Customizations != nil {
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := newF30Pipeline(packages[:], excludedPackages[:], b)
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setBootloader(p, "ro biosdevname=0 net.ifnames=0", b)
|
||||
setFirewall(p, nil, nil, b)
|
||||
setServices(p, nil, nil, b)
|
||||
setTarAssembler(p, t.getName(), "xz")
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,20 +23,17 @@ func (t *vhdOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, error
|
|||
excludedPackages := [...]string{
|
||||
"dracut-config-rescue",
|
||||
}
|
||||
p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
|
||||
addF30LocaleStage(p)
|
||||
addF30FSTabStage(p)
|
||||
addF30GRUB2Stage(p, b.GetKernelCustomization())
|
||||
addF30FixBlsStage(p)
|
||||
addF30SELinuxStage(p)
|
||||
addF30QemuAssembler(p, "vpc", t.getName())
|
||||
|
||||
if b.Customizations != nil {
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := newF30Pipeline(packages[:], excludedPackages[:], b)
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setFilesystems(p)
|
||||
setBootloader(p, "ro biosdevname=0 net.ifnames=0", b)
|
||||
setFirewall(p, nil, nil, b)
|
||||
setServices(p, nil, nil, b)
|
||||
setQemuAssembler(p, "vpc", t.getName())
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,20 +21,17 @@ func (t *vmdkOutput) translate(b *blueprint.Blueprint) (*pipeline.Pipeline, erro
|
|||
excludedPackages := [...]string{
|
||||
"dracut-config-rescue",
|
||||
}
|
||||
p := getCustomF30PackageSet(packages[:], excludedPackages[:], b)
|
||||
addF30LocaleStage(p)
|
||||
addF30FSTabStage(p)
|
||||
addF30GRUB2Stage(p, b.GetKernelCustomization())
|
||||
addF30FixBlsStage(p)
|
||||
addF30SELinuxStage(p)
|
||||
addF30QemuAssembler(p, "vmdk", t.getName())
|
||||
|
||||
if b.Customizations != nil {
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := newF30Pipeline(packages[:], excludedPackages[:], b)
|
||||
err := customizeAll(p, b.Customizations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setFilesystems(p)
|
||||
setBootloader(p, "ro biosdevname=0 net.ifnames=0", b)
|
||||
setFirewall(p, nil, nil, b)
|
||||
setServices(p, nil, nil, b)
|
||||
setQemuAssembler(p, "vmdk", t.getName())
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue