rops 1.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +43 -0
- data/LICENSE +20 -0
- data/README.md +156 -0
- data/bin/rops +243 -0
- data/lib/core_ext.rb +210 -0
- data/lib/deployer.rb +189 -0
- data/lib/git_ext.rb +23 -0
- data/lib/image.rb +77 -0
- metadata +137 -0
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
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: []
|