diff --git a/cmd/chisel/cmd_info.go b/cmd/chisel/cmd_info.go index 67ede89a..f360e6d6 100644 --- a/cmd/chisel/cmd_info.go +++ b/cmd/chisel/cmd_info.go @@ -123,12 +123,13 @@ func selectPackageSlices(release *setup.Release, queries []string) (packages []* } else { releasePkg := release.Packages[pkgName] pkg = &setup.Package{ - Name: releasePkg.Name, - RealName: releasePkg.RealName, - Archive: releasePkg.Archive, - Store: releasePkg.Store, - DefaultTrack: releasePkg.DefaultTrack, - Slices: make(map[string]*setup.Slice), + Name: releasePkg.Name, + RealName: releasePkg.RealName, + Archive: releasePkg.Archive, + Store: releasePkg.Store, + DefaultTrack: releasePkg.DefaultTrack, + ChannelPatterns: releasePkg.ChannelPatterns, + Slices: make(map[string]*setup.Slice), } for _, sliceName := range pkgSlices[pkgName] { pkg.Slices[sliceName] = releasePkg.Slices[sliceName] diff --git a/internal/setup/channel.go b/internal/setup/channel.go new file mode 100644 index 00000000..7685dd6d --- /dev/null +++ b/internal/setup/channel.go @@ -0,0 +1,187 @@ +package setup + +import ( + "errors" + "fmt" + "slices" + "strings" + "unicode" +) + +// Channel is a store channel, as in "/[/]". +type Channel struct { + Track string + Risk string + Branch string +} + +func (c Channel) String() string { + if c == (Channel{}) { + return "" + } + channel := c.Risk + if c.Track != "" { + channel = c.Track + "/" + channel + } + if c.Branch != "" { + channel += "/" + c.Branch + } + return channel +} + +const DefaultRisk = "stable" + +// The form a channel pattern must take, as reported to the user. +const channelPatternForm = "/" + +// splitChannel splits a channel or a channel pattern on "/", rejecting values +// holding spaces or an empty segment. +func splitChannel(value, form string) ([]string, error) { + if strings.ContainsFunc(value, unicode.IsSpace) { + return nil, errors.New("must not contain spaces") + } + segments := strings.Split(value, "/") + if slices.Contains(segments, "") { + return nil, fmt.Errorf("must be %s", form) + } + return segments, nil +} + +// knownRisks holds every risk a channel may hold, from the most to the least +// stable. The set is defined by the store and does not depend on its content. +var knownRisks = []string{"stable", "candidate", "beta", "edge"} + +func validateRisk(risk string) error { + if !slices.Contains(knownRisks, risk) { + return fmt.Errorf("unknown risk %q, must be one of %s", risk, strings.Join(knownRisks, ", ")) + } + return nil +} + +// validateChannelPatterns validates a list of patterns. A track may appear +// at most once across the patterns so that the resulting set of channels is +// unambiguous. +func validateChannelPatterns(patterns []string) error { + seen := make(map[string]bool, len(patterns)) + for _, pattern := range patterns { + track, err := validateChannelPattern(pattern) + if err != nil { + return fmt.Errorf("%q: %s", pattern, err) + } + if seen[track] { + return fmt.Errorf("track %q is repeated", track) + } + seen[track] = true + } + return nil +} + +// validateChannelPattern validates a single pattern and returns its track. A +// pattern holds no branch, hence exactly one track and one risk pattern. +func validateChannelPattern(pattern string) (track string, err error) { + segments, err := splitChannel(pattern, channelPatternForm) + if err != nil { + return "", err + } + if len(segments) != 2 { + return "", fmt.Errorf("must be %s", channelPatternForm) + } + track, riskPattern := segments[0], segments[1] + if strings.ContainsAny(track, "*!,") { + return "", errors.New("only the risk accepts '*', '!' and ','") + } + if riskPattern != "*" && strings.Contains(riskPattern, "*") { + // Checked before the risk pattern takes one of the forms below, as a + // wildcard is not allowed within any of them. + return "", errors.New("'*' must be the whole risk") + } + + // The risk pattern takes one of the three forms of the grammar. + switch { + case riskPattern == "*": + // Every risk of the track, nothing more to validate. + case strings.HasPrefix(riskPattern, "!"): + // Every risk of the track but the excluded one. + except := strings.TrimPrefix(riskPattern, "!") + if strings.Contains(except, ",") { + return "", errors.New("'!' cannot be combined with other risks") + } + if except == "" { + return "", fmt.Errorf("must be %s", channelPatternForm) + } + if err := validateRisk(except); err != nil { + return "", err + } + default: + // Only the listed risks of the track. + risks := strings.Split(riskPattern, ",") + for i, risk := range risks { + if risk == "" { + return "", fmt.Errorf("must be %s", channelPatternForm) + } + if strings.Contains(risk, "!") { + return "", errors.New("'!' must prefix the whole risk") + } + if slices.Contains(risks[:i], risk) { + return "", fmt.Errorf("risk %q is repeated", risk) + } + if err := validateRisk(risk); err != nil { + return "", err + } + } + } + return track, nil +} + +// MatchChannelPatterns returns whether the concrete "/" channel +// matches any of the patterns. An empty list matches every channel, which +// means the patterns carry no restriction. +// +// Channel patterns follow the "/" form, where the +// track is a literal and only the risk pattern accepts operators: +// +// - "*": any risk of that track +// - "!": any risk of that track but that one +// - "[,...]": only those risks of that track +// +// Patterns are kept as written and interpreted on each match. +// +// A branch, as in "//", is ignored. Branches are ephemeral +// and thus never part of a pattern, so an entry applies to every branch of the +// risk it matches. +func MatchChannelPatterns(patterns []string, channel Channel) bool { + if len(patterns) == 0 { + return true + } + if channel.Track == "" || channel.Risk == "" { + // An empty track or risk means the channel is not fully specified, + // so it matches no pattern. In particular, an empty risk must not + // match an exclusion such as "!stable", which it otherwise would. + return false + } + for _, pattern := range patterns { + if matchChannelPattern(pattern, channel) { + return true + } + } + return false +} + +// matchChannelPattern returns whether the pattern matches the given channel. +// Note that the exclusion form is scoped to its own track, so "1.0/!stable" +// does not match any risk of the "2.0" track. +// +// The pattern is expected to be valid. +func matchChannelPattern(pattern string, channel Channel) bool { + patternTrack, riskPattern, _ := strings.Cut(pattern, "/") + if channel.Track != patternTrack { + return false + } + if riskPattern == "*" { + return true + } + if except, ok := strings.CutPrefix(riskPattern, "!"); ok { + return channel.Risk != except + } + return slices.Contains(strings.Split(riskPattern, ","), channel.Risk) +} diff --git a/internal/setup/channel_test.go b/internal/setup/channel_test.go new file mode 100644 index 00000000..a63a312f --- /dev/null +++ b/internal/setup/channel_test.go @@ -0,0 +1,184 @@ +package setup_test + +import ( + . "gopkg.in/check.v1" + + "github.com/canonical/chisel/internal/setup" +) + +var channelPatternTests = []struct { + summary string + values []string + err string + // match maps a concrete channel to whether the patterns match it. + match map[setup.Channel]bool +}{{ + summary: "No pattern matches every channel", + values: nil, + match: map[setup.Channel]bool{ + {"3", "stable", ""}: true, + {"2", "edge", ""}: true, + }, +}, { + summary: "Precise channel", + values: []string{"3/stable"}, + match: map[setup.Channel]bool{ + {"3", "stable", ""}: true, + {"3", "edge", ""}: false, + {"2", "stable", ""}: false, + // Branches are ephemeral, hence never part of a pattern. + {"3", "stable", "mybranch"}: true, + {"3", "edge", "mybranch"}: false, + }, +}, { + summary: "All risks of a track", + values: []string{"3/*"}, + match: map[setup.Channel]bool{ + {"3", "stable", ""}: true, + {"3", "edge", ""}: true, + {"2", "stable", ""}: false, + }, +}, { + summary: "Excluded risk", + values: []string{"2/!stable"}, + match: map[setup.Channel]bool{ + {"2", "stable", ""}: false, + {"2", "edge", ""}: true, + {"2", "beta", ""}: true, + // The exclusion is scoped to its own track. + {"3", "edge", ""}: false, + // The branch is ignored, it is not part of the risk. + {"2", "stable", "mybranch"}: false, + {"2", "edge", "mybranch"}: true, + }, +}, { + summary: "Channel without a risk never matches", + values: []string{"3/*"}, + match: map[setup.Channel]bool{ + {Track: "3"}: false, + {}: false, + }, +}, { + summary: "Several risks", + values: []string{"2/beta,edge"}, + match: map[setup.Channel]bool{ + {"2", "beta", ""}: true, + {"2", "edge", ""}: true, + {"2", "stable", ""}: false, + }, +}, { + summary: "Union of several tracks", + values: []string{"2/!stable", "3/*"}, + match: map[setup.Channel]bool{ + {"2", "edge", ""}: true, + {"2", "stable", ""}: false, + {"3", "stable", ""}: true, + }, +}, { + summary: "Every known risk", + values: []string{"3/stable,candidate,beta,edge"}, + match: map[setup.Channel]bool{ + {"3", "stable", ""}: true, + {"3", "candidate", ""}: true, + {"3", "beta", ""}: true, + {"3", "edge", ""}: true, + }, +}, { + summary: "Spaces", + values: []string{"3/not stable"}, + err: `"3/not stable": must not contain spaces`, +}, { + summary: "Missing risk", + values: []string{"3"}, + err: `"3": must be /`, +}, { + summary: "Empty risk", + values: []string{"3/"}, + err: `"3/": must be /`, +}, { + summary: "Empty track", + values: []string{"/stable"}, + err: `"/stable": must be /`, +}, { + summary: "Pattern holding a branch", + values: []string{"3/stable/mybranch"}, + err: `"3/stable/mybranch": must be /`, +}, { + summary: "Wildcard track", + values: []string{"*/stable"}, + err: `"\*/stable": only the risk accepts '\*', '!' and ','`, +}, { + summary: "Partial wildcard in track", + values: []string{"3-*/stable"}, + err: `"3-\*/stable": only the risk accepts '\*', '!' and ','`, +}, { + summary: "Wildcard is not a glob", + values: []string{"3/e*"}, + err: `"3/e\*": '\*' must be the whole risk`, +}, { + summary: "Unknown excluded risk", + values: []string{"3/!whatever"}, + err: `"3/!whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, +}, { + summary: "Exclusion combined with other risks", + values: []string{"3/!stable,edge"}, + err: `"3/!stable,edge": '!' cannot be combined with other risks`, +}, { + summary: "Empty excluded risk", + values: []string{"3/!"}, + err: `"3/!": must be /`, +}, { + summary: "Unknown risk", + values: []string{"3/whatever"}, + err: `"3/whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, +}, { + summary: "Unknown risk in a list", + values: []string{"3/edge,whatever"}, + err: `"3/edge,whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, +}, { + summary: "Risks are case sensitive", + values: []string{"3/Stable"}, + err: `"3/Stable": unknown risk "Stable", must be one of stable, candidate, beta, edge`, +}, { + summary: "Exclusion not prefixing the risk part", + values: []string{"3/edge,!stable"}, + err: `"3/edge,!stable": '!' must prefix the whole risk`, +}, { + summary: "Repeated risk", + values: []string{"3/edge,edge"}, + err: `"3/edge,edge": risk "edge" is repeated`, +}, { + summary: "Trailing comma in a list", + values: []string{"3/edge,"}, + err: `"3/edge,": must be /`, +}, { + summary: "Leading comma in a list", + values: []string{"3/,edge"}, + err: `"3/,edge": must be /`, +}, { + summary: "Repeated track", + values: []string{"3/*", "3/edge"}, + err: `track "3" is repeated`, +}, { + summary: "Repeated track with identical values", + values: []string{"3/edge", "3/edge"}, + err: `track "3" is repeated`, +}} + +func (s *S) TestChannelPatterns(c *C) { + for _, test := range channelPatternTests { + c.Logf("Summary: %s", test.summary) + + err := setup.ValidateChannelPatterns(test.values) + if test.err != "" { + c.Assert(err, ErrorMatches, test.err) + continue + } + c.Assert(err, IsNil) + + for channel, expected := range test.match { + c.Assert(setup.MatchChannelPatterns(test.values, channel), Equals, expected, + Commentf("channel %q", channel.String())) + } + } +} diff --git a/internal/setup/export_test.go b/internal/setup/export_test.go index 7fd2d933..ce47de7e 100644 --- a/internal/setup/export_test.go +++ b/internal/setup/export_test.go @@ -2,6 +2,8 @@ package setup type YAMLPath = yamlPath +var ValidateChannelPatterns = validateChannelPatterns + type PathSegment = segment type PathSegmentSlice = segmentSlice type PathNode = node diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 10953ada..24c207c9 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -59,13 +59,14 @@ type Archive struct { // Package holds a collection of slices that represent parts of themselves. type Package struct { - Name string // Unique across all known packages (e.g. "bin-curl") - RealName string // Actual name as known in the archive or store (e.g. "curl") - Path string - Archive string - Store string - DefaultTrack string - Slices map[string]*Slice + Name string // Unique across all known packages (e.g. "bin-curl") + RealName string // Actual name as known in the archive or store (e.g. "curl") + Path string + Archive string + Store string + DefaultTrack string + ChannelPatterns []string // Channel patterns the package is compatible with. + Slices map[string]*Slice } // Slice holds the details about a package slice. diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index ec61490b..e8d62ccf 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -4091,6 +4091,7 @@ var setupTests = []setupTest{{ package: mypkg store: bin default-track: "3.0" + channels: ["3.0/*"] `, }, release: &setup.Release{ @@ -4115,12 +4116,13 @@ var setupTests = []setupTest{{ }, Packages: map[string]*setup.Package{ "bin-mypkg": { - RealName: "mypkg", - Name: "bin-mypkg", - Path: "bin-slices/mypkg.yaml", - Store: "bin", - DefaultTrack: "3.0", - Slices: map[string]*setup.Slice{}, + RealName: "mypkg", + Name: "bin-mypkg", + Path: "bin-slices/mypkg.yaml", + Store: "bin", + DefaultTrack: "3.0", + ChannelPatterns: []string{"3.0/*"}, + Slices: map[string]*setup.Slice{}, }, }, Maintenance: &setup.Maintenance{ @@ -4138,7 +4140,7 @@ var setupTests = []setupTest{{ default-track: "3.0" `, }, - relerror: `cannot parse package "mypkg": 'store' and 'default-track' are unsupported before format v3`, + relerror: `cannot parse package "mypkg": 'store', 'default-track' and 'channels' are unsupported before format v3`, }, { summary: "Store and archive are mutually exclusive", input: map[string]string{ @@ -4193,6 +4195,179 @@ var setupTests = []setupTest{{ `, }, relerror: `cannot parse package "mypkg": store "non-existing" not defined in release`, +}, { + summary: "Store package with multiple channels is parsed correctly", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "2" + channels: ["2/*", "3/*", "4/edge"] + `, + }, + release: &setup.Release{ + Format: "v3", + Archives: map[string]*setup.Archive{ + "ubuntu": { + Name: "ubuntu", + Version: "22.04", + Suites: []string{"jammy"}, + Components: []string{"main", "universe"}, + PubKeys: []*packet.PublicKey{testKey.PubKey}, + Maintained: true, + }, + }, + Stores: map[string]*setup.Store{ + "bin": { + Name: "bin", + Kind: "bin", + Version: "26.10", + DefaultPrefix: "bin-", + }, + }, + Packages: map[string]*setup.Package{ + "bin-mypkg": { + RealName: "mypkg", + Name: "bin-mypkg", + Path: "bin-slices/mypkg.yaml", + Store: "bin", + DefaultTrack: "2", + ChannelPatterns: []string{"2/*", "3/*", "4/edge"}, + Slices: map[string]*setup.Slice{}, + }, + }, + Maintenance: &setup.Maintenance{ + Standard: time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + EndOfLife: time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + }, +}, { + summary: "Store package with single channel value is parsed correctly", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "2" + channels: 2/stable + `, + }, + release: &setup.Release{ + Format: "v3", + Archives: map[string]*setup.Archive{ + "ubuntu": { + Name: "ubuntu", + Version: "22.04", + Suites: []string{"jammy"}, + Components: []string{"main", "universe"}, + PubKeys: []*packet.PublicKey{testKey.PubKey}, + Maintained: true, + }, + }, + Stores: map[string]*setup.Store{ + "bin": { + Name: "bin", + Kind: "bin", + Version: "26.10", + DefaultPrefix: "bin-", + }, + }, + Packages: map[string]*setup.Package{ + "bin-mypkg": { + RealName: "mypkg", + Name: "bin-mypkg", + Path: "bin-slices/mypkg.yaml", + Store: "bin", + DefaultTrack: "2", + ChannelPatterns: []string{"2/stable"}, + Slices: map[string]*setup.Slice{}, + }, + }, + Maintenance: &setup.Maintenance{ + Standard: time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + EndOfLife: time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + }, +}, { + summary: "Store package missing channels", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "3.0" + `, + }, + relerror: `cannot parse package "bin-mypkg": 'store' requires 'channels'`, +}, { + summary: "Store package with empty channels list", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "3.0" + channels: [] + `, + }, + relerror: `cannot parse package "bin-mypkg": 'store' requires 'channels'`, +}, { + summary: "channels without store", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + channels: ["3.0/*"] + `, + }, + relerror: `cannot parse package "mypkg": 'channels' requires 'store'`, +}, { + summary: "channels used with older format (v1/v2) is not allowed", + input: map[string]string{ + "chisel.yaml": strings.ReplaceAll(testutil.DefaultChiselYaml, "format: v1", "format: v2"), + "slices/mypkg.yaml": ` + package: mypkg + channels: ["3.0/*"] + `, + }, + relerror: `cannot parse package "mypkg": 'store', 'default-track' and 'channels' are unsupported before format v3`, +}, { + summary: "channels with invalid value report the package context", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "3.0" + channels: ["3.0/foo"] + `, + }, + relerror: `cannot parse package "bin-mypkg": invalid 'channels' value: "3\.0/foo": unknown risk "foo", must be one of stable, candidate, beta, edge`, +}, { + summary: "channels not matching default track", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "2" + channels: ["3.0/*"] + `, + }, + relerror: `cannot parse package "bin-mypkg": 'channels' must match the default channel "2/stable"`, +}, { + summary: "channels excluding the default risk", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "2" + channels: ["2/!stable"] + `, + }, + relerror: `cannot parse package "bin-mypkg": 'channels' must match the default channel "2/stable"`, }, { summary: "Store missing version", input: map[string]string{ @@ -4286,6 +4461,7 @@ var setupTests = []setupTest{{ package: curl store: bin default-track: "3.0" + channels: ["3.0/*"] slices: bins: contents: @@ -4335,11 +4511,12 @@ var setupTests = []setupTest{{ }, }, "bin-curl": { - RealName: "curl", - Name: "bin-curl", - Path: "bin-slices/curl.yaml", - Store: "bin", - DefaultTrack: "3.0", + RealName: "curl", + Name: "bin-curl", + Path: "bin-slices/curl.yaml", + Store: "bin", + DefaultTrack: "3.0", + ChannelPatterns: []string{"3.0/*"}, Slices: map[string]*setup.Slice{ "bins": { Package: "bin-curl", @@ -4364,6 +4541,7 @@ var setupTests = []setupTest{{ package: mypkg store: bin default-track: "3.0" + channels: ["3.0/*"] `, }, release: &setup.Release{ @@ -4388,12 +4566,13 @@ var setupTests = []setupTest{{ }, Packages: map[string]*setup.Package{ "bin-mypkg": { - RealName: "mypkg", - Name: "bin-mypkg", - Path: "slices/mypkg.yaml", - Store: "bin", - DefaultTrack: "3.0", - Slices: map[string]*setup.Slice{}, + RealName: "mypkg", + Name: "bin-mypkg", + Path: "slices/mypkg.yaml", + Store: "bin", + DefaultTrack: "3.0", + ChannelPatterns: []string{"3.0/*"}, + Slices: map[string]*setup.Slice{}, }, }, Maintenance: &setup.Maintenance{ @@ -4455,6 +4634,7 @@ var setupTests = []setupTest{{ package: curl store: bin default-track: "3.0" + channels: ["3.0/*"] slices: bins: contents: @@ -4504,11 +4684,12 @@ var setupTests = []setupTest{{ }, }, "bin-curl": { - RealName: "curl", - Name: "bin-curl", - Path: "slices/bins/curl.yaml", - Store: "bin", - DefaultTrack: "3.0", + RealName: "curl", + Name: "bin-curl", + Path: "slices/bins/curl.yaml", + Store: "bin", + DefaultTrack: "3.0", + ChannelPatterns: []string{"3.0/*"}, Slices: map[string]*setup.Slice{ "bins": { Package: "bin-curl", @@ -4554,6 +4735,7 @@ var setupTests = []setupTest{{ package: mypkg store: bin default-track: "3.0" + channels: ["3.0/*"] slices: myslice: contents: @@ -4935,6 +5117,7 @@ func (s *S) TestPackageYAMLFormat(c *C) { package: mypkg store: bin default-track: "3.0" + channels: 3.0/* slices: myslice: contents: @@ -4949,6 +5132,7 @@ func (s *S) TestPackageYAMLFormat(c *C) { package: mypkg store: bin default-track: "3.0" + channels: 3.0/* slices: myslice: contents: diff --git a/internal/setup/yaml.go b/internal/setup/yaml.go index 1760f1b4..d1eefb4f 100644 --- a/internal/setup/yaml.go +++ b/internal/setup/yaml.go @@ -60,10 +60,11 @@ type yamlStore struct { } type yamlPackage struct { - RealName string `yaml:"package"` - Archive string `yaml:"archive,omitempty"` - Store string `yaml:"store,omitempty"` - DefaultTrack string `yaml:"default-track,omitempty"` + RealName string `yaml:"package"` + Archive string `yaml:"archive,omitempty"` + Store string `yaml:"store,omitempty"` + DefaultTrack string `yaml:"default-track,omitempty"` + ChannelPatterns yamlChannelPattern `yaml:"channels,omitempty"` // For backwards-compatibility reasons with v1 and v2, essential needs // custom logic to be parsed. See [yamlEssentialListMap]. Essential yamlEssentialListMap `yaml:"essential,omitempty"` @@ -197,6 +198,33 @@ func (ya yamlArch) MarshalYAML() (any, error) { var _ yaml.Marshaler = yamlArch{} +type yamlChannelPattern struct { + List []string +} + +func (yc *yamlChannelPattern) UnmarshalYAML(value *yaml.Node) error { + var s string + var l []string + if value.Decode(&s) == nil { + yc.List = []string{s} + } else if value.Decode(&l) == nil { + yc.List = l + } else { + return fmt.Errorf("cannot decode channels") + } + // Validate channel pattern correctness later for a better error message. + return nil +} + +func (yc yamlChannelPattern) MarshalYAML() (any, error) { + if len(yc.List) == 1 { + return yc.List[0], nil + } + return yc.List, nil +} + +var _ yaml.Marshaler = yamlChannelPattern{} + type yamlMode uint func (ym yamlMode) MarshalYAML() (any, error) { @@ -491,8 +519,8 @@ func parsePackage(release *Release, pkgName, pkgPath string, data []byte) (*Pack if yamlPkg.RealName != pkg.RealName { return nil, fmt.Errorf("%s: filename and 'package' field (%q) disagree", pkgPath, yamlPkg.RealName) } - if (pkg.Store != "" || pkg.DefaultTrack != "") && (release.Format == "v1" || release.Format == "v2") { - return nil, fmt.Errorf("cannot parse package %q: 'store' and 'default-track' are unsupported before format v3", pkg.Name) + if (pkg.Store != "" || pkg.DefaultTrack != "" || len(yamlPkg.ChannelPatterns.List) > 0) && (release.Format == "v1" || release.Format == "v2") { + return nil, fmt.Errorf("cannot parse package %q: 'store', 'default-track' and 'channels' are unsupported before format v3", pkg.Name) } // Derive the package unique name from its store prefix if applicable. @@ -514,8 +542,27 @@ func parsePackage(release *Release, pkgName, pkgPath string, data []byte) (*Pack if strings.Contains(pkg.DefaultTrack, "/") { return nil, fmt.Errorf("cannot parse package %q: 'default-track' must not contain /", pkg.Name) } - } else if pkg.DefaultTrack != "" { - return nil, fmt.Errorf("cannot parse package %q: 'default-track' requires 'store'", pkg.Name) + if len(yamlPkg.ChannelPatterns.List) == 0 { + return nil, fmt.Errorf("cannot parse package %q: 'store' requires 'channels'", pkg.Name) + } + if err := validateChannelPatterns(yamlPkg.ChannelPatterns.List); err != nil { + return nil, fmt.Errorf("cannot parse package %q: invalid 'channels' value: %s", pkg.Name, err) + } + // The default track is resolved with the default risk, so the + // package must be compatible with that channel, otherwise cutting + // it would always fail. + defaultChannel := Channel{Track: pkg.DefaultTrack, Risk: DefaultRisk} + if !MatchChannelPatterns(yamlPkg.ChannelPatterns.List, defaultChannel) { + return nil, fmt.Errorf("cannot parse package %q: 'channels' must match the default channel %q", pkg.Name, defaultChannel) + } + pkg.ChannelPatterns = yamlPkg.ChannelPatterns.List + } else { + if pkg.DefaultTrack != "" { + return nil, fmt.Errorf("cannot parse package %q: 'default-track' requires 'store'", pkg.Name) + } + if len(yamlPkg.ChannelPatterns.List) > 0 { + return nil, fmt.Errorf("cannot parse package %q: 'channels' requires 'store'", pkg.Name) + } } if release.Format == "v1" || release.Format == "v2" { @@ -754,11 +801,12 @@ func sliceToYAML(s *Slice) (*yamlSlice, error) { // packageToYAML converts a Package object to a yamlPackage object. func packageToYAML(p *Package) (*yamlPackage, error) { pkg := &yamlPackage{ - RealName: p.RealName, - Archive: p.Archive, - Store: p.Store, - DefaultTrack: p.DefaultTrack, - Slices: make(map[string]yamlSlice, len(p.Slices)), + RealName: p.RealName, + Archive: p.Archive, + Store: p.Store, + DefaultTrack: p.DefaultTrack, + ChannelPatterns: yamlChannelPattern{List: p.ChannelPatterns}, + Slices: make(map[string]yamlSlice, len(p.Slices)), } for name, slice := range p.Slices { yamlSlice, err := sliceToYAML(slice) diff --git a/internal/slicer/slicer_test.go b/internal/slicer/slicer_test.go index d6ef9ca0..f6910c9f 100644 --- a/internal/slicer/slicer_test.go +++ b/internal/slicer/slicer_test.go @@ -1986,7 +1986,8 @@ var slicerTests = []slicerTest{{ "slices/curl.yaml": ` package: curl store: bin - default-track: latest + default-track: 1 + channels: ["1/*"] slices: bin: contents: