bde148f286
* wip * some extra fixes * stuff kinda works? * need to figure out mocks * need to figure out mocks * fix db listener * gqlgen fix * minor gqlgen watch adjustment * lint fixes * delete old codegen file * converting migrations to ESM * getModuleDIrectory * vitest sort of works * added back ts-vitest * resolve gql double load * fixing test timeout configs * TSC lint fix * fix automate tests * moar debugging * debugging * more debugging * codegen update * server works * yargs migrated * chore(server): getting rid of global mocks for Server ESM (#5046) * got rid of email mock * got rid of comment mocks * got rid of multi region mocks * got rid of stripe mock * admin override mock updated * removed final mock * fixing import.meta.resolve calls * another import.meta.resolve fix * added requested test * nyc ESM fix * removed unneeded deps + linting * yarn lock forgot to commit * tryna fix flakyness * email capture util fix * sendEmail fix * fix TSX check * sender transporter fix + CR comments * merge main fix * test fixx * circleci fix * gqlgen bigint fix * error formatter fix * more error formatting improvements * esmloader added to Dockerfile * more dockerfile fixes * bg jobs fix
604 lines
18 KiB
TypeScript
604 lines
18 KiB
TypeScript
import {
|
|
InsertableAutomationRevision,
|
|
InsertableAutomationRevisionFunction,
|
|
InsertableAutomationRevisionTrigger
|
|
} from '@/modules/automate/repositories/automations'
|
|
import { getServerOrigin } from '@/modules/shared/helpers/envHelper'
|
|
import cryptoRandomString from 'crypto-random-string'
|
|
import {
|
|
createAutomation as clientCreateAutomation,
|
|
getFunctionReleaseFactory,
|
|
getFunctionReleasesFactory
|
|
} from '@/modules/automate/clients/executionEngine'
|
|
import {
|
|
Automate,
|
|
Roles,
|
|
ensureError,
|
|
removeNullOrUndefinedKeys
|
|
} from '@speckle/shared'
|
|
import { AuthCodePayloadAction } from '@/modules/automate/services/authCode'
|
|
import {
|
|
ProjectAutomationCreateInput,
|
|
ProjectAutomationRevisionCreateInput,
|
|
ProjectAutomationUpdateInput
|
|
} from '@/modules/core/graph/generated/graphql'
|
|
import { ContextResourceAccessRules } from '@/modules/core/helpers/token'
|
|
import {
|
|
AutomationFunctionInputEncryptionError,
|
|
AutomationRevisionCreationError,
|
|
AutomationUpdateError,
|
|
JsonSchemaInputValidationError
|
|
} from '@/modules/automate/errors/management'
|
|
import {
|
|
AutomationRunStatus,
|
|
AutomationRunStatuses,
|
|
VersionCreationTriggerType
|
|
} from '@/modules/automate/helpers/types'
|
|
import { keyBy, uniq } from 'lodash-es'
|
|
import { resolveStatusFromFunctionRunStatuses } from '@/modules/automate/services/runsManagement'
|
|
import { TriggeredAutomationsStatusGraphQLReturn } from '@/modules/automate/helpers/graphTypes'
|
|
import { FunctionInputDecryptor } from '@/modules/automate/services/encryption'
|
|
import { LibsodiumEncryptionError } from '@/modules/shared/errors/encryption'
|
|
import { validateInputAgainstFunctionSchema } from '@/modules/automate/utils/inputSchemaValidator'
|
|
import { validateAutomationName } from '@/modules/automate/utils/automationConfigurationValidator'
|
|
import {
|
|
CreateAutomation,
|
|
CreateStoredAuthCode,
|
|
MarkAutomationDeleted,
|
|
GetAutomation,
|
|
GetEncryptionKeyPair,
|
|
GetLatestVersionAutomationRuns,
|
|
StoreAutomation,
|
|
StoreAutomationRevision,
|
|
StoreAutomationToken,
|
|
UpdateAutomation
|
|
} from '@/modules/automate/domain/operations'
|
|
import { GetBranchesByIds } from '@/modules/core/domain/branches/operations'
|
|
import { ValidateStreamAccess } from '@/modules/core/domain/streams/operations'
|
|
import { EventBusEmit } from '@/modules/shared/services/eventBus'
|
|
import { AutomationEvents } from '@/modules/automate/domain/events'
|
|
import { UnformattableTriggerDefinitionSchemaError } from '@speckle/shared/automate'
|
|
|
|
export type CreateAutomationDeps = {
|
|
createAuthCode: CreateStoredAuthCode
|
|
automateCreateAutomation: typeof clientCreateAutomation
|
|
storeAutomation: StoreAutomation
|
|
storeAutomationToken: StoreAutomationToken
|
|
eventEmit: EventBusEmit
|
|
}
|
|
|
|
export const createAutomationFactory =
|
|
(deps: CreateAutomationDeps): CreateAutomation =>
|
|
async (params: {
|
|
input: ProjectAutomationCreateInput
|
|
projectId: string
|
|
userId: string
|
|
userResourceAccessRules?: ContextResourceAccessRules
|
|
}) => {
|
|
const {
|
|
input: { name, enabled },
|
|
projectId,
|
|
userId
|
|
} = params
|
|
const {
|
|
createAuthCode,
|
|
automateCreateAutomation,
|
|
storeAutomation,
|
|
storeAutomationToken,
|
|
eventEmit
|
|
} = deps
|
|
|
|
validateAutomationName(name)
|
|
|
|
const authCode = await createAuthCode({
|
|
userId,
|
|
action: AuthCodePayloadAction.CreateAutomation
|
|
})
|
|
|
|
// trigger automation creation on automate
|
|
const { automationId: executionEngineAutomationId, token } =
|
|
await automateCreateAutomation({
|
|
speckleServerUrl: getServerOrigin(),
|
|
authCode
|
|
})
|
|
|
|
const automationId = cryptoRandomString({ length: 10 })
|
|
|
|
const automationRecord = await storeAutomation({
|
|
id: automationId,
|
|
name,
|
|
userId,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
enabled,
|
|
projectId,
|
|
executionEngineAutomationId,
|
|
isTestAutomation: false,
|
|
isDeleted: false
|
|
})
|
|
|
|
const automationTokenRecord = await storeAutomationToken({
|
|
automationId,
|
|
automateToken: token
|
|
})
|
|
|
|
await eventEmit({
|
|
eventName: AutomationEvents.Created,
|
|
payload: {
|
|
automation: automationRecord
|
|
}
|
|
})
|
|
|
|
return { automation: automationRecord, token: automationTokenRecord }
|
|
}
|
|
|
|
export type CreateTestAutomationDeps = {
|
|
getEncryptionKeyPair: GetEncryptionKeyPair
|
|
storeAutomation: StoreAutomation
|
|
storeAutomationRevision: StoreAutomationRevision
|
|
validateStreamAccess: ValidateStreamAccess
|
|
eventEmit: EventBusEmit
|
|
}
|
|
|
|
/**
|
|
* Create a test automation and its first revision in one request.
|
|
* TODO: Reduce code duplication w/ createAutomation
|
|
*/
|
|
export const createTestAutomationFactory =
|
|
(deps: CreateTestAutomationDeps) =>
|
|
async (params: {
|
|
automationName: string
|
|
projectId: string
|
|
modelId: string
|
|
userId: string
|
|
}) => {
|
|
const { automationName, projectId, modelId, userId } = params
|
|
const {
|
|
getEncryptionKeyPair,
|
|
storeAutomation,
|
|
storeAutomationRevision,
|
|
eventEmit
|
|
} = deps
|
|
|
|
validateAutomationName(automationName)
|
|
|
|
// Create and store the automation record
|
|
const automationId = cryptoRandomString({ length: 10 })
|
|
|
|
const automationRecord = await storeAutomation({
|
|
id: automationId,
|
|
name: automationName,
|
|
userId,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
enabled: true,
|
|
projectId,
|
|
executionEngineAutomationId: null,
|
|
isTestAutomation: true,
|
|
isDeleted: false
|
|
})
|
|
|
|
await eventEmit({
|
|
eventName: AutomationEvents.Created,
|
|
payload: {
|
|
automation: automationRecord
|
|
}
|
|
})
|
|
|
|
// Create and store the automation revision
|
|
const encryptionKeyPair = await getEncryptionKeyPair()
|
|
|
|
const automationRevisionRecord = await storeAutomationRevision({
|
|
functions: [],
|
|
triggers: [
|
|
{
|
|
triggerType: VersionCreationTriggerType,
|
|
triggeringId: modelId
|
|
}
|
|
],
|
|
automationId,
|
|
userId,
|
|
active: true,
|
|
publicKey: encryptionKeyPair.publicKey
|
|
})
|
|
|
|
await eventEmit({
|
|
eventName: AutomationEvents.CreatedRevision,
|
|
payload: {
|
|
automation: automationRecord,
|
|
revision: automationRevisionRecord
|
|
}
|
|
})
|
|
|
|
return automationRecord
|
|
}
|
|
|
|
export const deleteAutomationFactory =
|
|
(deps: { deleteAutomation: MarkAutomationDeleted }) =>
|
|
async (params: { automationId: string }) => {
|
|
const { automationId } = params
|
|
return await deps.deleteAutomation({ automationId })
|
|
}
|
|
|
|
export type ValidateAndUpdateAutomationDeps = {
|
|
getAutomation: GetAutomation
|
|
updateAutomation: UpdateAutomation
|
|
eventEmit: EventBusEmit
|
|
}
|
|
|
|
export const validateAndUpdateAutomationFactory =
|
|
(deps: ValidateAndUpdateAutomationDeps) =>
|
|
async (params: {
|
|
input: ProjectAutomationUpdateInput
|
|
userId: string
|
|
userResourceAccessRules?: ContextResourceAccessRules
|
|
/**
|
|
* If set, will validate that the automation belongs to that user
|
|
*/
|
|
projectId?: string
|
|
}) => {
|
|
const { getAutomation, updateAutomation, eventEmit } = deps
|
|
const { input, projectId } = params
|
|
|
|
const existingAutomation = await getAutomation({
|
|
automationId: input.id,
|
|
projectId
|
|
})
|
|
if (!existingAutomation) {
|
|
throw new AutomationUpdateError('Automation not found')
|
|
}
|
|
|
|
// Filter out empty (null) values from input
|
|
const updates = removeNullOrUndefinedKeys(input)
|
|
|
|
// Skip if there's nothing left
|
|
if (Object.keys(updates).length === 0) {
|
|
return existingAutomation
|
|
}
|
|
|
|
const res = await updateAutomation({
|
|
...updates,
|
|
id: input.id
|
|
})
|
|
|
|
await eventEmit({
|
|
eventName: AutomationEvents.Updated,
|
|
payload: {
|
|
automation: res
|
|
}
|
|
})
|
|
|
|
return res
|
|
}
|
|
|
|
type ValidateNewTriggerDefinitionsDeps = {
|
|
getBranchesByIds: GetBranchesByIds
|
|
}
|
|
|
|
const validateNewTriggerDefinitions =
|
|
(deps: ValidateNewTriggerDefinitionsDeps) =>
|
|
async (params: {
|
|
triggerDefinitions: InsertableAutomationRevisionTrigger[]
|
|
projectId: string
|
|
}) => {
|
|
const { triggerDefinitions, projectId } = params
|
|
const { getBranchesByIds } = deps
|
|
|
|
if (!triggerDefinitions.length) {
|
|
throw new AutomationRevisionCreationError(
|
|
'At least one trigger definition is required'
|
|
)
|
|
}
|
|
|
|
const invalidTriggers = triggerDefinitions.filter(
|
|
(t) => t.triggerType !== VersionCreationTriggerType
|
|
)
|
|
if (invalidTriggers.length) {
|
|
throw new AutomationRevisionCreationError(
|
|
'Only version creation triggers are currently supported'
|
|
)
|
|
}
|
|
|
|
// Validate version creation triggers
|
|
const versionCreationTriggerDefinitions = triggerDefinitions
|
|
const modelIds = uniq(versionCreationTriggerDefinitions.map((t) => t.triggeringId))
|
|
const models = keyBy(
|
|
await getBranchesByIds(modelIds, { streamId: projectId }),
|
|
'id'
|
|
)
|
|
|
|
for (const modelId of modelIds) {
|
|
const model = models[modelId]
|
|
if (!model) {
|
|
throw new AutomationRevisionCreationError(
|
|
`Model with ID ${modelId} not found in project`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
type ValidateNewRevisionFunctionsDeps = {
|
|
getFunctionRelease: ReturnType<typeof getFunctionReleaseFactory>
|
|
}
|
|
|
|
const validateNewRevisionFunctions =
|
|
(deps: ValidateNewRevisionFunctionsDeps) =>
|
|
async (params: { functions: InsertableAutomationRevisionFunction[] }) => {
|
|
const { functions } = params
|
|
const { getFunctionRelease } = deps
|
|
|
|
const updateId = (params: { functionId: string; functionReleaseId: string }) =>
|
|
`${params.functionId}-${params.functionReleaseId}`
|
|
|
|
// Validate functions exist
|
|
const uniqueUpdates = keyBy(functions, updateId)
|
|
const releases = keyBy(
|
|
await Promise.all(
|
|
Object.values(uniqueUpdates).map(async (fn) => ({
|
|
// TODO: Replace w/ batch call, when/if possible
|
|
...(await getFunctionRelease(fn)),
|
|
functionId: fn.functionId
|
|
}))
|
|
),
|
|
(r) =>
|
|
updateId({
|
|
functionReleaseId: r?.functionVersionId ?? '',
|
|
functionId: r.functionId
|
|
})
|
|
)
|
|
|
|
for (const [key, uniqueUpdate] of Object.entries(uniqueUpdates)) {
|
|
if (!releases[key]) {
|
|
throw new AutomationRevisionCreationError(
|
|
`Function release for function ID ${uniqueUpdate.functionId} and function release id ${uniqueUpdate.functionReleaseId} not found`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
export type CreateAutomationRevisionDeps = {
|
|
getAutomation: GetAutomation
|
|
storeAutomationRevision: StoreAutomationRevision
|
|
getEncryptionKeyPair: GetEncryptionKeyPair
|
|
getFunctionInputDecryptor: FunctionInputDecryptor
|
|
getFunctionReleases: ReturnType<typeof getFunctionReleasesFactory>
|
|
validateStreamAccess: ValidateStreamAccess
|
|
eventEmit: EventBusEmit
|
|
} & ValidateNewTriggerDefinitionsDeps &
|
|
ValidateNewRevisionFunctionsDeps
|
|
|
|
export const createAutomationRevisionFactory =
|
|
(deps: CreateAutomationRevisionDeps) =>
|
|
async (params: {
|
|
input: ProjectAutomationRevisionCreateInput
|
|
userId: string
|
|
userResourceAccessRules?: ContextResourceAccessRules
|
|
projectId?: string
|
|
}) => {
|
|
const { input, userId, userResourceAccessRules, projectId } = params
|
|
const {
|
|
storeAutomationRevision,
|
|
getAutomation,
|
|
getEncryptionKeyPair,
|
|
getFunctionInputDecryptor,
|
|
getFunctionReleases,
|
|
validateStreamAccess,
|
|
eventEmit
|
|
} = deps
|
|
|
|
const existingAutomation = await getAutomation({
|
|
automationId: input.automationId,
|
|
projectId
|
|
})
|
|
if (!existingAutomation) {
|
|
throw new AutomationUpdateError('Automation not found')
|
|
}
|
|
|
|
await validateStreamAccess(
|
|
userId,
|
|
existingAutomation.projectId,
|
|
Roles.Stream.Owner,
|
|
userResourceAccessRules
|
|
)
|
|
|
|
let triggers: Automate.AutomateTypes.TriggerDefinitionsSchema
|
|
try {
|
|
triggers = Automate.AutomateTypes.formatTriggerDefinitionSchema(
|
|
input.triggerDefinitions
|
|
)
|
|
} catch (e) {
|
|
if (e instanceof UnformattableTriggerDefinitionSchemaError) {
|
|
throw new AutomationRevisionCreationError(
|
|
'One or more trigger definitions are not valid',
|
|
{ cause: ensureError(e, 'Unknown error when formatting trigger definition') }
|
|
)
|
|
}
|
|
throw e
|
|
}
|
|
const triggerDefinitions = triggers.definitions.map((d) => {
|
|
if (Automate.AutomateTypes.isVersionCreatedTriggerDefinition(d)) {
|
|
const triggerDef: InsertableAutomationRevisionTrigger = {
|
|
triggerType: VersionCreationTriggerType,
|
|
triggeringId: d.modelId
|
|
}
|
|
|
|
return triggerDef
|
|
}
|
|
|
|
throw new AutomationRevisionCreationError('Unexpected trigger type')
|
|
})
|
|
await validateNewTriggerDefinitions(deps)({
|
|
triggerDefinitions,
|
|
projectId: projectId || existingAutomation.projectId
|
|
})
|
|
|
|
const encryptionKeys = await getEncryptionKeyPair()
|
|
const decryptor = await getFunctionInputDecryptor({ keyPair: encryptionKeys })
|
|
let functions: InsertableAutomationRevisionFunction[] = []
|
|
try {
|
|
const releases = await getFunctionReleases({
|
|
ids: input.functions.map((f) => ({
|
|
functionReleaseId: f.functionReleaseId,
|
|
functionId: f.functionId
|
|
}))
|
|
})
|
|
|
|
functions = await Promise.all(
|
|
input.functions.map(async (f) => {
|
|
const release = releases.find(
|
|
(r) =>
|
|
r.functionVersionId === f.functionReleaseId &&
|
|
r.functionId === f.functionId
|
|
)
|
|
if (!release) {
|
|
throw new AutomationRevisionCreationError(
|
|
`Function release for function ID ${f.functionId} and function release ID ${f.functionReleaseId} not found`
|
|
)
|
|
}
|
|
const schema = release.inputSchema
|
|
|
|
// Validate parameters
|
|
const decryptedParams = await decryptor.decryptInputs(f.parameters || null)
|
|
if (decryptedParams && !schema) {
|
|
throw new AutomationRevisionCreationError(
|
|
'Function inputs provided for a function that does not have an input schema'
|
|
)
|
|
}
|
|
|
|
validateInputAgainstFunctionSchema(schema, decryptedParams)
|
|
|
|
// Didn't throw, let's continue
|
|
const fn: InsertableAutomationRevisionFunction = {
|
|
functionReleaseId: f.functionReleaseId,
|
|
functionId: f.functionId,
|
|
functionInputs: f.parameters || null
|
|
}
|
|
|
|
return fn
|
|
})
|
|
)
|
|
} catch (e) {
|
|
if (e instanceof AutomationFunctionInputEncryptionError) {
|
|
throw new AutomationRevisionCreationError(
|
|
'One or more function inputs are not proper input objects',
|
|
{ cause: e }
|
|
)
|
|
}
|
|
|
|
if (e instanceof LibsodiumEncryptionError) {
|
|
throw new AutomationRevisionCreationError(
|
|
'Failed to decrypt one or more function inputs. Please ensure they have been properly encrypted',
|
|
{ cause: e }
|
|
)
|
|
}
|
|
|
|
if (e instanceof JsonSchemaInputValidationError) {
|
|
throw new AutomationRevisionCreationError(
|
|
"One or more function inputs do not match their function's schema",
|
|
{ cause: e }
|
|
)
|
|
}
|
|
|
|
throw e
|
|
} finally {
|
|
decryptor.dispose()
|
|
}
|
|
|
|
await validateNewRevisionFunctions(deps)({ functions })
|
|
|
|
const revisionInput: InsertableAutomationRevision = {
|
|
functions,
|
|
triggers: triggerDefinitions,
|
|
automationId: input.automationId,
|
|
userId,
|
|
active: true,
|
|
publicKey: encryptionKeys.publicKey
|
|
}
|
|
const res = await storeAutomationRevision(revisionInput)
|
|
|
|
await eventEmit({
|
|
eventName: AutomationEvents.CreatedRevision,
|
|
payload: {
|
|
automation: existingAutomation,
|
|
revision: res
|
|
}
|
|
})
|
|
|
|
return res
|
|
}
|
|
|
|
export type GetAutomationsStatusDeps = {
|
|
getLatestVersionAutomationRuns: GetLatestVersionAutomationRuns
|
|
}
|
|
|
|
export const getAutomationsStatusFactory =
|
|
(deps: GetAutomationsStatusDeps) =>
|
|
async (params: {
|
|
projectId: string
|
|
modelId: string
|
|
versionId: string
|
|
}): Promise<TriggeredAutomationsStatusGraphQLReturn | null> => {
|
|
const { projectId, modelId, versionId } = params
|
|
const { getLatestVersionAutomationRuns } = deps
|
|
|
|
const runs = await getLatestVersionAutomationRuns({
|
|
projectId,
|
|
modelId,
|
|
versionId
|
|
})
|
|
if (!runs.length) return null
|
|
|
|
// automation run has its own status field that should be up to date, but
|
|
// lets calculate it again to be sure
|
|
const runsWithUpdatedStatus = runs.map((r) => ({
|
|
...r,
|
|
status: resolveStatusFromFunctionRunStatuses(
|
|
r.functionRuns.map((fr) => fr.status)
|
|
),
|
|
projectId: params.projectId
|
|
}))
|
|
|
|
const failedAutomations = runsWithUpdatedStatus.filter(
|
|
(a) =>
|
|
a.status === AutomationRunStatuses.failed ||
|
|
a.status === AutomationRunStatuses.exception
|
|
)
|
|
|
|
const runningAutomations = runsWithUpdatedStatus.filter(
|
|
(a) => a.status === AutomationRunStatuses.running
|
|
)
|
|
const initializingAutomations = runsWithUpdatedStatus.filter(
|
|
(a) => a.status === AutomationRunStatuses.pending
|
|
)
|
|
|
|
let status: AutomationRunStatus = AutomationRunStatuses.succeeded
|
|
let statusMessage = 'All automations have succeeded'
|
|
|
|
if (failedAutomations.length) {
|
|
status = AutomationRunStatuses.failed
|
|
statusMessage = 'Some automations have failed:'
|
|
for (const fa of failedAutomations) {
|
|
for (const functionRunStatus of fa.functionRuns) {
|
|
if (
|
|
functionRunStatus.status === AutomationRunStatuses.failed ||
|
|
functionRunStatus.status === AutomationRunStatuses.exception
|
|
)
|
|
statusMessage += `\n${functionRunStatus.statusMessage}`
|
|
}
|
|
}
|
|
} else if (runningAutomations.length) {
|
|
status = AutomationRunStatuses.running
|
|
statusMessage = 'Some automations are running'
|
|
} else if (initializingAutomations.length) {
|
|
status = AutomationRunStatuses.pending
|
|
statusMessage = 'Some automations are initializing'
|
|
}
|
|
|
|
return {
|
|
id: versionId,
|
|
status,
|
|
statusMessage,
|
|
automationRuns: runsWithUpdatedStatus
|
|
}
|
|
}
|