-
Notifications
You must be signed in to change notification settings - Fork 728
feat: implement rubygems registry [CM-1241] #4322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e2d60a1
6fb9792
bfaf69b
3cabf60
6d8c9cf
ec1aa93
4d82dce
f2acf32
43d82e7
ac19f22
e76438f
9d24e80
4c366d7
729e883
b0ea11c
f256e91
7bebc27
f87aa20
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| DOCKERFILE="./services/docker/Dockerfile.packages" | ||
| CONTEXT="../" | ||
| REPO="sjc.ocir.io/axbydjxa5zuh/packages" | ||
| SERVICES="github-repos-enricher bq-dataset-ingest npm-worker pypi-worker maven-worker osv-worker dockerhub-sync cargo-worker go-worker nuget-worker security-contacts-worker" | ||
| SERVICES="github-repos-enricher bq-dataset-ingest npm-worker pypi-worker maven-worker osv-worker dockerhub-sync cargo-worker go-worker nuget-worker security-contacts-worker rubygems-worker" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| version: '3.1' | ||
|
|
||
| x-env-args: &env-args | ||
| DOCKER_BUILDKIT: 1 | ||
| NODE_ENV: docker | ||
| SERVICE: rubygems-worker | ||
| SHELL: /bin/sh | ||
| SUPPRESS_NO_CONFIG_WARNING: 'true' | ||
| CROWD_TEMPORAL_TASKQUEUE: rubygems-worker | ||
|
mbani01 marked this conversation as resolved.
mbani01 marked this conversation as resolved.
|
||
|
|
||
| services: | ||
| rubygems-worker: | ||
| build: | ||
| context: ../../ | ||
| dockerfile: ./scripts/services/docker/Dockerfile.packages | ||
| command: 'pnpm run start:rubygems-worker' | ||
| working_dir: /usr/crowd/app/services/apps/packages_worker | ||
| env_file: | ||
| - ../../backend/.env.dist.local | ||
| - ../../backend/.env.dist.composed | ||
| - ../../backend/.env.override.local | ||
| - ../../backend/.env.override.composed | ||
| environment: | ||
| <<: *env-args | ||
| restart: always | ||
| networks: | ||
| - crowd-bridge | ||
|
|
||
| rubygems-worker-dev: | ||
| build: | ||
| context: ../../ | ||
| dockerfile: ./scripts/services/docker/Dockerfile.packages | ||
| command: 'pnpm run dev:rubygems-worker' | ||
| working_dir: /usr/crowd/app/services/apps/packages_worker | ||
| env_file: | ||
| - ../../backend/.env.dist.local | ||
| - ../../backend/.env.dist.composed | ||
| - ../../backend/.env.override.local | ||
| - ../../backend/.env.override.composed | ||
| environment: | ||
| <<: *env-args | ||
| hostname: rubygems-worker | ||
| networks: | ||
| - crowd-bridge | ||
| volumes: | ||
| - ../../services/libs/audit-logs/src:/usr/crowd/app/services/libs/audit-logs/src | ||
| - ../../services/libs/common/src:/usr/crowd/app/services/libs/common/src | ||
| - ../../services/libs/common_services/src:/usr/crowd/app/services/libs/common_services/src | ||
| - ../../services/libs/data-access-layer/src:/usr/crowd/app/services/libs/data-access-layer/src | ||
| - ../../services/libs/database/src:/usr/crowd/app/services/libs/database/src | ||
| - ../../services/libs/integrations/src:/usr/crowd/app/services/libs/integrations/src | ||
| - ../../services/libs/logging/src:/usr/crowd/app/services/libs/logging/src | ||
| - ../../services/libs/nango/src:/usr/crowd/app/services/libs/nango/src | ||
| - ../../services/libs/opensearch/src:/usr/crowd/app/services/libs/opensearch/src | ||
| - ../../services/libs/queue/src:/usr/crowd/app/services/libs/queue/src | ||
| - ../../services/libs/redis/src:/usr/crowd/app/services/libs/redis/src | ||
| - ../../services/libs/snowflake/src:/usr/crowd/app/services/libs/snowflake/src | ||
| - ../../services/libs/telemetry/src:/usr/crowd/app/services/libs/telemetry/src | ||
| - ../../services/libs/temporal/src:/usr/crowd/app/services/libs/temporal/src | ||
| - ../../services/libs/types/src:/usr/crowd/app/services/libs/types/src | ||
| - ../../services/apps/packages_worker/src:/usr/crowd/app/services/apps/packages_worker/src | ||
|
|
||
| networks: | ||
| crowd-bridge: | ||
| external: true | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { | ||
| scheduleRubyGemsCriticalIngestion, | ||
| scheduleRubyGemsDependentsIngestion, | ||
| scheduleRubyGemsIngestion, | ||
| } from '../rubygems/schedule' | ||
| import { svc } from '../service' | ||
|
|
||
| setImmediate(async () => { | ||
| await svc.init() | ||
| await scheduleRubyGemsIngestion() | ||
| await scheduleRubyGemsCriticalIngestion() | ||
| await scheduleRubyGemsDependentsIngestion() | ||
| await svc.start() | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { getServiceChildLogger } from '@crowd/logging' | ||
|
|
||
| import { | ||
| getRubyGemsConfig, | ||
| getRubyGemsCriticalConfig, | ||
| getRubyGemsDependentsConfig, | ||
| } from '../config' | ||
| import { getPackagesDb } from '../db' | ||
|
|
||
| import { processBatch as processCoreBatch } from './runRubyGemsCoreLoop' | ||
| import { processBatch as processCriticalBatch } from './runRubyGemsCriticalLoop' | ||
| import { | ||
| DependentsBatchResult, | ||
| processBatch as processDependentsBatch, | ||
| } from './runRubyGemsDependentsLoop' | ||
| import { BatchResult } from './types' | ||
|
|
||
| const log = getServiceChildLogger('rubygems-activity') | ||
|
|
||
| export async function processRubyGemsCoreBatch(): Promise<BatchResult> { | ||
| const config = getRubyGemsConfig() | ||
| const qx = await getPackagesDb() | ||
| const today = new Date().toISOString().split('T')[0] | ||
| const result = await processCoreBatch(qx, config, today) | ||
| log.info({ ...result }, 'RubyGems core batch complete') | ||
| return result | ||
| } | ||
|
|
||
| export async function processRubyGemsCriticalBatch( | ||
| afterId = '0', | ||
| ): Promise<BatchResult & { lastId: string | null }> { | ||
| const config = getRubyGemsCriticalConfig() | ||
| const qx = await getPackagesDb() | ||
| const result = await processCriticalBatch(qx, config, afterId) | ||
| log.info({ ...result }, 'RubyGems critical batch complete') | ||
| return result | ||
| } | ||
|
|
||
| export async function processRubyGemsDependentsBatch( | ||
| afterId = '0', | ||
| ): Promise<DependentsBatchResult> { | ||
| const config = getRubyGemsDependentsConfig() | ||
| const qx = await getPackagesDb() | ||
| const result = await processDependentsBatch(qx, config, afterId) | ||
| log.info({ ...result }, 'RubyGems dependents batch complete') | ||
| return result | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import axios from 'axios' | ||
|
|
||
| import { acquireRubyGemsSlot, parseRetryAfterMs } from './rateLimiter' | ||
| import { | ||
| RubyGemsFetchResult, | ||
| RubyGemsGemResponse, | ||
| RubyGemsOwner, | ||
| RubyGemsVersionItem, | ||
| } from './types' | ||
|
|
||
| const MAX_RATE_LIMIT_RETRIES = 5 | ||
|
|
||
| async function rubyGemsGet<T>(url: string): Promise<RubyGemsFetchResult<T>> { | ||
| for (let attempt = 0; ; attempt++) { | ||
| await acquireRubyGemsSlot() | ||
| try { | ||
| const resp = await axios.get<T>(url, { timeout: 15000 }) | ||
| return resp.data | ||
| } catch (err) { | ||
| if (axios.isAxiosError(err)) { | ||
| const status = err.response?.status | ||
| if (status === 404) return { kind: 'NOT_FOUND', status, message: err.message } | ||
| if (status === 429) { | ||
| if (attempt >= MAX_RATE_LIMIT_RETRIES) { | ||
| return { kind: 'RATE_LIMIT', status, message: err.message } | ||
| } | ||
| await new Promise((r) => | ||
| setTimeout(r, parseRetryAfterMs(err.response?.headers['retry-after'])), | ||
| ) | ||
| continue | ||
| } | ||
| } | ||
| throw err | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export function fetchGem(name: string): Promise<RubyGemsFetchResult<RubyGemsGemResponse>> { | ||
| return rubyGemsGet<RubyGemsGemResponse>( | ||
| `https://rubygems.org/api/v1/gems/${encodeURIComponent(name)}.json`, | ||
| ) | ||
| } | ||
|
|
||
| export function fetchVersions(name: string): Promise<RubyGemsFetchResult<RubyGemsVersionItem[]>> { | ||
| return rubyGemsGet<RubyGemsVersionItem[]>( | ||
| `https://rubygems.org/api/v1/versions/${encodeURIComponent(name)}.json`, | ||
| ) | ||
| } | ||
|
|
||
| export function fetchOwners(name: string): Promise<RubyGemsFetchResult<RubyGemsOwner[]>> { | ||
| return rubyGemsGet<RubyGemsOwner[]>( | ||
| `https://rubygems.org/api/v1/gems/${encodeURIComponent(name)}/owners.json`, | ||
| ) | ||
|
Comment on lines
+50
to
+53
|
||
| } | ||
|
|
||
| export function fetchReverseDependencies(name: string): Promise<RubyGemsFetchResult<string[]>> { | ||
| return rubyGemsGet<string[]>( | ||
| `https://rubygems.org/api/v1/gems/${encodeURIComponent(name)}/reverse_dependencies.json`, | ||
| ) | ||
| } | ||
|
mbani01 marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { | ||
| NormalizedRubyGemsOwner, | ||
| NormalizedRubyGemsPackage, | ||
| NormalizedRubyGemsVersion, | ||
| RubyGemsGemResponse, | ||
| RubyGemsOwner, | ||
| RubyGemsVersionItem, | ||
| } from './types' | ||
|
|
||
| function nonEmpty(value: string | null | undefined): string | null { | ||
| if (!value) return null | ||
| const trimmed = value.trim() | ||
| return trimmed === '' ? null : trimmed | ||
| } | ||
|
|
||
| export function normalizeRubyGemsPackage(doc: RubyGemsGemResponse): NormalizedRubyGemsPackage { | ||
| const licenses = doc.licenses && doc.licenses.length > 0 ? doc.licenses : null | ||
| return { | ||
| description: nonEmpty(doc.info), | ||
| homepage: nonEmpty(doc.homepage_uri), | ||
| declaredRepositoryUrl: nonEmpty(doc.source_code_uri), | ||
| licenses, | ||
| licensesRaw: licenses ? licenses.join(', ') : null, | ||
| latestVersion: nonEmpty(doc.version), | ||
| totalDownloads: doc.downloads ?? 0, | ||
| } | ||
| } | ||
|
|
||
| function parseCreatedAt(value: string | undefined): Date | null { | ||
| if (!value) return null | ||
| const date = new Date(value) | ||
| return isNaN(date.getTime()) ? null : date | ||
| } | ||
|
|
||
| export function normalizeRubyGemsVersions( | ||
| items: RubyGemsVersionItem[], | ||
| ): NormalizedRubyGemsVersion[] { | ||
| return items.map((item) => ({ | ||
|
Comment on lines
+35
to
+38
|
||
| number: item.number, | ||
| publishedAt: parseCreatedAt(item.created_at), | ||
| isPrerelease: item.prerelease ?? false, | ||
| licenses: item.licenses && item.licenses.length > 0 ? item.licenses : null, | ||
| })) | ||
| } | ||
|
|
||
| export function pickLatestRubyGemsVersion( | ||
| versions: NormalizedRubyGemsVersion[], | ||
| ): NormalizedRubyGemsVersion | null { | ||
|
Comment on lines
+46
to
+48
|
||
| if (versions.length === 0) return null | ||
|
mbani01 marked this conversation as resolved.
|
||
| const stable = versions.find((v) => !v.isPrerelease) | ||
| return stable ?? versions[0] | ||
|
mbani01 marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical latest version mismatchHigh Severity
Additional Locations (2)Reviewed by Cursor Bugbot for commit f87aa20. Configure here. |
||
| } | ||
|
mbani01 marked this conversation as resolved.
|
||
|
|
||
| export function normalizeRubyGemsOwners(owners: RubyGemsOwner[]): NormalizedRubyGemsOwner[] { | ||
| return owners | ||
| .filter((o): o is RubyGemsOwner & { handle: string } => !!o.handle && o.handle.trim() !== '') | ||
| .map((o) => ({ username: o.handle, email: nonEmpty(o.email) })) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| const MAX_RPS = Math.max(1, parseInt(process.env.RUBYGEMS_MAX_RPS ?? '10', 10)) | ||
| const INTERVAL_MS = 1000 / MAX_RPS | ||
|
Comment on lines
+1
to
+2
|
||
|
|
||
| let nextSlot = 0 | ||
|
|
||
| export async function acquireRubyGemsSlot(): Promise<void> { | ||
| const now = Date.now() | ||
| const slot = Math.max(now, nextSlot) | ||
| nextSlot = slot + INTERVAL_MS | ||
| const wait = slot - now | ||
| if (wait > 0) await new Promise((r) => setTimeout(r, wait)) | ||
| } | ||
|
|
||
| export function parseRetryAfterMs(header: unknown): number { | ||
| const FALLBACK_MS = 1000 | ||
| if (typeof header !== 'string') return FALLBACK_MS | ||
| const seconds = Number(header) | ||
| if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000) | ||
| const date = new Date(header) | ||
| if (!Number.isNaN(date.getTime())) return Math.max(0, date.getTime() - Date.now()) | ||
| return FALLBACK_MS | ||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.