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 18, 2018
1 parent ef200ee commit 5f9dd31
Show file tree
Hide file tree
Showing 37 changed files with 534 additions and 90 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
3 changes: 2 additions & 1 deletion kubernetes-deploy.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ 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_development_dependency "bundler"
spec.add_development_dependency "rake", "~> 10.0"
Expand Down
60 changes: 26 additions & 34 deletions lib/kubernetes-deploy/deploy_task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
require 'yaml'
require 'shellwords'
require 'tempfile'
require 'kubernetes-deploy/discoverable_resource'
require 'kubernetes-deploy/kubernetes_resource'

%w(
cloudsql
config_map
Expand All @@ -29,6 +31,7 @@
bucket
stateful_set
cron_job
customresourcedefinition
).each do |subresource|
require "kubernetes-deploy/kubernetes_resource/#{subresource}"
end
Expand All @@ -41,17 +44,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 +58,18 @@ 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"
end
wl
def prune_whitelist
prunable_resources = all_resources.select(&:prunable?)
prunable_resources.map(&:qualified_kind)
end

def predeploy_sequence
predeploy_resources = all_resources.select(&:predeploy?)
predeploy_resources.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 +98,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 +174,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 +205,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 +386,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
208 changes: 208 additions & 0 deletions lib/kubernetes-deploy/discoverable_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# 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.child_classes
@child_classes ||= Set.new
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'])

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 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
12 changes: 10 additions & 2 deletions lib/kubernetes-deploy/kubeclient_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,15 @@ def build_apps_v1beta1_kubeclient(context)
)
end

def _build_kubeclient(api_version:, context:, endpoint_path: nil)
def build_apiextensions_v1beta1_kubeclient(context)
_build_kubeclient(
api_version: "v1beta1",
context: context,
endpoint_path: "/apis/apiextensions.k8s.io"
)
end

def _build_kubeclient(api_version:, context:, endpoint_path: nil, discover: true)
# Find a context defined in kube conf files that matches the input context by name
friendly_configs = config_files.map { |f| GoogleFriendlyConfig.read(f) }
config = friendly_configs.find { |c| c.contexts.include?(context) }
Expand All @@ -67,7 +75,7 @@ def _build_kubeclient(api_version:, context:, endpoint_path: nil)
ssl_options: kube_context.ssl_options,
auth_options: kube_context.auth_options
)
client.discover
client.discover if discover
client
end

Expand Down
Loading

0 comments on commit 5f9dd31

Please sign in to comment.