Files
speckle-server/packages/server/modules/automate/services/trigger.ts
T
Chuck Driesler 86c113b29b feat(regions): move project automations (#3925)
* feat(regions): repo functions for copying project branches and commits

* chore(regions): wire up move to resolver

* chore(regions): successful basic test of project region change

* fix(regions): sabrina carpenter please please please

* fix(regions): repair multiregion test setup

* chore(regions): appease ts

* chore(multiregion): update test multiregion config

* chore(multiregion): fix test docker config and test

* chore(multiregion): use transaction

* chore(multiregion): maybe this will work

* fix(multiregion): drop subs synchronously

* chore(multiregion): desperate test logs

* chore(multiregion): somehow that worked?

* chore(multiregion): add load-bearing log statement

* chore(multiregion): move services

* fix(multiregion): test drop waits

* chore(regions): fix import

* chore(regions): make test a bit more thorough for good measure

* fix(regions): move project objects

* chore(regions): add tests for object move

* feat(regions): move project automations

* chore(regions): add tests for moving automations

* chore(regions): more tests for moving automate data

* fix(regions): speed up inserts

* fix(regions): simplify postgres usage

* chore(regions): repair build

* fix(regions): improve queries

* chore(regions): again
2025-02-18 15:48:00 +00:00

659 lines
21 KiB
TypeScript

import { InsertableAutomationRun } from '@/modules/automate/repositories/automations'
import {
AutomationWithRevision,
AutomationTriggerDefinitionRecord,
AutomationRevisionWithTriggersFunctions,
VersionCreatedTriggerManifest,
VersionCreationTriggerType,
BaseTriggerManifest,
isVersionCreatedTriggerManifest,
LiveAutomation,
RunTriggerSource
} from '@/modules/automate/helpers/types'
import { Roles, Scopes } from '@speckle/shared'
import cryptoRandomString from 'crypto-random-string'
import { DefaultAppIds } from '@/modules/auth/defaultApps'
import { Merge } from 'type-fest'
import {
AutomateInvalidTriggerError,
AutomationFunctionInputEncryptionError
} from '@/modules/automate/errors/management'
import {
triggerAutomationRun,
type TriggeredAutomationFunctionRun
} from '@/modules/automate/clients/executionEngine'
import { TriggerAutomationError } from '@/modules/automate/errors/runs'
import { ContextResourceAccessRules } from '@/modules/core/helpers/token'
import { TokenResourceIdentifierType } from '@/modules/core/graph/generated/graphql'
import { automateLogger } from '@/logging/logging'
import { FunctionInputDecryptor } from '@/modules/automate/services/encryption'
import { LibsodiumEncryptionError } from '@/modules/shared/errors/encryption'
import {
GetActiveTriggerDefinitions,
GetAutomation,
GetAutomationRevision,
GetAutomationToken,
GetAutomationTriggerDefinitions,
GetEncryptionKeyPairFor,
GetFullAutomationRevisionMetadata,
GetLatestAutomationRevision,
TriggerAutomationRevisionRun,
UpsertAutomationRun
} from '@/modules/automate/domain/operations'
import { GetBranchLatestCommits } from '@/modules/core/domain/branches/operations'
import { GetCommit } from '@/modules/core/domain/commits/operations'
import { ValidateStreamAccess } from '@/modules/core/domain/streams/operations'
import { CreateAndStoreAppToken } from '@/modules/core/domain/tokens/operations'
import { EventBusEmit } from '@/modules/shared/services/eventBus'
import { AutomationRunEvents } from '@/modules/automate/domain/events'
import { isTestEnv } from '@/modules/shared/helpers/envHelper'
export type OnModelVersionCreateDeps = {
getAutomation: GetAutomation
getAutomationRevision: GetAutomationRevision
getTriggers: GetActiveTriggerDefinitions
triggerFunction: TriggerAutomationRevisionRun
}
/**
* This should hook into the model version create event
*/
export const onModelVersionCreateFactory =
(deps: OnModelVersionCreateDeps) =>
async (params: { modelId: string; versionId: string; projectId: string }) => {
const { modelId, versionId, projectId } = params
const { getAutomation, getAutomationRevision, getTriggers, triggerFunction } = deps
// get triggers where modelId matches
const triggerDefinitions = await getTriggers({
triggeringId: modelId,
triggerType: VersionCreationTriggerType
})
// get revisions where it matches any of the triggers and the revision is published
await Promise.all(
triggerDefinitions.map(async (tr) => {
try {
const { automationRevisionId, triggeringId, triggerType } = tr
const automationRevisionRecord = await getAutomationRevision({
automationRevisionId
})
if (!automationRevisionRecord) {
throw new AutomateInvalidTriggerError(
'Specified automation revision does not exist'
)
}
const automationRecord = await getAutomation({
automationId: automationRevisionRecord.automationId
})
if (!automationRecord) {
throw new AutomateInvalidTriggerError('Specified automation does not exist')
}
if (automationRecord.isTestAutomation) {
// Do not trigger functions on test automations
return
}
await triggerFunction<VersionCreatedTriggerManifest>({
revisionId: tr.automationRevisionId,
manifest: {
versionId,
projectId,
modelId: triggeringId,
triggerType
},
source: RunTriggerSource.Automatic
})
} catch (error) {
// TODO: this error should be persisted for automation status display somehow
automateLogger.error(
{ error, params },
'Failure while triggering run onModelVersionCreate'
)
}
})
)
}
type InsertableAutomationRunWithExtendedFunctionRuns = Merge<
InsertableAutomationRun,
{
functionRuns: Omit<TriggeredAutomationFunctionRun, 'runId'>[]
}
>
type CreateAutomationRunDataDeps = {
getEncryptionKeyPairFor: GetEncryptionKeyPairFor
getFunctionInputDecryptor: FunctionInputDecryptor
}
export const createAutomationRunDataFactory =
(deps: CreateAutomationRunDataDeps) =>
async (params: {
manifests: BaseTriggerManifest[]
automationWithRevision: AutomationWithRevision<AutomationRevisionWithTriggersFunctions>
}): Promise<InsertableAutomationRunWithExtendedFunctionRuns> => {
const { getEncryptionKeyPairFor, getFunctionInputDecryptor } = deps
const { manifests, automationWithRevision } = params
const runId = cryptoRandomString({ length: 15 })
const versionCreatedManifests = manifests.filter(isVersionCreatedTriggerManifest)
if (!versionCreatedManifests.length) {
throw new AutomateInvalidTriggerError(
'Only version creation triggers currently supported'
)
}
const keyPair = await getEncryptionKeyPairFor(
automationWithRevision.revision.publicKey
)
const functionInputDecryptor = await getFunctionInputDecryptor({ keyPair })
let automationRun: InsertableAutomationRunWithExtendedFunctionRuns
try {
automationRun = {
id: runId,
triggers: [
...versionCreatedManifests.map((m) => ({
triggeringId: m.versionId,
triggerType: m.triggerType
}))
],
executionEngineRunId: null,
status: 'pending' as const,
automationRevisionId: automationWithRevision.revision.id,
createdAt: new Date(),
updatedAt: new Date(),
functionRuns: await Promise.all(
automationWithRevision.revision.functions.map(async (f) => ({
functionId: f.functionId,
id: cryptoRandomString({ length: 15 }),
status: 'pending' as const,
elapsed: 0,
results: null,
contextView: null,
statusMessage: null,
resultVersions: [],
functionReleaseId: f.functionReleaseId,
functionInputs: await functionInputDecryptor.decryptInputs(
f.functionInputs
),
createdAt: new Date(),
updatedAt: new Date()
}))
)
}
} catch (e) {
if (e instanceof AutomationFunctionInputEncryptionError) {
throw new AutomateInvalidTriggerError(
'One or more function inputs are not proper input objects',
{ cause: e }
)
}
if (e instanceof LibsodiumEncryptionError) {
throw new AutomateInvalidTriggerError(
'Failed to decrypt one or more function inputs, they might not have been properly encrypted',
{ cause: e }
)
}
throw e
} finally {
functionInputDecryptor.dispose()
}
return automationRun
}
export type TriggerAutomationRevisionRunDeps = {
automateRunTrigger: typeof triggerAutomationRun
getAutomationToken: GetAutomationToken
createAppToken: CreateAndStoreAppToken
upsertAutomationRun: UpsertAutomationRun
emitEvent: EventBusEmit
getFullAutomationRevisionMetadata: GetFullAutomationRevisionMetadata
getCommit: GetCommit
} & CreateAutomationRunDataDeps &
ComposeTriggerDataDeps
/**
* This triggers a run for a specific automation revision
*/
export const triggerAutomationRevisionRunFactory =
(deps: TriggerAutomationRevisionRunDeps): TriggerAutomationRevisionRun =>
async <M extends BaseTriggerManifest = BaseTriggerManifest>(params: {
revisionId: string
manifest: M
source: RunTriggerSource
}): Promise<{ automationRunId: string }> => {
const {
automateRunTrigger,
getAutomationToken,
createAppToken,
upsertAutomationRun,
emitEvent,
getFullAutomationRevisionMetadata,
getCommit
} = deps
const { revisionId, manifest, source } = params
if (!isVersionCreatedTriggerManifest(manifest)) {
throw new AutomateInvalidTriggerError(
'Only model version triggers are currently supported'
)
}
const { automationWithRevision, userId, automateToken } =
await ensureRunConditionsFactory({
revisionGetter: getFullAutomationRevisionMetadata,
versionGetter: getCommit,
automationTokenGetter: getAutomationToken
})({
revisionId,
manifest
})
const triggerManifests = await composeTriggerDataFactory(deps)({
manifest,
projectId: automationWithRevision.projectId,
triggerDefinitions: automationWithRevision.revision.triggers
})
// TODO: Q Gergo: Should this really be project scoped?
const projectScopedToken = await createAppToken({
appId: DefaultAppIds.Automate,
name: `at-${automationWithRevision.id}@${manifest.versionId}`,
userId,
// for now this is a baked in constant
// should rely on the function definitions requesting the needed scopes
scopes: [
Scopes.Profile.Read,
Scopes.Streams.Read,
Scopes.Streams.Write,
Scopes.Automate.ReportResults
],
limitResources: [
{
id: automationWithRevision.projectId,
type: TokenResourceIdentifierType.Project
}
]
})
const automationRun = await createAutomationRunDataFactory(deps)({
manifests: triggerManifests,
automationWithRevision
})
await upsertAutomationRun(automationRun)
try {
const { automationRunId } = await automateRunTrigger({
projectId: automationWithRevision.projectId,
automationId: automationWithRevision.executionEngineAutomationId,
manifests: triggerManifests,
functionRuns: automationRun.functionRuns.map((r) => ({
...r,
runId: automationRun.id
})),
speckleToken: projectScopedToken,
automationToken: automateToken
})
automationRun.executionEngineRunId = automationRunId
await upsertAutomationRun(automationRun)
} catch (error) {
const statusMessage = error instanceof Error ? error.message : `${error}`
automationRun.status = 'exception'
automationRun.functionRuns = automationRun.functionRuns.map((fr) => ({
...fr,
status: 'exception',
statusMessage
}))
await upsertAutomationRun(automationRun)
}
await emitEvent({
eventName: AutomationRunEvents.Created,
payload: {
run: automationRun,
manifests: triggerManifests,
automation: automationWithRevision,
source,
triggerType: manifest.triggerType
}
})
return { automationRunId: automationRun.id }
}
export const ensureRunConditionsFactory =
(deps: {
revisionGetter: GetFullAutomationRevisionMetadata
versionGetter: GetCommit
automationTokenGetter: GetAutomationToken
}) =>
async <M extends BaseTriggerManifest = BaseTriggerManifest>(params: {
revisionId: string
manifest: M
}): Promise<{
automationWithRevision: LiveAutomation<
AutomationWithRevision<AutomationRevisionWithTriggersFunctions>
>
userId: string
automateToken: string
}> => {
const { revisionGetter, versionGetter, automationTokenGetter } = deps
const { revisionId, manifest } = params
const automationWithRevision = await revisionGetter(revisionId)
if (!automationWithRevision)
throw new AutomateInvalidTriggerError(
"Cannot trigger the given revision, it doesn't exist"
)
// if the automation is a test automation, do not trigger
if (automationWithRevision.isTestAutomation) {
throw new AutomateInvalidTriggerError(
'This is a test automation and cannot be triggered outside of local testing'
)
}
// if the automation is not active, do not trigger
if (!automationWithRevision.enabled)
throw new AutomateInvalidTriggerError(
'The automation is not enabled, cannot trigger it'
)
if (!automationWithRevision.revision.active)
throw new AutomateInvalidTriggerError(
'The automation revision is not active, cannot trigger it'
)
if (!isVersionCreatedTriggerManifest(manifest))
throw new AutomateInvalidTriggerError('Only model version triggers are supported')
const triggerDefinition = automationWithRevision.revision.triggers.find((t) => {
if (t.triggerType !== manifest.triggerType) return false
if (isVersionCreatedTriggerManifest(manifest)) {
return t.triggeringId === manifest.modelId
}
return false
})
if (!triggerDefinition)
throw new AutomateInvalidTriggerError(
"The given revision doesn't have a trigger registered matching the input trigger"
)
const triggeringVersion = await versionGetter(manifest.versionId)
if (!triggeringVersion)
throw new AutomateInvalidTriggerError('The triggering version is not found')
const userId = triggeringVersion.author
if (!userId)
throw new AutomateInvalidTriggerError(
"The user, that created the triggering version doesn't exist any more"
)
const token = await automationTokenGetter(automationWithRevision.id)
if (!token)
throw new AutomateInvalidTriggerError('Cannot find a token for the automation')
return {
automationWithRevision,
userId,
automateToken: token.automateToken
}
}
type ComposeTriggerDataDeps = {
getBranchLatestCommits: GetBranchLatestCommits
}
export const composeTriggerDataFactory =
(deps: ComposeTriggerDataDeps) =>
async (params: {
projectId: string
manifest: BaseTriggerManifest
triggerDefinitions: AutomationTriggerDefinitionRecord[]
}): Promise<BaseTriggerManifest[]> => {
const { projectId, manifest, triggerDefinitions } = params
const manifests: BaseTriggerManifest[] = [{ ...manifest }]
/**
* The reason why we collect multiple triggers, even tho there's only one:
* - We want to collect the current context (all active versions of all triggers) at the time when the run is triggered,
* cause once the function already runs, there may be new versions already
*/
if (triggerDefinitions.length > 1) {
const associatedTriggers = triggerDefinitions.filter((t) => {
if (t.triggerType !== manifest.triggerType) return false
if (isVersionCreatedTriggerManifest(manifest)) {
return t.triggeringId === manifest.modelId
}
return false
})
// Version creation triggers
if (manifest.triggerType === VersionCreationTriggerType) {
const latestVersions = await deps.getBranchLatestCommits(
associatedTriggers.map((t) => t.triggeringId),
projectId
)
manifests.push(
...latestVersions.map(
(version): VersionCreatedTriggerManifest => ({
modelId: version.branchId,
projectId,
versionId: version.id,
triggerType: VersionCreationTriggerType
})
)
)
}
}
return manifests
}
export type ManuallyTriggerAutomationDeps = {
getAutomationTriggerDefinitions: GetAutomationTriggerDefinitions
getAutomation: GetAutomation
getBranchLatestCommits: GetBranchLatestCommits
triggerFunction: TriggerAutomationRevisionRun
validateStreamAccess: ValidateStreamAccess
}
export const manuallyTriggerAutomationFactory =
(deps: ManuallyTriggerAutomationDeps) =>
async (params: {
automationId: string
userId: string
projectId?: string
userResourceAccessRules?: ContextResourceAccessRules
}) => {
const { automationId, userId, projectId, userResourceAccessRules } = params
const {
getAutomationTriggerDefinitions,
getAutomation,
getBranchLatestCommits,
triggerFunction,
validateStreamAccess
} = deps
const [automation, triggerDefs] = await Promise.all([
getAutomation({ automationId, projectId }),
getAutomationTriggerDefinitions({
automationId,
projectId,
triggerType: VersionCreationTriggerType
})
])
if (!automation) {
throw new TriggerAutomationError('Automation not found')
}
if (!triggerDefs.length) {
throw new TriggerAutomationError(
'No model version creation triggers found for the automation'
)
}
await validateStreamAccess(
userId,
automation.projectId,
Roles.Stream.Owner,
userResourceAccessRules
)
const validModelIds = triggerDefs.map((t) => t.triggeringId)
const [latestCommit] = await getBranchLatestCommits(
validModelIds,
automation.projectId,
{ limit: 1 }
)
if (!latestCommit) {
throw new TriggerAutomationError(
'No version to trigger on found for the available triggers'
)
}
// Trigger "model version created"
const { automationRunId } = await triggerFunction({
revisionId: triggerDefs[0].automationRevisionId,
manifest: <VersionCreatedTriggerManifest>{
projectId,
modelId: latestCommit.branchId,
versionId: latestCommit.id,
triggerType: VersionCreationTriggerType
},
source: RunTriggerSource.Manual
})
return { automationRunId }
}
export type CreateTestAutomationRunDeps = {
getAutomation: GetAutomation
getLatestAutomationRevision: GetLatestAutomationRevision
getFullAutomationRevisionMetadata: GetFullAutomationRevisionMetadata
upsertAutomationRun: UpsertAutomationRun
validateStreamAccess: ValidateStreamAccess
getBranchLatestCommits: GetBranchLatestCommits
emitEvent: EventBusEmit
} & CreateAutomationRunDataDeps &
ComposeTriggerDataDeps
/**
* TODO: Reduce duplication w/ other fns in this service
*/
export const createTestAutomationRunFactory =
(deps: CreateTestAutomationRunDeps) =>
async (params: { projectId: string; automationId: string; userId: string }) => {
const {
getAutomation,
getLatestAutomationRevision,
getFullAutomationRevisionMetadata,
upsertAutomationRun,
validateStreamAccess,
getBranchLatestCommits,
emitEvent
} = deps
const { projectId, automationId, userId } = params
await validateStreamAccess(userId, projectId, Roles.Stream.Owner)
const automationRecord = await getAutomation({ automationId })
if (!automationRecord) {
throw new TriggerAutomationError('Automation not found')
}
if (!isTestEnv() && !automationRecord.isTestAutomation) {
throw new TriggerAutomationError(
'Automation is not a test automation and cannot create test function runs'
)
}
const { id: automationRevisionId } =
(await getLatestAutomationRevision({ automationId })) ?? {}
if (!automationRevisionId) {
throw new TriggerAutomationError('Automation revision not found')
}
const automationRevisionRecord = await getFullAutomationRevisionMetadata(
automationRevisionId
)
if (!automationRevisionRecord) {
throw new TriggerAutomationError('Automation revision metadata not found')
}
const trigger = automationRevisionRecord.revision.triggers[0]
if (!trigger || !isVersionCreatedTriggerManifest(trigger)) {
throw new TriggerAutomationError('Trigger is not found or malformed')
}
const modelId = trigger.triggeringId
const [latestCommit] = await getBranchLatestCommits(
[modelId],
automationRevisionRecord.projectId,
{ limit: 1 }
)
const triggerManifests = await composeTriggerDataFactory(deps)({
projectId: automationRevisionRecord.projectId,
triggerDefinitions: automationRevisionRecord.revision.triggers,
manifest: <VersionCreatedTriggerManifest>{
projectId: automationRevisionRecord.projectId,
modelId: latestCommit.branchId,
versionId: latestCommit.id,
triggerType: VersionCreationTriggerType
}
})
const automationRunRecord = await createAutomationRunDataFactory(deps)({
manifests: triggerManifests,
automationWithRevision: automationRevisionRecord
})
await upsertAutomationRun(automationRunRecord)
await emitEvent({
eventName: 'automationRuns.created',
payload: {
automation: automationRevisionRecord,
run: automationRunRecord,
source: RunTriggerSource.Test,
manifests: triggerManifests,
triggerType: VersionCreationTriggerType
}
})
// TODO: Test functions only support one function run per automation
const functionRunId = automationRunRecord.functionRuns[0].id
return {
automationRunId: automationRunRecord.id,
functionRunId,
triggers: [
{
payload: {
modelId: latestCommit.branchId,
versionId: latestCommit.id
},
triggerType: VersionCreationTriggerType
}
]
}
}