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 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