rops 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: edce405e87d5a4199f1bf81eb2b308ffd59e980bfd8cca9e5af65d2766fe765d
4
+ data.tar.gz: 28579f4f236865908f1a0fdbac465225285c9961a2aaf13aa1513bd477a7481f
5
+ SHA512:
6
+ metadata.gz: 70d5f614103f56e270c43014c81bc4801223a0fbae5977f4f658367b590c96ba0308c631c88f75c8f18c88882dfb350fee85b620f477afa1db524775f406d1c7
7
+ data.tar.gz: ed7bb0203a5dbe569019bef605e9a8ab805de1d8a0914f8d9162b4157c64b7a78489000ef13fbb1e112feced24a5a9a7f71244a1582e48ea4d18aa8a909703e4
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
3
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,43 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ rops (1.0.1)
5
+ activesupport (~> 6.1.4)
6
+ dry-cli (~> 0.7.0)
7
+ git (~> 1.9.1)
8
+ hashdiff (~> 1.0.1)
9
+ net-ssh (~> 6.1.0)
10
+ ptools (~> 1.4.2)
11
+
12
+ GEM
13
+ remote: https://rubygems.org/
14
+ specs:
15
+ activesupport (6.1.4.1)
16
+ concurrent-ruby (~> 1.0, >= 1.0.2)
17
+ i18n (>= 1.6, < 2)
18
+ minitest (>= 5.1)
19
+ tzinfo (~> 2.0)
20
+ zeitwerk (~> 2.3)
21
+ concurrent-ruby (1.1.9)
22
+ dry-cli (0.7.0)
23
+ git (1.9.1)
24
+ rchardet (~> 1.8)
25
+ hashdiff (1.0.1)
26
+ i18n (1.8.10)
27
+ concurrent-ruby (~> 1.0)
28
+ minitest (5.14.4)
29
+ net-ssh (6.1.0)
30
+ ptools (1.4.2)
31
+ rchardet (1.8.0)
32
+ tzinfo (2.0.4)
33
+ concurrent-ruby (~> 1.0)
34
+ zeitwerk (2.4.2)
35
+
36
+ PLATFORMS
37
+ x86_64-linux
38
+
39
+ DEPENDENCIES
40
+ rops!
41
+
42
+ BUNDLED WITH
43
+ 2.2.25
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2021 Record360, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included
12
+ in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # rops
2
+
3
+ The Record360 Operations tool - checkout, build, deploy
4
+
5
+ ## Usage
6
+
7
+ This tool implements the Record360 Best Practices for building and deploying projects. It interfaces with Git (for source control), Docker/Podman (for container images), and Kubernetes (cluster deployments).
8
+
9
+ ### Installation
10
+
11
+ Add `rops` to your Gemfile and then run `bundle install`.
12
+
13
+ ```ruby
14
+ # Gemfile
15
+ group :development do
16
+ gem 'rops', github: 'Record360/rops'
17
+ end
18
+ ```
19
+
20
+ ### Configuration
21
+
22
+ `rops` has several opinionated defaults, which can be overridden by command line options or a configuration file.
23
+
24
+ #### Project Root Directory
25
+ By default, the current working directory when `rops` runs. It can be overridden with the `--root=<DIR>` option.
26
+
27
+ The project root directory must contain:
28
+ * Git repository (`./`)
29
+ * Dockerfile (`./Dockerfile`)
30
+ * Kubernetes configuration files (`./platform/`)
31
+
32
+ `rops` will search for an optional configuration file in:
33
+ * `./.rops.yaml`
34
+ * `./platform/rops.yaml`
35
+ * `./config/rops.yaml`
36
+
37
+ #### Docker Container Images
38
+ By default, a single image named from the the Project root directory and built from `./Dockerfile`. May be overridden by setting the `images` array in the configuration file, e.g.:
39
+
40
+ ```yaml
41
+ images:
42
+ - name: 'first-image'
43
+ dockerfile: dockerfiles/first.Dockerfile
44
+ - name: 'second-image'
45
+ dockerfile: dockerfiles/second.Dockerfile
46
+ ```
47
+
48
+ #### Git Default Branch
49
+ The Git branch to build, by default `master`. Overridden with the `default_branch` field in the configuration file.
50
+
51
+ #### Docker Registry
52
+ The Docker registry to push container images. By default, `docker.io/r360`, which is probably not what you want and should be overridden by setting the `registry` field in the configuration file.
53
+
54
+ #### Kubernetes Context
55
+ The name of the Kubernetes context to deploy to (as listed in `~/.kube/config`). Defaults to `staging` and overridden with the `default_context` field in the configuration file.
56
+
57
+ There are extra safety features when deploying to the production context, which defaults to `production` and may be overridden with the `production_context` field in the configuration file.
58
+
59
+ Kubernetes configuration is organized by Kubernetes context name, under the `./platform` directory. For example, the Kubernetes configuration for the default contexts (`staging` and `production`) is stored under `./platform/staging` and `./platform/production` respectively.
60
+
61
+ ### Operations
62
+
63
+ ### Status
64
+
65
+ Arguments:
66
+ * `context`: Kubernetes context (default `staging`, or the value of `staging_context`)
67
+
68
+ Displays the statuses of all deployable Kubernetes objects, including image version and number of pods running/desired. Objects which exist in the Kubernetes configuration directory but don't exist in the cluster are listed as `MISSING`, for example:
69
+
70
+ ```shell
71
+ $ rops status
72
+ Currently running (staging):
73
+ * MISSING web-activity-purge (cronjob)
74
+ * gfc50028b web-archive-media-files (cronjob)
75
+ * gfc50028b web-company-report (cronjob)
76
+ * gfc50028b web-subscriptions (cronjob)
77
+ * gfc50028b web-sync-billing (cronjob)
78
+ * gfc50028b jobs [1/1] (deployment)
79
+ * gfc50028b web [1/1] (deployment)
80
+ ```
81
+
82
+ ### Build
83
+
84
+ Arguments:
85
+ * `branch`: Git branch/commit (default `master`, or the value of `default_branch`)
86
+
87
+ Builds the Docker image(s), optionally specifying a Git branch name.
88
+
89
+ The image will be tagged with the shortened Git commit ID prefixed with a `g` (e.g. `g12345678` ). If a non-default branch name is specified, it will be appended to the image tag (e.g. `g1234abcd-feature`). The build sets the full Git commit ID in the container image as the `GIT_VERSION` environment variable.
90
+
91
+ The build sets the number of cores to use for building as the `JOBS` environment variable (usually passed to `bundle` or `make`). By default, it uses one less than the total number of CPU cores, although you can override this by setting the `R360_BUILD_CORES` environment variable.
92
+
93
+
94
+ ```shell
95
+ $ rops build feature
96
+ Building image web:gfc50028b-feature using 7 cores ...
97
+ ...
98
+ STEP 15/16: ARG GIT_VERSION
99
+ --> 69cfcafa4c3
100
+ STEP 16/16: ENV GIT_VERSION=$GIT_VERSION
101
+ COMMIT web:gfc50028b-feature
102
+ --> 8c5ea56990b
103
+ Successfully tagged localhost/web:gfc50028b-feature
104
+ 8c5ea56990bfb1329b8180e4826fca7c5fb08d14d2097e9a05e29296c669cc86
105
+ ```
106
+
107
+ ### Push
108
+
109
+ Arguments:
110
+ * `branch`: Git branch/commit (default `master`, or the value of `default_branch`)
111
+
112
+ Builds the Docker image, if necessary (as in `rops build` above), then pushes the image to the Docker registry.
113
+
114
+ ```shell
115
+ $ rops push feature
116
+ Local image web:gfc50028b-some-branch already exists
117
+ ...
118
+ Writing manifest to image destination
119
+ Storing signatures
120
+ ```
121
+
122
+ ### Deploy
123
+
124
+ Arguments:
125
+ * `branch`: Git branch/commit (default `master`, or the value of `default_branch`)
126
+ * `context`: Kubernetes context (default `staging`, or the value of `default_context`)
127
+
128
+ Deploys the Docker image to Kubernetes, building and pushing if necessary (like `rops push`). Displays the currently running versions (like `rops status`), and any changes to the Kubernetes object configuration, and prompts for confirmation. `branch` must be specified if deploying to the production context.
129
+
130
+ The Kubernetes configuration is taken from the Git repository on the same branch/commit as the source code to build. Only Kubernetes objects that reference one of the built `images` will be deployed to the cluster (e.g. `Pod`, `Deployment`, `CronJob`, `StatefulSet`, etc.). Other Kubernetes object (e.g. `ConfigMaps`, `Secrets`, `Service`, `Ingress`, etc.) will not be automatically deployed, even if they're in the same YAML file as other objects. They will need to be applied to the cluster manually.
131
+
132
+ ```shell
133
+ $ rops deploy
134
+ Currently running (staging):
135
+ * MISSING web-activity-purge (cronjob)
136
+ * gfc50028b web-archive-media-files (cronjob)
137
+ * gfc50028b web-company-report (cronjob)
138
+ * gfc50028b web-subscriptions (cronjob)
139
+ * gfc50028b web-sync-billing (cronjob)
140
+ * gfc50028b jobs [1/1] (deployment)
141
+ * gfc50028b web [1/1] (deployment)
142
+
143
+ Configuration changes:
144
+ web (deployment)
145
+ - spec.template.spec.containers[0].imagePullPolicy: "Always"
146
+
147
+ Deploy g12345678 (master) to staging? (y/N): y
148
+
149
+ deployment.apps/web configured
150
+ deployment.apps/jobs configured
151
+ cronjob.batch/web-archive-media-files configured
152
+ cronjob.batch/web-company-report configured
153
+ cronjob.batch/web-subscriptions configured
154
+ cronjob.batch/web-sync-billing configured
155
+ cronjob.batch/web-activity-purge created
156
+ ```
data/bin/rops ADDED
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env ruby
2
+ $: << __dir__+'/../lib'
3
+
4
+ require 'bundler/setup'
5
+ require 'dry/cli'
6
+ require 'active_support'
7
+ require 'active_support/core_ext'
8
+ require 'active_support/concern'
9
+ require 'hashdiff'
10
+
11
+ require 'core_ext'
12
+ require 'deployer'
13
+
14
+ module Record360
15
+ module Operations
16
+ extend Dry::CLI::Registry
17
+
18
+ module Common
19
+ extend ActiveSupport::Concern
20
+ included do
21
+ attr_reader :deployer, :context
22
+ delegate [:branch, :image_tag, :images, :production_context] => :deployer
23
+
24
+ option :root, desc: "Root directory", default: Dir.pwd
25
+ end
26
+
27
+ def initialize(deployer = nil)
28
+ @deployer = deployer
29
+ super()
30
+ end
31
+
32
+ def call(root: nil, branch: nil, context: nil, **)
33
+ @root = root if root
34
+ @deployer ||= Deployer.new(@root)
35
+ deployer.branch = branch if branch
36
+ @context = context || deployer.default_context
37
+ end
38
+
39
+ protected
40
+
41
+ def print_statuses(context, spec_statuses = nil)
42
+ spec_statuses ||= deployer.specs_running(context)
43
+ return if spec_statuses.blank?
44
+
45
+ puts "Currently running (#{context}):"
46
+ spec_statuses.each do |spec, status|
47
+ msg = String.new(" * ")
48
+ msg << (status&.dig(:version) || "MISSING ")
49
+ msg << " #{spec.dig('metadata', 'name')}"
50
+ if (replicas = status&.dig(:status, :replicas))
51
+ msg << " [#{status[:status][:availableReplicas] || 0}/#{replicas}]"
52
+ end
53
+ msg << " (#{spec['kind'].downcase})"
54
+ puts msg
55
+ end
56
+ end
57
+
58
+ def print_spec_diffs(diffs)
59
+ puts "Configuration changes:"
60
+ diffs.each do |spec, diff|
61
+ puts " #{spec.dig('metadata', 'name')} (#{spec['kind'].downcase})"
62
+ diff.each do |op, key, old_val, new_val|
63
+ msg = String.new(" ")
64
+ if op.in? %w(- +)
65
+ msg += "#{op} #{key}: #{old_val.to_json}"
66
+ elsif op
67
+ msg += " #{key}: #{old_val.to_json} -> #{new_val.to_json}"
68
+ else
69
+ msg += " #{spec.to_json}"
70
+ end
71
+ puts msg
72
+ end
73
+ end
74
+ end
75
+
76
+ def spec_diffs(spec_statuses)
77
+ spec_statuses.map do |new_spec, status|
78
+ if status
79
+ # remove runtime info from old spec
80
+ old_spec = status[:spec].deep_dup
81
+ old_spec.deep_each do |key, val, obj|
82
+ if (key == 'metadata') && val.is_a?(Hash)
83
+ val.except! *%w(annotations creationTimestamp resourceVersion selfLink uid generation managedFields)
84
+ obj.delete(key) if val.blank?
85
+ end
86
+ end
87
+ diff = filter_diff( Hashdiff.diff(old_spec, new_spec, use_lcs: false) ).presence
88
+ else
89
+ diff = [ [] ]
90
+ end
91
+ [ new_spec, diff ]
92
+ end.to_h.compact
93
+ end
94
+
95
+ FILTER_DIFF = {
96
+ 'metadata.namespace' => 'default',
97
+ 'spec.suspend' => false,
98
+ 'spec.progressDeadlineSeconds' => 600,
99
+ /spec\.template\.spec\.dnsPolicy$/ => 'ClusterFirst',
100
+ /spec\.template\.spec\.schedulerName$/ => 'default-scheduler',
101
+ /spec\.template\.spec\.securityContext$/ => {},
102
+ /spec\.template\.spec\.terminationGracePeriodSeconds$/ => 30,
103
+ /spec\.template\.spec\.restartPolicy$/ => 'Always',
104
+ /spec\.template\.spec\.containers\[\d+\]\.imagePullPolicy$/ => 'IfNotPresent',
105
+ /spec\.template\.spec\.containers\[\d+\]\.terminationMessagePath$/ => '/dev/termination-log',
106
+ /spec\.template\.spec\.containers\[\d+\]\.terminationMessagePolicy$/ => 'File',
107
+ /spec\.template\.spec\.containers\[\d+\]\.readinessProbe\.httpGet\.scheme$/ => 'HTTP',
108
+ /spec\.template\.spec\.containers\[\d+\]\.readinessProbe\.timeoutSeconds$/ => 1,
109
+ /spec\.template\.spec\.containers\[\d+\]\.readinessProbe\.successThreshold$/ => 1,
110
+ /spec\.template\.spec\.containers\[\d+\]\.readinessProbe\.failureThreshold$/ => 3,
111
+ /spec\.template\.spec\.containers\[\d+\]\.resources$/ => {},
112
+ /spec\.template\.spec\.containers\[\d+\]\.env\[\d+\]\.valueFrom\.fieldRef\.apiVersion$/ => 'v1',
113
+ /spec\.template\.spec\.containers\[\d+\]\.ports\[\d+\]\.protocol$/ => 'TCP',
114
+ /spec\.template\.spec\.volumes\[\d+\]\.secret\.defaultMode/ => 420,
115
+ }.freeze
116
+
117
+ def filter_diff(diff)
118
+ diff.reject do |op, path, old_val, new_val|
119
+ case op
120
+ when '-'
121
+ FILTER_DIFF.any? do |key, default|
122
+ ((key.is_a?(String) && (key == path)) || (key.is_a?(Regexp) && key.match(path))) && (default == old_val)
123
+ end
124
+
125
+ when '~'
126
+ if path.match(/spec\.template\.spec\.containers\[\d+\]\.image$/)
127
+ true
128
+
129
+ elsif path.match(/spec\.template\.spec\.containers\[\d+\]\.resources\.requests\.cpu$/)
130
+ # normalize miliCPUs to fractional CPUs, filter if equal
131
+ old_val.end_with?('m') && ((old_val.delete_suffix('m').to_f / 1000.0) == new_val)
132
+
133
+ elsif path.match(/spec\.template\.spec\.containers\[\d+\]\.resources\.requests\.memory$/)
134
+ # normalize Mi to fractional Gi, filter if equal
135
+ old_val.end_with?('Mi') && ("#{(old_val.delete_suffix('Mi').to_f / 1024.0)}Gi" == new_val)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ class CurrentStatus < Dry::CLI::Command
143
+ desc "Display status of all running specs"
144
+ argument :context, desc: "Kubernetes context"
145
+ include Common
146
+
147
+ def call(**)
148
+ super
149
+ print_statuses(context)
150
+ end
151
+ end
152
+
153
+ class BuildImage < Dry::CLI::Command
154
+ desc "Build the docker image" #" (COMMIT=#{DEFAULT_COMMIT})"
155
+ argument :branch, desc: "Branch (or commit) to build" #, default: DEFAULT_COMMIT
156
+ include Common
157
+
158
+ def call(**)
159
+ super
160
+ images.each do |image|
161
+ if image.local_exists?
162
+ puts "Local image #{image.local_image} already exists"
163
+ else
164
+ puts "Building image #{image.local_image} using #{Image.build_cores} cores ..."
165
+ image.build! or exit(-1)
166
+ end
167
+ end
168
+ end
169
+ end
170
+
171
+ class PushImage < Dry::CLI::Command
172
+ desc "Build and push the docker image to the repository"
173
+ argument :branch, desc: "Branch (or commit) to build"
174
+ include Common
175
+
176
+ def call(**)
177
+ super
178
+ images.each do |image|
179
+ if image.remote_exists?
180
+ puts "Remote image #{image.remote_image} already exists"
181
+ next
182
+ end
183
+
184
+ unless image.local_exists?
185
+ puts "Building image #{image.local_image} using #{Image.build_cores} cores ..."
186
+ image.build! or exit(-1)
187
+ end
188
+ image.push! or exit(-1)
189
+ end
190
+ end
191
+ end
192
+
193
+ class DeployImage < Dry::CLI::Command
194
+ desc "Deploy the docker image to the cluster"
195
+ argument :branch, desc: "Branch (or commit) to build"
196
+ argument :context, desc: "Kubernetes context"
197
+ include Common
198
+
199
+ def call(**)
200
+ super
201
+ if context == production_context
202
+ if branch.blank?
203
+ puts "Must specify commit for production deployment"
204
+ exit(-1)
205
+ end
206
+ images.each do |image|
207
+ unless image.remote_exists?
208
+ puts "Remote image #{image.remote_image} doesn't exists. Run `push` first"
209
+ exit(-1)
210
+ end
211
+ end
212
+ else
213
+ PushImage.new(deployer).call(context: context)
214
+ end
215
+
216
+ spec_statuses = deployer.specs_running(context) or exit(-1)
217
+ print_statuses(context, spec_statuses)
218
+ puts
219
+
220
+ if (diffs = spec_diffs(spec_statuses)).present?
221
+ print_spec_diffs(diffs)
222
+ puts
223
+ end
224
+
225
+ if $stdout.tty?
226
+ print "Deploy #{branch} (#{image_tag}) to #{context}? (y/N): "
227
+ exit(-1) unless $stdin.gets&.chomp == 'y'
228
+ else
229
+ puts "Deploying #{branch} (#{image_tag}) to #{context}"
230
+ end
231
+
232
+ deployer.deploy!(context)
233
+ end
234
+ end
235
+
236
+ register 'status', CurrentStatus
237
+ register 'build', BuildImage
238
+ register 'push', PushImage
239
+ register 'deploy', DeployImage
240
+ end
241
+ end
242
+
243
+ Dry::CLI.new(Record360::Operations).call
data/lib/core_ext.rb ADDED
@@ -0,0 +1,210 @@
1
+ require 'tempfile'
2
+
3
+ module CoreExtensions
4
+
5
+ # STRING ##########################################################################################
6
+
7
+ module String
8
+ # https://en.wikipedia.org/wiki/Whitespace_character#Unicode
9
+ SPACE_CHAR_CLASS = '\p{Space}\u180e\u200b\u200c\u200d\u2060\ufeff'.freeze
10
+ LSTRIP_SPACE_REGEX = %r{\A[#{SPACE_CHAR_CLASS}]+}.freeze
11
+ RSTRIP_SPACE_REGEX = %r{[#{SPACE_CHAR_CLASS}]+\z}.freeze
12
+
13
+ def lstrip
14
+ (encoding == Encoding::UTF_8) ? sub(LSTRIP_SPACE_REGEX, '') : super
15
+ end
16
+
17
+ def rstrip
18
+ (encoding == Encoding::UTF_8) ? sub(RSTRIP_SPACE_REGEX, '') : super
19
+ end
20
+
21
+ def strip
22
+ if encoding == Encoding::UTF_8
23
+ dup.tap do |str|
24
+ str.sub!(LSTRIP_SPACE_REGEX, '')
25
+ str.sub!(RSTRIP_SPACE_REGEX, '')
26
+ end
27
+ else
28
+ super
29
+ end
30
+ end
31
+
32
+ def possessive
33
+ str = self + "'"
34
+ str += 's' unless %r{(s|se|z|ze|ce|x|xe)$}i.match(self)
35
+ str
36
+ end
37
+
38
+ def force_utf8
39
+ if (encoding == Encoding::UTF_8) && valid_encoding?
40
+ self
41
+ else
42
+ encode('utf-8', invalid: :replace, undef: :replace)
43
+ end
44
+ end
45
+
46
+ def to_hex
47
+ self.b.unpack('H*').first
48
+ end
49
+ end
50
+
51
+ # ARRAY ###########################################################################################
52
+
53
+ module Array
54
+ def except!(*vals)
55
+ vals.each { |v| delete(v) }
56
+ self
57
+ end
58
+
59
+ def except(*vals)
60
+ dup.except!(*vals)
61
+ end
62
+
63
+ def group_index_by(&blk)
64
+ index = {}
65
+ group_by(&blk).each do |name, group|
66
+ if group.length == 1
67
+ index[group[0]] = name
68
+ next
69
+ end
70
+
71
+ idx_digits = Math.log10(group.length).floor + 1
72
+ group.each.with_index do |obj, idx|
73
+ index[obj] = [ name, "%0#{idx_digits}d" % (idx+1) ]
74
+ end
75
+ end
76
+ index
77
+ end
78
+
79
+ def deep_reject(&blk)
80
+ dup.deep_reject!(&blk)
81
+ end
82
+
83
+ def deep_reject!(&blk)
84
+ idx = 0
85
+ while idx < length do
86
+ val = self[idx]
87
+ val.deep_reject!(&blk) if val.respond_to?(:deep_reject!)
88
+ if blk.call(idx, val)
89
+ delete_at(idx)
90
+ else
91
+ idx += 1
92
+ end
93
+ end
94
+ self
95
+ end
96
+
97
+ def deep_each(&blk)
98
+ idx = 0
99
+ while idx < length do
100
+ val = self[idx]
101
+ if blk.arity == 3
102
+ blk.call(idx, val, self)
103
+ val = self[idx]
104
+ else
105
+ blk.call(idx, val)
106
+ end
107
+ val.deep_each(&blk) if val.respond_to?(:deep_each)
108
+ idx += 1
109
+ end
110
+ self
111
+ end
112
+
113
+ def force_utf8
114
+ map { |el| el.respond_to?(:force_utf8) ? el.force_utf8 : el }
115
+ end
116
+ end
117
+
118
+ # HASH ############################################################################################
119
+
120
+ module Hash
121
+ def deep_reject(&blk)
122
+ dup.deep_reject!(&blk)
123
+ end
124
+
125
+ def deep_reject!(&blk)
126
+ each do |key, val|
127
+ val.deep_reject!(&blk) if val.respond_to?(:deep_reject!)
128
+ delete(key) if blk.call(key, val)
129
+ end
130
+ self
131
+ end
132
+
133
+ def deep_each(&blk)
134
+ keys.each do |key|
135
+ val = self[key]
136
+ if blk.arity == 3
137
+ blk.call(key, val, self)
138
+ val = self[key]
139
+ else
140
+ blk.call(key, val)
141
+ end
142
+ val.deep_each(&blk) if val.respond_to?(:deep_each)
143
+ end
144
+ self
145
+ end
146
+
147
+ def deep_map(&blk)
148
+ keys.each do |key|
149
+ val = self[key]
150
+ (blk.arity == 3) ? blk.call(key, val, self) : blk.call(key, val)
151
+ val.deep_each(&blk) if val.respond_to?(:deep_each)
152
+ end
153
+ self
154
+ end
155
+
156
+ def force_utf8
157
+ map do |key, val|
158
+ [
159
+ key.respond_to?(:force_utf8) ? key.force_utf8 : key,
160
+ val.respond_to?(:force_utf8) ? val.force_utf8 : val,
161
+ ]
162
+ end.to_h
163
+ end
164
+
165
+ def safe_dig(*path)
166
+ dig(*path)
167
+ rescue TypeError => ex
168
+ return nil if ex.message.include?('does not have #dig method')
169
+ raise
170
+ end
171
+ end
172
+
173
+ # BOOLEAN #########################################################################################
174
+
175
+ module String
176
+ def to_bool(default = nil)
177
+ return true if %w(true 1 yes on t).include?(self.downcase.strip)
178
+ return false if %w(false 0 no off f).include?(self.downcase.strip)
179
+ default
180
+ end
181
+ end
182
+ module Numeric
183
+ def to_bool(_default = nil) !zero? end
184
+ end
185
+ module NilClass
186
+ def to_bool(default = nil) default end
187
+ end
188
+ module TrueClass
189
+ def to_bool(_default = nil) self end
190
+ end
191
+ module FalseClass
192
+ def to_bool(_default = nil) self end
193
+ end
194
+
195
+ # TEMPFILE ########################################################################################
196
+
197
+ require 'active_support/number_helper/number_to_human_size_converter'
198
+ module Tempfile
199
+ def inspect
200
+ "#{path} (#{ActiveSupport::NumberHelper.number_to_human_size(size)})"
201
+ end
202
+ end
203
+
204
+ end
205
+
206
+ ###################################################################################################
207
+
208
+ CoreExtensions.constants.each do |mod|
209
+ Kernel.const_get(mod).prepend(CoreExtensions.const_get(mod))
210
+ end
data/lib/deployer.rb ADDED
@@ -0,0 +1,189 @@
1
+ require 'yaml'
2
+ require 'open3'
3
+ require 'ptools'
4
+ require 'git_ext'
5
+ require 'image'
6
+
7
+ class Deployer
8
+ CONFIG_LOCATIONS = %w(.rops.yaml platform/rops.yaml config/rops.yaml).freeze
9
+ CONFIG_DEFAULTS = {
10
+ 'repository' => nil,
11
+ 'default_branch' => 'master',
12
+ 'registry' => 'docker.io/r360',
13
+ 'default_context' => 'staging',
14
+ 'production_context' => 'production',
15
+ 'images' => []
16
+ }.freeze
17
+
18
+ attr_reader :root, :repository, :registry, :ssh_host, :images
19
+ attr_reader :default_branch, :default_context, :production_context
20
+ attr_reader :branch, :commit, :image_tag
21
+
22
+ def self.docker
23
+ @docker_path ||= File.which('docker') || File.which('podman')
24
+ end
25
+
26
+ def self.podman?
27
+ docker.include?('podman')
28
+ end
29
+
30
+ def initialize(root = nil)
31
+ @images = []
32
+ @specs = {}
33
+
34
+ @root = root || Dir.pwd
35
+ load_config
36
+ self.branch = default_branch
37
+ end
38
+
39
+ def branch=(branch)
40
+ return unless branch
41
+ @branch = branch.dup
42
+ @branch.delete_prefix!('g') if @branch.match(/^g\h{8}$/)
43
+ @commit = git.object(@branch).sha
44
+
45
+ short_id = @commit[0, 8]
46
+ @image_tag = "g#{short_id}"
47
+ if branch.present? && (branch != default_branch) && !branch.start_with?(short_id)
48
+ @image_tag += "-#{branch}"
49
+ end
50
+ images.each do |image|
51
+ image.commit = commit
52
+ image.tag = image_tag
53
+ end
54
+ end
55
+
56
+ def specs_running(context = nil)
57
+ context ||= default_context
58
+ context = context.to_s
59
+ specs = deploy_specs(context)
60
+
61
+ cmd = String.new "--output=json"
62
+ if (namespace = specs.first.dig('metadata', 'namespace'))
63
+ cmd += " --namespace #{namespace}"
64
+ end
65
+ cmd += " get"
66
+ specs.each do |spec|
67
+ cmd += " #{spec['kind'].downcase}/#{spec.dig('metadata', 'name')}"
68
+ end
69
+
70
+ statuses, stderr, success = kubectl(context, cmd)
71
+ unless success || stderr.match(/not found/)
72
+ puts stderr if stderr.present?
73
+ return nil
74
+ end
75
+
76
+ spec_status = specs.map { |spec| [ spec, nil ] }.to_h
77
+ statuses = JSON.parse(statuses)
78
+ statuses = statuses['items'] if statuses.key?('items')
79
+ Array.wrap(statuses).each do |item|
80
+ containers = Array(item.dig('spec', 'template', 'spec', 'containers')) +
81
+ Array(item.dig('spec', 'jobTemplate', 'spec', 'template', 'spec', 'containers'))
82
+ version = containers.first['image'].split(':').last # FIXME: support multiple containers
83
+ status = item.delete('status').with_indifferent_access
84
+
85
+ spec = specs.detect do |s|
86
+ (item['kind'] == s['kind']) &&
87
+ (item.dig('metadata', 'name') == s.dig('metadata', 'name')) &&
88
+ (item.dig('metadata', 'namespace') == (s.dig('metadata', 'namespace') || 'default'))
89
+ end
90
+ spec_status[spec] = { spec: item, version: version, status: status }
91
+ end
92
+ spec_status
93
+ end
94
+
95
+ def deploy!(context)
96
+ context ||= default_context
97
+ context = context.to_s
98
+ specs = deploy_specs(context).presence or raise "No kubernetes specs to deploy"
99
+ stdout, stderr, _success = kubectl(context, 'apply -f -', YAML.dump_stream(*specs))
100
+ puts stdout if stdout.present?
101
+ puts stderr if stderr.present?
102
+ end
103
+
104
+ def deploy_specs(context = nil)
105
+ dspecs = []
106
+ specs(context).deep_dup.each do |spec|
107
+ containers =
108
+ Array(spec.dig('spec', 'template', 'spec', 'containers')) + # deployments/statefulsets
109
+ Array(spec.dig('spec', 'jobTemplate', 'spec', 'template', 'spec', 'containers')) # cronjobs
110
+
111
+ containers.each do |container|
112
+ image = images.detect { |image| image.remote_repo == container['image'] }
113
+ if image
114
+ container['image'] = image.remote_image
115
+ dspecs << spec unless dspecs.include?(spec)
116
+ elsif !container['image'].include?(':')
117
+ raise "Unknown image #{container['image']}"
118
+ end
119
+ end
120
+ end
121
+ dspecs
122
+ end
123
+
124
+ def specs(context = nil)
125
+ context ||= default_context
126
+ context = context.to_s
127
+ @specs[context] ||= begin
128
+ paths = git.ls_tree(commit, "platform/#{context}/")['blob'].keys
129
+ raise "No specs found for context #{context}" unless paths.present?
130
+ paths.map { |path| YAML.load_stream( git.show(commit, path) ) }.flatten.compact
131
+ end
132
+ end
133
+
134
+ def kubectl(context, cmd, data = nil)
135
+ cmd = "kubectl --context #{context} #{cmd}"
136
+
137
+ if ssh_host.blank?
138
+ stdout, stderr, cmd_status = Open3.capture3(cmd)
139
+ [ stdout, stderr, cmd_status.success? ]
140
+ else
141
+ require 'net/ssh'
142
+ exit_code = -1
143
+ stdout = String.new
144
+ stderr = String.new
145
+
146
+ ssh = Net::SSH.start(ssh_host)
147
+ ssh.open_channel do |channel|
148
+ channel.exec(cmd) do |_ch, success|
149
+ success or raise "FAILED: couldn't execute command on #{ssh_host}: #{cmd.inspect}"
150
+ channel.on_data { |_ch, in_data| stdout << in_data }
151
+ channel.on_extended_data { |_ch, _type, in_data| stderr << in_data }
152
+ channel.on_request('exit-status') { |_ch, in_data| exit_code = in_data.read_long }
153
+ channel.send_data(data) if data
154
+ channel.eof!
155
+ end
156
+ end
157
+ ssh.loop
158
+ [ stdout, stderr, exit_code.zero? ]
159
+ end
160
+ end
161
+
162
+ private
163
+
164
+ def git
165
+ @git ||= Git.open(repository, log: nil)
166
+ end
167
+
168
+ def load_config
169
+ conf_path = find_config(root)
170
+ conf = conf_path ? YAML.load_file(conf_path) : {}
171
+ conf = conf.reverse_merge(CONFIG_DEFAULTS)
172
+
173
+ @repository, @registry, @default_branch, @default_context, @production_context, @ssh_host, images =
174
+ conf.values_at('repository', 'registry', 'default_branch', 'default_context', 'production_context', 'ssh', 'images').map(&:presence)
175
+ @repository ||= root
176
+
177
+ images ||= [{ 'name' => File.basename(File.absolute_path(repository)) }]
178
+ @images = images.map do |image|
179
+ name = image['name']
180
+ dockerfile = image['dockerfile'].presence || 'Dockerfile'
181
+ Image.new(name: name, repository: repository, dockerfile: dockerfile, registry: registry, commit: nil, tag: nil)
182
+ end
183
+ end
184
+
185
+ def find_config(dir_or_path)
186
+ return dir_or_path unless File.directory?(dir_or_path)
187
+ CONFIG_LOCATIONS.map { |location| File.join(dir_or_path, location) }.detect { |path| File.exist?(path) }
188
+ end
189
+ end
data/lib/git_ext.rb ADDED
@@ -0,0 +1,23 @@
1
+ require 'git'
2
+
3
+ module Git
4
+ class Base
5
+ def ls_tree(*args)
6
+ self.lib.ls_tree(*args)
7
+ end
8
+ end
9
+
10
+ class Lib
11
+ # monkey-patched to add 'files' argument
12
+ def ls_tree(sha, files = nil)
13
+ data = {'blob' => {}, 'tree' => {}}
14
+ command_lines('ls-tree', [sha, files].compact).each do |line|
15
+ (info, filenm) = line.split("\t")
16
+ (mode, type, sha) = info.split
17
+ data[type][filenm] = {:mode => mode, :sha => sha}
18
+ end
19
+ data
20
+ end
21
+ end
22
+ end
23
+
data/lib/image.rb ADDED
@@ -0,0 +1,77 @@
1
+ class Image
2
+ def self.build_cores
3
+ @build_cores ||= ENV.fetch('R360_BUILD_CORES', [ 1, Concurrent::Utility::ProcessorCounter.new.processor_count - 1 ].max).to_i
4
+ end
5
+
6
+ attr_reader :name, :repository, :dockerfile, :commit, :tag, :registry
7
+ attr_writer :commit
8
+
9
+ def initialize(name:, repository:, dockerfile:, commit:, tag:, registry:)
10
+ @name = name.downcase
11
+ @repository = repository
12
+ @dockerfile = dockerfile
13
+ @commit = commit
14
+ @tag = tag
15
+ @registry = registry
16
+ end
17
+
18
+ def tag=(tag)
19
+ @tag = tag
20
+ @remote_exists = nil
21
+ end
22
+
23
+ def build!
24
+ return if local_exists?
25
+
26
+ Dir.mktmpdir("#{name}-build") do |dir|
27
+ system("git -C #{repository} archive #{commit} | tar -x -C #{dir}") and
28
+ system("#{Deployer.docker} build -f #{dockerfile} -t #{local_image} --build-arg JOBS=#{Image.build_cores} --build-arg GIT_VERSION=#{commit} #{dir}")
29
+ end
30
+ end
31
+
32
+ def push!
33
+ return if remote_exists?
34
+
35
+ if Deployer.podman?
36
+ system("#{Deployer.docker} push #{local_image} #{remote_image}")
37
+ else
38
+ system("#{Deployer.docker} tag #{local_image} #{remote_image}") and
39
+ system("#{Deployer.docker} push #{remote_image}") and
40
+ system("#{Deployer.docker} rmi #{remote_image}")
41
+ end
42
+ end
43
+
44
+ def local_repo
45
+ name
46
+ end
47
+
48
+ def remote_repo
49
+ "#{registry}/#{name}"
50
+ end
51
+
52
+ def local_image
53
+ "#{local_repo}:#{tag}"
54
+ end
55
+
56
+ def local_exists?
57
+ system("#{Deployer.docker} image exists #{local_image}")
58
+ end
59
+
60
+ def remote_image
61
+ "#{remote_repo}:#{tag}"
62
+ end
63
+
64
+ def remote_exists?
65
+ if @remote_exists.nil?
66
+ # this fails to parse the manifest of some images (built with Podman?), and gives warnings on others
67
+ _stdout, stderr, status = Open3.capture3("DOCKER_CLI_EXPERIMENTAL=enabled #{Deployer.docker} manifest inspect #{remote_image}")
68
+ if status.success? || stderr.include?('error parsing manifest blob')
69
+ @remote_exists = true
70
+ else
71
+ puts stderr if stderr.present? && !stderr.include?('manifest unknown')
72
+ @remote_exists = false
73
+ end
74
+ end
75
+ @remote_exists
76
+ end
77
+ end
metadata ADDED
@@ -0,0 +1,137 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rops
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Steve Sloan
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-09-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-cli
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.7.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.7.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 6.1.4
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 6.1.4
41
+ - !ruby/object:Gem::Dependency
42
+ name: git
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 1.9.1
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 1.9.1
55
+ - !ruby/object:Gem::Dependency
56
+ name: ptools
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 1.4.2
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.4.2
69
+ - !ruby/object:Gem::Dependency
70
+ name: hashdiff
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 1.0.1
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 1.0.1
83
+ - !ruby/object:Gem::Dependency
84
+ name: net-ssh
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 6.1.0
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 6.1.0
97
+ description: A tool to checkout, build, and deploy projects using Git, Docker, and
98
+ Kubernetes
99
+ email: steve@record360.com
100
+ executables:
101
+ - rops
102
+ extensions: []
103
+ extra_rdoc_files: []
104
+ files:
105
+ - Gemfile
106
+ - Gemfile.lock
107
+ - LICENSE
108
+ - README.md
109
+ - bin/rops
110
+ - lib/core_ext.rb
111
+ - lib/deployer.rb
112
+ - lib/git_ext.rb
113
+ - lib/image.rb
114
+ homepage: https://github.com/Record360/rops
115
+ licenses:
116
+ - MIT
117
+ metadata: {}
118
+ post_install_message:
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubygems_version: 3.1.6
134
+ signing_key:
135
+ specification_version: 4
136
+ summary: Record360 Operations tool
137
+ test_files: []