333 lines
14 KiB
Ruby

# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1
module VagrantPlugins
module CommandServe
class Mappers
module Internal
class Graph
# Provides searching of a graph to determine if a
# destination vertex is accessible from a given
# root vertex.
class Search
class NoPathError < StandardError; end
class InvalidVertex < StandardError
attr_reader :vertex
def initialize(v)
super("Invalid vertex within path")
@vertex = v
end
end
include Util::HasLogger
attr_reader :graph
# Create a new Search instance
#
# @param graph [Graph] Graph used for searching
def initialize(graph:)
@m = Mutex.new
@graph = graph
@root = nil
end
# Generate a path from a given source vertex
#
# @param src [Vertex] Source vertex
# @return [Array<Vertex>] path from source to destination
# @raises [NoPathError] when no path can be determined
def path(src, dst)
@m.synchronize do
logger.debug { "finding path #{src} -> #{dst}" }
# Mark the root of our graph which will be used later
@root = src
# When searching for a path in the graph, we will be looking
# for the shortest path. Since we know the desired final
# destination, we want to use that as our starting point
# and attempt to find a path from that destination vertex
# to the source vertex. So we start by reversing the graph
graph.reverse!
# Perform an initial DFS to prune the graph down. This will remove any
# vertices which are not connected to the root of the graph (which will
# be the destination since we reversed the graph). The reason we do this
# is to reduce the overall size of the graph before generating our path,
# and to ensure that the both ends of the graph are still available after
# the pruning.
vertices = []
descender = lambda { |v|
graph.depth_first_visit(v) { |vrt|
vertices << vrt
}
vertices.uniq!
vertices.each do |vrt|
if vrt.incoming_edges_required
graph.out_vertices(vrt).each do |ov|
if !vertices.include?(ov)
descender.call(ov)
end
end
end
end
}
# Since the destination is now the "root" of our reversed graph,
# we want to start our search from there to generate the list
# of verticies it can connect to.
descender.call(dst)
# Remove excess vertices from the graph
(graph.vertices - vertices).each { |v|
graph.remove_vertex(v)
}
logger.trace { "graph after DFS reduction:\n#{graph.reverse.inspect}" }
logger.trace { "generating list of required vertices for path #{src} -> #{dst}" }
# If the graph no longer contains the source vertex (this is the root of the
# non-reversed graph) then it is impossible to generate a valid path.
if !graph.vertices.include?(src)
raise NoPathError,
"Graph no longer includes source vertex #{src} (#{src} -> #{dst})"
end
# This should not happen since we start our DFS from the destination vertex
# but we keep this sanity check regardless.
if !graph.vertices.include?(dst)
raise NoPathError,
"Graph no longer includes destination vertex #{dst} (#{src} -> #{dst})"
end
# Generate list of required vertices from the graph
required_vertices = generate_path(dst, src)
# If no vertices were returned, then the path generation failed
if required_vertices.nil?
raise NoPathError,
"Path generation failed to reach destination (#{src} -> #{dst&.type&.inspect})"
end
logger.trace { "required vertices list generation complete for path #{src} -> #{dst}" }
# Now that we have a path, remove all extraneous vertices
# from the graph. We're going to use the graph to provide
# us a proper ordering of the vertices below
(graph.vertices - required_vertices).each do |vrt|
graph.remove_vertex(vrt)
end
# Reverse the graph so the direction of the graph is in its
# original state.
graph.reverse!
# Check if the graph is acyclic. If it is not, break any cycles found in the graph.
graph.break_cycles!(src) if !graph.acyclic?
logger.debug { "graph after acyclic breakage:\n#{graph.reverse.inspect}" }
# Apply topological sort to the graph so we have
# a proper order for execution. This is required
# to ensure that all inputs for a given vertex are
# available before executing it.
result = Array.new.tap do |path|
t = graph.topsort_iterator
until t.at_end?
path << t.forward
end
end
# If the first vertex of the path is not the expected source,
# or the last vertex is not the expected destination, then
# a valid path was not found. Even though we generated our
# list of required vertices, this can still occur if the
# resultant graph included any cycles, and breaking the cycle(s)
# resulted in orphaned vertices.
if result.first != src
raise NoPathError,
"Initial vertex is not source #{src} != #{result.first}"
end
if result.last != dst
raise NoPathError,
"Final vertex is not destination #{dst} != #{result.last}"
end
result
end
rescue NoPathError
if ENV["VAGRANT_LOG_MAPPER"].to_s != ""
begin
require 'rgl/dot'
graph.reverse.write_to_graphic_file('jpg', 'graph-no-path')
rescue
#
end
end
raise
end
protected
# Generate a list of required vertices for making a
# path from the given source vertex to the given
# destination vertex.
#
# @param src [Vertex] source vertex
# @param dst [Vertex] destination vertex
# @return [Array<Vertex>, NilClass]
def generate_path(src, dst)
begin
# Find the shortest path based edge weights. Since
# we are generating a path against the reversed graph,
# the source is our desired destination vertex and the
# destination is the source vertex.
path = graph.shortest_path(src, dst)
logger.trace {
o = Array(path).reverse.map { |v|
" #{v} ->"
}.join("\n")
"path generation #{dst} -> #{src}\n#{o}"
}
if path.nil?
raise NoPathError,
"Path generation failed to reach destination (#{dst} -> #{src})"
end
# Once we have the path, we need to expand the
# path to ensure that all vertices in the path
# are fully reachable, and if any vertices are
# missing that they are added
expand_path(path, dst, graph)
rescue InvalidVertex => err
# An invalid vertex will be flagged when path expansion exposes
# a vertex which requires all incoming edges and all the edges
# cannot reach the destination vertex. When this happens, we
# remove that vertex from the graph and then retry the path
# generation.
logger.trace { "invalid vertex in path, removing (#{err.vertex})" }
graph.remove_vertex(err.vertex)
retry
end
end
# Expand a given path of vertices by iterating through
# the provided path and validating all required vertices
# are in the path for any vertices which require all incoming
# edges. The result will be the list of original vertices
# plus all additional vertices found to be missing.
#
# @param path [Array<Vertex>] current path
# @param dst [Vertex]
def expand_path(path, dst, graph)
new_path = path.dup
path.each do |v|
# Only inspect vertices that require all
# incoming edges
next if !v.incoming_edges_required
logger.trace { "validating incoming edges for vertex #{v}" }
# Since we are using a reversed graph, the incoming edges are now
# outgoing edges, so we graph the out vertices
outs = graph.out_vertices(v)
# Make a clone of the graph so we can modify it without affecting
# the original
g = graph.clone
# Remove the original vertex we are currently inspecting from
# the cloned graph. This is done to prevent generating a cycle
# where the vetex is required in the path to reach the destination
g.remove_vertex(v)
# Now we find the path from each vertex to the destination
outs.each do |src|
# Since inputs can support named arguments, temporarily update
# weights of value vertices.
ipath = reweight_for(src, g) do |ig|
ig.shortest_path(src, dst)
end
# Remove any other incoming edges to this input
# (graph.out_vertices(src) - [ipath.first]).each do |dv|
# graph.remove_edge(src, dv)
# end
# If no path was found an exception is raised that the vertex
# in the path (this is the original vertex from the first loop)
# is not valid within the path.
if ipath.nil? || ipath.empty?
logger.trace { "failed to find validating path from #{dst} -> #{src}" }
raise InvalidVertex.new(v)
else
logger.trace { "found validating path from #{dst} -> #{src}" }
end
# Remove any vertices that already exist in our final collection
# so we don't duplicate the inspection on them
ipath = ipath - new_path
if !ipath.empty?
# Since we have new vertices we need to expand them to
# ensure that they all have valid paths to the destination.
# The graph provided here is our modified clone to prevent
# a cycle where the original vertex is required
ipath = expand_path(ipath, dst, g)
# And now we add any new vertices which were discovered to
# be required
new_path |= ipath
end
end
logger.trace { "incoming edge validation complete for vertex #{v}" }
end
new_path
end
# Modify the weights of value type vertices when the given
# vertex is a Vertex::Input and has a name defined. All
# value vertices will have their weight reset to the default
# value weight, and if a Vertex::NamedValue exists with a
# matching name, it will have the NAMED_VALUE_WEIGHT
# applied. After the given block is executed, the value
# vertices will have their original weights re-applied.
#
# Since value vertices are identified by type, only
# one value of any type may exist in the graph. However,
# if the value is named, then multiple values of the same
# type may exist in the graph. For instance, with named
# values two strings could be provided, one named "local_path"
# and another named "remote_path". If a mapper function is
# only interested in the value of the "remote_path", it
# can include that name within its input definition. Then
# this method can be used to prefer the name string argument
# "remote_path" over the "local_path" named argument, or
# just a regular string value.
#
# @param vertex [Vertex] source vertex
# @param graph [Graph] graph to modify
# @yieldparam [Graph] modified graph (passed instance, not a copy)
# @yield block to execute
# @return [Object] result of block
def reweight_for(vertex, graph)
original_weights = {}
begin
if vertex.is_a?(Vertex::Input) && vertex.name
graph.each_vertex do |v|
next if !v.is_a?(Vertex::Value)
original_weights[v] = v.weight
if v.name.to_s == vertex.name.to_s
v.weight = Mappers::NAMED_VALUE_WEIGHT
else
v.weight = Mappers::VALUE_WEIGHT
end
end
end
yield graph
ensure
# Set the weights of the value vertices back to
# their original values
original_weights.each do |v, w|
v.weight = w
end
end
end
end
end
end
end
end
end