Skip to content

Commit

Permalink
Runtime resource discovery
Browse files Browse the repository at this point in the history
  • Loading branch information
stefanmb committed Jan 23, 2018
1 parent 13f91bf commit fd9d842
Show file tree
Hide file tree
Showing 37 changed files with 571 additions and 89 deletions.
2 changes: 1 addition & 1 deletion dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ up:
- custom:
name: Minikube Cluster
met?: test $(minikube status | grep Running | wc -l) -eq 2 && $(minikube status | grep -q 'Correctly Configured')
meet: minikube start --vm-driver=xhyve --kubernetes-version=v1.7.5
meet: minikube start --vm-driver=xhyve --kubernetes-version=v1.8.0
down: minikube stop
commands:
reset-minikube: minikube delete && rm -rf ~/.minikube
Expand Down
4 changes: 3 additions & 1 deletion kubernetes-deploy.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ Gem::Specification.new do |spec|

spec.required_ruby_version = '>= 2.3.0'
spec.add_dependency "activesupport", ">= 4.2"
spec.add_dependency "kubeclient", "~> 2.4"
spec.add_dependency "kubeclient", "~> 2.5.1"
spec.add_dependency "rest-client", ">= 1.7" # Minimum required by kubeclient. Remove when kubeclient releases v3.0.
spec.add_dependency "googleauth", ">= 0.5"
spec.add_dependency "ejson", "1.0.1"
spec.add_dependency "colorize", "~> 0.8"
spec.add_dependency "statsd-instrument", "~> 2.1"
spec.add_dependency "jsonpath", "0.8.8"
spec.add_dependency "rgl", "0.5.3"

spec.add_development_dependency "bundler"
spec.add_development_dependency "rake", "~> 10.0"
Expand Down
84 changes: 51 additions & 33 deletions lib/kubernetes-deploy/deploy_task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
require 'yaml'
require 'shellwords'
require 'tempfile'
require 'rgl/adjacency'
require 'rgl/transitivity'
require 'rgl/traversal'
require 'kubernetes-deploy/discoverable_resource'
require 'kubernetes-deploy/kubernetes_resource'

%w(
cloudsql
config_map
Expand All @@ -29,6 +34,7 @@
bucket
stateful_set
cron_job
customresourcedefinition
).each do |subresource|
require "kubernetes-deploy/kubernetes_resource/#{subresource}"
end
Expand All @@ -41,17 +47,6 @@ module KubernetesDeploy
class DeployTask
include KubeclientBuilder

PREDEPLOY_SEQUENCE = %w(
ResourceQuota
Cloudsql
Redis
Memcached
Bugsnag
ConfigMap
PersistentVolumeClaim
ServiceAccount
Pod
)
PROTECTED_NAMESPACES = %w(
default
kube-system
Expand All @@ -66,23 +61,41 @@ class DeployTask
# core/v1/ReplicationController -- superseded by deployments/replicasets
# extensions/v1beta1/ReplicaSet -- managed by deployments
# core/v1/Secret -- should not committed / managed by shipit
def prune_whitelist
wl = %w(
core/v1/ConfigMap
core/v1/Pod
core/v1/Service
batch/v1/Job
extensions/v1beta1/DaemonSet
extensions/v1beta1/Deployment
apps/v1beta1/Deployment
extensions/v1beta1/Ingress
apps/v1beta1/StatefulSet
autoscaling/v1/HorizontalPodAutoscaler
)
if server_version >= Gem::Version.new('1.8.0')
wl << "batch/v1beta1/CronJob"
def prune_whitelist
prunable_resources = all_resources.select(&:prunable?)
prunable_resources.map(&:qualified_kind)
end

def predeploy_sequence
predeploy_resources = all_resources.select(&:predeploy?)

# Find resources that have explicit predeploy (inter-)dependencies:
predeploy_res_with_deps = all_resources.select(&:predeploy_dependencies)

# Compute dependencies:
graph = RGL::DirectedAdjacencyGraph.new
graph.add_vertex(:ROOT_NODE)
predeploy_res_with_deps.each do |res|
kind = res.kind
res.predeploy_dependencies.each do |dep|
# Edge [A,B] means A is required to be deployed before B
graph.add_edge(dep, kind)
# This is a partially ordered set, so we must make sure everything is reachable
graph.add_edge(:ROOT_NODE, kind)
graph.add_edge(:ROOT_NODE, dep)
end
end
wl

predeploy_order = graph.transitive_reduction.bfs_iterator(:ROOT_NODE).to_a
predeploy_order.delete(:ROOT_NODE)

# Append resources that must be predeployed but have no specific dependencies:
predeploy_res_without_deps = predeploy_resources - predeploy_res_with_deps
predeploy_order + predeploy_res_without_deps.map(&:kind) # Predeploy list does not use fully-qualifed kind
end

def all_resources
DiscoverableResource.all + KubernetesResource.all
end

def server_version
Expand Down Expand Up @@ -111,7 +124,8 @@ def run(verify_result: true, allow_protected_ns: false, prune: true)
validate_configuration(allow_protected_ns: allow_protected_ns, prune: prune)
confirm_context_exists
confirm_namespace_exists
resources = discover_resources
discover_resources
resources = load_resource_from_file
validate_definitions(resources)

@logger.phase_heading("Checking initial resource statuses")
Expand Down Expand Up @@ -186,12 +200,12 @@ def find_bad_files_from_kubectl_output(stderr)
end

def deploy_has_priority_resources?(resources)
resources.any? { |r| PREDEPLOY_SEQUENCE.include?(r.type) }
resources.any? { |r| predeploy_sequence.include?(r.kind) }
end

def predeploy_priority_resources(resource_list)
PREDEPLOY_SEQUENCE.each do |resource_type|
matching_resources = resource_list.select { |r| r.type == resource_type }
predeploy_sequence.each do |resource_kind|
matching_resources = resource_list.select { |r| r.kind == resource_kind }
next if matching_resources.empty?
deploy_resources(matching_resources, verify: true, record_summary: false)

Expand All @@ -217,14 +231,18 @@ def validate_definitions(resources)
end

def discover_resources
DiscoverableResource.discover(context: @context, logger: @logger, server_version: server_version)
end

def load_resource_from_file
resources = []
@logger.info("Discovering templates:")

Dir.foreach(@template_dir) do |filename|
next unless filename.end_with?(".yml.erb", ".yml", ".yaml", ".yaml.erb")

split_templates(filename) do |r_def|
r = KubernetesResource.build(namespace: @namespace, context: @context, logger: @logger, definition: r_def)
r = DiscoverableResource.build(namespace: @namespace, context: @context, logger: @logger, definition: r_def)
resources << r
@logger.info " - #{r.id}"
end
Expand Down Expand Up @@ -394,7 +412,7 @@ def apply_all(resources, prune)

if prune
command.push("--prune", "--all")
prune_whitelist.each { |type| command.push("--prune-whitelist=#{type}") }
prune_whitelist.each { |kind| command.push("--prune-whitelist=#{kind}") }
end

out, err, st = kubectl.run(*command, log_failure: false)
Expand Down
213 changes: 213 additions & 0 deletions lib/kubernetes-deploy/discoverable_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# frozen_string_literal: true
require 'kubernetes-deploy/kubernetes_resource'
require 'kubernetes-deploy/kubeclient_builder'
require 'erb'
require 'json'
require "jsonpath"

module KubernetesDeploy
class DiscoverableResource < KubernetesResource
extend KubernetesDeploy::KubeclientBuilder

TRUE_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE'].to_set
DEPLOY_METADATA_ANNOTATION = 'kubernetes-deploy.shopify.io/metadata'

def self.inherited(child_class)
DiscoverableResource.child_classes.add(child_class)
end

def self.discover(context:, logger:, server_version:)
logger.info("Discovering custom resources:")
with_retries { discover_groups(context) }
if server_version >= Gem::Version.new('1.7.0')
kube_client = v1beta1_crd_kubeclient(context)
with_retries { discover_crd(v1beta1_crd_kubeclient(context)) }
end
end

def self.discover_groups(context)
kinds = discover_kinds(context)
kinds.each_pair do |key, val|
klass = get_static_class(kind: key)
next unless klass
klass.const_set(:GROUP, val[:group]) unless klass.constants.include?(:GROUP)
klass.const_set(:VERSION, val[:version]) unless klass.constants.include?(:VERSION)
end
end

def self.discover_kinds(context)
kinds = {}

# At the top level there is the core group (everything below /api/v1),
rest_client = v1_kubeclient(context).create_rest_client
raw_json = rest_client['v1'].get(rest_client.headers)
resource_list = JSON.parse(raw_json)
v1_group_version = { group: 'core', version: 'v1' }
resource_list['resources'].map do |res|
kind = res['kind']
kinds[kind] = v1_group_version
end

# ...and the named groups (at path /apis/$NAME/$VERSION)
rest_client = apis_kubeclient(context).create_rest_client
raw_json = rest_client.get(rest_client.headers)
group_list = JSON.parse(raw_json)
group_versions = group_list['groups']

# Map out all detected kinds to their (preferred) group version
group_versions.each do |group_version|
preferred_version = group_version['preferredVersion']['groupVersion']
all_versions = group_version['versions'].map { |version| version['groupVersion']}
# Make sure the preferred version gets checked first.
all_versions.delete(preferred_version)
all_versions.unshift(preferred_version)

# Grab kinds from all versions
all_versions.each do |group_version|
raw_response = rest_client[group_version].get(rest_client.headers)
json_response = JSON.parse(raw_response)
resources = json_response['resources']
resources.each do |res|
kind = res['kind']
next if kinds.has_key?(kind) # Respect the preferred version
group, _, version = group_version.rpartition('/')
kinds[kind] = { group: group, version: version }
end
end
end

kinds
end

def self.build(namespace:, context:, definition:, logger:)
opts = { namespace: namespace, context: context, definition: definition, logger: logger }
kind = definition["kind"]
group, _, version = definition['apiVersion'].rpartition('/')

klass = get_static_class(kind: kind)
klass = get_dynamic_class(group: group, version: version, kind: kind) unless klass
klass.new(**opts)
end

def self.get_static_class(kind:)
KubernetesDeploy.const_get(kind) if KubernetesDeploy.const_defined?(kind)
end

def self.get_dynamic_class(group:, version:, kind:)
unless DiscoverableResource.const_defined?(kind)
generate_resource(group: group, version: version, kind: kind, annotations: {})
end
DiscoverableResource.const_get(kind)
end

def self.with_retries(retries=3, backoff=10)
yield
rescue KubeException => err
if (retries -= 1) > 0
logger.warn("Retrying to discover CustomResourceDefinitions: #{err}")
sleep(backoff)
retry
else
logger.warn("Unable to discover CustomResourceDefinitions: #{err}")
end
end

def self.discover_crd(client)
@child_classes = Set.new
resources = client.get_custom_resource_definitions
resources.each do |res|
kind = res.spec.names.kind
# Remove and redefine the class if it already exists so we can be up to date.
if DiscoverableResource.const_defined?(kind)
klass = DiscoverableResource.const_get(kind)
DiscoverableResource.send(:remove_const, kind)
child_classes.delete(klass)
end
generate_resource(group: res.spec.group,
version: res.spec.version,
kind: kind,
annotations: res.metadata.annotations)
end
end

def self.generate_resource(group:, kind:, version:, annotations:)
deploy_metadata = annotations[DEPLOY_METADATA_ANNOTATION] || '{}'
metadata = JSON.parse(deploy_metadata)
raise FatalDeploymentError, "Invalid metadata content: #{metadata}" unless metadata.is_a?(Hash)

prunable = parse_bool(metadata['prunable'])
predeploy = parse_bool(metadata['predeploy'])
predeploy_dependencies = metadata['predeploy-dependencies']

status_field = metadata['status-field']
success_status = metadata['status-success']

resource_template = ERB.new <<-CLASS
class #{kind.capitalize} < DiscoverableResource
GROUP = '#{group}'
VERSION = '#{version}'
PREDEPLOY = #{predeploy}
PRUNABLE = #{prunable}
<% if predeploy_dependencies %>
PREDEPLOY_DEPENDENCIES = #{predeploy_dependencies}
<% end %>
<% if status_field && success_status %>
def deploy_succeeded?
getter = "get_#{kind.downcase}"
@client ||= DiscoverableResource.kubeclient(context: @context, resource_class: self.class)
raw_json = @client.send(getter, @name, @namespace, as: :raw)
query_path = JsonPath.new('#{status_field}')
current_status = query_path.first(raw_json)
current_status == '#{success_status}'
end
<% end %>
self
end
CLASS

rendered_template = resource_template.result(binding)
resource_class = self.class_eval(rendered_template)
end

def self.parse_bool(value)
return true if TRUE_VALUES.include?(value)
false
end

def self.kubeclient(context:, resource_class:)
_build_kubeclient(
api_version: resource_class.version,
context: context,
endpoint_path: "/apis/#{resource_class.group}"
)
end

def self.apis_kubeclient(context)
@apis_kubeclient ||= _build_kubeclient(
api_version: '', # The apis endpoint is not versioned
context: context,
endpoint_path: "/apis",
discover: false # Will fail on apis endpoint
)
end

def self.v1_kubeclient(context)
@v1_kubeclient ||= build_v1_kubeclient(context)
end

def self.v1beta1_kubeclient(context)
@v1beta1_kubeclient ||= build_v1beta1_kubeclient(context)
end

def self.v1beta1_crd_kubeclient(context)
@v1beta1_kubeclient_crd ||= _build_kubeclient(
api_version: "v1beta1",
context: context,
endpoint_path: "/apis/apiextensions.k8s.io/"
)
end
end
end
Loading

0 comments on commit fd9d842

Please sign in to comment.