Skip to content

Commit 418047f

Browse files
authored
Merge branch 'main' into fix/affiliation-oversized-file-limit
2 parents ebd5a56 + fd93162 commit 418047f

12 files changed

Lines changed: 213 additions & 10 deletions

File tree

backend/.env.dist.local

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ CROWD_TEMPORAL_ENCRYPTION_KEY_ID=local
151151
CROWD_TEMPORAL_ENCRYPTION_KEY=FweBMRnGCLshER8FlSvNusQA6G3MRUKt
152152

153153
# Temporal — packages namespace
154+
CROWD_PACKAGES_TEMPORAL_SERVER_URL=localhost:7233
154155
CROWD_PACKAGES_TEMPORAL_NAMESPACE=default
155156

156157
# Seach sync api

backend/config/custom-environment-variables.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,12 @@
180180
"certificate": "CROWD_TEMPORAL_CERTIFICATE",
181181
"privateKey": "CROWD_TEMPORAL_PRIVATE_KEY"
182182
},
183+
"packagesTemporal": {
184+
"serverUrl": "CROWD_PACKAGES_TEMPORAL_SERVER_URL",
185+
"namespace": "CROWD_PACKAGES_TEMPORAL_NAMESPACE",
186+
"certificate": "CROWD_TEMPORAL_CERTIFICATE",
187+
"privateKey": "CROWD_TEMPORAL_PRIVATE_KEY"
188+
},
183189
"searchSyncApi": {
184190
"baseUrl": "CROWD_SEARCH_SYNC_API_URL"
185191
},

backend/config/default.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"isEnabled": "false"
4444
},
4545
"temporal": {},
46+
"packagesTemporal": {},
4647
"searchSyncApi": {},
4748
"encryption": {},
4849
"openStatusApi": {},

backend/src/conf/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ export const PACKAGES_DB_CONFIG: IDatabaseConfig | undefined = config.has('packa
8686
? config.get<IDatabaseConfig>('packagesDb')
8787
: undefined
8888

89+
// packages_worker (npm/maven/pypi/osv/security-contacts/...) runs in its own Temporal
90+
// namespace, separate from the API's default namespace — see CROWD_PACKAGES_TEMPORAL_NAMESPACE.
91+
export const PACKAGES_TEMPORAL_CONFIG: ITemporalConfig | undefined = config.has(
92+
'packagesTemporal.namespace',
93+
)
94+
? config.get<ITemporalConfig>('packagesTemporal')
95+
: undefined
96+
8997
export const SEGMENT_CONFIG: SegmentConfiguration = config.get<SegmentConfiguration>('segment')
9098

9199
export const COMPREHEND_CONFIG: ComprehendConfiguration =

backend/src/db/packagesTemporal.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { IS_DEV_ENV, SERVICE } from '@crowd/common'
2+
import { Client, Connection, getDataConverter } from '@crowd/temporal'
3+
4+
import { PACKAGES_TEMPORAL_CONFIG } from '@/conf'
5+
6+
let _init: Promise<Client> | undefined
7+
8+
// Separate connection from the API's default req.temporal client — packages_worker
9+
// (npm/maven/pypi/osv/security-contacts/...) polls task queues in its own Temporal
10+
// namespace (CROWD_PACKAGES_TEMPORAL_NAMESPACE), not the API's default namespace.
11+
export function getPackagesTemporalClient(): Promise<Client> {
12+
if (!_init) {
13+
if (!PACKAGES_TEMPORAL_CONFIG?.serverUrl) {
14+
throw new Error('Packages Temporal is not configured — set CROWD_PACKAGES_TEMPORAL_NAMESPACE')
15+
}
16+
17+
const cfg = PACKAGES_TEMPORAL_CONFIG
18+
_init = Connection.connect({
19+
address: cfg.serverUrl,
20+
tls:
21+
cfg.certificate && cfg.privateKey
22+
? {
23+
clientCertPair: {
24+
crt: Buffer.from(cfg.certificate, 'base64'),
25+
key: Buffer.from(cfg.privateKey, 'base64'),
26+
},
27+
}
28+
: undefined,
29+
})
30+
.then(
31+
async (connection) =>
32+
new Client({
33+
connection,
34+
namespace: cfg.namespace,
35+
identity: SERVICE,
36+
dataConverter: IS_DEV_ENV ? undefined : await getDataConverter(),
37+
}),
38+
)
39+
.catch((err) => {
40+
_init = undefined
41+
throw err
42+
})
43+
}
44+
return _init
45+
}

scripts/services/security-contacts-worker.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ x-env-args: &env-args
77
SHELL: /bin/sh
88
SUPPRESS_NO_CONFIG_WARNING: 'true'
99
CROWD_TEMPORAL_TASKQUEUE: packages-worker
10+
CROWD_TEMPORAL_NAMESPACE: ${CROWD_PACKAGES_TEMPORAL_NAMESPACE}
1011

1112
services:
1213
security-contacts-worker:

services/apps/packages_worker/src/activities.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,7 @@ export {
3232
} from './pypi/activities'
3333
export { getCriticalPypiCount } from './pypi/downloads/getCriticalPypiCount'
3434
export { processNuGetBatch } from './nuget/activities'
35-
export { processSecurityContactsBatch } from './security-contacts/activities'
35+
export {
36+
processSecurityContactsBatch,
37+
ingestSecurityContactsForPurlActivity,
38+
} from './security-contacts/activities'

services/apps/packages_worker/src/security-contacts/activities.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getServiceChildLogger } from '@crowd/logging'
33
import { getSecurityContactsConfig } from '../config'
44
import { getPackagesDb } from '../db'
55

6+
import { IngestSingleResult, ingestSecurityContactsForPurl } from './ingestSingle'
67
import { BatchResult, processBatch } from './processBatch'
78

89
const log = getServiceChildLogger('security-contacts-activity')
@@ -15,3 +16,14 @@ export async function processSecurityContactsBatch(): Promise<BatchResult> {
1516
log.info({ ...result }, 'Security contacts batch activity complete')
1617
return result
1718
}
19+
20+
export async function ingestSecurityContactsForPurlActivity(
21+
purl: string,
22+
): Promise<IngestSingleResult> {
23+
const config = getSecurityContactsConfig()
24+
const qx = await getPackagesDb()
25+
26+
const result = await ingestSecurityContactsForPurl(qx, config, purl)
27+
log.info({ purl, ...result }, 'On-demand security contacts ingest activity complete')
28+
return result
29+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor'
2+
import { getServiceChildLogger } from '@crowd/logging'
3+
4+
import { getSecurityContactsConfig } from '../config'
5+
6+
import { buildBaseDeps, processRepo } from './processBatch'
7+
import { RepoPackage, RepoTarget } from './types'
8+
import { markRepoAttempted } from './writeContacts'
9+
10+
const log = getServiceChildLogger('security-contacts-ondemand')
11+
12+
type Config = ReturnType<typeof getSecurityContactsConfig>
13+
14+
export interface IngestSingleResult {
15+
found: boolean
16+
repoId?: string
17+
}
18+
19+
interface SingleRepoRow {
20+
repoId: string
21+
url: string
22+
homepage: string | null
23+
archived: boolean | null
24+
packages: RepoPackage[] | null
25+
}
26+
27+
// Mirrors the best-repo LATERAL selection in getPackageDetailByPurl
28+
// (services/libs/data-access-layer/src/osspckgs/api.ts) so the repo we ingest is the
29+
// one the read side surfaces. No `is_critical` filter — non-critical purls are exactly
30+
// what this on-demand path exists for. `packages` aggregates every package linked to
31+
// the repo (not just the requested purl) so extractors see every ecosystem, matching
32+
// the shape the batch sweep builds.
33+
//
34+
// No host filter: processRepo already degrades gracefully for non-github repos (the
35+
// github-specific extractors no-op, security.txt/registry-manifest extractors still run) —
36+
// filtering here would leave non-github repos permanently NULL, re-triggering this
37+
// on-demand path on every single request.
38+
async function findBestRepoForPurl(qx: QueryExecutor, purl: string): Promise<SingleRepoRow | null> {
39+
return qx.selectOneOrNone(
40+
`
41+
SELECT r.id::text AS "repoId",
42+
r.url,
43+
r.homepage,
44+
r.archived,
45+
(
46+
SELECT json_agg(json_build_object('purl', p2.purl, 'ecosystem', p2.ecosystem))
47+
FROM package_repos pr2
48+
JOIN packages p2 ON p2.id = pr2.package_id
49+
WHERE pr2.repo_id = r.id
50+
) AS packages
51+
FROM packages p
52+
JOIN LATERAL (
53+
SELECT pr.repo_id
54+
FROM package_repos pr
55+
WHERE pr.package_id = p.id
56+
ORDER BY pr.confidence DESC, (pr.source = 'declared') DESC
57+
LIMIT 1
58+
) best ON true
59+
JOIN repos r ON r.id = best.repo_id
60+
WHERE p.purl = $(purl)
61+
`,
62+
{ purl },
63+
)
64+
}
65+
66+
function toTarget(row: SingleRepoRow): RepoTarget {
67+
return {
68+
repoId: row.repoId,
69+
url: row.url,
70+
homepage: row.homepage,
71+
archived: row.archived,
72+
packages: row.packages ?? [],
73+
}
74+
}
75+
76+
/**
77+
* On-demand ingest for a single purl, bypassing the daily critical-only sweep.
78+
* Used when the akrites API hits a repo that has never been evaluated
79+
* (repos.contacts_last_refreshed IS NULL).
80+
*/
81+
export async function ingestSecurityContactsForPurl(
82+
qx: QueryExecutor,
83+
config: Config,
84+
purl: string,
85+
): Promise<IngestSingleResult> {
86+
const row = await findBestRepoForPurl(qx, purl)
87+
if (!row) {
88+
log.info({ purl }, 'On-demand security contacts: no linked repo found — skipping')
89+
return { found: false }
90+
}
91+
92+
const target = toTarget(row)
93+
const baseDeps = buildBaseDeps(config)
94+
try {
95+
await processRepo(target, baseDeps, qx)
96+
} catch (err) {
97+
log.error({ repoId: target.repoId, errMsg: (err as Error).message }, 'Repo processing failed')
98+
await markRepoAttempted(qx, target.repoId).catch(() => undefined)
99+
}
100+
101+
return { found: true, repoId: target.repoId }
102+
}

services/apps/packages_worker/src/security-contacts/processBatch.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,7 @@ async function fetchBatch(qx: QueryExecutor): Promise<SweepRow[]> {
7272
FROM repos r
7373
JOIN package_repos pr ON pr.repo_id = r.id
7474
JOIN packages p ON p.id = pr.package_id AND p.is_critical
75-
WHERE r.host = 'github'
76-
AND (
75+
WHERE (
7776
-- never evaluated → always eligible
7877
r.contacts_last_refreshed IS NULL
7978
-- evaluated but no contacts found yet → retry on the daily cadence
@@ -109,7 +108,15 @@ function toTarget(row: SweepRow): RepoTarget {
109108
}
110109
}
111110

112-
async function processRepo(
111+
export function buildBaseDeps(config: Config): Omit<ExtractorDeps, 'repoTree'> {
112+
return {
113+
fetchTimeoutMs: FETCH_TIMEOUT_MS,
114+
userAgent: config.userAgent,
115+
githubGet: (path, opts) => githubApiGet(path, FETCH_TIMEOUT_MS, opts),
116+
}
117+
}
118+
119+
export async function processRepo(
113120
target: RepoTarget,
114121
baseDeps: Omit<ExtractorDeps, 'repoTree'>,
115122
qx: QueryExecutor,
@@ -162,11 +169,7 @@ export async function processBatch(qx: QueryExecutor, config: Config): Promise<B
162169
const batch = await fetchBatch(qx)
163170
if (batch.length === 0) return { processed: 0 }
164171

165-
const deps: Omit<ExtractorDeps, 'repoTree'> = {
166-
fetchTimeoutMs: FETCH_TIMEOUT_MS,
167-
userAgent: config.userAgent,
168-
githubGet: (path, opts) => githubApiGet(path, FETCH_TIMEOUT_MS, opts),
169-
}
172+
const deps = buildBaseDeps(config)
170173

171174
const targets = batch.map(toTarget)
172175
// Fixed-cadence heartbeat: a slow repo can outlast the 2-minute heartbeatTimeout even while

0 commit comments

Comments
 (0)