Skip to content

Commit 870f516

Browse files
committed
opm validate: channel validation improvements
1. improve detection of bundles stranded due to usage of ancestor skips 2. find and report all cycles Signed-off-by: Joe Lanford <[email protected]>
1 parent 2629279 commit 870f516

File tree

2 files changed

+319
-40
lines changed

2 files changed

+319
-40
lines changed

alpha/model/model.go

Lines changed: 215 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package model
22

33
import (
4+
"cmp"
45
"errors"
56
"fmt"
7+
"slices"
68
"sort"
79
"strings"
810

@@ -261,49 +263,232 @@ func (c *Channel) Validate() error {
261263
return result.orNil()
262264
}
263265

264-
// validateReplacesChain checks the replaces chain of a channel.
265-
// Specifically the following rules must be followed:
266-
// 1. There must be exactly 1 channel head.
267-
// 2. Beginning at the head, the replaces chain must reach all non-skipped entries.
268-
// Non-skipped entries are defined as entries that are not skipped by any other entry in the channel.
269-
// 3. There must be no cycles in the replaces chain.
270-
// 4. The tail entry in the replaces chain is permitted to replace a non-existent entry.
271-
func (c *Channel) validateReplacesChain() error {
272-
head, err := c.Head()
273-
if err != nil {
274-
return err
266+
type node struct {
267+
name string
268+
version semver.Version
269+
replacedBy map[string]*node
270+
replaces *node
271+
skippedBy map[string]*node
272+
skips map[string]*node
273+
skipRange string
274+
hasEntry bool
275+
}
276+
277+
type graph struct {
278+
nodes map[string]*node
279+
}
280+
281+
func newGraph(c *Channel) *graph {
282+
nodes := map[string]*node{}
283+
for _, b := range c.Bundles {
284+
nodes[b.Name] = &node{
285+
name: b.Name,
286+
version: b.Version,
287+
skipRange: b.SkipRange,
288+
replacedBy: make(map[string]*node),
289+
skippedBy: make(map[string]*node),
290+
skips: make(map[string]*node),
291+
}
275292
}
276293

277-
allBundles := sets.NewString()
278-
skippedBundles := sets.NewString()
279294
for _, b := range c.Bundles {
280-
allBundles = allBundles.Insert(b.Name)
281-
skippedBundles = skippedBundles.Insert(b.Skips...)
295+
n := nodes[b.Name]
296+
297+
if b.Replaces != "" {
298+
replaces, ok := nodes[b.Replaces]
299+
if !ok {
300+
replaces = &node{
301+
name: b.Replaces,
302+
replacedBy: make(map[string]*node),
303+
hasEntry: false,
304+
}
305+
nodes[b.Replaces] = replaces
306+
}
307+
n.replaces = replaces
308+
n.replaces.replacedBy[n.name] = n
309+
}
310+
311+
for _, skipName := range b.Skips {
312+
skip, ok := nodes[skipName]
313+
if !ok {
314+
skip = &node{
315+
name: skipName,
316+
skippedBy: make(map[string]*node),
317+
skips: make(map[string]*node),
318+
hasEntry: false,
319+
}
320+
}
321+
skip.skippedBy[b.Name] = n
322+
n.skips[skipName] = skip
323+
}
282324
}
283325

284-
chainFrom := map[string][]string{}
285-
replacesChainFromHead := sets.NewString(head.Name)
326+
return &graph{
327+
nodes: nodes,
328+
}
329+
}
330+
331+
func (g *graph) validate() error {
332+
result := newValidationError("invalid upgrade graph")
333+
if err := g.validateNoCycles(); err != nil {
334+
result.subErrors = append(result.subErrors, err)
335+
}
336+
if err := g.validateNoStranded(); err != nil {
337+
result.subErrors = append(result.subErrors, err)
338+
}
339+
return result.orNil()
340+
}
341+
342+
func (g *graph) validateNoCycles() error {
343+
result := newValidationError("cycles found in graph")
344+
allCycles := [][]*node{}
345+
for _, n := range g.nodes {
346+
ancestors := map[string]*node{}
347+
maps.Copy(ancestors, n.replacedBy)
348+
maps.Copy(ancestors, n.skippedBy)
349+
allCycles = append(allCycles, paths([]*node{n}, ancestors, n)...)
350+
}
351+
dedupPaths(&allCycles)
352+
for _, cycle := range allCycles {
353+
cycleStr := strings.Join(mapSlice(cycle, nodeName), " -> ")
354+
result.subErrors = append(result.subErrors, errors.New(cycleStr))
355+
}
356+
357+
return result.orNil()
358+
}
359+
360+
func (g *graph) validateNoStranded() error {
361+
head, err := g.head()
362+
if err != nil {
363+
return err
364+
}
365+
all := sets.New[*node](maps.Values(g.nodes)...)
366+
chain := sets.New[*node]()
367+
skipped := sets.New[*node]()
368+
286369
cur := head
287-
for cur != nil {
288-
if _, ok := chainFrom[cur.Name]; !ok {
289-
chainFrom[cur.Name] = []string{cur.Name}
370+
for cur != nil && !skipped.Has(cur) && !chain.Has(cur) {
371+
chain.Insert(cur)
372+
skipped.Insert(maps.Values(cur.skips)...)
373+
cur = cur.replaces
374+
}
375+
376+
stranded := all.Difference(chain).Difference(skipped)
377+
if stranded.Len() > 0 {
378+
strandedNames := mapSlice(stranded.UnsortedList(), func(n *node) string {
379+
return n.name
380+
})
381+
slices.Sort(strandedNames)
382+
return fmt.Errorf("channel contains one or more stranded bundles: %s", strings.Join(strandedNames, ", "))
383+
}
384+
385+
return nil
386+
}
387+
388+
func (g *graph) head() (*node, error) {
389+
heads := []*node{}
390+
for _, n := range g.nodes {
391+
if len(n.replacedBy) == 0 && len(n.skippedBy) == 0 {
392+
heads = append(heads, n)
393+
}
394+
}
395+
if len(heads) == 0 {
396+
return nil, fmt.Errorf("no channel head found in graph")
397+
}
398+
if len(heads) > 1 {
399+
var headNames []string
400+
for _, head := range heads {
401+
headNames = append(headNames, head.name)
402+
}
403+
sort.Strings(headNames)
404+
return nil, fmt.Errorf("multiple channel heads found in graph: %s", strings.Join(headNames, ", "))
405+
}
406+
return heads[0], nil
407+
}
408+
409+
func nodeName(n *node) string {
410+
return n.name
411+
}
412+
413+
func mapSlice[I, O any](s []I, fn func(I) O) []O {
414+
result := make([]O, 0, len(s))
415+
for _, i := range s {
416+
result = append(result, fn(i))
417+
}
418+
return result
419+
}
420+
421+
func paths(existingPath []*node, froms map[string]*node, to *node) [][]*node {
422+
if len(froms) == 0 {
423+
// we never found a path to "to"
424+
return nil
425+
}
426+
var allPaths [][]*node
427+
for _, f := range froms {
428+
path := append(slices.Clone(existingPath), f)
429+
if f == to {
430+
// we found "to"!
431+
allPaths = append(allPaths, path)
432+
} else {
433+
allPaths = append(allPaths, paths(path, f.replacedBy, to)...)
290434
}
291-
for k := range chainFrom {
292-
chainFrom[k] = append(chainFrom[k], cur.Replaces)
435+
}
436+
return allPaths
437+
}
438+
439+
// dedupPaths removes rotations of the same cycle.
440+
// For example there are three paths:
441+
// 1. a -> b -> c -> a
442+
// 2. b -> c -> a -> b
443+
// 3. c -> a -> b -> c
444+
//
445+
// These are all the same cycle, so we want to choose just one of them.
446+
// dedupPaths chooses to keep the one whose first node has the highest version.
447+
func dedupPaths(paths *[][]*node) {
448+
slices.SortFunc(*paths, func(a, b []*node) int {
449+
if v := cmp.Compare(len(a), len(b)); v != 0 {
450+
return v
293451
}
294-
if replacesChainFromHead.Has(cur.Replaces) {
295-
return fmt.Errorf("detected cycle in replaces chain of upgrade graph: %s", strings.Join(chainFrom[cur.Replaces], " -> "))
452+
return b[0].version.Compare(a[0].version)
453+
})
454+
deleteIndices := sets.New[int]()
455+
for i, path := range *paths {
456+
for j, other := range (*paths)[i+1:] {
457+
if isSameRotation(path, other) {
458+
deleteIndices.Insert(j + i + 1)
459+
}
296460
}
297-
replacesChainFromHead = replacesChainFromHead.Insert(cur.Replaces)
298-
cur = c.Bundles[cur.Replaces]
299461
}
300462

301-
strandedBundles := allBundles.Difference(replacesChainFromHead).Difference(skippedBundles).List()
302-
if len(strandedBundles) > 0 {
303-
return fmt.Errorf("channel contains one or more stranded bundles: %s", strings.Join(strandedBundles, ", "))
463+
toDelete := sets.List(deleteIndices)
464+
slices.Reverse(toDelete)
465+
for _, i := range toDelete {
466+
(*paths) = slices.Delete(*paths, i, i+1)
304467
}
468+
}
305469

306-
return nil
470+
func isSameRotation(a, b []*node) bool {
471+
if len(a) != len(b) {
472+
return false
473+
}
474+
aStr := strings.Join(mapSlice(a[:len(a)-1], nodeName), " -> ")
475+
bStr := strings.Join(mapSlice(b[:len(b)-1], nodeName), " -> ")
476+
aPlusA := aStr + " -> " + aStr
477+
return strings.Contains(aPlusA, bStr)
478+
}
479+
480+
// validateReplacesChain checks the replaces chain of a channel.
481+
// Specifically the following rules must be followed:
482+
// 1. There must be exactly 1 channel head.
483+
// 2. Beginning at the head, the replaces chain traversal must reach all entries.
484+
// Unreached entries are considered "stranded" and cause a channel to be invalid.
485+
// 3. Skipped entries are always leaf nodes. We never follow replaces or skips edges
486+
// of skipped entries during replaces chain traversal.
487+
// 4. There must be no cycles in the replaces chain.
488+
// 5. The tail entry in the replaces chain is permitted to replace a non-existent entry.
489+
func (c *Channel) validateReplacesChain() error {
490+
g := newGraph(c)
491+
return g.validate()
307492
}
308493

309494
type Bundle struct {

0 commit comments

Comments
 (0)