rops 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []