circleci-tools 0.1.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 +7 -0
- data/README.md +61 -0
- data/bin/circleci-metrics +281 -0
- data/lib/circleci-tools/api_service.rb +177 -0
- data/lib/circleci-tools/cloudwatch_metrics_service.rb +217 -0
- data/lib/circleci-tools/data_aggregator.rb +57 -0
- data/lib/circleci-tools/job_analyzer.rb +58 -0
- data/lib/circleci-tools/log_uploader.rb +274 -0
- data/lib/circleci-tools/retryable.rb +29 -0
- data/lib/circleci-tools/runner_calculator.rb +14 -0
- data/lib/circleci-tools/s3_upload_service.rb +20 -0
- data/lib/circleci-tools/usage_report_service.rb +119 -0
- metadata +307 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 65da82885c98d74e328659ed86b6b2333781926b224d5d291339a982c35f1031
|
4
|
+
data.tar.gz: 04ba44f30782f8934656efe8822072d2bcaceebaf876a9f66ae62f5fb3cd28bf
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: bc5a56488a87728059c683603ca462e1243ad9e40eab1bd5bd986a927fa37e66383ccd0011255d20a82b880abd9d4047677f9ef1668a377a0e6fe737aabfe9bf
|
7
|
+
data.tar.gz: 9e6e4a915b075d0622b897935add9be6986a60c293bb10311eea82a3402076415b87ec5a623480a2ed71001976fb3d98ce98c8c8c4752b8b738fa9bc72e5f812
|
data/README.md
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
# CircleCI Tools
|
2
|
+
|
3
|
+
CircleCI Tools is a collection of utilities designed to enhance and streamline your CircleCI workflows. This CLI provides various commands to evaluate concurrency requirements, aggregate data, upload metrics, and generate usage reports.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
To set up the project, follow these steps:
|
8
|
+
|
9
|
+
1. Clone the repository:
|
10
|
+
```bash
|
11
|
+
git clone https://github.com/sofatutor/circleci-tools.git
|
12
|
+
cd circleci-tools
|
13
|
+
```
|
14
|
+
|
15
|
+
2. Install the dependencies:
|
16
|
+
```bash
|
17
|
+
bundle install
|
18
|
+
```
|
19
|
+
|
20
|
+
## Usage
|
21
|
+
|
22
|
+
The CLI provides the following commands:
|
23
|
+
|
24
|
+
- **evaluate**: Evaluate concurrency requirements for self-hosted runners.
|
25
|
+
```bash
|
26
|
+
bin/circleci-metrics evaluate --org=ORG_NAME --project=PROJECT_NAME
|
27
|
+
```
|
28
|
+
|
29
|
+
- **aggregate**: Aggregate data from an existing jobs JSON file.
|
30
|
+
```bash
|
31
|
+
bin/circleci-metrics aggregate --jobs_json=JOBS_JSON_PATH
|
32
|
+
```
|
33
|
+
|
34
|
+
- **upload**: Store aggregated CSV data into SQLite database for analysis.
|
35
|
+
```bash
|
36
|
+
bin/circleci-metrics upload --csv_file_path=CSV_FILE_PATH
|
37
|
+
```
|
38
|
+
|
39
|
+
- **usage_report**: Create usage export job, download CSV, and print file references.
|
40
|
+
```bash
|
41
|
+
bin/circleci-metrics usage_report --org_id=ORG_ID
|
42
|
+
```
|
43
|
+
|
44
|
+
- **upload_metrics**: Upload CloudWatch metrics from CSV file.
|
45
|
+
```bash
|
46
|
+
bin/circleci-metrics upload_metrics --csv_file_path=CSV_FILE_PATH
|
47
|
+
```
|
48
|
+
|
49
|
+
## Contributing
|
50
|
+
|
51
|
+
We welcome contributions to enhance the functionality of CircleCI Tools. Please follow these steps to contribute:
|
52
|
+
|
53
|
+
1. Fork the repository.
|
54
|
+
2. Create a new branch for your feature or bugfix.
|
55
|
+
3. Commit your changes with clear commit messages.
|
56
|
+
4. Push your changes to your fork.
|
57
|
+
5. Open a pull request with a detailed description of your changes.
|
58
|
+
|
59
|
+
## License
|
60
|
+
|
61
|
+
This project is licensed under the MIT License.
|
@@ -0,0 +1,281 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
$LOAD_PATH.unshift(File.expand_path('../lib/circleci-tools', __dir__))
|
4
|
+
|
5
|
+
require 'thor'
|
6
|
+
require 'tty-prompt'
|
7
|
+
require 'json'
|
8
|
+
require 'csv'
|
9
|
+
require 'date'
|
10
|
+
require 'active_support/all'
|
11
|
+
|
12
|
+
require 'api_service'
|
13
|
+
require 'job_analyzer'
|
14
|
+
require 'runner_calculator'
|
15
|
+
require 'data_aggregator'
|
16
|
+
require 'log_uploader'
|
17
|
+
require 'usage_report_service'
|
18
|
+
require 'cloudwatch_metrics_service'
|
19
|
+
require 's3_upload_service'
|
20
|
+
|
21
|
+
module CircleciMetrics
|
22
|
+
class CLI < Thor
|
23
|
+
desc "evaluate", "Evaluate concurrency requirements for self-hosted runners"
|
24
|
+
|
25
|
+
method_option :org, aliases: '-o', type: :string, desc: 'VCS org'
|
26
|
+
method_option :project, aliases: '-p', type: :string, desc: 'Project name'
|
27
|
+
method_option :days, aliases: '-d', type: :numeric, desc: 'Number of days to look back for pipelines', default: 30
|
28
|
+
method_option :pipelines_json, type: :string, desc: 'Path to JSON file with pre-fetched pipelines'
|
29
|
+
method_option :jobs_json, aliases: '-jjson', type: :string, desc: 'Path to JSON file with pre-fetched jobs'
|
30
|
+
|
31
|
+
def evaluate
|
32
|
+
prompt = TTY::Prompt.new
|
33
|
+
|
34
|
+
org = fetch_param(:org, 'CIRCLECI_ORG', prompt, "Enter your VCS org:")
|
35
|
+
project = fetch_param(:project, 'CIRCLECI_PROJECT', prompt, "Enter your project name:")
|
36
|
+
days = options[:days]
|
37
|
+
|
38
|
+
api_token = fetch_api_token(prompt)
|
39
|
+
|
40
|
+
circleci_service = CircleciTools::ApiService.new(api_token:, org:, project:)
|
41
|
+
job_analyzer = CircleciTools::JobAnalyzer.new
|
42
|
+
runner_calculator = CircleciTools::RunnerCalculator.new
|
43
|
+
|
44
|
+
pipelines = load_or_fetch_pipelines(circleci_service, org, project, days)
|
45
|
+
puts "Fetched pipelines: #{pipelines.size}"
|
46
|
+
|
47
|
+
return if pipelines.empty?
|
48
|
+
|
49
|
+
all_jobs = load_or_fetch_jobs(circleci_service, pipelines)
|
50
|
+
return if all_jobs.empty?
|
51
|
+
|
52
|
+
puts "Calculating peak RAM usage..."
|
53
|
+
peak_ram = job_analyzer.calculate_peak_ram(jobs: all_jobs)
|
54
|
+
puts "Peak concurrent RAM required: #{peak_ram} MB"
|
55
|
+
|
56
|
+
recommended_runners = runner_calculator.calculate_runners(peak_ram)
|
57
|
+
puts "Recommended number of runners (#{runner_calculator.runner_ram_gb} GB each): #{recommended_runners}"
|
58
|
+
|
59
|
+
aggregator = CircleciTools::DataAggregator.new(all_jobs)
|
60
|
+
|
61
|
+
aggregator.generate_csv
|
62
|
+
end
|
63
|
+
|
64
|
+
desc "upload", "Store aggregated CSV data into SQLite database for analysis"
|
65
|
+
|
66
|
+
method_option :csv_file_path, aliases: '-c', type: :string, required: true, desc: 'Path to the aggregated CSV file'
|
67
|
+
method_option :log_group_name, aliases: '-l', type: :string, default: '/CircleCi', desc: 'Log group name'
|
68
|
+
method_option :dry_run, type: :boolean, default: false, desc: 'Dry run mode'
|
69
|
+
|
70
|
+
def upload
|
71
|
+
csv_file_path = options[:csv_file_path]
|
72
|
+
log_group_name = options[:log_group_name]
|
73
|
+
|
74
|
+
importer = CircleciTools::LogUploader.new(log_group_name, dry_run: options[:dry_run])
|
75
|
+
importer.upload_file(csv_file_path)
|
76
|
+
end
|
77
|
+
|
78
|
+
desc "aggregate", "Aggregate data from an existing jobs JSON file"
|
79
|
+
|
80
|
+
method_option :jobs_json, aliases: '-j', type: :string, desc: 'Path to JSON file with jobs'
|
81
|
+
|
82
|
+
def aggregate
|
83
|
+
jobs_json_path = options[:jobs_json] || abort("Error: --jobs_json option is required")
|
84
|
+
jobs = JSON.parse(File.read(jobs_json_path))
|
85
|
+
aggregator = CircleciTools::DataAggregator.new(jobs)
|
86
|
+
aggregator.generate_csv
|
87
|
+
end
|
88
|
+
|
89
|
+
desc "usage_report", "Create usage export job, download CSV, and print file references"
|
90
|
+
method_option :org_id, type: :string, desc: 'Organization ID'
|
91
|
+
method_option :shared_org_ids, type: :array, desc: 'Shared organization IDs'
|
92
|
+
method_option :dry_run, type: :boolean, default: false, desc: 'Dry run mode'
|
93
|
+
method_option :verbose, aliases: '-v', type: :boolean, default: false, desc: 'Enable verbose logging'
|
94
|
+
method_option :days_ago, type: :numeric, default: 0, desc: 'Number of days to look back from now'
|
95
|
+
method_option :months_ago, type: :numeric, default: nil, desc: 'Number of months to look back from now'
|
96
|
+
method_option :usage_export_job_id, aliases: 'j', type: :string, desc: 'Existing usage export job ID'
|
97
|
+
method_option :upload, type: :boolean, default: false, desc: 'Upload the usage report to CloudWatch'
|
98
|
+
method_option :s3_bucket, type: :string, desc: 'S3 bucket name for uploading the usage report'
|
99
|
+
def usage_report
|
100
|
+
prompt = TTY::Prompt.new
|
101
|
+
org_id = options[:org_id] || ENV.fetch('CIRCLECI_ORG_ID', '4f925b2b-47d2-4775-8200-af0e5e2d9806')
|
102
|
+
|
103
|
+
shared_org_ids = options[:shared_org_ids] || []
|
104
|
+
api_token = fetch_api_token(prompt) # ...call existing helper or use logic shown in evaluate method
|
105
|
+
log_level = options[:verbose] ? Logger::DEBUG : Logger::INFO
|
106
|
+
logger = Logger.new(STDOUT)
|
107
|
+
logger.level = log_level
|
108
|
+
|
109
|
+
circleci_service = CircleciTools::ApiService.new(api_token:, org: 'N/A', project: 'N/A', logger:)
|
110
|
+
usage_report_service = CircleciTools::UsageReportService.new(
|
111
|
+
circleci_service,
|
112
|
+
org_id,
|
113
|
+
shared_org_ids,
|
114
|
+
600,
|
115
|
+
logger: logger,
|
116
|
+
log_level: log_level
|
117
|
+
)
|
118
|
+
|
119
|
+
usage_export_job_id = options[:usage_export_job_id]
|
120
|
+
if usage_export_job_id
|
121
|
+
downloaded_files = usage_report_service.call(usage_export_job_id:)
|
122
|
+
else
|
123
|
+
current_time = Time.now.utc
|
124
|
+
if options[:months_ago]
|
125
|
+
months_ago = options[:months_ago]
|
126
|
+
if months_ago.zero?
|
127
|
+
start_time = current_time.beginning_of_month
|
128
|
+
end_time = current_time
|
129
|
+
else
|
130
|
+
start_time = (current_time - months_ago.months).beginning_of_month
|
131
|
+
end_time = (current_time - months_ago.months).end_of_month
|
132
|
+
end
|
133
|
+
elsif options[:days_ago]
|
134
|
+
days_ago = options[:days_ago]
|
135
|
+
if days_ago.zero?
|
136
|
+
start_time = current_time.beginning_of_day
|
137
|
+
end_time = current_time
|
138
|
+
else
|
139
|
+
start_time = (current_time - days_ago.days).beginning_of_day
|
140
|
+
end_time = (current_time - days_ago.days).end_of_day
|
141
|
+
end
|
142
|
+
else
|
143
|
+
raise "Either days_ago or months_ago must be specified"
|
144
|
+
end
|
145
|
+
|
146
|
+
begin
|
147
|
+
downloaded_files = usage_report_service.call(start_time:, end_time:)
|
148
|
+
rescue RuntimeError => e
|
149
|
+
puts "Error: #{e.message}"
|
150
|
+
exit(1)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
csv_files = downloaded_files.select { |file| file.end_with?('.csv') }
|
155
|
+
|
156
|
+
if csv_files.size == 0
|
157
|
+
puts "No usage report available for the given time range."
|
158
|
+
elsif csv_files.size == 1
|
159
|
+
puts "Usage report file downloaded: #{csv_files.first}"
|
160
|
+
else
|
161
|
+
puts "Usage report files downloaded:"
|
162
|
+
csv_files.each { |f| puts " - #{f}" }
|
163
|
+
end
|
164
|
+
|
165
|
+
if options[:s3_bucket]
|
166
|
+
s3_service = CircleciTools::S3UploadService.new(options[:s3_bucket], logger: logger)
|
167
|
+
csv_files.each do |file|
|
168
|
+
s3_key = File.basename(file)
|
169
|
+
s3_service.upload_file(file, s3_key)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
if options[:upload]
|
174
|
+
if !options[:s3_bucket]
|
175
|
+
unless prompt.yes?("Warning: No S3 bucket specified. Events could be uploaded multiple times. Do you want to continue?")
|
176
|
+
puts "Operation aborted."
|
177
|
+
exit(1)
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
metrics_service = CircleciTools::CloudWatchMetricsService.new(
|
182
|
+
namespace: 'CircleCI',
|
183
|
+
dry_run: options[:dry_run],
|
184
|
+
s3_bucket: options[:s3_bucket]
|
185
|
+
)
|
186
|
+
csv_files.each do |file|
|
187
|
+
metrics_service.upload_metrics(file)
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
desc "upload_metrics", "Upload CloudWatch metrics from CSV file"
|
193
|
+
method_option :csv_file_path, aliases: '-c', type: :string, required: true, desc: 'Path to the CSV file'
|
194
|
+
method_option :namespace, aliases: '-n', type: :string, default: 'CircleCI', desc: 'CloudWatch namespace'
|
195
|
+
method_option :dry_run, type: :boolean, default: false, desc: 'Dry run mode'
|
196
|
+
def upload_metrics
|
197
|
+
csv_file_path = options[:csv_file_path]
|
198
|
+
namespace = options[:namespace]
|
199
|
+
dry_run = options[:dry_run]
|
200
|
+
|
201
|
+
metrics_service = CircleciTools::CloudWatchMetricsService.new(namespace: namespace, dry_run: dry_run)
|
202
|
+
metrics_service.upload_metrics(csv_file_path)
|
203
|
+
end
|
204
|
+
|
205
|
+
no_commands do
|
206
|
+
def load_or_fetch_pipelines(circleci_service, org, project, days)
|
207
|
+
if options[:pipelines_json]
|
208
|
+
puts "Loading pipelines from JSON file: #{options[:pipelines_json]}"
|
209
|
+
JSON.parse(File.read(options[:pipelines_json]))
|
210
|
+
else
|
211
|
+
puts "Fetching pipelines for project #{org}/#{project} that ran in the last #{days} days..."
|
212
|
+
pipelines = circleci_service.fetch_pipelines(days: days)
|
213
|
+
puts "Total pipelines fetched: #{pipelines.size}"
|
214
|
+
|
215
|
+
timestamp = Time.now.strftime('%Y%m%d%H%M%S')
|
216
|
+
filename = "tmp/pipelines_#{org}_#{project}_#{timestamp}.json"
|
217
|
+
File.write(filename, JSON.pretty_generate(pipelines))
|
218
|
+
puts "Pipelines exported to #{filename}"
|
219
|
+
|
220
|
+
pipelines
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
def load_or_fetch_jobs(circleci_service, pipelines)
|
225
|
+
if options[:jobs_json]
|
226
|
+
puts "Loading jobs from JSON file: #{options[:jobs_json]}"
|
227
|
+
JSON.parse(File.read(options[:jobs_json]))
|
228
|
+
else
|
229
|
+
puts "Fetching jobs for all pipelines..."
|
230
|
+
all_jobs = circleci_service.fetch_all_jobs(pipelines)
|
231
|
+
puts "Total jobs fetched: #{all_jobs.size}"
|
232
|
+
|
233
|
+
timestamp = Time.now.strftime('%Y%m%d%H%M%S')
|
234
|
+
filename = "tmp/jobs_#{timestamp}.json"
|
235
|
+
File.write(filename, JSON.pretty_generate(all_jobs))
|
236
|
+
puts "Jobs exported to #{filename}"
|
237
|
+
|
238
|
+
all_jobs
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
def fetch_param(option_key, env_var, prompt, message)
|
243
|
+
if options[option_key]
|
244
|
+
options[option_key]
|
245
|
+
else
|
246
|
+
ENV.fetch(env_var) do
|
247
|
+
if $stdin.tty?
|
248
|
+
prompt.ask(message) { |q| q.required(true) }
|
249
|
+
else
|
250
|
+
abort("Error: Environment variable #{env_var} is not set and no option provided.")
|
251
|
+
end
|
252
|
+
end
|
253
|
+
end
|
254
|
+
rescue KeyError
|
255
|
+
if $stdin.tty?
|
256
|
+
prompt.ask(message) { |q| q.required(true) }
|
257
|
+
else
|
258
|
+
abort("Error: Environment variable #{env_var} is not set and no option provided.")
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
def fetch_api_token(prompt)
|
263
|
+
ENV.fetch('CIRCLECI_API_TOKEN') do
|
264
|
+
if $stdin.tty?
|
265
|
+
prompt.mask("Enter your CircleCI API Token:") { |q| q.required(true) }
|
266
|
+
else
|
267
|
+
abort("Error: Environment variable CIRCLECI_API_TOKEN is not set and no option provided.")
|
268
|
+
end
|
269
|
+
end
|
270
|
+
rescue KeyError
|
271
|
+
if $stdin.tty?
|
272
|
+
prompt.mask("Enter your CircleCI API Token:") { |q| q.required(true) }
|
273
|
+
else
|
274
|
+
abort("Error: Environment variable CIRCLECI_API_TOKEN is not set and no option provided.")
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
CircleciMetrics::CLI.start(ARGV)
|
@@ -0,0 +1,177 @@
|
|
1
|
+
# lib/circleci_concurrency_evaluator/circleci_service.rb
|
2
|
+
|
3
|
+
require 'faraday'
|
4
|
+
require 'json'
|
5
|
+
require 'base64'
|
6
|
+
require 'logger'
|
7
|
+
require_relative 'retryable'
|
8
|
+
|
9
|
+
module CircleciTools
|
10
|
+
class ApiService
|
11
|
+
include Retryable
|
12
|
+
BASE_URL = 'https://circleci.com'
|
13
|
+
MAX_THREADS = 10
|
14
|
+
|
15
|
+
def initialize(api_token:, org:, project:, vcs_type: 'gh', logger: Logger.new(STDOUT))
|
16
|
+
@api_token = api_token
|
17
|
+
@vcs_type = vcs_type
|
18
|
+
@org = org
|
19
|
+
@project = project
|
20
|
+
@logger = logger
|
21
|
+
end
|
22
|
+
|
23
|
+
def fetch_pipelines(days: nil)
|
24
|
+
pipelines = []
|
25
|
+
page_token = nil
|
26
|
+
cutoff_time = days ? Time.now - (days * 24 * 60 * 60) : nil
|
27
|
+
|
28
|
+
loop do
|
29
|
+
url = '/api/v2/pipeline'
|
30
|
+
params = {
|
31
|
+
'org-slug' => "#{@vcs_type}/#{@org}",
|
32
|
+
'page-token' => page_token,
|
33
|
+
'mine' => false
|
34
|
+
}
|
35
|
+
|
36
|
+
response = with_retries { connection.get(url, params.compact, headers) }
|
37
|
+
break unless response
|
38
|
+
raise "API Error: #{response.body}" unless response.status == 200
|
39
|
+
|
40
|
+
data = JSON.parse(response.body)
|
41
|
+
|
42
|
+
pipelines.concat(data['items'])
|
43
|
+
|
44
|
+
page_token = data['next_page_token']
|
45
|
+
break unless page_token
|
46
|
+
|
47
|
+
break if cutoff_time && data['items'].any? { |pipeline| Time.parse(pipeline['created_at']) < cutoff_time }
|
48
|
+
end
|
49
|
+
|
50
|
+
pipelines
|
51
|
+
end
|
52
|
+
|
53
|
+
def fetch_jobs(pipeline_id)
|
54
|
+
jobs = []
|
55
|
+
page_token = nil
|
56
|
+
|
57
|
+
loop do
|
58
|
+
url = "/api/v2/pipeline/#{pipeline_id}/workflow"
|
59
|
+
params = {}
|
60
|
+
params['page-token'] = page_token if page_token
|
61
|
+
|
62
|
+
response = with_retries { connection.get(url, params, headers) }
|
63
|
+
break unless response
|
64
|
+
raise "API Error: #{response.body}" unless response.status == 200
|
65
|
+
|
66
|
+
data = JSON.parse(response.body)
|
67
|
+
workflows = data['items']
|
68
|
+
|
69
|
+
threads = workflows.map do |workflow|
|
70
|
+
Thread.new do
|
71
|
+
workflow_jobs = fetch_workflow_jobs(workflow['id'])
|
72
|
+
jobs.concat(workflow_jobs)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
threads.each(&:join)
|
77
|
+
|
78
|
+
page_token = data['next_page_token']
|
79
|
+
break unless page_token
|
80
|
+
end
|
81
|
+
|
82
|
+
jobs
|
83
|
+
end
|
84
|
+
|
85
|
+
def fetch_workflow_jobs(workflow_id)
|
86
|
+
url = "/api/v2/workflow/#{workflow_id}/job"
|
87
|
+
|
88
|
+
response = with_retries { connection.get(url, nil, headers) }
|
89
|
+
return [] unless response
|
90
|
+
raise "API Error: #{response.body}" unless response.status == 200
|
91
|
+
|
92
|
+
data = JSON.parse(response.body)
|
93
|
+
data['items']
|
94
|
+
end
|
95
|
+
|
96
|
+
def fetch_job_details(job)
|
97
|
+
url = "/api/v2/project/#{job['project_slug']}/job/#{job['job_number']}"
|
98
|
+
|
99
|
+
response = with_retries { connection.get(url, nil, headers) }
|
100
|
+
return nil unless response
|
101
|
+
raise "API Error: #{response.body}" unless response.status == 200
|
102
|
+
|
103
|
+
JSON.parse(response.body)
|
104
|
+
end
|
105
|
+
|
106
|
+
def fetch_all_jobs(pipelines)
|
107
|
+
all_jobs = []
|
108
|
+
semaphore = Mutex.new
|
109
|
+
threads = []
|
110
|
+
|
111
|
+
pipelines.each_with_index do |pipeline, index|
|
112
|
+
threads << Thread.new do
|
113
|
+
jobs = fetch_jobs(pipeline['id'])
|
114
|
+
jobs.each do |job|
|
115
|
+
next unless job['job_number']
|
116
|
+
next if job['status'] == 'not_run'
|
117
|
+
|
118
|
+
job_details = fetch_job_details(job)
|
119
|
+
next unless job_details
|
120
|
+
next unless job_details['duration']
|
121
|
+
|
122
|
+
semaphore.synchronize { all_jobs << job_details }
|
123
|
+
end
|
124
|
+
@logger.info("Fetched jobs for pipeline #{index + 1}/#{pipelines.size} (ID: #{pipeline['id']})")
|
125
|
+
end
|
126
|
+
|
127
|
+
if threads.size >= MAX_THREADS
|
128
|
+
threads.each(&:join)
|
129
|
+
threads.clear
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
threads.each(&:join)
|
134
|
+
all_jobs
|
135
|
+
end
|
136
|
+
|
137
|
+
def create_usage_export_job(org_id:, start_time:, end_time:, shared_org_ids: [])
|
138
|
+
url = "/api/v2/organizations/#{org_id}/usage_export_job"
|
139
|
+
body = { start: start_time, end: end_time, shared_org_ids: shared_org_ids }
|
140
|
+
response = with_retries(max_retries: 60) do
|
141
|
+
response = connection.post(url, body.to_json, headers.merge('Content-Type' => 'application/json'))
|
142
|
+
raise "API Error: #{response&.body}" unless response&.status == 201
|
143
|
+
|
144
|
+
response
|
145
|
+
end
|
146
|
+
|
147
|
+
return nil unless response
|
148
|
+
|
149
|
+
JSON.parse(response.body)
|
150
|
+
end
|
151
|
+
|
152
|
+
def get_usage_export_job(org_id:, usage_export_job_id:)
|
153
|
+
url = "/api/v2/organizations/#{org_id}/usage_export_job/#{usage_export_job_id}"
|
154
|
+
response = with_retries { connection.get(url, nil, headers) }
|
155
|
+
return nil unless response
|
156
|
+
raise "API Error: #{response.body}" unless response.status == 200
|
157
|
+
|
158
|
+
JSON.parse(response.body)
|
159
|
+
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
def connection
|
164
|
+
@connection ||= Faraday.new(url: BASE_URL) do |faraday|
|
165
|
+
faraday.request :url_encoded
|
166
|
+
faraday.adapter Faraday.default_adapter
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def headers
|
171
|
+
{
|
172
|
+
'Circle-Token' => @api_token,
|
173
|
+
'Accept' => 'application/json'
|
174
|
+
}
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|