From d47b3f6a7c38ed4ed05a610f50cfc733bcd83108 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Nov 2025 04:44:08 +0000 Subject: [PATCH] Complete JS code refactoring - break down long methods into focused functions Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com> --- dist/index.js | 697 +++++++++++++++++++-------- dist/post/index.js | 1032 +++++++++++++++++++++++++++------------- lib/config.js | 350 ++++++++------ lib/exporter.js | 170 +++++-- package.json | 2 +- test/collector.test.js | 49 ++ test/config.test.js | 20 + 7 files changed, 1615 insertions(+), 705 deletions(-) create mode 100644 test/config.test.js diff --git a/dist/index.js b/dist/index.js index ce31e52..c0ac7f2 100644 --- a/dist/index.js +++ b/dist/index.js @@ -32,6 +32,83 @@ async function checkRepositoryVisibility(token) { } } +/** + * Fetches IAM policy for a project + * @param {Object} credentials - Service account credentials + * @param {string} projectId - GCP project ID + * @returns {Promise} IAM policy + */ +async function fetchIAMPolicy(credentials, projectId) { + const auth = new GoogleAuth({ + credentials, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + const client = await auth.getClient(); + + const url = `https://cloudresourcemanager.googleapis.com/v1/projects/${projectId}:getIamPolicy`; + const response = await client.request({ + url, + method: 'POST', + data: {}, + headers: { + 'Content-Type': 'application/json', + }, + }); + + return response.data; +} + +/** + * Extracts roles assigned to a service account from IAM policy + * @param {Object} policy - IAM policy + * @param {string} serviceAccountEmail - Service account email + * @returns {Array} List of roles + */ +function extractServiceAccountRoles(policy, serviceAccountEmail) { + const bindings = policy.bindings || []; + const serviceAccountRoles = []; + const memberString = `serviceAccount:${serviceAccountEmail}`; + + for (const binding of bindings) { + if (binding.members && binding.members.includes(memberString)) { + serviceAccountRoles.push(binding.role); + } + } + + return serviceAccountRoles; +} + +/** + * Logs security error for excessive permissions + * @param {string} projectId - GCP project ID + * @param {string} serviceAccountEmail - Service account email + * @param {Array} excessiveRoles - List of excessive roles + */ +function logExcessivePermissionsError(projectId, serviceAccountEmail, excessiveRoles) { + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error('⚠️ SECURITY ERROR: Service account has excessive permissions!'); + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error(''); + core.error(`Service account: ${serviceAccountEmail}`); + core.error(`Project: ${projectId}`); + core.error(''); + core.error('Excessive roles detected:'); + excessiveRoles.forEach(role => core.error(` - ${role}`)); + core.error(''); + core.error('This service account should ONLY have:'); + core.error(' - roles/monitoring.metricWriter (for metrics)'); + core.error(' - roles/cloudtrace.agent (for traces)'); + core.error(''); + core.error('To fix, remove excessive roles:'); + excessiveRoles.forEach(role => { + core.error(` gcloud projects remove-iam-policy-binding ${projectId} \\`); + core.error(` --member="serviceAccount:${serviceAccountEmail}" \\`); + core.error(` --role="${role}"`); + }); + core.error(''); +} + /** * Checks if a service account has excessive permissions * @param {string} projectId - GCP project ID @@ -43,36 +120,8 @@ async function checkServiceAccountPermissions(projectId, serviceAccountEmail, cr try { core.info('Checking service account IAM permissions...'); - const auth = new GoogleAuth({ - credentials, - scopes: ['https://www.googleapis.com/auth/cloud-platform'], - }); - - const client = await auth.getClient(); - - // Get project IAM policy using v1 API with POST - const url = `https://cloudresourcemanager.googleapis.com/v1/projects/${projectId}:getIamPolicy`; - const response = await client.request({ - url, - method: 'POST', - data: {}, - headers: { - 'Content-Type': 'application/json', - }, - }); - - const policy = response.data; - const bindings = policy.bindings || []; - - // Find all roles assigned to this service account - const serviceAccountRoles = []; - const memberString = `serviceAccount:${serviceAccountEmail}`; - - for (const binding of bindings) { - if (binding.members && binding.members.includes(memberString)) { - serviceAccountRoles.push(binding.role); - } - } + const policy = await fetchIAMPolicy(credentials, projectId); + const serviceAccountRoles = extractServiceAccountRoles(policy, serviceAccountEmail); core.info(`Service account has ${serviceAccountRoles.length} role(s): ${serviceAccountRoles.join(', ')}`); @@ -84,27 +133,7 @@ async function checkServiceAccountPermissions(projectId, serviceAccountEmail, cr const excessiveRoles = serviceAccountRoles.filter(role => !allowedRoles.includes(role)); if (excessiveRoles.length > 0) { - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error('⚠️ SECURITY ERROR: Service account has excessive permissions!'); - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error(''); - core.error(`Service account: ${serviceAccountEmail}`); - core.error(`Project: ${projectId}`); - core.error(''); - core.error('Excessive roles detected:'); - excessiveRoles.forEach(role => core.error(` - ${role}`)); - core.error(''); - core.error('This service account should ONLY have:'); - core.error(' - roles/monitoring.metricWriter (for metrics)'); - core.error(' - roles/cloudtrace.agent (for traces)'); - core.error(''); - core.error('To fix, remove excessive roles:'); - excessiveRoles.forEach(role => { - core.error(` gcloud projects remove-iam-policy-binding ${projectId} \\`); - core.error(` --member="serviceAccount:${serviceAccountEmail}" \\`); - core.error(` --role="${role}"`); - }); - core.error(''); + logExcessivePermissionsError(projectId, serviceAccountEmail, excessiveRoles); throw new Error('Service account has excessive permissions - refusing to use'); } @@ -128,6 +157,120 @@ async function checkServiceAccountPermissions(projectId, serviceAccountEmail, cr } } +/** + * Validates repository security for service account key file usage + * @param {string} token - GitHub token + * @returns {Promise} + */ +async function validateRepositorySecurity(token) { + let repoVisibility = null; + + if (token) { + core.debug('Checking repository visibility via GitHub API...'); + repoVisibility = await checkRepositoryVisibility(token); + } + + const isPublic = repoVisibility === 'public'; + const isPrivate = repoVisibility === 'private' || repoVisibility === 'internal'; + + if (isPublic) { + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error('⚠️ SECURITY ERROR: Service account key in PUBLIC repository!'); + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error(''); + core.error('Service account key files should NEVER be committed to public repositories.'); + core.error(''); + core.error('Options:'); + core.error(' 1. Make this repository private'); + core.error(' 2. Use GitHub Secrets instead of committing the key file'); + core.error(' 3. Use Workload Identity Federation (recommended)'); + core.error(''); + throw new Error('Refusing to use service account key file in public repository'); + } + + if (isPrivate) { + core.info(`✓ Repository is ${repoVisibility} - safe to use service account key file`); + } else { + core.warning(`Could not determine repository visibility (got: ${repoVisibility})`); + core.warning('⚠️ WARNING: Proceeding without confirming repository is private'); + core.warning('Service account key files should ONLY be used in private repositories!'); + } +} + +/** + * Reads service account key from file + * @param {string} serviceAccountKeyFile - Path to service account key file + * @returns {string} Service account key content + */ +function readServiceAccountKeyFile(serviceAccountKeyFile) { + try { + const filePath = path.resolve(serviceAccountKeyFile); + core.info(`Reading service account key from: ${filePath}`); + return fs.readFileSync(filePath, 'utf8'); + } catch (error) { + core.error(`Failed to read service account key file: ${error.message}`); + throw new Error(`Cannot read service account key file: ${serviceAccountKeyFile}`); + } +} + +/** + * Parses and extracts information from service account key + * @param {string} serviceAccountKey - Service account key JSON string + * @returns {Object} Parsed key data with project ID and email + */ +function parseServiceAccountKey(serviceAccountKey) { + try { + const keyData = JSON.parse(serviceAccountKey); + + // Validate key structure + if (!keyData.private_key || !keyData.client_email || !keyData.project_id) { + core.warning('Service account key appears to be incomplete or malformed'); + } + + return { + keyData, + projectId: keyData.project_id, + email: keyData.client_email + }; + } catch (error) { + core.warning(`Could not parse service account key: ${error.message}`); + return { keyData: null, projectId: null, email: null }; + } +} + +/** + * Tries to detect GCP project ID from environment variables + * @returns {string|null} Project ID or null + */ +function detectProjectFromEnvironment() { + const envProject = process.env.GOOGLE_CLOUD_PROJECT || + process.env.GCLOUD_PROJECT || + process.env.GCP_PROJECT; + if (envProject) { + core.info(`Using project ID from environment: ${envProject}`); + return envProject; + } + return null; +} + +/** + * Tries to detect GCP project ID from Application Default Credentials + * @returns {Promise} Project ID or null + */ +async function detectProjectFromADC() { + try { + const auth = new GoogleAuth(); + const projectId = await auth.getProjectId(); + if (projectId) { + core.info(`Detected project ID from Application Default Credentials: ${projectId}`); + return projectId; + } + } catch (error) { + core.debug(`Could not detect project from ADC: ${error.message}`); + } + return null; +} + /** * Parses and validates action configuration from inputs * @returns {Object} Configuration object @@ -138,116 +281,49 @@ async function getConfig() { // Read service account key from file if provided if (serviceAccountKeyFile) { - // Check if repository is private when using service account key file const token = core.getInput('github-token'); - let repoVisibility = null; - - if (token) { - core.debug('Checking repository visibility via GitHub API...'); - repoVisibility = await checkRepositoryVisibility(token); - } - - // Determine if repo is public - const isPublic = repoVisibility === 'public'; - const isPrivate = repoVisibility === 'private' || repoVisibility === 'internal'; - - if (isPublic) { - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error('⚠️ SECURITY ERROR: Service account key in PUBLIC repository!'); - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error(''); - core.error('Service account key files should NEVER be committed to public repositories.'); - core.error(''); - core.error('Options:'); - core.error(' 1. Make this repository private'); - core.error(' 2. Use GitHub Secrets instead of committing the key file'); - core.error(' 3. Use Workload Identity Federation (recommended)'); - core.error(''); - throw new Error('Refusing to use service account key file in public repository'); - } - - if (isPrivate) { - core.info(`✓ Repository is ${repoVisibility} - safe to use service account key file`); - } else { - core.warning(`Could not determine repository visibility (got: ${repoVisibility})`); - core.warning('⚠️ WARNING: Proceeding without confirming repository is private'); - core.warning('Service account key files should ONLY be used in private repositories!'); - } - try { - const filePath = path.resolve(serviceAccountKeyFile); - core.info(`Reading service account key from: ${filePath}`); - serviceAccountKey = fs.readFileSync(filePath, 'utf8'); - } catch (error) { - core.error(`Failed to read service account key file: ${error.message}`); - throw new Error(`Cannot read service account key file: ${serviceAccountKeyFile}`); - } + await validateRepositorySecurity(token); + serviceAccountKey = readServiceAccountKeyFile(serviceAccountKeyFile); } - // Determine GCP project ID and validate service account + // Parse service account key and extract information let gcpProjectId = core.getInput('gcp-project-id'); let serviceAccountEmail = null; let keyData = null; - // If not explicitly provided, try to extract from service account key if (serviceAccountKey) { - try { - keyData = JSON.parse(serviceAccountKey); - - // Extract project ID if not provided - if (!gcpProjectId && keyData.project_id) { - gcpProjectId = keyData.project_id; - core.info(`Using project ID from service account key: ${gcpProjectId}`); - } - - // Extract and log service account email - if (keyData.client_email) { - serviceAccountEmail = keyData.client_email; - core.info(`Using service account: ${serviceAccountEmail}`); - } - - // Validate key structure - if (!keyData.private_key || !keyData.client_email || !keyData.project_id) { - core.warning('Service account key appears to be incomplete or malformed'); - } - - } catch (error) { - core.warning(`Could not parse service account key: ${error.message}`); + const parsed = parseServiceAccountKey(serviceAccountKey); + keyData = parsed.keyData; + + if (!gcpProjectId && parsed.projectId) { + gcpProjectId = parsed.projectId; + core.info(`Using project ID from service account key: ${gcpProjectId}`); + } + + if (parsed.email) { + serviceAccountEmail = parsed.email; + core.info(`Using service account: ${serviceAccountEmail}`); } } - const failOnError = core.getBooleanInput('fail-on-error'); - + // Build config object const config = { gcpProjectId, gcpServiceAccountKey: serviceAccountKey, serviceName: core.getInput('service-name') || 'github-actions', serviceNamespace: core.getInput('service-namespace') || 'ci', metricPrefix: core.getInput('metric-prefix') || 'github.actions', - failOnError, + failOnError: core.getBooleanInput('fail-on-error'), }; - // Try to get project from environment (set by google-github-actions/auth) + // Try to get project from environment if (!config.gcpProjectId) { - const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || process.env.GCP_PROJECT; - if (envProject) { - config.gcpProjectId = envProject; - core.info(`Using project ID from environment: ${envProject}`); - } + config.gcpProjectId = detectProjectFromEnvironment(); } // Try to detect project from ADC if (!config.gcpProjectId) { - try { - const { GoogleAuth } = __nccwpck_require__(492); - const auth = new GoogleAuth(); - const projectId = await auth.getProjectId(); - if (projectId) { - config.gcpProjectId = projectId; - core.info(`Detected project ID from Application Default Credentials: ${projectId}`); - } - } catch (error) { - core.debug(`Could not detect project from ADC: ${error.message}`); - } + config.gcpProjectId = await detectProjectFromADC(); } // Validate configuration @@ -382,14 +458,11 @@ function createMeterProvider(config) { } /** - * Records metrics for collected workflow data - * @param {Object} meter - OpenTelemetry meter + * Builds base attributes for metrics from collected data * @param {Object} metrics - Collected metrics from GitHub - * @param {string} metricPrefix - Metric name prefix + * @returns {Object} Base attributes object */ -function recordMetrics(meter, metrics, metricPrefix) { - core.info('Recording metrics to OpenTelemetry'); - +function buildBaseAttributes(metrics) { const baseAttributes = { 'workflow.name': metrics.workflow, 'job.name': metrics.job.name, @@ -405,7 +478,7 @@ function recordMetrics(meter, metrics, metricPrefix) { 'event.actor': metrics.event.actor, }; - // Add optional attributes if present + // Add optional git attributes if (metrics.git.refName) { baseAttributes['git.ref_name'] = metrics.git.refName; } @@ -426,27 +499,27 @@ function recordMetrics(meter, metrics, metricPrefix) { if (metrics.runner.name) { baseAttributes['runner.name'] = metrics.runner.name; } - // Add primary runner label (usually indicates size like 'ubuntu-latest', 'ubuntu-4-core', etc.) if (metrics.runner.labels && metrics.runner.labels.length > 0) { baseAttributes['runner.label'] = metrics.runner.labels[0]; } } - // Create meters for step metrics - const stepDurationName = `${metricPrefix}.step.duration`; - const stepDurationHistogram = meter.createHistogram(stepDurationName, { - description: 'Duration of workflow steps in milliseconds', - unit: 'ms', - }); - core.debug(`Created histogram metric: ${stepDurationName}`); + return baseAttributes; +} - // Record job-level metrics (always record, even if job not fully complete) +/** + * Records job-level metrics + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordJobMetrics(meter, metrics, metricPrefix, baseAttributes) { const jobDurationHistogram = meter.createHistogram(`${metricPrefix}.job.duration`, { description: 'Duration of workflow jobs in milliseconds', unit: 'ms', }); - // Calculate estimated cost if we have runner info const jobAttributes = { ...baseAttributes, 'job.status': metrics.job.status, @@ -454,21 +527,9 @@ function recordMetrics(meter, metrics, metricPrefix) { }; jobDurationHistogram.record(metrics.job.durationMs, jobAttributes); - core.info(`Recorded job duration: ${metrics.job.durationMs}ms`); - // Record repository size if available - if (metrics.repository.sizeKB) { - const repoSizeGauge = meter.createGauge(`${metricPrefix}.repo.size`, { - description: 'Repository size in kilobytes', - unit: 'KB', - }); - - repoSizeGauge.record(metrics.repository.sizeKB, baseAttributes); - core.info(`Recorded repository size: ${metrics.repository.sizeKB} KB`); - } - - // Record estimated cost as a separate metric + // Record estimated cost if we have runner info if (metrics.runner && metrics.runner.os) { const runnerLabel = metrics.runner.labels && metrics.runner.labels.length > 0 ? metrics.runner.labels[0] : null; core.debug(`Calculating cost for runner: OS=${metrics.runner.os}, label=${runnerLabel}, duration=${metrics.job.durationMs}ms`); @@ -491,15 +552,41 @@ function recordMetrics(meter, metrics, metricPrefix) { } else { core.debug(`Not recording cost metric: runner.os=${metrics.runner?.os}, runner exists=${!!metrics.runner}`); } +} - // Record artifact metrics if available +/** + * Records repository size metric + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordRepositorySizeMetric(meter, metrics, metricPrefix, baseAttributes) { + if (metrics.repository.sizeKB) { + const repoSizeGauge = meter.createGauge(`${metricPrefix}.repo.size`, { + description: 'Repository size in kilobytes', + unit: 'KB', + }); + + repoSizeGauge.record(metrics.repository.sizeKB, baseAttributes); + core.info(`Recorded repository size: ${metrics.repository.sizeKB} KB`); + } +} + +/** + * Records artifact metrics + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordArtifactMetrics(meter, metrics, metricPrefix, baseAttributes) { if (metrics.artifacts && metrics.artifacts.count > 0) { const artifactSizeHistogram = meter.createHistogram(`${metricPrefix}.artifact.size`, { description: 'Size of individual workflow artifacts in bytes', unit: 'bytes', }); - // Record each artifact separately with its name for (const artifact of metrics.artifacts.artifacts) { const artifactAttributes = { ...baseAttributes, @@ -512,8 +599,23 @@ function recordMetrics(meter, metrics, metricPrefix) { core.info(`Recorded ${metrics.artifacts.count} artifact metrics (total: ${metrics.artifacts.totalBytes} bytes)`); } +} + +/** + * Records step-level metrics + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordStepMetrics(meter, metrics, metricPrefix, baseAttributes) { + const stepDurationName = `${metricPrefix}.step.duration`; + const stepDurationHistogram = meter.createHistogram(stepDurationName, { + description: 'Duration of workflow steps in milliseconds', + unit: 'ms', + }); + core.debug(`Created histogram metric: ${stepDurationName}`); - // Record step-level metrics for (const step of metrics.steps) { const stepAttributes = { ...baseAttributes, @@ -523,7 +625,6 @@ function recordMetrics(meter, metrics, metricPrefix) { 'step.conclusion': step.conclusion || 'unknown', }; - // Record step duration if (step.durationMs > 0) { stepDurationHistogram.record(step.durationMs, stepAttributes); core.info(`Recorded step "${step.name}" duration: ${step.durationMs}ms`); @@ -533,6 +634,23 @@ function recordMetrics(meter, metrics, metricPrefix) { core.info(`Recorded metrics for ${metrics.steps.length} steps`); } +/** + * Records metrics for collected workflow data + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics from GitHub + * @param {string} metricPrefix - Metric name prefix + */ +function recordMetrics(meter, metrics, metricPrefix) { + core.info('Recording metrics to OpenTelemetry'); + + const baseAttributes = buildBaseAttributes(metrics); + + recordJobMetrics(meter, metrics, metricPrefix, baseAttributes); + recordRepositorySizeMetric(meter, metrics, metricPrefix, baseAttributes); + recordArtifactMetrics(meter, metrics, metricPrefix, baseAttributes); + recordStepMetrics(meter, metrics, metricPrefix, baseAttributes); +} + /** * Forces metrics export and shuts down the meter provider * @param {Object} meterProvider - MeterProvider instance @@ -599,14 +717,11 @@ function createTracerProvider(config) { } /** - * Records traces for collected workflow data - * @param {Object} tracer - OpenTelemetry tracer + * Builds base attributes for traces from collected data * @param {Object} metrics - Collected metrics from GitHub - * @returns {Object} Root span for the job + * @returns {Object} Base attributes object for traces */ -function recordTraces(tracer, metrics) { - core.info('Recording traces to OpenTelemetry'); - +function buildTraceBaseAttributes(metrics) { const baseAttributes = { 'workflow.name': metrics.workflow, 'repository.owner': metrics.repository.owner, @@ -621,7 +736,7 @@ function recordTraces(tracer, metrics) { 'event.actor': metrics.event.actor, }; - // Add optional attributes if present + // Add optional attributes if (metrics.git.refName) { baseAttributes['git.ref_name'] = metrics.git.refName; } @@ -649,8 +764,18 @@ function recordTraces(tracer, metrics) { } } - // Create a span for the entire job - const jobSpan = tracer.startSpan(`Job: ${metrics.job.name}`, { + return baseAttributes; +} + +/** + * Creates a job span with appropriate attributes + * @param {Object} tracer - OpenTelemetry tracer + * @param {Object} metrics - Collected metrics + * @param {Object} baseAttributes - Base attributes for the span + * @returns {Object} Job span + */ +function createJobSpan(tracer, metrics, baseAttributes) { + return tracer.startSpan(`Job: ${metrics.job.name}`, { startTime: metrics.job.startedAt, attributes: { ...baseAttributes, @@ -660,14 +785,18 @@ function recordTraces(tracer, metrics) { 'job.conclusion': metrics.job.conclusion || 'unknown', }, }); +} - // Set job span as active in context for creating child spans - const jobContext = trace.setSpan(context.active(), jobSpan); - - // Create child spans for each step within job context +/** + * Creates step spans as children of the job span + * @param {Object} tracer - OpenTelemetry tracer + * @param {Object} metrics - Collected metrics + * @param {Object} baseAttributes - Base attributes for spans + * @param {Object} jobContext - Job context for creating child spans + */ +function createStepSpans(tracer, metrics, baseAttributes, jobContext) { for (const step of metrics.steps) { if (step.startedAt && step.completedAt) { - // Start span within the job context (makes it a child of jobSpan) const stepSpan = tracer.startSpan( `Step: ${step.name}`, { @@ -681,7 +810,7 @@ function recordTraces(tracer, metrics) { 'step.conclusion': step.conclusion || 'unknown', }, }, - jobContext // Use job context to make this a child span + jobContext ); // Mark span as error if step failed @@ -694,6 +823,25 @@ function recordTraces(tracer, metrics) { core.debug(`Created span for step: ${step.name}`); } } +} + +/** + * Records traces for collected workflow data + * @param {Object} tracer - OpenTelemetry tracer + * @param {Object} metrics - Collected metrics from GitHub + * @returns {Object} Root span for the job + */ +function recordTraces(tracer, metrics) { + core.info('Recording traces to OpenTelemetry'); + + const baseAttributes = buildTraceBaseAttributes(metrics); + const jobSpan = createJobSpan(tracer, metrics, baseAttributes); + + // Set job span as active in context for creating child spans + const jobContext = trace.setSpan(context.active(), jobSpan); + + // Create child spans for each step + createStepSpans(tracer, metrics, baseAttributes, jobContext); // End the job span if (metrics.job.completedAt) { @@ -24946,7 +25094,7 @@ Object.defineProperty(exports, "subchannelAddressToString", ({ enumerable: true, Object.defineProperty(exports, "endpointToString", ({ enumerable: true, get: function () { return subchannel_address_1.endpointToString; } })); Object.defineProperty(exports, "endpointHasAddress", ({ enumerable: true, get: function () { return subchannel_address_1.endpointHasAddress; } })); Object.defineProperty(exports, "EndpointMap", ({ enumerable: true, get: function () { return subchannel_address_1.EndpointMap; } })); -var load_balancer_child_handler_1 = __nccwpck_require__(21450); +var load_balancer_child_handler_1 = __nccwpck_require__(99069); Object.defineProperty(exports, "ChildLoadBalancerHandler", ({ enumerable: true, get: function () { return load_balancer_child_handler_1.ChildLoadBalancerHandler; } })); var picker_1 = __nccwpck_require__(71663); Object.defineProperty(exports, "UnavailablePicker", ({ enumerable: true, get: function () { return picker_1.UnavailablePicker; } })); @@ -26158,7 +26306,7 @@ exports.InternalChannel = InternalChannel; /***/ }), -/***/ 21450: +/***/ 99069: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; @@ -26346,7 +26494,7 @@ const constants_1 = __nccwpck_require__(68288); const duration_1 = __nccwpck_require__(63929); const experimental_1 = __nccwpck_require__(20079); const load_balancer_1 = __nccwpck_require__(7000); -const load_balancer_child_handler_1 = __nccwpck_require__(21450); +const load_balancer_child_handler_1 = __nccwpck_require__(99069); const picker_1 = __nccwpck_require__(71663); const subchannel_address_1 = __nccwpck_require__(97021); const subchannel_interface_1 = __nccwpck_require__(70098); @@ -30561,7 +30709,7 @@ const metadata_1 = __nccwpck_require__(36100); const logging = __nccwpck_require__(8536); const constants_2 = __nccwpck_require__(68288); const uri_parser_1 = __nccwpck_require__(56027); -const load_balancer_child_handler_1 = __nccwpck_require__(21450); +const load_balancer_child_handler_1 = __nccwpck_require__(99069); const TRACER_NAME = 'resolving_load_balancer'; function trace(text) { logging.trace(constants_2.LogVerbosity.DEBUG, TRACER_NAME, text); @@ -66342,7 +66490,7 @@ exports.colors = [6, 2, 3, 4, 5, 1]; try { // Optional dependency (as in, doesn't need to be installed, NOT like optionalDependencies in package.json) // eslint-disable-next-line import/no-extraneous-dependencies - const supportsColor = __nccwpck_require__(60075); + const supportsColor = __nccwpck_require__(21450); if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) { exports.colors = [ @@ -90094,6 +90242,22 @@ function getNextRetryDelay(config) { } //# sourceMappingURL=retry.js.map +/***/ }), + +/***/ 83813: +/***/ ((module) => { + +"use strict"; + + +module.exports = (flag, argv = process.argv) => { + const prefix = flag.startsWith('-') ? '' : (flag.length === 1 ? '-' : '--'); + const position = argv.indexOf(prefix + flag); + const terminatorPosition = argv.indexOf('--'); + return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition); +}; + + /***/ }), /***/ 23336: @@ -106593,6 +106757,149 @@ module.exports = function getSideChannel() { }; +/***/ }), + +/***/ 21450: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +"use strict"; + +const os = __nccwpck_require__(70857); +const tty = __nccwpck_require__(52018); +const hasFlag = __nccwpck_require__(83813); + +const {env} = process; + +let forceColor; +if (hasFlag('no-color') || + hasFlag('no-colors') || + hasFlag('color=false') || + hasFlag('color=never')) { + forceColor = 0; +} else if (hasFlag('color') || + hasFlag('colors') || + hasFlag('color=true') || + hasFlag('color=always')) { + forceColor = 1; +} + +if ('FORCE_COLOR' in env) { + if (env.FORCE_COLOR === 'true') { + forceColor = 1; + } else if (env.FORCE_COLOR === 'false') { + forceColor = 0; + } else { + forceColor = env.FORCE_COLOR.length === 0 ? 1 : Math.min(parseInt(env.FORCE_COLOR, 10), 3); + } +} + +function translateLevel(level) { + if (level === 0) { + return false; + } + + return { + level, + hasBasic: true, + has256: level >= 2, + has16m: level >= 3 + }; +} + +function supportsColor(haveStream, streamIsTTY) { + if (forceColor === 0) { + return 0; + } + + if (hasFlag('color=16m') || + hasFlag('color=full') || + hasFlag('color=truecolor')) { + return 3; + } + + if (hasFlag('color=256')) { + return 2; + } + + if (haveStream && !streamIsTTY && forceColor === undefined) { + return 0; + } + + const min = forceColor || 0; + + if (env.TERM === 'dumb') { + return min; + } + + if (process.platform === 'win32') { + // Windows 10 build 10586 is the first Windows release that supports 256 colors. + // Windows 10 build 14931 is the first release that supports 16m/TrueColor. + const osRelease = os.release().split('.'); + if ( + Number(osRelease[0]) >= 10 && + Number(osRelease[2]) >= 10586 + ) { + return Number(osRelease[2]) >= 14931 ? 3 : 2; + } + + return 1; + } + + if ('CI' in env) { + if (['TRAVIS', 'CIRCLECI', 'APPVEYOR', 'GITLAB_CI', 'GITHUB_ACTIONS', 'BUILDKITE'].some(sign => sign in env) || env.CI_NAME === 'codeship') { + return 1; + } + + return min; + } + + if ('TEAMCITY_VERSION' in env) { + return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0; + } + + if (env.COLORTERM === 'truecolor') { + return 3; + } + + if ('TERM_PROGRAM' in env) { + const version = parseInt((env.TERM_PROGRAM_VERSION || '').split('.')[0], 10); + + switch (env.TERM_PROGRAM) { + case 'iTerm.app': + return version >= 3 ? 3 : 2; + case 'Apple_Terminal': + return 2; + // No default + } + } + + if (/-256(color)?$/i.test(env.TERM)) { + return 2; + } + + if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) { + return 1; + } + + if ('COLORTERM' in env) { + return 1; + } + + return min; +} + +function getSupportLevel(stream) { + const level = supportsColor(stream, stream && stream.isTTY); + return translateLevel(level); +} + +module.exports = { + supportsColor: getSupportLevel, + stdout: translateLevel(supportsColor(true, tty.isatty(1))), + stderr: translateLevel(supportsColor(true, tty.isatty(2))) +}; + + /***/ }), /***/ 1552: @@ -132153,14 +132460,6 @@ function wrappy (fn, cb) { module.exports = eval("require")("encoding"); -/***/ }), - -/***/ 60075: -/***/ ((module) => { - -module.exports = eval("require")("supports-color"); - - /***/ }), /***/ 42613: diff --git a/dist/post/index.js b/dist/post/index.js index c0a8ac5..e110e75 100644 --- a/dist/post/index.js +++ b/dist/post/index.js @@ -4,9 +4,198 @@ /***/ 70219: /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { -const github = __nccwpck_require__(93228); const core = __nccwpck_require__(37484); +/** + * Finds the current job from the list of jobs + * @param {Array} jobs - List of jobs + * @param {string} currentJobName - Name of the current job + * @returns {Object} The current job or first job as fallback + */ +function findCurrentJob(jobs, currentJobName) { + const currentJob = jobs.find(job => job.name === currentJobName); + + if (!currentJob) { + core.warning(`Could not find current job "${currentJobName}". Using first job as fallback.`); + } + + const job = currentJob || jobs[0]; + + if (!job) { + throw new Error('No jobs found for this workflow run'); + } + + return job; +} + +/** + * Fetches repository size from GitHub API + * @param {Object} octokit - Authenticated Octokit instance + * @param {string} owner - Repository owner + * @param {string} repo - Repository name + * @returns {Promise} Repository size in KB or null if unavailable + */ +async function fetchRepositorySize(octokit, owner, repo) { + try { + const { data: repoData } = await octokit.rest.repos.get({ + owner, + repo, + }); + const size = repoData.size; // Size in KB + core.debug(`Repository size: ${size} KB`); + return size; + } catch (error) { + core.debug(`Could not fetch repository size: ${error.message}`); + return null; + } +} + +/** + * Parses job steps and calculates their durations + * @param {Array} rawSteps - Raw step data from GitHub API + * @returns {Array} Parsed steps with calculated durations + */ +function parseSteps(rawSteps) { + return (rawSteps || []).map(step => { + const startedAt = step.started_at ? new Date(step.started_at) : null; + const completedAt = step.completed_at ? new Date(step.completed_at) : null; + const durationMs = startedAt && completedAt ? completedAt - startedAt : 0; + + return { + name: step.name, + status: step.status, + conclusion: step.conclusion, + number: step.number, + startedAt, + completedAt, + durationMs, + }; + }); +} + +/** + * Calculates job duration, estimating if job is not yet complete + * @param {Object} job - Job data from GitHub API + * @returns {Object} Job timing information + */ +function calculateJobDuration(job) { + const jobStartedAt = job.started_at ? new Date(job.started_at) : new Date(); + const jobCompletedAt = job.completed_at ? new Date(job.completed_at) : new Date(); + const jobDurationMs = jobCompletedAt - jobStartedAt; + + if (!job.completed_at) { + core.debug(`Job not marked complete yet, estimating duration: ${jobDurationMs}ms`); + } + + return { jobStartedAt, jobCompletedAt, jobDurationMs }; +} + +/** + * Infers job conclusion from completed steps + * @param {string} jobConclusion - Original job conclusion + * @param {Array} steps - Parsed steps + * @returns {string} Inferred or original job conclusion + */ +function inferJobConclusion(jobConclusion, steps) { + // Return early if we already have a valid conclusion + if (jobConclusion && jobConclusion !== 'unknown') { + return jobConclusion; + } + + // Only look at completed steps (ignore pending/in-progress post-action steps) + const completedSteps = steps.filter(s => s.conclusion !== null); + + const hasFailure = completedSteps.some(s => s.conclusion === 'failure'); + const hasCancelled = completedSteps.some(s => s.conclusion === 'cancelled'); + + if (hasFailure) { + core.debug('Inferred job conclusion as "failure" based on failed steps'); + return 'failure'; + } else if (hasCancelled) { + core.debug('Inferred job conclusion as "cancelled" based on cancelled steps'); + return 'cancelled'; + } else if (completedSteps.length > 0 && completedSteps.every(s => s.conclusion === 'success' || s.conclusion === 'skipped')) { + core.debug(`Inferred job conclusion as "success" based on ${completedSteps.length} completed steps`); + return 'success'; + } else { + core.debug(`Could not infer job conclusion from steps (${completedSteps.length} completed steps)`); + return 'unknown'; + } +} + +/** + * Extracts PR number from context + * @param {Object} context - GitHub context + * @returns {string|null} PR number or null + */ +function extractPRNumber(context) { + return context.payload?.pull_request?.number || + (process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1]) || + null; +} + +/** + * Builds the metrics object from collected data + * @param {Object} params - Parameters for building metrics + * @returns {Object} Complete metrics object + */ +function buildMetricsObject({ + context, + job, + jobStartedAt, + jobCompletedAt, + jobDurationMs, + jobConclusion, + steps, + repoSize, + prNumber +}) { + const { owner, repo } = context.repo; + + return { + workflow: context.workflow, + job: { + name: job.name, + id: job.id, + status: job.status || 'in_progress', + conclusion: jobConclusion, + startedAt: jobStartedAt, + completedAt: jobCompletedAt, + durationMs: jobDurationMs, + }, + steps, + repository: { + owner, + repo, + fullName: `${owner}/${repo}`, + sizeKB: repoSize, + }, + run: { + id: context.runId, + number: context.runNumber, + attempt: process.env.GITHUB_RUN_ATTEMPT || '1', + }, + git: { + sha: context.sha || process.env.GITHUB_SHA, + ref: context.ref || process.env.GITHUB_REF, + refName: process.env.GITHUB_REF_NAME || null, + baseRef: process.env.GITHUB_BASE_REF || null, + headRef: process.env.GITHUB_HEAD_REF || null, + }, + event: { + name: context.eventName || process.env.GITHUB_EVENT_NAME, + actor: context.actor || process.env.GITHUB_ACTOR, + prNumber: prNumber, + }, + runner: { + os: process.env.RUNNER_OS || 'unknown', + arch: process.env.RUNNER_ARCH || 'unknown', + name: process.env.RUNNER_NAME || null, + labels: job.labels || [], + }, + }; +} + /** * Collects metrics from GitHub Actions workflow * @param {Object} octokit - Authenticated Octokit instance @@ -29,135 +218,38 @@ async function collectMetrics(octokit, context) { core.debug(`Found ${jobs.jobs.length} jobs`); - // Find the current job by matching the job name or ID - // GitHub Actions doesn't expose the current job ID directly, so we try to match by name + // Find the current job const currentJobName = process.env.GITHUB_JOB; - const currentJob = jobs.jobs.find(job => job.name === currentJobName); - - if (!currentJob) { - core.warning(`Could not find current job "${currentJobName}". Using first job as fallback.`); - } - - const job = currentJob || jobs.jobs[0]; - - if (!job) { - throw new Error('No jobs found for this workflow run'); - } - + const job = findCurrentJob(jobs.jobs, currentJobName); core.info(`Analyzing job: ${job.name} (${job.id})`); // Fetch repository size - let repoSize = null; - try { - const { data: repoData } = await octokit.rest.repos.get({ - owner, - repo, - }); - repoSize = repoData.size; // Size in KB - core.debug(`Repository size: ${repoSize} KB`); - } catch (error) { - core.debug(`Could not fetch repository size: ${error.message}`); - } + const repoSize = await fetchRepositorySize(octokit, owner, repo); // Parse steps and calculate durations - const steps = (job.steps || []).map(step => { - const startedAt = step.started_at ? new Date(step.started_at) : null; - const completedAt = step.completed_at ? new Date(step.completed_at) : null; - const durationMs = startedAt && completedAt ? completedAt - startedAt : 0; - - return { - name: step.name, - status: step.status, - conclusion: step.conclusion, - number: step.number, - startedAt, - completedAt, - durationMs, - }; - }); + const steps = parseSteps(job.steps); // Calculate job duration - // Post-action runs before job is marked complete, so estimate using current time - const jobStartedAt = job.started_at ? new Date(job.started_at) : new Date(); - const jobCompletedAt = job.completed_at ? new Date(job.completed_at) : new Date(); - const jobDurationMs = jobCompletedAt - jobStartedAt; + const { jobStartedAt, jobCompletedAt, jobDurationMs } = calculateJobDuration(job); - if (!job.completed_at) { - core.debug(`Job not marked complete yet, estimating duration: ${jobDurationMs}ms`); - } + // Infer job conclusion from steps if not set + const jobConclusion = inferJobConclusion(job.conclusion, steps); - // Infer job conclusion from steps if not set (post-action runs before job completes) - let jobConclusion = job.conclusion; - if (!jobConclusion || jobConclusion === 'unknown') { - // Only look at completed steps (ignore pending/in-progress post-action steps) - const completedSteps = steps.filter(s => s.conclusion !== null); + // Extract PR number if available + const prNumber = extractPRNumber(context); - const hasFailure = completedSteps.some(s => s.conclusion === 'failure'); - const hasCancelled = completedSteps.some(s => s.conclusion === 'cancelled'); - - if (hasFailure) { - jobConclusion = 'failure'; - core.debug('Inferred job conclusion as "failure" based on failed steps'); - } else if (hasCancelled) { - jobConclusion = 'cancelled'; - core.debug('Inferred job conclusion as "cancelled" based on cancelled steps'); - } else if (completedSteps.length > 0 && completedSteps.every(s => s.conclusion === 'success' || s.conclusion === 'skipped')) { - jobConclusion = 'success'; - core.debug(`Inferred job conclusion as "success" based on ${completedSteps.length} completed steps`); - } else { - jobConclusion = 'unknown'; - core.debug(`Could not infer job conclusion from steps (${completedSteps.length} completed steps)`); - } - } - - // Extract PR number if this is a pull request event - const prNumber = context.payload?.pull_request?.number || - (process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\/merge/)?.[1]) || - null; - - const metrics = { - workflow: context.workflow, - job: { - name: job.name, - id: job.id, - status: job.status || 'in_progress', - conclusion: jobConclusion, - startedAt: jobStartedAt, - completedAt: jobCompletedAt, - durationMs: jobDurationMs, - }, + // Build and return the metrics object + const metrics = buildMetricsObject({ + context, + job, + jobStartedAt, + jobCompletedAt, + jobDurationMs, + jobConclusion, steps, - repository: { - owner, - repo, - fullName: `${owner}/${repo}`, - sizeKB: repoSize, - }, - run: { - id: runId, - number: context.runNumber, - attempt: process.env.GITHUB_RUN_ATTEMPT || '1', - }, - git: { - sha: context.sha || process.env.GITHUB_SHA, - ref: context.ref || process.env.GITHUB_REF, - refName: process.env.GITHUB_REF_NAME || null, - baseRef: process.env.GITHUB_BASE_REF || null, - headRef: process.env.GITHUB_HEAD_REF || null, - }, - event: { - name: context.eventName || process.env.GITHUB_EVENT_NAME, - actor: context.actor || process.env.GITHUB_ACTOR, - prNumber: prNumber, - }, - runner: { - os: process.env.RUNNER_OS || 'unknown', - arch: process.env.RUNNER_ARCH || 'unknown', - name: process.env.RUNNER_NAME || null, - // Runner size/type from job labels (if available) - labels: job.labels || [], - }, - }; + repoSize, + prNumber + }); core.debug(`Collected metrics: ${JSON.stringify(metrics, null, 2)}`); return metrics; @@ -246,6 +338,83 @@ async function checkRepositoryVisibility(token) { } } +/** + * Fetches IAM policy for a project + * @param {Object} credentials - Service account credentials + * @param {string} projectId - GCP project ID + * @returns {Promise} IAM policy + */ +async function fetchIAMPolicy(credentials, projectId) { + const auth = new GoogleAuth({ + credentials, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + const client = await auth.getClient(); + + const url = `https://cloudresourcemanager.googleapis.com/v1/projects/${projectId}:getIamPolicy`; + const response = await client.request({ + url, + method: 'POST', + data: {}, + headers: { + 'Content-Type': 'application/json', + }, + }); + + return response.data; +} + +/** + * Extracts roles assigned to a service account from IAM policy + * @param {Object} policy - IAM policy + * @param {string} serviceAccountEmail - Service account email + * @returns {Array} List of roles + */ +function extractServiceAccountRoles(policy, serviceAccountEmail) { + const bindings = policy.bindings || []; + const serviceAccountRoles = []; + const memberString = `serviceAccount:${serviceAccountEmail}`; + + for (const binding of bindings) { + if (binding.members && binding.members.includes(memberString)) { + serviceAccountRoles.push(binding.role); + } + } + + return serviceAccountRoles; +} + +/** + * Logs security error for excessive permissions + * @param {string} projectId - GCP project ID + * @param {string} serviceAccountEmail - Service account email + * @param {Array} excessiveRoles - List of excessive roles + */ +function logExcessivePermissionsError(projectId, serviceAccountEmail, excessiveRoles) { + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error('⚠️ SECURITY ERROR: Service account has excessive permissions!'); + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error(''); + core.error(`Service account: ${serviceAccountEmail}`); + core.error(`Project: ${projectId}`); + core.error(''); + core.error('Excessive roles detected:'); + excessiveRoles.forEach(role => core.error(` - ${role}`)); + core.error(''); + core.error('This service account should ONLY have:'); + core.error(' - roles/monitoring.metricWriter (for metrics)'); + core.error(' - roles/cloudtrace.agent (for traces)'); + core.error(''); + core.error('To fix, remove excessive roles:'); + excessiveRoles.forEach(role => { + core.error(` gcloud projects remove-iam-policy-binding ${projectId} \\`); + core.error(` --member="serviceAccount:${serviceAccountEmail}" \\`); + core.error(` --role="${role}"`); + }); + core.error(''); +} + /** * Checks if a service account has excessive permissions * @param {string} projectId - GCP project ID @@ -257,36 +426,8 @@ async function checkServiceAccountPermissions(projectId, serviceAccountEmail, cr try { core.info('Checking service account IAM permissions...'); - const auth = new GoogleAuth({ - credentials, - scopes: ['https://www.googleapis.com/auth/cloud-platform'], - }); - - const client = await auth.getClient(); - - // Get project IAM policy using v1 API with POST - const url = `https://cloudresourcemanager.googleapis.com/v1/projects/${projectId}:getIamPolicy`; - const response = await client.request({ - url, - method: 'POST', - data: {}, - headers: { - 'Content-Type': 'application/json', - }, - }); - - const policy = response.data; - const bindings = policy.bindings || []; - - // Find all roles assigned to this service account - const serviceAccountRoles = []; - const memberString = `serviceAccount:${serviceAccountEmail}`; - - for (const binding of bindings) { - if (binding.members && binding.members.includes(memberString)) { - serviceAccountRoles.push(binding.role); - } - } + const policy = await fetchIAMPolicy(credentials, projectId); + const serviceAccountRoles = extractServiceAccountRoles(policy, serviceAccountEmail); core.info(`Service account has ${serviceAccountRoles.length} role(s): ${serviceAccountRoles.join(', ')}`); @@ -298,27 +439,7 @@ async function checkServiceAccountPermissions(projectId, serviceAccountEmail, cr const excessiveRoles = serviceAccountRoles.filter(role => !allowedRoles.includes(role)); if (excessiveRoles.length > 0) { - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error('⚠️ SECURITY ERROR: Service account has excessive permissions!'); - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error(''); - core.error(`Service account: ${serviceAccountEmail}`); - core.error(`Project: ${projectId}`); - core.error(''); - core.error('Excessive roles detected:'); - excessiveRoles.forEach(role => core.error(` - ${role}`)); - core.error(''); - core.error('This service account should ONLY have:'); - core.error(' - roles/monitoring.metricWriter (for metrics)'); - core.error(' - roles/cloudtrace.agent (for traces)'); - core.error(''); - core.error('To fix, remove excessive roles:'); - excessiveRoles.forEach(role => { - core.error(` gcloud projects remove-iam-policy-binding ${projectId} \\`); - core.error(` --member="serviceAccount:${serviceAccountEmail}" \\`); - core.error(` --role="${role}"`); - }); - core.error(''); + logExcessivePermissionsError(projectId, serviceAccountEmail, excessiveRoles); throw new Error('Service account has excessive permissions - refusing to use'); } @@ -342,6 +463,120 @@ async function checkServiceAccountPermissions(projectId, serviceAccountEmail, cr } } +/** + * Validates repository security for service account key file usage + * @param {string} token - GitHub token + * @returns {Promise} + */ +async function validateRepositorySecurity(token) { + let repoVisibility = null; + + if (token) { + core.debug('Checking repository visibility via GitHub API...'); + repoVisibility = await checkRepositoryVisibility(token); + } + + const isPublic = repoVisibility === 'public'; + const isPrivate = repoVisibility === 'private' || repoVisibility === 'internal'; + + if (isPublic) { + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error('⚠️ SECURITY ERROR: Service account key in PUBLIC repository!'); + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error(''); + core.error('Service account key files should NEVER be committed to public repositories.'); + core.error(''); + core.error('Options:'); + core.error(' 1. Make this repository private'); + core.error(' 2. Use GitHub Secrets instead of committing the key file'); + core.error(' 3. Use Workload Identity Federation (recommended)'); + core.error(''); + throw new Error('Refusing to use service account key file in public repository'); + } + + if (isPrivate) { + core.info(`✓ Repository is ${repoVisibility} - safe to use service account key file`); + } else { + core.warning(`Could not determine repository visibility (got: ${repoVisibility})`); + core.warning('⚠️ WARNING: Proceeding without confirming repository is private'); + core.warning('Service account key files should ONLY be used in private repositories!'); + } +} + +/** + * Reads service account key from file + * @param {string} serviceAccountKeyFile - Path to service account key file + * @returns {string} Service account key content + */ +function readServiceAccountKeyFile(serviceAccountKeyFile) { + try { + const filePath = path.resolve(serviceAccountKeyFile); + core.info(`Reading service account key from: ${filePath}`); + return fs.readFileSync(filePath, 'utf8'); + } catch (error) { + core.error(`Failed to read service account key file: ${error.message}`); + throw new Error(`Cannot read service account key file: ${serviceAccountKeyFile}`); + } +} + +/** + * Parses and extracts information from service account key + * @param {string} serviceAccountKey - Service account key JSON string + * @returns {Object} Parsed key data with project ID and email + */ +function parseServiceAccountKey(serviceAccountKey) { + try { + const keyData = JSON.parse(serviceAccountKey); + + // Validate key structure + if (!keyData.private_key || !keyData.client_email || !keyData.project_id) { + core.warning('Service account key appears to be incomplete or malformed'); + } + + return { + keyData, + projectId: keyData.project_id, + email: keyData.client_email + }; + } catch (error) { + core.warning(`Could not parse service account key: ${error.message}`); + return { keyData: null, projectId: null, email: null }; + } +} + +/** + * Tries to detect GCP project ID from environment variables + * @returns {string|null} Project ID or null + */ +function detectProjectFromEnvironment() { + const envProject = process.env.GOOGLE_CLOUD_PROJECT || + process.env.GCLOUD_PROJECT || + process.env.GCP_PROJECT; + if (envProject) { + core.info(`Using project ID from environment: ${envProject}`); + return envProject; + } + return null; +} + +/** + * Tries to detect GCP project ID from Application Default Credentials + * @returns {Promise} Project ID or null + */ +async function detectProjectFromADC() { + try { + const auth = new GoogleAuth(); + const projectId = await auth.getProjectId(); + if (projectId) { + core.info(`Detected project ID from Application Default Credentials: ${projectId}`); + return projectId; + } + } catch (error) { + core.debug(`Could not detect project from ADC: ${error.message}`); + } + return null; +} + /** * Parses and validates action configuration from inputs * @returns {Object} Configuration object @@ -352,116 +587,49 @@ async function getConfig() { // Read service account key from file if provided if (serviceAccountKeyFile) { - // Check if repository is private when using service account key file const token = core.getInput('github-token'); - let repoVisibility = null; - - if (token) { - core.debug('Checking repository visibility via GitHub API...'); - repoVisibility = await checkRepositoryVisibility(token); - } - - // Determine if repo is public - const isPublic = repoVisibility === 'public'; - const isPrivate = repoVisibility === 'private' || repoVisibility === 'internal'; - - if (isPublic) { - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error('⚠️ SECURITY ERROR: Service account key in PUBLIC repository!'); - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error(''); - core.error('Service account key files should NEVER be committed to public repositories.'); - core.error(''); - core.error('Options:'); - core.error(' 1. Make this repository private'); - core.error(' 2. Use GitHub Secrets instead of committing the key file'); - core.error(' 3. Use Workload Identity Federation (recommended)'); - core.error(''); - throw new Error('Refusing to use service account key file in public repository'); - } - - if (isPrivate) { - core.info(`✓ Repository is ${repoVisibility} - safe to use service account key file`); - } else { - core.warning(`Could not determine repository visibility (got: ${repoVisibility})`); - core.warning('⚠️ WARNING: Proceeding without confirming repository is private'); - core.warning('Service account key files should ONLY be used in private repositories!'); - } - try { - const filePath = path.resolve(serviceAccountKeyFile); - core.info(`Reading service account key from: ${filePath}`); - serviceAccountKey = fs.readFileSync(filePath, 'utf8'); - } catch (error) { - core.error(`Failed to read service account key file: ${error.message}`); - throw new Error(`Cannot read service account key file: ${serviceAccountKeyFile}`); - } + await validateRepositorySecurity(token); + serviceAccountKey = readServiceAccountKeyFile(serviceAccountKeyFile); } - // Determine GCP project ID and validate service account + // Parse service account key and extract information let gcpProjectId = core.getInput('gcp-project-id'); let serviceAccountEmail = null; let keyData = null; - // If not explicitly provided, try to extract from service account key if (serviceAccountKey) { - try { - keyData = JSON.parse(serviceAccountKey); - - // Extract project ID if not provided - if (!gcpProjectId && keyData.project_id) { - gcpProjectId = keyData.project_id; - core.info(`Using project ID from service account key: ${gcpProjectId}`); - } - - // Extract and log service account email - if (keyData.client_email) { - serviceAccountEmail = keyData.client_email; - core.info(`Using service account: ${serviceAccountEmail}`); - } - - // Validate key structure - if (!keyData.private_key || !keyData.client_email || !keyData.project_id) { - core.warning('Service account key appears to be incomplete or malformed'); - } - - } catch (error) { - core.warning(`Could not parse service account key: ${error.message}`); + const parsed = parseServiceAccountKey(serviceAccountKey); + keyData = parsed.keyData; + + if (!gcpProjectId && parsed.projectId) { + gcpProjectId = parsed.projectId; + core.info(`Using project ID from service account key: ${gcpProjectId}`); + } + + if (parsed.email) { + serviceAccountEmail = parsed.email; + core.info(`Using service account: ${serviceAccountEmail}`); } } - const failOnError = core.getBooleanInput('fail-on-error'); - + // Build config object const config = { gcpProjectId, gcpServiceAccountKey: serviceAccountKey, serviceName: core.getInput('service-name') || 'github-actions', serviceNamespace: core.getInput('service-namespace') || 'ci', metricPrefix: core.getInput('metric-prefix') || 'github.actions', - failOnError, + failOnError: core.getBooleanInput('fail-on-error'), }; - // Try to get project from environment (set by google-github-actions/auth) + // Try to get project from environment if (!config.gcpProjectId) { - const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || process.env.GCP_PROJECT; - if (envProject) { - config.gcpProjectId = envProject; - core.info(`Using project ID from environment: ${envProject}`); - } + config.gcpProjectId = detectProjectFromEnvironment(); } // Try to detect project from ADC if (!config.gcpProjectId) { - try { - const { GoogleAuth } = __nccwpck_require__(492); - const auth = new GoogleAuth(); - const projectId = await auth.getProjectId(); - if (projectId) { - config.gcpProjectId = projectId; - core.info(`Detected project ID from Application Default Credentials: ${projectId}`); - } - } catch (error) { - core.debug(`Could not detect project from ADC: ${error.message}`); - } + config.gcpProjectId = await detectProjectFromADC(); } // Validate configuration @@ -596,14 +764,11 @@ function createMeterProvider(config) { } /** - * Records metrics for collected workflow data - * @param {Object} meter - OpenTelemetry meter + * Builds base attributes for metrics from collected data * @param {Object} metrics - Collected metrics from GitHub - * @param {string} metricPrefix - Metric name prefix + * @returns {Object} Base attributes object */ -function recordMetrics(meter, metrics, metricPrefix) { - core.info('Recording metrics to OpenTelemetry'); - +function buildBaseAttributes(metrics) { const baseAttributes = { 'workflow.name': metrics.workflow, 'job.name': metrics.job.name, @@ -619,7 +784,7 @@ function recordMetrics(meter, metrics, metricPrefix) { 'event.actor': metrics.event.actor, }; - // Add optional attributes if present + // Add optional git attributes if (metrics.git.refName) { baseAttributes['git.ref_name'] = metrics.git.refName; } @@ -640,27 +805,27 @@ function recordMetrics(meter, metrics, metricPrefix) { if (metrics.runner.name) { baseAttributes['runner.name'] = metrics.runner.name; } - // Add primary runner label (usually indicates size like 'ubuntu-latest', 'ubuntu-4-core', etc.) if (metrics.runner.labels && metrics.runner.labels.length > 0) { baseAttributes['runner.label'] = metrics.runner.labels[0]; } } - // Create meters for step metrics - const stepDurationName = `${metricPrefix}.step.duration`; - const stepDurationHistogram = meter.createHistogram(stepDurationName, { - description: 'Duration of workflow steps in milliseconds', - unit: 'ms', - }); - core.debug(`Created histogram metric: ${stepDurationName}`); + return baseAttributes; +} - // Record job-level metrics (always record, even if job not fully complete) +/** + * Records job-level metrics + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordJobMetrics(meter, metrics, metricPrefix, baseAttributes) { const jobDurationHistogram = meter.createHistogram(`${metricPrefix}.job.duration`, { description: 'Duration of workflow jobs in milliseconds', unit: 'ms', }); - // Calculate estimated cost if we have runner info const jobAttributes = { ...baseAttributes, 'job.status': metrics.job.status, @@ -668,21 +833,9 @@ function recordMetrics(meter, metrics, metricPrefix) { }; jobDurationHistogram.record(metrics.job.durationMs, jobAttributes); - core.info(`Recorded job duration: ${metrics.job.durationMs}ms`); - // Record repository size if available - if (metrics.repository.sizeKB) { - const repoSizeGauge = meter.createGauge(`${metricPrefix}.repo.size`, { - description: 'Repository size in kilobytes', - unit: 'KB', - }); - - repoSizeGauge.record(metrics.repository.sizeKB, baseAttributes); - core.info(`Recorded repository size: ${metrics.repository.sizeKB} KB`); - } - - // Record estimated cost as a separate metric + // Record estimated cost if we have runner info if (metrics.runner && metrics.runner.os) { const runnerLabel = metrics.runner.labels && metrics.runner.labels.length > 0 ? metrics.runner.labels[0] : null; core.debug(`Calculating cost for runner: OS=${metrics.runner.os}, label=${runnerLabel}, duration=${metrics.job.durationMs}ms`); @@ -705,15 +858,41 @@ function recordMetrics(meter, metrics, metricPrefix) { } else { core.debug(`Not recording cost metric: runner.os=${metrics.runner?.os}, runner exists=${!!metrics.runner}`); } +} - // Record artifact metrics if available +/** + * Records repository size metric + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordRepositorySizeMetric(meter, metrics, metricPrefix, baseAttributes) { + if (metrics.repository.sizeKB) { + const repoSizeGauge = meter.createGauge(`${metricPrefix}.repo.size`, { + description: 'Repository size in kilobytes', + unit: 'KB', + }); + + repoSizeGauge.record(metrics.repository.sizeKB, baseAttributes); + core.info(`Recorded repository size: ${metrics.repository.sizeKB} KB`); + } +} + +/** + * Records artifact metrics + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordArtifactMetrics(meter, metrics, metricPrefix, baseAttributes) { if (metrics.artifacts && metrics.artifacts.count > 0) { const artifactSizeHistogram = meter.createHistogram(`${metricPrefix}.artifact.size`, { description: 'Size of individual workflow artifacts in bytes', unit: 'bytes', }); - // Record each artifact separately with its name for (const artifact of metrics.artifacts.artifacts) { const artifactAttributes = { ...baseAttributes, @@ -726,8 +905,23 @@ function recordMetrics(meter, metrics, metricPrefix) { core.info(`Recorded ${metrics.artifacts.count} artifact metrics (total: ${metrics.artifacts.totalBytes} bytes)`); } +} + +/** + * Records step-level metrics + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordStepMetrics(meter, metrics, metricPrefix, baseAttributes) { + const stepDurationName = `${metricPrefix}.step.duration`; + const stepDurationHistogram = meter.createHistogram(stepDurationName, { + description: 'Duration of workflow steps in milliseconds', + unit: 'ms', + }); + core.debug(`Created histogram metric: ${stepDurationName}`); - // Record step-level metrics for (const step of metrics.steps) { const stepAttributes = { ...baseAttributes, @@ -737,7 +931,6 @@ function recordMetrics(meter, metrics, metricPrefix) { 'step.conclusion': step.conclusion || 'unknown', }; - // Record step duration if (step.durationMs > 0) { stepDurationHistogram.record(step.durationMs, stepAttributes); core.info(`Recorded step "${step.name}" duration: ${step.durationMs}ms`); @@ -747,6 +940,23 @@ function recordMetrics(meter, metrics, metricPrefix) { core.info(`Recorded metrics for ${metrics.steps.length} steps`); } +/** + * Records metrics for collected workflow data + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics from GitHub + * @param {string} metricPrefix - Metric name prefix + */ +function recordMetrics(meter, metrics, metricPrefix) { + core.info('Recording metrics to OpenTelemetry'); + + const baseAttributes = buildBaseAttributes(metrics); + + recordJobMetrics(meter, metrics, metricPrefix, baseAttributes); + recordRepositorySizeMetric(meter, metrics, metricPrefix, baseAttributes); + recordArtifactMetrics(meter, metrics, metricPrefix, baseAttributes); + recordStepMetrics(meter, metrics, metricPrefix, baseAttributes); +} + /** * Forces metrics export and shuts down the meter provider * @param {Object} meterProvider - MeterProvider instance @@ -813,14 +1023,11 @@ function createTracerProvider(config) { } /** - * Records traces for collected workflow data - * @param {Object} tracer - OpenTelemetry tracer + * Builds base attributes for traces from collected data * @param {Object} metrics - Collected metrics from GitHub - * @returns {Object} Root span for the job + * @returns {Object} Base attributes object for traces */ -function recordTraces(tracer, metrics) { - core.info('Recording traces to OpenTelemetry'); - +function buildTraceBaseAttributes(metrics) { const baseAttributes = { 'workflow.name': metrics.workflow, 'repository.owner': metrics.repository.owner, @@ -835,7 +1042,7 @@ function recordTraces(tracer, metrics) { 'event.actor': metrics.event.actor, }; - // Add optional attributes if present + // Add optional attributes if (metrics.git.refName) { baseAttributes['git.ref_name'] = metrics.git.refName; } @@ -863,8 +1070,18 @@ function recordTraces(tracer, metrics) { } } - // Create a span for the entire job - const jobSpan = tracer.startSpan(`Job: ${metrics.job.name}`, { + return baseAttributes; +} + +/** + * Creates a job span with appropriate attributes + * @param {Object} tracer - OpenTelemetry tracer + * @param {Object} metrics - Collected metrics + * @param {Object} baseAttributes - Base attributes for the span + * @returns {Object} Job span + */ +function createJobSpan(tracer, metrics, baseAttributes) { + return tracer.startSpan(`Job: ${metrics.job.name}`, { startTime: metrics.job.startedAt, attributes: { ...baseAttributes, @@ -874,14 +1091,18 @@ function recordTraces(tracer, metrics) { 'job.conclusion': metrics.job.conclusion || 'unknown', }, }); +} - // Set job span as active in context for creating child spans - const jobContext = trace.setSpan(context.active(), jobSpan); - - // Create child spans for each step within job context +/** + * Creates step spans as children of the job span + * @param {Object} tracer - OpenTelemetry tracer + * @param {Object} metrics - Collected metrics + * @param {Object} baseAttributes - Base attributes for spans + * @param {Object} jobContext - Job context for creating child spans + */ +function createStepSpans(tracer, metrics, baseAttributes, jobContext) { for (const step of metrics.steps) { if (step.startedAt && step.completedAt) { - // Start span within the job context (makes it a child of jobSpan) const stepSpan = tracer.startSpan( `Step: ${step.name}`, { @@ -895,7 +1116,7 @@ function recordTraces(tracer, metrics) { 'step.conclusion': step.conclusion || 'unknown', }, }, - jobContext // Use job context to make this a child span + jobContext ); // Mark span as error if step failed @@ -908,6 +1129,25 @@ function recordTraces(tracer, metrics) { core.debug(`Created span for step: ${step.name}`); } } +} + +/** + * Records traces for collected workflow data + * @param {Object} tracer - OpenTelemetry tracer + * @param {Object} metrics - Collected metrics from GitHub + * @returns {Object} Root span for the job + */ +function recordTraces(tracer, metrics) { + core.info('Recording traces to OpenTelemetry'); + + const baseAttributes = buildTraceBaseAttributes(metrics); + const jobSpan = createJobSpan(tracer, metrics, baseAttributes); + + // Set job span as active in context for creating child spans + const jobContext = trace.setSpan(context.active(), jobSpan); + + // Create child spans for each step + createStepSpans(tracer, metrics, baseAttributes, jobContext); // End the job span if (metrics.job.completedAt) { @@ -25160,7 +25400,7 @@ Object.defineProperty(exports, "subchannelAddressToString", ({ enumerable: true, Object.defineProperty(exports, "endpointToString", ({ enumerable: true, get: function () { return subchannel_address_1.endpointToString; } })); Object.defineProperty(exports, "endpointHasAddress", ({ enumerable: true, get: function () { return subchannel_address_1.endpointHasAddress; } })); Object.defineProperty(exports, "EndpointMap", ({ enumerable: true, get: function () { return subchannel_address_1.EndpointMap; } })); -var load_balancer_child_handler_1 = __nccwpck_require__(21450); +var load_balancer_child_handler_1 = __nccwpck_require__(99069); Object.defineProperty(exports, "ChildLoadBalancerHandler", ({ enumerable: true, get: function () { return load_balancer_child_handler_1.ChildLoadBalancerHandler; } })); var picker_1 = __nccwpck_require__(71663); Object.defineProperty(exports, "UnavailablePicker", ({ enumerable: true, get: function () { return picker_1.UnavailablePicker; } })); @@ -26372,7 +26612,7 @@ exports.InternalChannel = InternalChannel; /***/ }), -/***/ 21450: +/***/ 99069: /***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; @@ -26560,7 +26800,7 @@ const constants_1 = __nccwpck_require__(68288); const duration_1 = __nccwpck_require__(63929); const experimental_1 = __nccwpck_require__(20079); const load_balancer_1 = __nccwpck_require__(7000); -const load_balancer_child_handler_1 = __nccwpck_require__(21450); +const load_balancer_child_handler_1 = __nccwpck_require__(99069); const picker_1 = __nccwpck_require__(71663); const subchannel_address_1 = __nccwpck_require__(97021); const subchannel_interface_1 = __nccwpck_require__(70098); @@ -30775,7 +31015,7 @@ const metadata_1 = __nccwpck_require__(36100); const logging = __nccwpck_require__(8536); const constants_2 = __nccwpck_require__(68288); const uri_parser_1 = __nccwpck_require__(56027); -const load_balancer_child_handler_1 = __nccwpck_require__(21450); +const load_balancer_child_handler_1 = __nccwpck_require__(99069); const TRACER_NAME = 'resolving_load_balancer'; function trace(text) { logging.trace(constants_2.LogVerbosity.DEBUG, TRACER_NAME, text); @@ -66556,7 +66796,7 @@ exports.colors = [6, 2, 3, 4, 5, 1]; try { // Optional dependency (as in, doesn't need to be installed, NOT like optionalDependencies in package.json) // eslint-disable-next-line import/no-extraneous-dependencies - const supportsColor = __nccwpck_require__(60075); + const supportsColor = __nccwpck_require__(21450); if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) { exports.colors = [ @@ -90308,6 +90548,22 @@ function getNextRetryDelay(config) { } //# sourceMappingURL=retry.js.map +/***/ }), + +/***/ 83813: +/***/ ((module) => { + +"use strict"; + + +module.exports = (flag, argv = process.argv) => { + const prefix = flag.startsWith('-') ? '' : (flag.length === 1 ? '-' : '--'); + const position = argv.indexOf(prefix + flag); + const terminatorPosition = argv.indexOf('--'); + return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition); +}; + + /***/ }), /***/ 23336: @@ -106807,6 +107063,149 @@ module.exports = function getSideChannel() { }; +/***/ }), + +/***/ 21450: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +"use strict"; + +const os = __nccwpck_require__(70857); +const tty = __nccwpck_require__(52018); +const hasFlag = __nccwpck_require__(83813); + +const {env} = process; + +let forceColor; +if (hasFlag('no-color') || + hasFlag('no-colors') || + hasFlag('color=false') || + hasFlag('color=never')) { + forceColor = 0; +} else if (hasFlag('color') || + hasFlag('colors') || + hasFlag('color=true') || + hasFlag('color=always')) { + forceColor = 1; +} + +if ('FORCE_COLOR' in env) { + if (env.FORCE_COLOR === 'true') { + forceColor = 1; + } else if (env.FORCE_COLOR === 'false') { + forceColor = 0; + } else { + forceColor = env.FORCE_COLOR.length === 0 ? 1 : Math.min(parseInt(env.FORCE_COLOR, 10), 3); + } +} + +function translateLevel(level) { + if (level === 0) { + return false; + } + + return { + level, + hasBasic: true, + has256: level >= 2, + has16m: level >= 3 + }; +} + +function supportsColor(haveStream, streamIsTTY) { + if (forceColor === 0) { + return 0; + } + + if (hasFlag('color=16m') || + hasFlag('color=full') || + hasFlag('color=truecolor')) { + return 3; + } + + if (hasFlag('color=256')) { + return 2; + } + + if (haveStream && !streamIsTTY && forceColor === undefined) { + return 0; + } + + const min = forceColor || 0; + + if (env.TERM === 'dumb') { + return min; + } + + if (process.platform === 'win32') { + // Windows 10 build 10586 is the first Windows release that supports 256 colors. + // Windows 10 build 14931 is the first release that supports 16m/TrueColor. + const osRelease = os.release().split('.'); + if ( + Number(osRelease[0]) >= 10 && + Number(osRelease[2]) >= 10586 + ) { + return Number(osRelease[2]) >= 14931 ? 3 : 2; + } + + return 1; + } + + if ('CI' in env) { + if (['TRAVIS', 'CIRCLECI', 'APPVEYOR', 'GITLAB_CI', 'GITHUB_ACTIONS', 'BUILDKITE'].some(sign => sign in env) || env.CI_NAME === 'codeship') { + return 1; + } + + return min; + } + + if ('TEAMCITY_VERSION' in env) { + return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0; + } + + if (env.COLORTERM === 'truecolor') { + return 3; + } + + if ('TERM_PROGRAM' in env) { + const version = parseInt((env.TERM_PROGRAM_VERSION || '').split('.')[0], 10); + + switch (env.TERM_PROGRAM) { + case 'iTerm.app': + return version >= 3 ? 3 : 2; + case 'Apple_Terminal': + return 2; + // No default + } + } + + if (/-256(color)?$/i.test(env.TERM)) { + return 2; + } + + if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) { + return 1; + } + + if ('COLORTERM' in env) { + return 1; + } + + return min; +} + +function getSupportLevel(stream) { + const level = supportsColor(stream, stream && stream.isTTY); + return translateLevel(level); +} + +module.exports = { + supportsColor: getSupportLevel, + stdout: translateLevel(supportsColor(true, tty.isatty(1))), + stderr: translateLevel(supportsColor(true, tty.isatty(2))) +}; + + /***/ }), /***/ 1552: @@ -132367,14 +132766,6 @@ function wrappy (fn, cb) { module.exports = eval("require")("encoding"); -/***/ }), - -/***/ 60075: -/***/ ((module) => { - -module.exports = eval("require")("supports-color"); - - /***/ }), /***/ 42613: @@ -137359,12 +137750,13 @@ const { async function run() { let meterProvider; let tracerProvider; + let config; try { core.info('Starting OpenTelemetry data collection post-action'); // Get configuration - const config = await getConfig(); + config = await getConfig(); // Get GitHub token and create Octokit client const token = core.getInput('github-token', { required: true }); @@ -137425,7 +137817,9 @@ async function run() { } // Decide whether to fail the workflow based on config - if (config.failOnError) { + // Note: config might not be defined if error occurred before getConfig() + const shouldFail = config?.failOnError || false; + if (shouldFail) { const errorMsg = error?.message || error?.toString() || 'Unknown error'; core.setFailed(`Observability export failed: ${errorMsg}`); } else { diff --git a/lib/config.js b/lib/config.js index f2d3028..8251018 100644 --- a/lib/config.js +++ b/lib/config.js @@ -26,6 +26,83 @@ async function checkRepositoryVisibility(token) { } } +/** + * Fetches IAM policy for a project + * @param {Object} credentials - Service account credentials + * @param {string} projectId - GCP project ID + * @returns {Promise} IAM policy + */ +async function fetchIAMPolicy(credentials, projectId) { + const auth = new GoogleAuth({ + credentials, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + const client = await auth.getClient(); + + const url = `https://cloudresourcemanager.googleapis.com/v1/projects/${projectId}:getIamPolicy`; + const response = await client.request({ + url, + method: 'POST', + data: {}, + headers: { + 'Content-Type': 'application/json', + }, + }); + + return response.data; +} + +/** + * Extracts roles assigned to a service account from IAM policy + * @param {Object} policy - IAM policy + * @param {string} serviceAccountEmail - Service account email + * @returns {Array} List of roles + */ +function extractServiceAccountRoles(policy, serviceAccountEmail) { + const bindings = policy.bindings || []; + const serviceAccountRoles = []; + const memberString = `serviceAccount:${serviceAccountEmail}`; + + for (const binding of bindings) { + if (binding.members && binding.members.includes(memberString)) { + serviceAccountRoles.push(binding.role); + } + } + + return serviceAccountRoles; +} + +/** + * Logs security error for excessive permissions + * @param {string} projectId - GCP project ID + * @param {string} serviceAccountEmail - Service account email + * @param {Array} excessiveRoles - List of excessive roles + */ +function logExcessivePermissionsError(projectId, serviceAccountEmail, excessiveRoles) { + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error('⚠️ SECURITY ERROR: Service account has excessive permissions!'); + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error(''); + core.error(`Service account: ${serviceAccountEmail}`); + core.error(`Project: ${projectId}`); + core.error(''); + core.error('Excessive roles detected:'); + excessiveRoles.forEach(role => core.error(` - ${role}`)); + core.error(''); + core.error('This service account should ONLY have:'); + core.error(' - roles/monitoring.metricWriter (for metrics)'); + core.error(' - roles/cloudtrace.agent (for traces)'); + core.error(''); + core.error('To fix, remove excessive roles:'); + excessiveRoles.forEach(role => { + core.error(` gcloud projects remove-iam-policy-binding ${projectId} \\`); + core.error(` --member="serviceAccount:${serviceAccountEmail}" \\`); + core.error(` --role="${role}"`); + }); + core.error(''); +} + /** * Checks if a service account has excessive permissions * @param {string} projectId - GCP project ID @@ -37,36 +114,8 @@ async function checkServiceAccountPermissions(projectId, serviceAccountEmail, cr try { core.info('Checking service account IAM permissions...'); - const auth = new GoogleAuth({ - credentials, - scopes: ['https://www.googleapis.com/auth/cloud-platform'], - }); - - const client = await auth.getClient(); - - // Get project IAM policy using v1 API with POST - const url = `https://cloudresourcemanager.googleapis.com/v1/projects/${projectId}:getIamPolicy`; - const response = await client.request({ - url, - method: 'POST', - data: {}, - headers: { - 'Content-Type': 'application/json', - }, - }); - - const policy = response.data; - const bindings = policy.bindings || []; - - // Find all roles assigned to this service account - const serviceAccountRoles = []; - const memberString = `serviceAccount:${serviceAccountEmail}`; - - for (const binding of bindings) { - if (binding.members && binding.members.includes(memberString)) { - serviceAccountRoles.push(binding.role); - } - } + const policy = await fetchIAMPolicy(credentials, projectId); + const serviceAccountRoles = extractServiceAccountRoles(policy, serviceAccountEmail); core.info(`Service account has ${serviceAccountRoles.length} role(s): ${serviceAccountRoles.join(', ')}`); @@ -78,27 +127,7 @@ async function checkServiceAccountPermissions(projectId, serviceAccountEmail, cr const excessiveRoles = serviceAccountRoles.filter(role => !allowedRoles.includes(role)); if (excessiveRoles.length > 0) { - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error('⚠️ SECURITY ERROR: Service account has excessive permissions!'); - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error(''); - core.error(`Service account: ${serviceAccountEmail}`); - core.error(`Project: ${projectId}`); - core.error(''); - core.error('Excessive roles detected:'); - excessiveRoles.forEach(role => core.error(` - ${role}`)); - core.error(''); - core.error('This service account should ONLY have:'); - core.error(' - roles/monitoring.metricWriter (for metrics)'); - core.error(' - roles/cloudtrace.agent (for traces)'); - core.error(''); - core.error('To fix, remove excessive roles:'); - excessiveRoles.forEach(role => { - core.error(` gcloud projects remove-iam-policy-binding ${projectId} \\`); - core.error(` --member="serviceAccount:${serviceAccountEmail}" \\`); - core.error(` --role="${role}"`); - }); - core.error(''); + logExcessivePermissionsError(projectId, serviceAccountEmail, excessiveRoles); throw new Error('Service account has excessive permissions - refusing to use'); } @@ -122,6 +151,120 @@ async function checkServiceAccountPermissions(projectId, serviceAccountEmail, cr } } +/** + * Validates repository security for service account key file usage + * @param {string} token - GitHub token + * @returns {Promise} + */ +async function validateRepositorySecurity(token) { + let repoVisibility = null; + + if (token) { + core.debug('Checking repository visibility via GitHub API...'); + repoVisibility = await checkRepositoryVisibility(token); + } + + const isPublic = repoVisibility === 'public'; + const isPrivate = repoVisibility === 'private' || repoVisibility === 'internal'; + + if (isPublic) { + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error('⚠️ SECURITY ERROR: Service account key in PUBLIC repository!'); + core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + core.error(''); + core.error('Service account key files should NEVER be committed to public repositories.'); + core.error(''); + core.error('Options:'); + core.error(' 1. Make this repository private'); + core.error(' 2. Use GitHub Secrets instead of committing the key file'); + core.error(' 3. Use Workload Identity Federation (recommended)'); + core.error(''); + throw new Error('Refusing to use service account key file in public repository'); + } + + if (isPrivate) { + core.info(`✓ Repository is ${repoVisibility} - safe to use service account key file`); + } else { + core.warning(`Could not determine repository visibility (got: ${repoVisibility})`); + core.warning('⚠️ WARNING: Proceeding without confirming repository is private'); + core.warning('Service account key files should ONLY be used in private repositories!'); + } +} + +/** + * Reads service account key from file + * @param {string} serviceAccountKeyFile - Path to service account key file + * @returns {string} Service account key content + */ +function readServiceAccountKeyFile(serviceAccountKeyFile) { + try { + const filePath = path.resolve(serviceAccountKeyFile); + core.info(`Reading service account key from: ${filePath}`); + return fs.readFileSync(filePath, 'utf8'); + } catch (error) { + core.error(`Failed to read service account key file: ${error.message}`); + throw new Error(`Cannot read service account key file: ${serviceAccountKeyFile}`); + } +} + +/** + * Parses and extracts information from service account key + * @param {string} serviceAccountKey - Service account key JSON string + * @returns {Object} Parsed key data with project ID and email + */ +function parseServiceAccountKey(serviceAccountKey) { + try { + const keyData = JSON.parse(serviceAccountKey); + + // Validate key structure + if (!keyData.private_key || !keyData.client_email || !keyData.project_id) { + core.warning('Service account key appears to be incomplete or malformed'); + } + + return { + keyData, + projectId: keyData.project_id, + email: keyData.client_email + }; + } catch (error) { + core.warning(`Could not parse service account key: ${error.message}`); + return { keyData: null, projectId: null, email: null }; + } +} + +/** + * Tries to detect GCP project ID from environment variables + * @returns {string|null} Project ID or null + */ +function detectProjectFromEnvironment() { + const envProject = process.env.GOOGLE_CLOUD_PROJECT || + process.env.GCLOUD_PROJECT || + process.env.GCP_PROJECT; + if (envProject) { + core.info(`Using project ID from environment: ${envProject}`); + return envProject; + } + return null; +} + +/** + * Tries to detect GCP project ID from Application Default Credentials + * @returns {Promise} Project ID or null + */ +async function detectProjectFromADC() { + try { + const auth = new GoogleAuth(); + const projectId = await auth.getProjectId(); + if (projectId) { + core.info(`Detected project ID from Application Default Credentials: ${projectId}`); + return projectId; + } + } catch (error) { + core.debug(`Could not detect project from ADC: ${error.message}`); + } + return null; +} + /** * Parses and validates action configuration from inputs * @returns {Object} Configuration object @@ -132,116 +275,49 @@ async function getConfig() { // Read service account key from file if provided if (serviceAccountKeyFile) { - // Check if repository is private when using service account key file const token = core.getInput('github-token'); - let repoVisibility = null; - - if (token) { - core.debug('Checking repository visibility via GitHub API...'); - repoVisibility = await checkRepositoryVisibility(token); - } - - // Determine if repo is public - const isPublic = repoVisibility === 'public'; - const isPrivate = repoVisibility === 'private' || repoVisibility === 'internal'; - - if (isPublic) { - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error('⚠️ SECURITY ERROR: Service account key in PUBLIC repository!'); - core.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - core.error(''); - core.error('Service account key files should NEVER be committed to public repositories.'); - core.error(''); - core.error('Options:'); - core.error(' 1. Make this repository private'); - core.error(' 2. Use GitHub Secrets instead of committing the key file'); - core.error(' 3. Use Workload Identity Federation (recommended)'); - core.error(''); - throw new Error('Refusing to use service account key file in public repository'); - } - - if (isPrivate) { - core.info(`✓ Repository is ${repoVisibility} - safe to use service account key file`); - } else { - core.warning(`Could not determine repository visibility (got: ${repoVisibility})`); - core.warning('⚠️ WARNING: Proceeding without confirming repository is private'); - core.warning('Service account key files should ONLY be used in private repositories!'); - } - try { - const filePath = path.resolve(serviceAccountKeyFile); - core.info(`Reading service account key from: ${filePath}`); - serviceAccountKey = fs.readFileSync(filePath, 'utf8'); - } catch (error) { - core.error(`Failed to read service account key file: ${error.message}`); - throw new Error(`Cannot read service account key file: ${serviceAccountKeyFile}`); - } + await validateRepositorySecurity(token); + serviceAccountKey = readServiceAccountKeyFile(serviceAccountKeyFile); } - // Determine GCP project ID and validate service account + // Parse service account key and extract information let gcpProjectId = core.getInput('gcp-project-id'); let serviceAccountEmail = null; let keyData = null; - // If not explicitly provided, try to extract from service account key if (serviceAccountKey) { - try { - keyData = JSON.parse(serviceAccountKey); - - // Extract project ID if not provided - if (!gcpProjectId && keyData.project_id) { - gcpProjectId = keyData.project_id; - core.info(`Using project ID from service account key: ${gcpProjectId}`); - } - - // Extract and log service account email - if (keyData.client_email) { - serviceAccountEmail = keyData.client_email; - core.info(`Using service account: ${serviceAccountEmail}`); - } - - // Validate key structure - if (!keyData.private_key || !keyData.client_email || !keyData.project_id) { - core.warning('Service account key appears to be incomplete or malformed'); - } - - } catch (error) { - core.warning(`Could not parse service account key: ${error.message}`); + const parsed = parseServiceAccountKey(serviceAccountKey); + keyData = parsed.keyData; + + if (!gcpProjectId && parsed.projectId) { + gcpProjectId = parsed.projectId; + core.info(`Using project ID from service account key: ${gcpProjectId}`); + } + + if (parsed.email) { + serviceAccountEmail = parsed.email; + core.info(`Using service account: ${serviceAccountEmail}`); } } - const failOnError = core.getBooleanInput('fail-on-error'); - + // Build config object const config = { gcpProjectId, gcpServiceAccountKey: serviceAccountKey, serviceName: core.getInput('service-name') || 'github-actions', serviceNamespace: core.getInput('service-namespace') || 'ci', metricPrefix: core.getInput('metric-prefix') || 'github.actions', - failOnError, + failOnError: core.getBooleanInput('fail-on-error'), }; - // Try to get project from environment (set by google-github-actions/auth) + // Try to get project from environment if (!config.gcpProjectId) { - const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GCLOUD_PROJECT || process.env.GCP_PROJECT; - if (envProject) { - config.gcpProjectId = envProject; - core.info(`Using project ID from environment: ${envProject}`); - } + config.gcpProjectId = detectProjectFromEnvironment(); } // Try to detect project from ADC if (!config.gcpProjectId) { - try { - const { GoogleAuth } = require('google-auth-library'); - const auth = new GoogleAuth(); - const projectId = await auth.getProjectId(); - if (projectId) { - config.gcpProjectId = projectId; - core.info(`Detected project ID from Application Default Credentials: ${projectId}`); - } - } catch (error) { - core.debug(`Could not detect project from ADC: ${error.message}`); - } + config.gcpProjectId = await detectProjectFromADC(); } // Validate configuration diff --git a/lib/exporter.js b/lib/exporter.js index 6893862..5124819 100644 --- a/lib/exporter.js +++ b/lib/exporter.js @@ -105,14 +105,11 @@ function createMeterProvider(config) { } /** - * Records metrics for collected workflow data - * @param {Object} meter - OpenTelemetry meter + * Builds base attributes for metrics from collected data * @param {Object} metrics - Collected metrics from GitHub - * @param {string} metricPrefix - Metric name prefix + * @returns {Object} Base attributes object */ -function recordMetrics(meter, metrics, metricPrefix) { - core.info('Recording metrics to OpenTelemetry'); - +function buildBaseAttributes(metrics) { const baseAttributes = { 'workflow.name': metrics.workflow, 'job.name': metrics.job.name, @@ -128,7 +125,7 @@ function recordMetrics(meter, metrics, metricPrefix) { 'event.actor': metrics.event.actor, }; - // Add optional attributes if present + // Add optional git attributes if (metrics.git.refName) { baseAttributes['git.ref_name'] = metrics.git.refName; } @@ -149,27 +146,27 @@ function recordMetrics(meter, metrics, metricPrefix) { if (metrics.runner.name) { baseAttributes['runner.name'] = metrics.runner.name; } - // Add primary runner label (usually indicates size like 'ubuntu-latest', 'ubuntu-4-core', etc.) if (metrics.runner.labels && metrics.runner.labels.length > 0) { baseAttributes['runner.label'] = metrics.runner.labels[0]; } } - // Create meters for step metrics - const stepDurationName = `${metricPrefix}.step.duration`; - const stepDurationHistogram = meter.createHistogram(stepDurationName, { - description: 'Duration of workflow steps in milliseconds', - unit: 'ms', - }); - core.debug(`Created histogram metric: ${stepDurationName}`); + return baseAttributes; +} - // Record job-level metrics (always record, even if job not fully complete) +/** + * Records job-level metrics + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordJobMetrics(meter, metrics, metricPrefix, baseAttributes) { const jobDurationHistogram = meter.createHistogram(`${metricPrefix}.job.duration`, { description: 'Duration of workflow jobs in milliseconds', unit: 'ms', }); - // Calculate estimated cost if we have runner info const jobAttributes = { ...baseAttributes, 'job.status': metrics.job.status, @@ -177,21 +174,9 @@ function recordMetrics(meter, metrics, metricPrefix) { }; jobDurationHistogram.record(metrics.job.durationMs, jobAttributes); - core.info(`Recorded job duration: ${metrics.job.durationMs}ms`); - // Record repository size if available - if (metrics.repository.sizeKB) { - const repoSizeGauge = meter.createGauge(`${metricPrefix}.repo.size`, { - description: 'Repository size in kilobytes', - unit: 'KB', - }); - - repoSizeGauge.record(metrics.repository.sizeKB, baseAttributes); - core.info(`Recorded repository size: ${metrics.repository.sizeKB} KB`); - } - - // Record estimated cost as a separate metric + // Record estimated cost if we have runner info if (metrics.runner && metrics.runner.os) { const runnerLabel = metrics.runner.labels && metrics.runner.labels.length > 0 ? metrics.runner.labels[0] : null; core.debug(`Calculating cost for runner: OS=${metrics.runner.os}, label=${runnerLabel}, duration=${metrics.job.durationMs}ms`); @@ -214,15 +199,41 @@ function recordMetrics(meter, metrics, metricPrefix) { } else { core.debug(`Not recording cost metric: runner.os=${metrics.runner?.os}, runner exists=${!!metrics.runner}`); } +} - // Record artifact metrics if available +/** + * Records repository size metric + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordRepositorySizeMetric(meter, metrics, metricPrefix, baseAttributes) { + if (metrics.repository.sizeKB) { + const repoSizeGauge = meter.createGauge(`${metricPrefix}.repo.size`, { + description: 'Repository size in kilobytes', + unit: 'KB', + }); + + repoSizeGauge.record(metrics.repository.sizeKB, baseAttributes); + core.info(`Recorded repository size: ${metrics.repository.sizeKB} KB`); + } +} + +/** + * Records artifact metrics + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordArtifactMetrics(meter, metrics, metricPrefix, baseAttributes) { if (metrics.artifacts && metrics.artifacts.count > 0) { const artifactSizeHistogram = meter.createHistogram(`${metricPrefix}.artifact.size`, { description: 'Size of individual workflow artifacts in bytes', unit: 'bytes', }); - // Record each artifact separately with its name for (const artifact of metrics.artifacts.artifacts) { const artifactAttributes = { ...baseAttributes, @@ -235,8 +246,23 @@ function recordMetrics(meter, metrics, metricPrefix) { core.info(`Recorded ${metrics.artifacts.count} artifact metrics (total: ${metrics.artifacts.totalBytes} bytes)`); } +} + +/** + * Records step-level metrics + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics + * @param {string} metricPrefix - Metric name prefix + * @param {Object} baseAttributes - Base attributes for all metrics + */ +function recordStepMetrics(meter, metrics, metricPrefix, baseAttributes) { + const stepDurationName = `${metricPrefix}.step.duration`; + const stepDurationHistogram = meter.createHistogram(stepDurationName, { + description: 'Duration of workflow steps in milliseconds', + unit: 'ms', + }); + core.debug(`Created histogram metric: ${stepDurationName}`); - // Record step-level metrics for (const step of metrics.steps) { const stepAttributes = { ...baseAttributes, @@ -246,7 +272,6 @@ function recordMetrics(meter, metrics, metricPrefix) { 'step.conclusion': step.conclusion || 'unknown', }; - // Record step duration if (step.durationMs > 0) { stepDurationHistogram.record(step.durationMs, stepAttributes); core.info(`Recorded step "${step.name}" duration: ${step.durationMs}ms`); @@ -256,6 +281,23 @@ function recordMetrics(meter, metrics, metricPrefix) { core.info(`Recorded metrics for ${metrics.steps.length} steps`); } +/** + * Records metrics for collected workflow data + * @param {Object} meter - OpenTelemetry meter + * @param {Object} metrics - Collected metrics from GitHub + * @param {string} metricPrefix - Metric name prefix + */ +function recordMetrics(meter, metrics, metricPrefix) { + core.info('Recording metrics to OpenTelemetry'); + + const baseAttributes = buildBaseAttributes(metrics); + + recordJobMetrics(meter, metrics, metricPrefix, baseAttributes); + recordRepositorySizeMetric(meter, metrics, metricPrefix, baseAttributes); + recordArtifactMetrics(meter, metrics, metricPrefix, baseAttributes); + recordStepMetrics(meter, metrics, metricPrefix, baseAttributes); +} + /** * Forces metrics export and shuts down the meter provider * @param {Object} meterProvider - MeterProvider instance @@ -322,14 +364,11 @@ function createTracerProvider(config) { } /** - * Records traces for collected workflow data - * @param {Object} tracer - OpenTelemetry tracer + * Builds base attributes for traces from collected data * @param {Object} metrics - Collected metrics from GitHub - * @returns {Object} Root span for the job + * @returns {Object} Base attributes object for traces */ -function recordTraces(tracer, metrics) { - core.info('Recording traces to OpenTelemetry'); - +function buildTraceBaseAttributes(metrics) { const baseAttributes = { 'workflow.name': metrics.workflow, 'repository.owner': metrics.repository.owner, @@ -344,7 +383,7 @@ function recordTraces(tracer, metrics) { 'event.actor': metrics.event.actor, }; - // Add optional attributes if present + // Add optional attributes if (metrics.git.refName) { baseAttributes['git.ref_name'] = metrics.git.refName; } @@ -372,8 +411,18 @@ function recordTraces(tracer, metrics) { } } - // Create a span for the entire job - const jobSpan = tracer.startSpan(`Job: ${metrics.job.name}`, { + return baseAttributes; +} + +/** + * Creates a job span with appropriate attributes + * @param {Object} tracer - OpenTelemetry tracer + * @param {Object} metrics - Collected metrics + * @param {Object} baseAttributes - Base attributes for the span + * @returns {Object} Job span + */ +function createJobSpan(tracer, metrics, baseAttributes) { + return tracer.startSpan(`Job: ${metrics.job.name}`, { startTime: metrics.job.startedAt, attributes: { ...baseAttributes, @@ -383,14 +432,18 @@ function recordTraces(tracer, metrics) { 'job.conclusion': metrics.job.conclusion || 'unknown', }, }); +} - // Set job span as active in context for creating child spans - const jobContext = trace.setSpan(context.active(), jobSpan); - - // Create child spans for each step within job context +/** + * Creates step spans as children of the job span + * @param {Object} tracer - OpenTelemetry tracer + * @param {Object} metrics - Collected metrics + * @param {Object} baseAttributes - Base attributes for spans + * @param {Object} jobContext - Job context for creating child spans + */ +function createStepSpans(tracer, metrics, baseAttributes, jobContext) { for (const step of metrics.steps) { if (step.startedAt && step.completedAt) { - // Start span within the job context (makes it a child of jobSpan) const stepSpan = tracer.startSpan( `Step: ${step.name}`, { @@ -404,7 +457,7 @@ function recordTraces(tracer, metrics) { 'step.conclusion': step.conclusion || 'unknown', }, }, - jobContext // Use job context to make this a child span + jobContext ); // Mark span as error if step failed @@ -417,6 +470,25 @@ function recordTraces(tracer, metrics) { core.debug(`Created span for step: ${step.name}`); } } +} + +/** + * Records traces for collected workflow data + * @param {Object} tracer - OpenTelemetry tracer + * @param {Object} metrics - Collected metrics from GitHub + * @returns {Object} Root span for the job + */ +function recordTraces(tracer, metrics) { + core.info('Recording traces to OpenTelemetry'); + + const baseAttributes = buildTraceBaseAttributes(metrics); + const jobSpan = createJobSpan(tracer, metrics, baseAttributes); + + // Set job span as active in context for creating child spans + const jobContext = trace.setSpan(context.active(), jobSpan); + + // Create child spans for each step + createStepSpans(tracer, metrics, baseAttributes, jobContext); // End the job span if (metrics.job.completedAt) { diff --git a/package.json b/package.json index e1a8796..27d9e6f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "GitHub Action that exports workflow metrics and traces with accurate timestamps to Google Cloud Monitoring and Cloud Trace", "main": "index.js", "scripts": { - "test": "node --test test/collector.test.js test/exporter.test.js", + "test": "node --test test/collector.test.js test/exporter.test.js test/config.test.js", "build": "ncc build index.js -o dist && ncc build post.js -o dist/post", "lint": "eslint ." }, diff --git a/test/collector.test.js b/test/collector.test.js index 38c22f7..7e2c9ef 100644 --- a/test/collector.test.js +++ b/test/collector.test.js @@ -243,4 +243,53 @@ test('collectMetrics', async (t) => { const metrics = await collectMetrics(mockOctokit, mockContext); assert.strictEqual(metrics.job.conclusion, 'cancelled'); }); + + await t.test('should handle PR context correctly', async () => { + const mockJobData = { + jobs: [{ + id: 12345, + name: 'test-job', + status: 'completed', + conclusion: 'success', + started_at: '2025-01-01T10:00:00Z', + completed_at: '2025-01-01T10:05:00Z', + steps: [], + }], + }; + + const mockOctokit = { rest: { actions: { listJobsForWorkflowRun: mock.fn(async () => ({ data: mockJobData })) } } }; + const mockContext = { + repo: { owner: 'test-owner', repo: 'test-repo' }, + runId: 67890, + runNumber: 42, + workflow: 'CI', + payload: { pull_request: { number: 123 } } + }; + process.env.GITHUB_JOB = 'test-job'; + + const metrics = await collectMetrics(mockOctokit, mockContext); + assert.strictEqual(metrics.event.prNumber, 123); + }); + + await t.test('should handle missing runner labels', async () => { + const mockJobData = { + jobs: [{ + id: 12345, + name: 'test-job', + status: 'completed', + conclusion: 'success', + started_at: '2025-01-01T10:00:00Z', + completed_at: '2025-01-01T10:05:00Z', + steps: [], + labels: [], + }], + }; + + const mockOctokit = { rest: { actions: { listJobsForWorkflowRun: mock.fn(async () => ({ data: mockJobData })) } } }; + const mockContext = { repo: { owner: 'test-owner', repo: 'test-repo' }, runId: 67890, runNumber: 42, workflow: 'CI' }; + process.env.GITHUB_JOB = 'test-job'; + + const metrics = await collectMetrics(mockOctokit, mockContext); + assert.strictEqual(metrics.runner.labels.length, 0); + }); }); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..abc6757 --- /dev/null +++ b/test/config.test.js @@ -0,0 +1,20 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); + +// Note: Testing config.js is challenging because it has many external dependencies +// and side effects (file I/O, GitHub API calls, etc.). These tests would need +// significant mocking infrastructure. For now, we'll add basic structural tests. + +test('config module exports', async (t) => { + await t.test('should export getConfig function', () => { + const config = require('../lib/config'); + assert.strictEqual(typeof config.getConfig, 'function'); + }); +}); + +// Additional tests for config would require mocking: +// - @actions/core +// - @actions/github +// - fs module +// - google-auth-library +// These would be good candidates for future test improvements