-
Notifications
You must be signed in to change notification settings - Fork 26
/
stack.rb
187 lines (160 loc) · 7.86 KB
/
stack.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# frozen_string_literal: true
require 'securerandom'
module K8s
# Usage: customize the LABEL and CHECKSUM_ANNOTATION
class Stack
include Logging
# Label used to identify resources belonging to this stack
LABEL = 'k8s.kontena.io/stack'
# Annotation used to identify resource versions
CHECKSUM_ANNOTATION = 'k8s.kontena.io/stack-checksum'
# Annotation used to identify last applied configuration
LAST_CONFIG_ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration'
# List of apiVersion:kind combinations to skip for stack prune
# These would lead to stack prune misbehaving if not skipped.
PRUNE_IGNORE = [
'v1:ComponentStatus', # apiserver ignores GET /v1/componentstatuses?labelSelector=... and returns all resources
'v1:Endpoints' # inherits stack label from service, but not checksum annotation
].freeze
# @param name [String] unique name for stack
# @param path [String] load resources from YAML files
# @param options [Hash] see Stack#initialize
# @return [K8s::Stack]
def self.load(name, path, **options)
resources = K8s::Resource.from_files(path)
new(name, resources, **options)
end
# @param name [String] unique name for stack
# @param path [String] load resources from YAML files
# @param client [K8s::Client] apply using client
# @param prune [Boolean] delete old resources
# @param options [Hash] see Stack#initialize
# @return [K8s::Stack]
def self.apply(name, path, client, prune: true, **options)
load(name, path, **options).apply(client, prune: prune)
end
# Remove any installed stack resources.
#
# @param name [String] unique name for stack
# @param client [K8s::Client] apply using client
def self.delete(name, client, **options)
new(name, **options).delete(client)
end
attr_reader :name, :resources
# @param name [String]
# @param resources [Array<K8s::Resource>]
# @param debug [Boolean]
# @param label [String]
# @param checksum_annotation [String]
# @param last_config_annotation [String]
def initialize(name, resources = [], debug: false, label: self.class::LABEL, checksum_annotation: self.class::CHECKSUM_ANNOTATION, last_configuration_annotation: self.class::LAST_CONFIG_ANNOTATION)
@name = name
@resources = resources
@keep_resources = {}
@label = label
@checksum_annotation = checksum_annotation
@last_config_annotation = last_configuration_annotation
logger! progname: name, debug: debug
end
# @param resource [K8s::Resource] to apply
# @param base_resource [K8s::Resource] DEPRECATED
# @return [K8s::Resource]
# rubocop:disable Lint/UnusedMethodArgument
def prepare_resource(resource, base_resource: nil)
# TODO: base_resource is not used anymore, kept for backwards compatibility for a while
# calculate checksum only from the "local" source
checksum = resource.checksum
# add stack metadata
resource.merge(
metadata: {
labels: { @label => name },
annotations: {
@checksum_annotation => checksum,
@last_config_annotation => Util.recursive_compact(resource.to_h).to_json
}
}
)
end
# rubocop:enable Lint/UnusedMethodArgument
# @param client [K8s::Client]
# @return [Array<K8s::Resource>]
def apply(client, prune: true)
server_resources = client.get_resources(resources)
resources.zip(server_resources).map do |resource, server_resource|
if !server_resource
logger.info "Create resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} with checksum=#{resource.checksum}"
keep_resource! client.create_resource(prepare_resource(resource))
elsif server_resource.metadata&.annotations&.dig(@checksum_annotation) != resource.checksum
logger.info "Update resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} with checksum=#{resource.checksum}"
r = prepare_resource(resource)
if server_resource.can_patch?(@last_config_annotation)
keep_resource! client.patch_resource(server_resource, server_resource.merge_patch_ops(r.to_h, @last_config_annotation))
else
# try to update with PUT
keep_resource! client.update_resource(server_resource.merge(prepare_resource(resource)))
end
else
logger.info "Keep resource #{server_resource.apiVersion}:#{server_resource.kind}/#{server_resource.metadata.name} in namespace #{server_resource.metadata.namespace} with checksum=#{server_resource.metadata.annotations[@checksum_annotation]}"
keep_resource! server_resource
end
end
prune(client, keep_resources: true) if prune
end
# key MUST NOT include resource.apiVersion: the same kind can be aliased in different APIs
# @param resource [K8s::Resource]
# @return [K8s::Resource]
def keep_resource!(resource)
@keep_resources["#{resource.kind}:#{resource.metadata.name}@#{resource.metadata.namespace}"] = resource.metadata&.annotations.dig(@checksum_annotation)
end
# @param resource [K8s::Resource]
# @return [Boolean]
def keep_resource?(resource)
keep_annotation = @keep_resources["#{resource.kind}:#{resource.metadata.name}@#{resource.metadata.namespace}"]
return false unless keep_annotation
keep_annotation == resource.metadata&.annotations&.dig(@checksum_annotation)
end
# Delete all stack resources that were not applied
# @param client [K8s::Client]
# @param keep_resources [NilClass, Boolean]
# @param skip_forbidden [Boolean]
def prune(client, keep_resources:, skip_forbidden: true)
# using skip_forbidden: assume we can't create resource types that we are forbidden to list, so we don't need to prune them either
client.list_resources(labelSelector: { @label => name }, skip_forbidden: skip_forbidden).sort do |a, b|
# Sort resources so that namespaced objects are deleted first
if a.metadata.namespace == b.metadata.namespace
0
elsif a.metadata.namespace.nil? && !b.metadata.namespace.nil?
1
else
-1
end
end.each do |resource|
next if PRUNE_IGNORE.include? "#{resource.apiVersion}:#{resource.kind}"
resource_label = resource.metadata&.labels&.dig(@label)
resource_checksum = resource.metadata&.annotations&.dig(@checksum_annotation)
logger.debug { "List resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} with checksum=#{resource_checksum}" }
if resource_label != name
# apiserver did not respect labelSelector
elsif resource.metadata&.ownerReferences && !resource.metadata.ownerReferences.empty?
logger.info "Server resource #{resource.apiVersion}:#{resource.apiKind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace} has ownerReferences and will be kept"
elsif keep_resources && keep_resource?(resource)
# resource is up-to-date
else
logger.info "Delete resource #{resource.apiVersion}:#{resource.kind}/#{resource.metadata.name} in namespace #{resource.metadata.namespace}"
begin
client.delete_resource(resource, propagationPolicy: 'Background')
rescue K8s::Error::NotFound => e
# assume aliased objects in multiple API groups, like for Deployments
# alternatively, a custom resource whose definition was already deleted earlier
logger.debug { "Ignoring #{e} : #{e.message}" }
end
end
end
end
# Delete all stack resources
# @param client [K8s::Client]
def delete(client)
prune(client, keep_resources: false)
end
end
end