Files
speckle-server/packages/server/modules/automate/graph/resolvers/automate.ts
T
2024-06-20 12:24:15 +03:00

812 lines
25 KiB
TypeScript

import {
createFunction,
triggerAutomationRun,
updateFunction as execEngineUpdateFunction,
getFunction,
getFunctionRelease,
getFunctions,
getFunctionReleases,
getUserGithubAuthState,
getUserGithubOrganizations
} from '@/modules/automate/clients/executionEngine'
import {
GetProjectAutomationsParams,
getAutomation,
getAutomationRunsItems,
getAutomationRunsTotalCount,
getAutomationTriggerDefinitions,
getFullAutomationRevisionMetadata,
getFunctionRun,
getLatestAutomationRevision,
getLatestVersionAutomationRuns,
getProjectAutomationsItems,
getProjectAutomationsTotalCount,
storeAutomation,
storeAutomationRevision,
storeAutomationToken,
updateAutomationRun,
updateAutomation as updateDbAutomation,
upsertAutomationFunctionRun
} from '@/modules/automate/repositories/automations'
import {
createAutomation,
createAutomationRevision,
createTestAutomation,
getAutomationsStatus,
updateAutomation
} from '@/modules/automate/services/automationManagement'
import {
AuthCodePayloadAction,
createStoredAuthCode,
validateStoredAuthCode
} from '@/modules/automate/services/authCode'
import {
convertFunctionReleaseToGraphQLReturn,
convertFunctionToGraphQLReturn,
createFunctionFromTemplate,
updateFunction
} from '@/modules/automate/services/functionManagement'
import {
Resolvers,
AutomateRunTriggerType
} from '@/modules/core/graph/generated/graphql'
import { getGenericRedis } from '@/modules/core/index'
import { getUser } from '@/modules/core/repositories/users'
import { createAutomation as clientCreateAutomation } from '@/modules/automate/clients/executionEngine'
import { validateStreamAccess } from '@/modules/core/services/streams/streamAccessService'
import { Automate, Roles, isNullOrUndefined, isNonNullable } from '@speckle/shared'
import { getFeatureFlags, getServerOrigin } from '@/modules/shared/helpers/envHelper'
import {
getBranchLatestCommits,
getBranchesByIds
} from '@/modules/core/repositories/branches'
import {
createTestAutomationRun,
manuallyTriggerAutomation,
triggerAutomationRevisionRun
} from '@/modules/automate/services/trigger'
import {
reportFunctionRunStatus,
ReportFunctionRunStatusDeps
} from '@/modules/automate/services/runsManagement'
import {
AutomationNotFoundError,
FunctionNotFoundError
} from '@/modules/automate/errors/management'
import {
FunctionReleaseSchemaType,
dbToGraphqlTriggerTypeMap,
functionTemplateRepos
} from '@/modules/automate/helpers/executionEngine'
import { authorizeResolver } from '@/modules/shared'
import {
AutomationRevisionFunctionForInputRedaction,
getEncryptionKeyPair,
getEncryptionKeyPairFor,
getEncryptionPublicKey,
getFunctionInputDecryptor,
getFunctionInputsForFrontend
} from '@/modules/automate/services/encryption'
import { buildDecryptor } from '@/modules/shared/utils/libsodium'
import { keyBy } from 'lodash'
import { redactWriteOnlyInputData } from '@/modules/automate/utils/jsonSchemaRedactor'
import {
ProjectSubscriptions,
filteredSubscribe
} from '@/modules/shared/utils/subscriptions'
import {
mapDbStatusToGqlStatus,
mapGqlStatusToDbStatus
} from '@/modules/automate/utils/automateFunctionRunStatus'
import { AutomateApiDisabledError } from '@/modules/automate/errors/core'
import {
ExecutionEngineFailedResponseError,
ExecutionEngineNetworkError
} from '@/modules/automate/errors/executionEngine'
const { FF_AUTOMATE_MODULE_ENABLED } = getFeatureFlags()
export = (FF_AUTOMATE_MODULE_ENABLED
? {
/**
* If automate module is enabled
*/
AutomationRevisionTriggerDefinition: {
__resolveType(parent) {
if (
dbToGraphqlTriggerTypeMap[parent.triggerType] ===
AutomateRunTriggerType.VersionCreated
) {
return 'VersionCreatedTriggerDefinition'
}
return null
}
},
AutomationRunTrigger: {
__resolveType(parent) {
if (
dbToGraphqlTriggerTypeMap[parent.triggerType] ===
AutomateRunTriggerType.VersionCreated
) {
return 'VersionCreatedTrigger'
}
return null
}
},
VersionCreatedTriggerDefinition: {
type: () => AutomateRunTriggerType.VersionCreated,
async model(parent, _args, ctx) {
return ctx.loaders.branches.getById.load(parent.triggeringId)
}
},
VersionCreatedTrigger: {
type: () => AutomateRunTriggerType.VersionCreated,
async version(parent, _args, ctx) {
return ctx.loaders.commits.getById.load(parent.triggeringId)
},
async model(parent, _args, ctx) {
return ctx.loaders.commits.getCommitBranch.load(parent.triggeringId)
}
},
ProjectTriggeredAutomationsStatusUpdatedMessage: {
async project(parent, _args, ctx) {
return ctx.loaders.streams.getStream.load(parent.projectId)
},
async model(parent, _args, ctx) {
return ctx.loaders.branches.getById.load(parent.modelId)
},
async version(parent, _args, ctx) {
return ctx.loaders.commits.getById.load(parent.versionId)
}
},
Project: {
async automation(parent, args, ctx) {
const res = ctx.loaders.streams.getAutomation
.forStream(parent.id)
.load(args.id)
if (!res) {
if (!res) {
throw new AutomationNotFoundError()
}
}
return res
},
async automations(parent, args) {
const retrievalArgs: GetProjectAutomationsParams = {
projectId: parent.id,
args
}
const [{ items, cursor }, totalCount] = await Promise.all([
getProjectAutomationsItems(retrievalArgs),
getProjectAutomationsTotalCount(retrievalArgs)
])
return {
items,
totalCount,
cursor
}
}
},
Model: {
async automationsStatus(parent, _args, ctx) {
const getStatus = getAutomationsStatus({
getLatestVersionAutomationRuns
})
const modelId = parent.id
const projectId = parent.streamId
const latestCommit = await ctx.loaders.branches.getLatestCommit.load(
parent.id
)
// if the model has no versions, no automations could have run
if (!latestCommit) return null
return await getStatus({
projectId,
modelId,
versionId: latestCommit.id
})
}
},
Version: {
async automationsStatus(parent, _args, ctx) {
const getStatus = getAutomationsStatus({
getLatestVersionAutomationRuns
})
const versionId = parent.id
const branch = await ctx.loaders.commits.getCommitBranch.load(versionId)
if (!branch) throw Error('Invalid version Id')
const projectId = branch.streamId
const modelId = branch.id
return await getStatus({
projectId,
modelId,
versionId
})
}
},
Automation: {
async currentRevision(parent, _args, ctx) {
return ctx.loaders.automations.getLatestAutomationRevision.load(parent.id)
},
async runs(parent, args) {
const retrievalArgs = {
automationId: parent.id,
...args
}
const [{ items, cursor }, totalCount] = await Promise.all([
getAutomationRunsItems({
args: retrievalArgs
}),
getAutomationRunsTotalCount({
args: retrievalArgs
})
])
return {
items,
totalCount,
cursor
}
},
async creationPublicKeys(parent, _args, ctx) {
await authorizeResolver(
ctx.userId,
parent.projectId,
Roles.Stream.Owner,
ctx.resourceAccessRules
)
const publicKey = await getEncryptionPublicKey()
return [publicKey]
}
},
AutomateRun: {
async trigger(parent, _args, ctx) {
const triggers =
parent.triggers ||
(await ctx.loaders.automations.getRunTriggers.load(parent.id))
const trigger = triggers[0]
return trigger
},
async functionRuns(parent) {
return parent.functionRuns
},
async automation(parent, _args, ctx) {
return ctx.loaders.automations.getAutomation.load(parent.automationId)
},
status: (parent) => mapDbStatusToGqlStatus(parent.status)
},
TriggeredAutomationsStatus: {
status: (parent) => mapDbStatusToGqlStatus(parent.status)
},
AutomateFunctionRun: {
async function(parent, _args, ctx) {
const fn = await ctx.loaders.automationsApi.getFunction.load(
parent.functionId
)
if (!fn) {
ctx.log.warn(
{ id: parent.functionId, fnRunId: parent.id, runid: parent.runId },
'AutomateFunctionRun function unexpectedly not found'
)
return null
}
return convertFunctionToGraphQLReturn(fn)
},
results(parent, _args, ctx) {
try {
return parent.results
? Automate.AutomateTypes.formatResultsSchema(parent.results)
: null
} catch (e) {
ctx.log.warn('Error formatting results schema', e)
}
},
status: (parent) => mapDbStatusToGqlStatus(parent.status)
},
AutomationRevision: {
async triggerDefinitions(parent, _args, ctx) {
const triggers =
await ctx.loaders.automations.getRevisionTriggerDefinitions.load(parent.id)
return triggers
},
async functions(parent, _args, ctx) {
const prepareInputs = getFunctionInputsForFrontend({
getEncryptionKeyPairFor,
buildDecryptor,
redactWriteOnlyInputData
})
const fns = await ctx.loaders.automations.getRevisionFunctions.load(parent.id)
const fnsReleases = keyBy(
(
await ctx.loaders.automationsApi.getFunctionRelease.loadMany(
fns.map((fn) => [fn.functionId, fn.functionReleaseId])
)
).filter(
(r): r is FunctionReleaseSchemaType => r !== null && !(r instanceof Error)
),
(r) => r.functionVersionId
)
const fnsForRedaction: Array<AutomationRevisionFunctionForInputRedaction | null> =
fns.map((fn) => {
const release = fnsReleases[fn.functionReleaseId]
if (!release) {
return null
}
return {
...fn,
release
}
})
return prepareInputs({
fns: fnsForRedaction.filter(isNonNullable),
publicKey: parent.publicKey
})
}
},
AutomationRevisionFunction: {
async parameters(parent) {
return parent.functionInputs
}
},
AutomateFunction: {
async automationCount(parent, _args, ctx) {
return ctx.loaders.automations.getFunctionAutomationCount.load(parent.id)
},
async releases(parent, args) {
try {
// TODO: Replace w/ dataloader batch call, when/if possible
const fn = await getFunction({
functionId: parent.id,
releases:
args?.cursor || args?.filter?.search || args?.limit
? {
cursor: args.cursor || undefined,
versionsFilter: args.filter?.search || undefined,
limit: args.limit || undefined
}
: {}
})
return {
cursor: fn.versionCursor,
totalCount: fn.versionCount,
items: fn.functionVersions.map((r) =>
convertFunctionReleaseToGraphQLReturn({ ...r, functionId: parent.id })
)
}
} catch (e) {
const isNotFound =
e instanceof ExecutionEngineFailedResponseError &&
e.response.statusMessage === 'FunctionNotFound'
if (e instanceof ExecutionEngineNetworkError || isNotFound) {
return {
cursor: null,
totalCount: 0,
items: []
}
}
throw e
}
},
async creator(parent, _args, ctx) {
if (
!parent.functionCreator ||
parent.functionCreator.speckleServerOrigin !== getServerOrigin()
) {
return null
}
return ctx.loaders.users.getUser.load(parent.functionCreator.speckleUserId)
}
},
AutomateFunctionRelease: {
async function(parent, _args, ctx) {
const fn = await ctx.loaders.automationsApi.getFunction.load(
parent.functionId
)
if (!fn) {
throw new FunctionNotFoundError('Function not found', {
info: { id: parent.functionId }
})
}
return convertFunctionToGraphQLReturn(fn)
}
},
AutomateMutations: {
async createFunction(_parent, args, ctx) {
const create = createFunctionFromTemplate({
createExecutionEngineFn: createFunction,
getUser,
createStoredAuthCode: createStoredAuthCode({ redis: getGenericRedis() })
})
return (await create({ input: args.input, userId: ctx.userId! }))
.graphqlReturn
},
async updateFunction(_parent, args, ctx) {
const update = updateFunction({
updateFunction: execEngineUpdateFunction,
getFunction,
createStoredAuthCode: createStoredAuthCode({ redis: getGenericRedis() })
})
return await update({ input: args.input, userId: ctx.userId! })
}
},
ProjectAutomationMutations: {
async create(parent, { input }, ctx) {
const create = createAutomation({
createAuthCode: createStoredAuthCode({ redis: getGenericRedis() }),
automateCreateAutomation: clientCreateAutomation,
storeAutomation,
storeAutomationToken
})
return (
await create({
input,
userId: ctx.userId!,
projectId: parent.projectId,
userResourceAccessRules: ctx.resourceAccessRules
})
).automation
},
async update(parent, { input }, ctx) {
const update = updateAutomation({
getAutomation,
updateAutomation: updateDbAutomation
})
return await update({
input,
userId: ctx.userId!,
projectId: parent.projectId,
userResourceAccessRules: ctx.resourceAccessRules
})
},
async createRevision(parent, { input }, ctx) {
const create = createAutomationRevision({
getAutomation,
storeAutomationRevision,
getBranchesByIds,
getFunctionRelease,
getEncryptionKeyPair,
getFunctionInputDecryptor: getFunctionInputDecryptor({ buildDecryptor }),
getFunctionReleases
})
return await create({
input,
projectId: parent.projectId,
userId: ctx.userId!,
userResourceAccessRules: ctx.resourceAccessRules
})
},
async trigger(parent, { automationId }, ctx) {
const trigger = manuallyTriggerAutomation({
getAutomationTriggerDefinitions,
getAutomation,
getBranchLatestCommits,
triggerFunction: triggerAutomationRevisionRun({
automateRunTrigger: triggerAutomationRun,
getEncryptionKeyPairFor,
getFunctionInputDecryptor: getFunctionInputDecryptor({ buildDecryptor })
})
})
const { automationRunId } = await trigger({
automationId,
userId: ctx.userId!,
userResourceAccessRules: ctx.resourceAccessRules,
projectId: parent.projectId
})
return automationRunId
},
async createTestAutomation(parent, { input }, ctx) {
const create = createTestAutomation({
getEncryptionKeyPair,
getFunction,
storeAutomation,
storeAutomationRevision
})
return await create({
input,
projectId: parent.projectId,
userId: ctx.userId!,
userResourceAccessRules: ctx.resourceAccessRules
})
},
async createTestAutomationRun(parent, { automationId }, ctx) {
const create = createTestAutomationRun({
getEncryptionKeyPairFor,
getFunctionInputDecryptor: getFunctionInputDecryptor({
buildDecryptor
}),
getAutomation,
getLatestAutomationRevision,
getFullAutomationRevisionMetadata
})
return await create({
projectId: parent.projectId,
automationId,
userId: ctx.userId!
})
}
},
Query: {
async automateValidateAuthCode(_parent, args) {
const validate = validateStoredAuthCode({
redis: getGenericRedis()
})
return await validate({
...args.payload,
action: args.payload.action as AuthCodePayloadAction
})
},
async automateFunction(_parent, { id }, ctx) {
const fn = await ctx.loaders.automationsApi.getFunction.load(id)
if (!fn) {
throw new FunctionNotFoundError('Function not found', {
info: { id }
})
}
return convertFunctionToGraphQLReturn(fn)
},
async automateFunctions(_parent, args) {
try {
const res = await getFunctions({
query: {
query: args.filter?.search || undefined,
cursor: args.cursor || undefined,
limit: isNullOrUndefined(args.limit) ? undefined : args.limit,
functionsWithoutVersions:
args.filter?.functionsWithoutReleases || undefined,
featuredFunctionsOnly: args.filter?.featuredFunctionsOnly || undefined
}
})
const items = res.items.map(convertFunctionToGraphQLReturn)
return {
cursor: res.cursor,
totalCount: res.totalCount,
items
}
} catch (e) {
const isNotFound =
e instanceof ExecutionEngineFailedResponseError &&
e.response.statusMessage === 'FunctionNotFound'
if (e instanceof ExecutionEngineNetworkError || isNotFound) {
return {
cursor: null,
totalCount: 0,
items: []
}
}
throw e
}
}
},
User: {
automateInfo: (parent) => ({ userId: parent.id })
},
UserAutomateInfo: {
hasAutomateGithubApp: async (parent, _args, ctx) => {
const userId = parent.userId
let hasAutomateGithubApp = false
try {
const authState = await getUserGithubAuthState({ userId })
hasAutomateGithubApp = authState.userHasAuthorizedGitHubApp
} catch (e) {
ctx.log.error(e, 'Failed to resolve user automate github auth state')
}
return hasAutomateGithubApp
},
availableGithubOrgs: async (parent, _args, ctx) => {
const userId = parent.userId
const authCode = await createStoredAuthCode({ redis: getGenericRedis() })({
userId,
action: AuthCodePayloadAction.GetAvailableGithubOrganizations
})
let orgs: string[] = []
try {
orgs = (
await getUserGithubOrganizations({
authCode
})
).availableGitHubOrganisations
} catch (e) {
let isSeriousError = true
if (e instanceof ExecutionEngineFailedResponseError) {
if (e.response.statusMessage === 'InvalidOrMissingGithubAuth') {
isSeriousError = false
}
}
if (isSeriousError) {
ctx.log.error(e, 'Failed to resolve user automate github orgs')
}
}
return orgs
}
},
ServerInfo: {
// TODO: Needs proper integration w/ Execution engine
automate: () => ({
availableFunctionTemplates: functionTemplateRepos.slice()
})
},
ProjectMutations: {
async automationMutations(_parent, { projectId }, ctx) {
await validateStreamAccess(
ctx.userId,
projectId,
Roles.Stream.Owner,
ctx.resourceAccessRules
)
return { projectId }
}
},
Mutation: {
async automateFunctionRunStatusReport(_parent, { input }) {
const deps: ReportFunctionRunStatusDeps = {
getAutomationFunctionRunRecord: getFunctionRun,
upsertAutomationFunctionRunRecord: upsertAutomationFunctionRun,
automationRunUpdater: updateAutomationRun
}
const payload = {
...input,
contextView: input.contextView ?? null,
results: (input.results as Automate.AutomateTypes.ResultsSchema) ?? null,
runId: input.functionRunId,
status: mapGqlStatusToDbStatus(input.status),
statusMessage: input.statusMessage ?? null
}
const result = await reportFunctionRunStatus(deps)(payload)
return result
},
automateMutations: () => ({})
},
Subscription: {
projectTriggeredAutomationsStatusUpdated: {
subscribe: filteredSubscribe(
ProjectSubscriptions.ProjectTriggeredAutomationsStatusUpdated,
async (payload, args, ctx) => {
if (payload.projectId !== args.projectId) return false
await authorizeResolver(
ctx.userId,
payload.projectId,
Roles.Stream.Owner,
ctx.resourceAccessRules
)
return true
}
)
},
projectAutomationsUpdated: {
subscribe: filteredSubscribe(
ProjectSubscriptions.ProjectAutomationsUpdated,
async (payload, args, ctx) => {
if (payload.projectId !== args.projectId) return false
await authorizeResolver(
ctx.userId,
payload.projectId,
Roles.Stream.Owner,
ctx.resourceAccessRules
)
return true
}
)
}
}
}
: {
/**
* If automate module is disabled
*/
Project: {
automation: () => {
throw new AutomateApiDisabledError()
},
automations: () => {
throw new AutomateApiDisabledError()
}
},
AutomateMutations: {
createFunction: () => {
throw new AutomateApiDisabledError()
},
updateFunction: () => {
throw new AutomateApiDisabledError()
}
},
ProjectAutomationMutations: {
create: () => {
throw new AutomateApiDisabledError()
},
update: () => {
throw new AutomateApiDisabledError()
},
createRevision: () => {
throw new AutomateApiDisabledError()
},
trigger: () => {
throw new AutomateApiDisabledError()
}
},
Query: {
automateValidateAuthCode: () => {
throw new AutomateApiDisabledError()
},
automateFunction: () => {
throw new AutomateApiDisabledError()
},
automateFunctions: () => {
throw new AutomateApiDisabledError()
}
},
User: {
automateInfo: () => ({
hasAutomateGithubApp: false,
availableGithubOrgs: []
})
},
ServerInfo: {
automate: () => ({
availableFunctionTemplates: []
})
},
Mutation: {
automateFunctionRunStatusReport: () => {
throw new AutomateApiDisabledError()
},
automateMutations: () => ({})
},
Subscription: {
projectTriggeredAutomationsStatusUpdated: {
subscribe: filteredSubscribe(
ProjectSubscriptions.ProjectTriggeredAutomationsStatusUpdated,
() => false
)
},
projectAutomationsUpdated: {
subscribe: filteredSubscribe(
ProjectSubscriptions.ProjectAutomationsUpdated,
() => false
)
}
}
}) as Resolvers