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 +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: []
|