Skip to content
17 changes: 13 additions & 4 deletions cmd/chisel/cmd_cut.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ to create a new filesystem tree in the root location.

By default it fetches the slices for the same Ubuntu version as the
current host, unless the --release flag is used.

Slices are named <package>_<slice>. For packages coming from a store, a
channel can be appended to select which one to fetch, as in
mybin_myslice@2.0/edge. A channel is a <track>/<risk> value.

The risk defaults to stable, so @2.0 and @2.0/stable are equivalent. When
no channel is given at all, the default track of the package is used,
again with the stable risk. Slices of the same package must all agree on
the channel.
`

var cutDescs = map[string]string{
Expand Down Expand Up @@ -49,13 +58,13 @@ func (cmd *cmdCut) Execute(args []string) error {
return ErrExtraArgs
}

sliceKeys := make([]setup.SliceKey, len(cmd.Positional.SliceRefs))
sliceRefs := make([]setup.SliceRef, len(cmd.Positional.SliceRefs))
for i, sliceRef := range cmd.Positional.SliceRefs {
sliceKey, err := setup.ParseSliceKey(sliceRef)
ref, err := setup.ParseSliceRef(sliceRef)
if err != nil {
return err
}
sliceKeys[i] = sliceKey
sliceRefs[i] = ref
}

release, err := obtainRelease(cmd.Release)
Expand All @@ -73,7 +82,7 @@ func (cmd *cmdCut) Execute(args []string) error {
}
}

selection, err := setup.Select(release, sliceKeys, cmd.Arch)
selection, err := setup.Select(release, sliceRefs, cmd.Arch)
if err != nil {
return err
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/chisel/cmd_find.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ func match(slice *setup.Slice, query string) bool {
// findSlices returns slices from the provided release that match all of the
// query strings (AND).
func findSlices(release *setup.Release, query []string) (slices []*setup.Slice, err error) {
for _, term := range query {
if strings.Contains(term, "@") {
return nil, fmt.Errorf("invalid slice reference %q: slices are not specific to a channel", term)
}
}
slices = []*setup.Slice{}
for _, pkg := range release.Packages {
for _, slice := range pkg.Slices {
Expand Down
10 changes: 10 additions & 0 deletions cmd/chisel/cmd_find_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type findTest struct {
release *setup.Release
query []string
result []*setup.Slice
err string
}

func makeSamplePackage(pkg string, slices []string) *setup.Package {
Expand Down Expand Up @@ -123,6 +124,11 @@ var findTests = []findTest{{
release: sampleRelease,
query: []string{"python", "slice"},
result: []*setup.Slice{},
}, {
summary: "Slices are not specific to a channel",
release: sampleRelease,
query: []string{"python3.10_bins@3.0"},
err: `invalid slice reference "python3.10_bins@3.0": slices are not specific to a channel`,
}}

func (s *ChiselSuite) TestFindSlices(c *C) {
Expand All @@ -131,6 +137,10 @@ func (s *ChiselSuite) TestFindSlices(c *C) {

for _, query := range testutil.Permutations(test.query) {
slices, err := chisel.FindSlices(test.release, query)
if test.err != "" {
c.Assert(err, ErrorMatches, test.err)
continue
}
c.Assert(err, IsNil)
c.Assert(slices, DeepEquals, test.result)
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/chisel/cmd_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ func (cmd *infoCmd) Execute(args []string) error {
return err
}

for _, query := range cmd.Positional.Queries {
if strings.Contains(query, "@") {
return fmt.Errorf("invalid slice reference %q: slices are not specific to a channel", query)
}
}

packages, notFound := selectPackageSlices(release, cmd.Positional.Queries)

for i, pkg := range packages {
Expand Down
5 changes: 5 additions & 0 deletions cmd/chisel/cmd_info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ var infoTests = []infoTest{{
input: infoRelease,
query: []string{"foo_bar_foo", "a_b", "7_c", "a_b c", "a_b x_y"},
err: `no slice definitions found for: "foo_bar_foo", "a_b", "7_c", "a_b c", "a_b x_y"`,
}, {
summary: "Slices are not specific to a channel",
input: infoRelease,
query: []string{"mypkg1_myslice1@3.0"},
err: `invalid slice reference "mypkg1_myslice1@3.0": slices are not specific to a channel`,
}}

var infoRelease = map[string]string{
Expand Down
54 changes: 54 additions & 0 deletions internal/setup/channel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package setup

import (
"errors"
"strings"
"unicode"
)

// Channel is a store channel, as in "<track>/<risk>[/<branch>]".
type Channel struct {
Track string
Risk string
Branch string
}

func (c Channel) String() string {
if c == (Channel{}) {
return ""
}
channel := c.Track + "/" + c.Risk
if c.Branch != "" {
channel += "/" + c.Branch
}
return channel
}

// parseChannel parses a "<track>[/<risk>[/<branch>]]" channel, as written.
// Validation is intentionally loose, the track, the risk and the branch are
// only checked for their presence so that their values are not rejected here.
func parseChannel(channel string) (Channel, error) {
if channel == "" {
return Channel{}, errors.New("missing channel")
}
if strings.ContainsFunc(channel, unicode.IsSpace) {
return Channel{}, errors.New("channel must not contain spaces")
}
segments := strings.Split(channel, "/")
if len(segments) > 3 {
return Channel{}, errors.New("channel must be <track>[/<risk>[/<branch>]]")
}
for _, segment := range segments {
if segment == "" {
return Channel{}, errors.New("channel must be <track>[/<risk>[/<branch>]]")
}
}
parsed := Channel{Track: segments[0]}
if len(segments) > 1 {
parsed.Risk = segments[1]
}
if len(segments) > 2 {
parsed.Branch = segments[2]
}
return parsed, nil
}
42 changes: 42 additions & 0 deletions internal/setup/channel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package setup_test

import (
. "gopkg.in/check.v1"

"github.com/canonical/chisel/internal/setup"
)

var channelStringTests = []struct {
summary string
channel setup.Channel
expected string
}{{
summary: "An unset channel renders as empty",
channel: setup.Channel{},
expected: "",
}, {
summary: "A track and a risk",
channel: setup.Channel{Track: "3.0", Risk: "stable"},
expected: "3.0/stable",
}, {
summary: "A branch is appended",
channel: setup.Channel{Track: "3.0", Risk: "edge", Branch: "mybranch"},
expected: "3.0/edge/mybranch",
}, {
// A channel is never built without a risk, but rendering the risk as
// optional would turn the branch into one, that is a different channel.
summary: "A missing risk is not skipped over",
channel: setup.Channel{Track: "3.0", Branch: "mybranch"},
expected: "3.0//mybranch",
}, {
summary: "A missing track is visible",
channel: setup.Channel{Risk: "edge"},
expected: "/edge",
}}

func (s *S) TestChannelString(c *C) {
for _, test := range channelStringTests {
c.Logf("Summary: %s", test.summary)
c.Assert(test.channel.String(), Equals, test.expected)
}
}
104 changes: 103 additions & 1 deletion internal/setup/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,42 @@ func ParseSliceKey(sliceKey string) (SliceKey, error) {
return apacheutil.ParseSliceKey(sliceKey)
}

// DefaultRisk is used when a slice reference does not specify a risk.
const DefaultRisk = "stable"

// SliceRef is a slice reference with an optional channel for store packages.
// The channel always holds a risk, the default one is used when the reference
// does not specify it.
type SliceRef struct {
SliceKey SliceKey
Channel Channel
}

// ParseSliceRef parses a "pkg_slice[@channel]" reference. See parseChannel
// for the accepted channel forms.
func ParseSliceRef(ref string) (SliceRef, error) {
keyPart, channel, ok := strings.Cut(ref, "@")
if !ok {
sliceKey, err := ParseSliceKey(ref)
if err != nil {
return SliceRef{}, err
}
return SliceRef{SliceKey: sliceKey}, nil
}
sliceKey, err := ParseSliceKey(keyPart)
if err != nil {
return SliceRef{}, err
}
parsed, err := parseChannel(channel)
if err != nil {
return SliceRef{}, fmt.Errorf("invalid slice reference %q: %s", ref, err)
}
if parsed.Risk == "" {
parsed.Risk = DefaultRisk
}
return SliceRef{SliceKey: sliceKey, Channel: parsed}, nil
}

func (s *Slice) String() string { return s.Package + "_" + s.Name }

// Selection holds the required configuration to create a Build for a selection
Expand All @@ -154,6 +190,8 @@ func (s *Slice) String() string { return s.Package + "_" + s.Name }
type Selection struct {
Release *Release
Slices []*Slice
// Channels holds the resolved channel per store package name.
Channels map[string]Channel
}

// Prefers uses the prefer relationships and returns a map from each path to
Expand Down Expand Up @@ -493,7 +531,7 @@ func stripBase(baseDir, path string) string {
return strings.TrimPrefix(path, baseDir+string(filepath.Separator))
}

func Select(release *Release, slices []SliceKey, arch string) (*Selection, error) {
func Select(release *Release, refs []SliceRef, arch string) (*Selection, error) {
logf("Selecting slices...")

var err error
Expand All @@ -506,10 +544,22 @@ func Select(release *Release, slices []SliceKey, arch string) (*Selection, error
return nil, err
}

// Resolve the channel of every store package, whether it is selected or
// not, and before ordering, because ordering depends on the channel of the
// packages it traverses.
channels, err := resolveChannels(release, refs)
if err != nil {
return nil, err
}

selection := &Selection{
Release: release,
}

slices := make([]SliceKey, len(refs))
for i, ref := range refs {
slices[i] = ref.SliceKey
}
sorted, err := order(release.Packages, slices, arch)
if err != nil {
return nil, err
Expand All @@ -519,6 +569,14 @@ func Select(release *Release, slices []SliceKey, arch string) (*Selection, error
selection.Slices[i] = release.Packages[key.Package].Slices[key.Slice]
}

// Only report the channels of the selected packages.
selection.Channels = make(map[string]Channel)
for _, slice := range selection.Slices {
if channel, ok := channels[slice.Package]; ok {
selection.Channels[slice.Package] = channel
}
}

for _, new := range selection.Slices {
for newPath, newInfo := range new.Contents {
// An invalid "generate" value should only throw an error if that
Expand Down Expand Up @@ -550,6 +608,50 @@ func Select(release *Release, slices []SliceKey, arch string) (*Selection, error
return selection, nil
}

// resolveChannels returns the channel of every store package of the release,
// taken from the references and falling back to the package 'default-track'
// with the default risk. Note the release only defines a track, the risk is
// implicit.
//
// It errors if a channel is set on a non-store package or if two references to
// the same package specify different channels.
func resolveChannels(release *Release, refs []SliceRef) (map[string]Channel, error) {
channels := make(map[string]Channel)
for _, ref := range refs {
pkg, ok := release.Packages[ref.SliceKey.Package]
if !ok {
// Nothing to validate; the package is unknown.
continue
}
if pkg.Store == "" {
if ref.Channel != (Channel{}) {
return nil, fmt.Errorf("slice %s has channel but package %q is not in a store",
ref.SliceKey, pkg.Name)
}
continue
}
if ref.Channel == (Channel{}) {
continue
}
if existing, ok := channels[pkg.Name]; ok && existing != ref.Channel {
return nil, fmt.Errorf("slices of package %q have conflicting channels %q and %q",
pkg.Name, existing, ref.Channel)
}
channels[pkg.Name] = ref.Channel
}
for _, pkg := range release.Packages {
if pkg.Store == "" {
continue
}
if _, ok := channels[pkg.Name]; ok {
// The references take precedence over the 'default-track'.
continue
}
channels[pkg.Name] = Channel{Track: pkg.DefaultTrack, Risk: DefaultRisk}
}
return channels, nil
}

const (
preferSource = 1
preferTarget = 2
Expand Down
Loading
Loading