pipedawg 0.2.2 → 0.5.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1df968015369cc666fc521d546b3194e82f04ec28b9a2473dbc2452b5c37780
4
- data.tar.gz: 5afe211e7fb69ac10808af059e02dc5706c882ad86d2309901d2f943e0ec6a6d
3
+ metadata.gz: 53733705295ce1c9b2ae80fc1c8368e7e08e7d7a0e1fcf3727fe972dd5714fb1
4
+ data.tar.gz: 10e86751592488fbb9972605892d79af662e60e3a3da070292cf4933f31b7981
5
5
  SHA512:
6
- metadata.gz: 7348909008c56e40d693f388da3b6eb8210c380a357c87c5e1cf906932a9c66246af77a9328f0746d70b2213943c95f73256d835b5163ca17348a9068998a5ac
7
- data.tar.gz: 9f3b4d44c1e2e5e2b955ecd135ccdcfd100d15fbcc137f40dca0af2797a0aa3637444e2e75ec910ac428a953147d883ecb24414c6e10bba17dfb0d40f3881b70
6
+ metadata.gz: 11584d541a5d63f54761dee9ca878e3c79ba168ee1e5e93ec9ddccd559d869672a900a6aa25d966df4ef807a56ac0c75385644baf3bce5498ac7a68a29fd8a9a
7
+ data.tar.gz: 97f20d550f1cbe4bf298a2f6884f02754cd385e317d777887d11a8748d7436d8bb6db035944c6f59d2593b4e3a615db76f28c678bb392cf16ef3e6c6f92770a9
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipedawg
4
+ # helm_copy_job class
5
+ class HelmCopyJob < Job
6
+ attr_accessor :helm_opts
7
+
8
+ def initialize(name = 'build', opts = {}, helm_opts = {})
9
+ @helm_opts = {
10
+ chart: name, debug: true,
11
+ destinations: [{ user: nil, password: nil, url: nil }],
12
+ helm: 'helm',
13
+ image: { entrypoint: [''], name: 'alpine/helm' },
14
+ password: nil, url: nil, user: nil, version: nil
15
+ }.merge(helm_opts)
16
+ super name, opts
17
+ update
18
+ end
19
+
20
+ def update # rubocop:disable Metrics/AbcSize
21
+ opts[:image] = helm_opts[:image] if helm_opts[:image]
22
+ opts[:script] = debug + pull + (helm_opts[:destinations].map { |d| push(d) }).flatten(1)
23
+ end
24
+
25
+ private
26
+
27
+ def debug
28
+ if helm_opts[:debug]
29
+ Pipedawg::Util.echo_proxy_vars
30
+ else
31
+ []
32
+ end
33
+ end
34
+
35
+ def pull
36
+ case helm_opts[:url]
37
+ when nil
38
+ []
39
+ when %r{^oci://}
40
+ pull_oci
41
+ else
42
+ pull_classic
43
+ end
44
+ end
45
+
46
+ def push(destination)
47
+ case destination[:url]
48
+ when nil
49
+ []
50
+ when %r{^oci://}
51
+ push_oci(destination)
52
+ else
53
+ push_classic(destination)
54
+ end
55
+ end
56
+
57
+ def pull_oci # rubocop:disable Metrics/AbcSize
58
+ script = []
59
+ if helm_opts[:url] && helm_opts[:chart] && helm_opts[:version]
60
+ script = ['export HELM_EXPERIMENTAL_OCI=1']
61
+ script << login_oci(helm_opts) if helm_opts[:user] && helm_opts[:password]
62
+ script << "\"#{helm_opts[:helm]}\" pull \"#{helm_opts[:url]}/#{helm_opts[:chart]}\" --version \"#{helm_opts[:version]}\"" # rubocop:disable Layout/LineLength
63
+ end
64
+ script
65
+ end
66
+
67
+ def push_oci(destination) # rubocop:disable Metrics/AbcSize
68
+ script = []
69
+ if destination[:url] && helm_opts[:chart] && helm_opts[:version]
70
+ script = ['export HELM_EXPERIMENTAL_OCI=1']
71
+ script << login_oci(destination) if destination[:user] && destination[:password]
72
+ script << "\"#{helm_opts[:helm]}\" push \"#{helm_opts[:chart]}-#{helm_opts[:version]}.tgz\" \"#{destination[:url]}\"" # rubocop:disable Layout/LineLength
73
+ end
74
+ script
75
+ end
76
+
77
+ def login_oci(login_opts)
78
+ require 'uri'
79
+ "echo \"#{login_opts[:password]}\" | \"#{helm_opts[:helm]}\" registry login --username \"#{login_opts[:user]}\" --password-stdin \"#{URI(login_opts[:url]).host}\"" # rubocop:disable Layout/LineLength
80
+ end
81
+
82
+ def pull_classic # rubocop:disable Metrics/AbcSize
83
+ script = []
84
+ if helm_opts[:url] && helm_opts[:chart] && helm_opts[:version]
85
+ suffix = login_classic(helm_opts)
86
+ script << "\"#{helm_opts[:helm]}\" repo add source \"#{helm_opts[:url]}\"#{suffix}"
87
+ script << "\"#{helm_opts[:helm]}\" repo update"
88
+ script << "\"#{helm_opts[:helm]}\" pull \"source/#{helm_opts[:chart]}\" --version \"#{helm_opts[:version]}\""
89
+ end
90
+ script
91
+ end
92
+
93
+ def push_classic(destination)
94
+ script = []
95
+ if destination[:url] && helm_opts[:chart] && helm_opts[:version]
96
+ script << plugin_classic
97
+ suffix = login_classic(destination)
98
+ script << "\"#{helm_opts[:helm]}\" cm-push \"#{helm_opts[:chart]}-#{helm_opts[:version]}.tgz\" \"#{destination[:url]}\"#{suffix}" # rubocop:disable Layout/LineLength
99
+ end
100
+ script
101
+ end
102
+
103
+ def login_classic(login_opts)
104
+ if login_opts[:user] && login_opts[:password]
105
+ " --username \"#{login_opts[:user]}\" --password \"#{login_opts[:password]}\""
106
+ else
107
+ ''
108
+ end
109
+ end
110
+
111
+ def plugin_classic
112
+ "\"#{helm_opts[:helm]}\" plugin list | grep -q cm-push || \"#{helm_opts[:helm]}\" plugin install https://github.com/chartmuseum/helm-push"
113
+ end
114
+ end
115
+ end
@@ -9,82 +9,100 @@ module Pipedawg
9
9
  @kaniko_opts = {
10
10
  build_args: {},
11
11
  config: {
12
- '$CI_REGISTRY': {
13
- username: '$CI_REGISTRY_USER',
14
- password: '$CI_REGISTRY_PASSWORD'
15
- }
12
+ '$CI_REGISTRY': { username: '$CI_REGISTRY_USER', password: '$CI_REGISTRY_PASSWORD' }
16
13
  },
17
- config_file: '/kaniko/.docker/config.json',
18
- context: '${CI_PROJECT_DIR}',
19
- destinations: [],
20
- dockerfile: 'Dockerfile',
21
- executor: '/kaniko/executor',
22
- external_files: {},
23
- flags: [],
24
- ignore_paths: [],
25
- insecure_registries: [],
26
- kaniko_image: {
27
- entrypoint: [''],
28
- name: 'gcr.io/kaniko-project/executor:debug'
29
- },
30
- options: {},
31
- registry_certificates: {},
32
- registry_mirrors: [],
33
- skip_tls_verify_registry: [],
34
- trusted_ca_cert_source_files: [],
35
- trusted_ca_cert_target_file: '/kaniko/ssl/certs/ca-certificates.crt'
14
+ config_file: '/kaniko/.docker/config.json', context: '${CI_PROJECT_DIR}', debug: true,
15
+ destinations: [], dockerfile: 'Dockerfile', executor: '/kaniko/executor', external_files: {},
16
+ flags: [], ignore_paths: [], insecure_registries: [],
17
+ image: { entrypoint: [''], name: 'gcr.io/kaniko-project/executor:debug' }, options: {},
18
+ registry_certificates: {}, registry_mirrors: [], skip_tls_verify_registry: [],
19
+ trusted_ca_cert_source_files: [], trusted_ca_cert_target_file: '/kaniko/ssl/certs/ca-certificates.crt'
36
20
  }.merge(kaniko_opts)
37
21
  super name, opts
38
22
  update
39
23
  end
40
24
 
41
- def update # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
25
+ def update # rubocop:disable Metrics/AbcSize
42
26
  require 'json'
43
- opts[:image] = kaniko_opts[:image]
44
- script = ["echo #{kaniko_opts[:config].to_json.inspect} > \"#{kaniko_opts[:config_file]}\""]
45
- cert_copies = Array(kaniko_opts[:trusted_ca_cert_source_files]).map do |cert|
27
+ opts[:image] = kaniko_opts[:image] if kaniko_opts[:image]
28
+ opts[:script] = debug + config + cert_copies + file_copies + Array(kaniko_cmd)
29
+ end
30
+
31
+ private
32
+
33
+ def debug
34
+ if kaniko_opts[:debug]
35
+ Pipedawg::Util.echo_proxy_vars
36
+ else
37
+ []
38
+ end
39
+ end
40
+
41
+ def config
42
+ ["echo #{kaniko_opts[:config].to_json.inspect} > \"#{kaniko_opts[:config_file]}\""]
43
+ end
44
+
45
+ def cert_copies
46
+ Array(kaniko_opts[:trusted_ca_cert_source_files]).map do |cert|
46
47
  "cat \"#{cert}\" >> \"#{kaniko_opts[:trusted_ca_cert_target_file]}\""
47
48
  end
48
- script.concat cert_copies
49
- file_copies = kaniko_opts[:external_files].map do |source, dest|
49
+ end
50
+
51
+ def file_copies
52
+ kaniko_opts[:external_files].map do |source, dest|
50
53
  "cp \"#{source}\" \"#{kaniko_opts[:context]}/#{dest}\""
51
54
  end
52
- script.concat file_copies
55
+ end
56
+
57
+ def kaniko_cmd # rubocop:disable Metrics/AbcSize
58
+ ["\"#{kaniko_opts[:executor]}\" --context \"#{kaniko_opts[:context]}\"",
59
+ "--dockerfile \"#{kaniko_opts[:dockerfile]}\"", flags, options, build_args,
60
+ ignore_paths, insecure_registries, registry_certificates, registry_mirrors,
61
+ destinations, skip_tls_verify_registries].reject(&:empty?).join(' ')
62
+ end
63
+
64
+ def flags
53
65
  flags = kaniko_opts[:flags].clone
54
66
  flags << 'no-push' if kaniko_opts[:destinations].empty?
55
- flags_cli = flags.uniq.map { |f| "--#{f}" }.join(' ')
56
- options_cli = kaniko_opts[:options].map { |k, v| "--#{k}=\"#{v}\"" }.join(' ')
57
- build_args_cli = kaniko_opts[:build_args].map { |k, v| "--build-arg #{k}=\"#{v}\"" }.join(' ')
58
- ignore_paths_cli = Array(kaniko_opts[:ignore_paths]).map { |p| "--ignore-path #{p}" }.join(' ')
59
- insecure_registries_cli = Array(kaniko_opts[:insecure_registries]).map do |r|
67
+ flags.uniq.map { |f| "--#{f}" }.join(' ')
68
+ end
69
+
70
+ def options
71
+ kaniko_opts[:options].map { |k, v| "--#{k}=\"#{v}\"" }.join(' ')
72
+ end
73
+
74
+ def build_args
75
+ kaniko_opts[:build_args].map { |k, v| "--build-arg #{k}=\"#{v}\"" }.join(' ')
76
+ end
77
+
78
+ def ignore_paths
79
+ Array(kaniko_opts[:ignore_paths]).map { |p| "--ignore-path #{p}" }.join(' ')
80
+ end
81
+
82
+ def insecure_registries
83
+ Array(kaniko_opts[:insecure_registries]).map do |r|
60
84
  "--insecure-registry #{r}"
61
85
  end.join(' ')
62
- registry_certificates_cli = kaniko_opts[:registry_certificates].map do |k, v|
86
+ end
87
+
88
+ def registry_certificates
89
+ kaniko_opts[:registry_certificates].map do |k, v|
63
90
  "--registry-certificate #{k}=\"#{v}\""
64
91
  end.join(' ')
65
- registry_mirrors_cli = Array(kaniko_opts[:registry_mirrors]).map { |r| "--registry-mirror #{r}" }.join(' ')
66
- skip_tls_verify_registrys_cli = Array(kaniko_opts[:skip_tls_verify_registry]).map do |r|
92
+ end
93
+
94
+ def registry_mirrors
95
+ Array(kaniko_opts[:registry_mirrors]).map { |r| "--registry-mirror #{r}" }.join(' ')
96
+ end
97
+
98
+ def destinations
99
+ kaniko_opts[:destinations].map { |d| "--destination #{d}" }.join(' ')
100
+ end
101
+
102
+ def skip_tls_verify_registries
103
+ Array(kaniko_opts[:skip_tls_verify_registry]).map do |r|
67
104
  "--skip-tls-verify-registry #{r}"
68
105
  end.join(' ')
69
- destinations_cli = kaniko_opts[:destinations].map { |d| "--destination #{d}" }.join(' ')
70
- kaniko_cmds = [
71
- "\"#{kaniko_opts[:executor]}\"",
72
- '--context',
73
- "\"#{kaniko_opts[:context]}\"",
74
- '--dockerfile',
75
- "\"#{kaniko_opts[:dockerfile]}\"",
76
- flags_cli,
77
- options_cli,
78
- build_args_cli,
79
- ignore_paths_cli,
80
- insecure_registries_cli,
81
- registry_certificates_cli,
82
- registry_mirrors_cli,
83
- destinations_cli,
84
- skip_tls_verify_registrys_cli
85
- ].reject(&:empty?)
86
- script << kaniko_cmds.join(' ')
87
- opts[:script] = script
88
106
  end
89
107
  end
90
108
  end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipedawg
4
+ # qualys_scan_job class
5
+ class QualysScanJob < Job
6
+ attr_accessor :qualys_opts
7
+
8
+ def initialize(name = 'build', opts = {}, qualys_opts = {})
9
+ @qualys_opts = {
10
+ acceptable_risk: '${QUALYS_ACCEPTABLE_IMAGE_RISK}',
11
+ artifacts: {
12
+ expire_in: '1 month', paths: ['software.json', 'vulnerabilities.json'], when: 'always'
13
+ }, debug: true, gateway: '${QUALYS_GATEWAY}', image: nil, password: '${QUALYS_PASSWORD}', rules: nil,
14
+ scan_image: '${QUALYS_IMAGE}', scan_target_prefix: 'qualys_scan_target', tags: nil, user: '${QUALYS_USERNAME}',
15
+ variables: { GIT_STRATEGY: 'clone' }
16
+ }.merge(qualys_opts)
17
+ super name, opts
18
+ update
19
+ end
20
+
21
+ def update # rubocop:disable Metrics/AbcSize
22
+ require 'json'
23
+ opts[:artifacts] = qualys_opts[:artifacts] if qualys_opts[:artifacts]
24
+ opts[:image] = qualys_opts[:image]
25
+ opts[:rules] = qualys_opts[:rules] if qualys_opts[:rules]
26
+ opts[:tags] = qualys_opts[:tags] if qualys_opts[:tags]
27
+ opts[:variables] = qualys_opts[:variables] if qualys_opts[:variables]
28
+ opts[:script] = debug + image + token + scan_start + scan_complete + artifacts + severities + outputs
29
+ end
30
+
31
+ private
32
+
33
+ def debug # rubocop:disable Metrics/MethodLength
34
+ if qualys_opts[:debug]
35
+ Pipedawg::Util.echo_proxy_vars + [
36
+ 'echo Qualys settings:',
37
+ "echo Qualys gateway: \"#{qualys_opts[:gateway]}\"",
38
+ "echo Qualys username: \"#{qualys_opts[:user]}\"",
39
+ "if [ \"#{qualys_opts[:password]}\" != '' ]; then " \
40
+ 'echo Qualys password is not empty; else ' \
41
+ 'echo Qualys password is not set; exit 1; fi'
42
+ ]
43
+ else
44
+ []
45
+ end
46
+ end
47
+
48
+ def image
49
+ [
50
+ "image_target=\"#{qualys_opts[:scan_target_prefix]}:$(echo #{qualys_opts[:scan_image]} | sed 's/^[^/]*\\///'| sed 's/[:/]/-/g')\"", # rubocop:disable Layout/LineLength
51
+ "docker pull \"#{qualys_opts[:scan_image]}\"",
52
+ "docker image tag \"#{qualys_opts[:scan_image]}\" \"${image_target}\"",
53
+ "image_id=$(docker inspect --format=\"{{index .Id}}\" \"#{qualys_opts[:scan_image]}\" | cut -c8-19)",
54
+ 'echo "Image ID: ${image_id}"'
55
+ ]
56
+ end
57
+
58
+ def token
59
+ ["token=$(curl -s --location --request POST \"https://#{qualys_opts[:gateway]}/auth\" --header \"Content-Type: application/x-www-form-urlencoded\" --data-urlencode \"username=#{qualys_opts[:user]}\" --data-urlencode \"password=#{qualys_opts[:password]}\" --data-urlencode \"token=true\")"] # rubocop:disable Layout/LineLength
60
+ end
61
+
62
+ def scan_start
63
+ [
64
+ 'while true; do ' \
65
+ "result=$(curl -s -o /dev/null -w ''%{http_code}'' --location --request GET \"https://#{qualys_opts[:gateway]}/csapi/v1.2/images/$image_id\" --header \"Authorization: Bearer $token\"); " + # rubocop:disable Layout/LineLength, Style/FormatStringToken
66
+ 'echo "Waiting for scan to start..."; ' \
67
+ 'echo " Result: ${result}"; ' \
68
+ 'if [ "${result}" = "200" ]; then break; fi; ' \
69
+ 'sleep 10; done'
70
+ ]
71
+ end
72
+
73
+ def scan_complete
74
+ [
75
+ 'while true; do ' \
76
+ "result=$(curl -s --location --request GET \"https://#{qualys_opts[:gateway]}/csapi/v1.2/images/$image_id\" --header \"Authorization: Bearer $token\" | jq -r '.scanStatus'); " + # rubocop:disable Layout/LineLength
77
+ 'echo "Waiting for scan to complete..."; ' \
78
+ 'echo " Result: ${result}"; ' \
79
+ 'if [ "${result}" = "SUCCESS" ]; then break; fi; ' \
80
+ 'sleep 10; done; sleep 30'
81
+ ]
82
+ end
83
+
84
+ def artifacts
85
+ [
86
+ "curl -s --location --request GET \"https://#{qualys_opts[:gateway]}/csapi/v1.2/images/$image_id/software\" --header \"Authorization: Bearer $token\" | jq . > software.json", # rubocop:disable Layout/LineLength
87
+ "curl -s --location --request GET \"https://#{qualys_opts[:gateway]}/csapi/v1.2/images/$image_id/vuln\" --header \"Authorization: Bearer $token\" | jq . > vulnerabilities.json" # rubocop:disable Layout/LineLength
88
+ ]
89
+ end
90
+
91
+ def severities
92
+ [
93
+ "response=$(curl -s --location --request GET \"https://#{qualys_opts[:gateway]}/csapi/v1.2/images/$image_id/vuln/count\" --header \"Authorization: Bearer $token\")", # rubocop:disable Layout/LineLength
94
+ 'severity5=$(jq -r ".severity5Count" <<< "${response}")',
95
+ 'severity4=$(jq -r ".severity4Count" <<< "${response}")'
96
+ ]
97
+ end
98
+
99
+ def outputs # rubocop:disable Metrics/MethodLength
100
+ [
101
+ 'if [ "$severity5" = "null" ]; then ' \
102
+ 'echo "ERROR: Wrong ImageID or problem during vulnerabilities count." >&2; ' \
103
+ 'exit 1; fi',
104
+ 'if [ "$severity4" = "null" ]; then ' \
105
+ 'echo "ERROR: Wrong ImageID or problem during vulnerabilities count." >&2; ' \
106
+ 'exit 1; fi',
107
+ 'echo "Severity5: $severity5, Severity4: $severity4"',
108
+ 'risk=$((($severity5*3)+($severity4)))',
109
+ 'echo "Risk: $risk"',
110
+ "if (($risk > \"#{qualys_opts[:acceptable_risk]}\")); then " \
111
+ 'echo "Too many vulnerabilities. Severity5: $severity5, Severity4: $severity4" >&2; ' \
112
+ 'exit 1; fi'
113
+ ]
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pipedawg
4
+ # util class
5
+ class Util
6
+ def self.expand_env_vars(item) # rubocop:disable Metrics/MethodLength
7
+ case item
8
+ when Array
9
+ item.map { |i| expand_env_vars(i) }
10
+ when Hash
11
+ item.each { |k, v| item[k] = expand_env_vars(v) }
12
+ item
13
+ when String
14
+ item.gsub(/\${([^} ]+)}/) do |e|
15
+ ENV[e.gsub('${', '').gsub('}', '')]
16
+ end
17
+ else
18
+ item
19
+ end
20
+ end
21
+
22
+ def self.puts_proxy_vars
23
+ puts 'Proxy settings:'
24
+ puts "http_proxy: #{ENV['http_proxy']}"
25
+ puts "https_proxy: #{ENV['https_proxy']}"
26
+ puts "no_proxy: #{ENV['no_proxy']}"
27
+ puts "HTTP_PROXY: #{ENV['HTTP_PROXY']}"
28
+ puts "HTTPS_PROXY: #{ENV['HTTPS_PROXY']}"
29
+ puts "NO_PROXY: #{ENV['NO_PROXY']}"
30
+ end
31
+
32
+ def self.echo_proxy_vars
33
+ script = ['echo Proxy settings:']
34
+ script << 'echo http_proxy: "${http_proxy}"'
35
+ script << 'echo https_proxy: "${https_proxy}"'
36
+ script << 'echo no_proxy: "${no_proxy}"'
37
+ script << 'echo HTTP_PROXY: "${HTTP_PROXY}"'
38
+ script << 'echo HTTPS_PROXY: "${HTTPS_PROXY}"'
39
+ script << 'echo NO_PROXY: "${NO_PROXY}"'
40
+ script
41
+ end
42
+ end
43
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pipedawg
4
- VERSION = '0.2.2'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/pipedawg.rb CHANGED
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pipedawg/job'
4
+ require 'pipedawg/helm_copy_job'
4
5
  require 'pipedawg/kaniko_job'
5
6
  require 'pipedawg/pipeline'
7
+ require 'pipedawg/qualys_scan_job'
8
+ require 'pipedawg/util'
6
9
  require 'pipedawg/version'
7
10
 
8
11
  module Pipedawg
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pipedawg
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - harbottle
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-02-01 00:00:00.000000000 Z
11
+ date: 2022-02-08 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Generate GitLab CI pipelines.
14
14
  email:
@@ -20,9 +20,12 @@ files:
20
20
  - LICENSE.txt
21
21
  - README.md
22
22
  - lib/pipedawg.rb
23
+ - lib/pipedawg/helm_copy_job.rb
23
24
  - lib/pipedawg/job.rb
24
25
  - lib/pipedawg/kaniko_job.rb
25
26
  - lib/pipedawg/pipeline.rb
27
+ - lib/pipedawg/qualys_scan_job.rb
28
+ - lib/pipedawg/util.rb
26
29
  - lib/pipedawg/version.rb
27
30
  homepage: https://github.com/liger1978/pipedawg
28
31
  licenses: