Skip to content

Commit 1841f30

Browse files
committed
opm validate: check for cycles and stranded bundles in channel validation (#755)
* fail model validation if cycle is detected * check for stranded bundles Signed-off-by: Joe Lanford <[email protected]> Upstream-repository: operator-registry Upstream-commit: cede7dbeae2c376c9154b81cdd2c6536f1b726a2
1 parent d1dea10 commit 1841f30

File tree

3 files changed

+147
-2
lines changed

3 files changed

+147
-2
lines changed

staging/operator-registry/internal/model/model.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/h2non/filetype/matchers"
1212
"github.com/h2non/filetype/types"
1313
svg "github.com/h2non/go-is-svg"
14+
"k8s.io/apimachinery/pkg/util/sets"
1415

1516
"github.com/operator-framework/operator-registry/internal/property"
1617
)
@@ -184,7 +185,7 @@ func (c *Channel) Validate() error {
184185
}
185186

186187
if len(c.Bundles) > 0 {
187-
if _, err := c.Head(); err != nil {
188+
if err := c.validateReplacesChain(); err != nil {
188189
result.subErrors = append(result.subErrors, err)
189190
}
190191
}
@@ -203,6 +204,51 @@ func (c *Channel) Validate() error {
203204
return result.orNil()
204205
}
205206

207+
// validateReplacesChain checks the replaces chain of a channel.
208+
// Specifically the following rules must be followed:
209+
// 1. There must be exactly 1 channel head.
210+
// 2. Beginning at the head, the replaces chain must reach all non-skipped entries.
211+
// Non-skipped entries are defined as entries that are not skipped by any other entry in the channel.
212+
// 3. There must be no cycles in the replaces chain.
213+
// 4. The tail entry in the replaces chain is permitted to replace a non-existent entry.
214+
func (c *Channel) validateReplacesChain() error {
215+
head, err := c.Head()
216+
if err != nil {
217+
return err
218+
}
219+
220+
allBundles := sets.NewString()
221+
skippedBundles := sets.NewString()
222+
for _, b := range c.Bundles {
223+
allBundles = allBundles.Insert(b.Name)
224+
skippedBundles = skippedBundles.Insert(b.Skips...)
225+
}
226+
227+
chainFrom := map[string][]string{}
228+
replacesChainFromHead := sets.NewString(head.Name)
229+
cur := head
230+
for cur != nil {
231+
if _, ok := chainFrom[cur.Name]; !ok {
232+
chainFrom[cur.Name] = []string{cur.Name}
233+
}
234+
for k := range chainFrom {
235+
chainFrom[k] = append(chainFrom[k], cur.Replaces)
236+
}
237+
if replacesChainFromHead.Has(cur.Replaces) {
238+
return fmt.Errorf("detected cycle in replaces chain of upgrade graph: %s", strings.Join(chainFrom[cur.Replaces], " -> "))
239+
}
240+
replacesChainFromHead = replacesChainFromHead.Insert(cur.Replaces)
241+
cur = c.Bundles[cur.Replaces]
242+
}
243+
244+
strandedBundles := allBundles.Difference(replacesChainFromHead).Difference(skippedBundles).List()
245+
if len(strandedBundles) > 0 {
246+
return fmt.Errorf("channel contains one or more stranded bundles: %s", strings.Join(strandedBundles, ", "))
247+
}
248+
249+
return nil
250+
}
251+
206252
type Bundle struct {
207253
Package *Package
208254
Channel *Channel

staging/operator-registry/internal/model/model_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,59 @@ func TestChannelHead(t *testing.T) {
118118
}
119119
}
120120

121+
func TestValidReplacesChain(t *testing.T) {
122+
type spec struct {
123+
name string
124+
ch Channel
125+
assertion require.ErrorAssertionFunc
126+
}
127+
specs := []spec{
128+
{
129+
name: "Success/Valid",
130+
ch: Channel{Bundles: map[string]*Bundle{
131+
"anakin.v0.0.1": {Name: "anakin.v0.0.1"},
132+
"anakin.v0.0.2": {Name: "anakin.v0.0.2", Skips: []string{"anakin.v0.0.1"}},
133+
"anakin.v0.0.3": {Name: "anakin.v0.0.3", Skips: []string{"anakin.v0.0.2"}},
134+
"anakin.v0.0.4": {Name: "anakin.v0.0.4", Replaces: "anakin.v0.0.3"},
135+
}},
136+
assertion: require.NoError,
137+
},
138+
{
139+
name: "Error/CycleNoHops",
140+
ch: Channel{Bundles: map[string]*Bundle{
141+
"anakin.v0.0.4": {Name: "anakin.v0.0.4", Replaces: "anakin.v0.0.4"},
142+
"anakin.v0.0.5": {Name: "anakin.v0.0.5", Replaces: "anakin.v0.0.4"},
143+
}},
144+
assertion: hasError(`detected cycle in replaces chain of upgrade graph: anakin.v0.0.4 -> anakin.v0.0.4`),
145+
},
146+
{
147+
name: "Error/CycleMultipleHops",
148+
ch: Channel{Bundles: map[string]*Bundle{
149+
"anakin.v0.0.1": {Name: "anakin.v0.0.1", Replaces: "anakin.v0.0.3"},
150+
"anakin.v0.0.2": {Name: "anakin.v0.0.2", Replaces: "anakin.v0.0.1"},
151+
"anakin.v0.0.3": {Name: "anakin.v0.0.3", Replaces: "anakin.v0.0.2"},
152+
"anakin.v0.0.4": {Name: "anakin.v0.0.4", Replaces: "anakin.v0.0.3"},
153+
}},
154+
assertion: hasError(`detected cycle in replaces chain of upgrade graph: anakin.v0.0.3 -> anakin.v0.0.2 -> anakin.v0.0.1 -> anakin.v0.0.3`),
155+
},
156+
{
157+
name: "Error/Stranded",
158+
ch: Channel{Bundles: map[string]*Bundle{
159+
"anakin.v0.0.1": {Name: "anakin.v0.0.1"},
160+
"anakin.v0.0.2": {Name: "anakin.v0.0.2", Replaces: "anakin.v0.0.1"},
161+
"anakin.v0.0.3": {Name: "anakin.v0.0.3", Skips: []string{"anakin.v0.0.2"}},
162+
}},
163+
assertion: hasError(`channel contains one or more stranded bundles: anakin.v0.0.1`),
164+
},
165+
}
166+
for _, s := range specs {
167+
t.Run(s.name, func(t *testing.T) {
168+
err := s.ch.validateReplacesChain()
169+
s.assertion(t, err)
170+
})
171+
}
172+
}
173+
121174
func hasError(expectedError string) require.ErrorAssertionFunc {
122175
return func(t require.TestingT, actualError error, args ...interface{}) {
123176
if stdt, ok := t.(*testing.T); ok {

vendor/github.com/operator-framework/operator-registry/internal/model/model.go

Lines changed: 47 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)