Skip to content

Commit ce64f8a

Browse files
authored
Run cloud project locally (#12198)
* feat: RemoteBackend upload * DRAFT: run locally * feat: download project middleware * fix: project downloading * feat: recover missing project metadata * fix: open project * update: move project opening logic to menu * misc: format * fix: lint * fix: open project * feat: add feature flag * feat: upload project * misc: cleanup * update: simplify download request * update: simplify upload * update: download directory name * update: use crypto * update: failure handling todo * update: list projects with ensureQueryData * update: downloadProject error * misc: format * update: project schema * misc: fixes * fix: lint * fix: after merge
1 parent 0b061a4 commit ce64f8a

File tree

10 files changed

+247
-15
lines changed

10 files changed

+247
-15
lines changed

app/common/src/text/english.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -810,7 +810,7 @@
810810
"settingsShortcut": "Settings",
811811
"closeTabShortcut": "Close Tab",
812812
"openShortcut": "Open",
813-
"runShortcut": "Execute as Task",
813+
"runShortcut": "Run Locally",
814814
"closeShortcut": "Close",
815815
"uploadToCloudShortcut": "Upload to Cloud",
816816
"renameShortcut": "Rename",

app/gui/project-manager-shim-middleware/index.ts

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
* @file A HTTP server middleware which handles routes normally proxied through to
33
* the Project Manager.
44
*/
5+
import * as crypto from 'node:crypto'
56
import * as fsSync from 'node:fs'
67
import * as fs from 'node:fs/promises'
78
import * as http from 'node:http'
9+
import * as https from 'node:https'
810
import * as path from 'node:path'
911

1012
import * as tar from 'tar'
@@ -21,6 +23,7 @@ import * as projectManagement from './projectManagement'
2123
const HTTP_STATUS_OK = 200
2224
const HTTP_STATUS_BAD_REQUEST = 400
2325
const HTTP_STATUS_NOT_FOUND = 404
26+
const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500
2427
const PROJECTS_ROOT_DIRECTORY = projectManagement.getProjectsDirectory()
2528

2629
const COMMON_HEADERS = {
@@ -135,6 +138,106 @@ export default function projectManagerShimMiddleware(
135138
),
136139
{ end: true },
137140
)
141+
} else if (requestUrl != null && requestUrl.startsWith('/api/cloud/')) {
142+
switch (requestPath) {
143+
case '/api/cloud/download-project': {
144+
const url = new URL(`https://example.com/${requestUrl}`)
145+
const downloadUrl = url.searchParams.get('downloadUrl')
146+
const projectId = url.searchParams.get('projectId')
147+
148+
if (downloadUrl == null) {
149+
response
150+
.writeHead(HTTP_STATUS_BAD_REQUEST, COMMON_HEADERS)
151+
.end('Request is missing search parameter `downloadUrl`.')
152+
break
153+
}
154+
155+
if (projectId == null) {
156+
response
157+
.writeHead(HTTP_STATUS_BAD_REQUEST, COMMON_HEADERS)
158+
.end('Request is missing search parameter `projectId`.')
159+
break
160+
}
161+
162+
https.get(downloadUrl, (actualResponse) => {
163+
const projectsDirectory = projectManagement.getProjectsDirectory()
164+
const targetDirectory = path.join(projectsDirectory, `cloud-${projectId}`)
165+
166+
fs.mkdir(targetDirectory, { recursive: true })
167+
.then(() => projectManagement.unpackBundle(actualResponse, targetDirectory))
168+
.then((projectDirectory) => {
169+
response.writeHead(HTTP_STATUS_OK, COMMON_HEADERS).end(projectDirectory)
170+
})
171+
.catch((e) => {
172+
console.error(e)
173+
response.writeHead(HTTP_STATUS_INTERNAL_SERVER_ERROR, COMMON_HEADERS).end()
174+
})
175+
})
176+
177+
break
178+
}
179+
case '/api/cloud/upload-project': {
180+
const url = new URL(`https://example.com/${requestUrl}`)
181+
const uploadUrl = url.searchParams.get('uploadUrl')
182+
const projectDir = url.searchParams.get('directory')
183+
184+
if (uploadUrl == null) {
185+
response
186+
.writeHead(HTTP_STATUS_BAD_REQUEST, COMMON_HEADERS)
187+
.end('Request is missing search parameter `uploadUrl`.')
188+
break
189+
}
190+
if (projectDir == null) {
191+
response
192+
.writeHead(HTTP_STATUS_BAD_REQUEST, COMMON_HEADERS)
193+
.end('Request is missing search parameter `directory`.')
194+
break
195+
}
196+
197+
projectManagement
198+
.createBundle(projectDir)
199+
.then((projectBundle) => {
200+
const headers = {
201+
authorization: request.headers.authorization,
202+
}
203+
const uploadRequest = https.request(
204+
uploadUrl,
205+
{ method: 'POST', headers },
206+
(actualResponse) => {
207+
if (!response.writableFinished) {
208+
response.writeHead(
209+
// This is SAFE. The documentation says:
210+
// Only valid for response obtained from ClientRequest.
211+
actualResponse.statusCode!,
212+
actualResponse.statusMessage,
213+
actualResponse.headers,
214+
)
215+
actualResponse.pipe(response, { end: true })
216+
}
217+
},
218+
)
219+
uploadRequest.write(projectBundle, (err) => {
220+
if (err) {
221+
console.error(err)
222+
response
223+
.writeHead(HTTP_STATUS_INTERNAL_SERVER_ERROR)
224+
.end('Failed to write project bundle.')
225+
}
226+
})
227+
uploadRequest.end()
228+
})
229+
.catch((err) => {
230+
console.error(err)
231+
response.writeHead(HTTP_STATUS_INTERNAL_SERVER_ERROR, COMMON_HEADERS).end()
232+
})
233+
234+
break
235+
}
236+
default: {
237+
console.error(`Unknown Cloud middleware request:`, requestPath)
238+
break
239+
}
240+
}
138241
} else if (request.method === 'POST') {
139242
switch (requestPath) {
140243
case '/api/upload-file': {
@@ -251,10 +354,30 @@ export default function projectManagerShimMiddleware(
251354
projectManagement.PROJECT_METADATA_RELATIVE_PATH,
252355
)
253356
const packageMetadataContents = await fs.readFile(packageMetadataPath)
254-
const projectMetadataContents = await fs.readFile(projectMetadataPath)
357+
const packageMetadataYaml = yaml.parse(packageMetadataContents.toString())
358+
let projectMetadataJson
359+
try {
360+
const projectMetadataContents = await fs.readFile(projectMetadataPath)
361+
projectMetadataJson = JSON.parse(projectMetadataContents.toString())
362+
} catch (e) {
363+
if (
364+
'name' in packageMetadataYaml &&
365+
typeof packageMetadataYaml.name === 'string'
366+
) {
367+
projectMetadataJson = {
368+
id: crypto.randomUUID(),
369+
kind: 'UserProject',
370+
created: new Date().toISOString(),
371+
lastOpened: null,
372+
}
373+
fs.writeFile(projectMetadataPath, JSON.stringify(projectMetadataJson))
374+
} else {
375+
throw e
376+
}
377+
}
255378
const metadata = extractProjectMetadata(
256-
yaml.parse(packageMetadataContents.toString()),
257-
JSON.parse(projectMetadataContents.toString()),
379+
packageMetadataYaml,
380+
projectMetadataJson,
258381
)
259382
if (metadata != null) {
260383
// This is a project.

app/gui/project-manager-shim-middleware/projectManagement.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,40 @@ export async function uploadBundle(
6060
return bumpMetadata(targetPath, directory, name ?? null)
6161
}
6262

63+
/** Create a .tar.gz enso-project bundle. */
64+
export function createBundle(directory: string): Promise<Buffer> {
65+
const readableStream = tar.c(
66+
{
67+
z: true,
68+
C: directory,
69+
},
70+
['.'],
71+
)
72+
return new Promise((resolve, reject) => {
73+
const chunks: Buffer[] = []
74+
readableStream.on('data', (data) => chunks.push(data))
75+
readableStream.on('end', () => resolve(Buffer.concat(chunks)))
76+
readableStream.on('error', reject)
77+
})
78+
}
79+
80+
/** Unpack a .tar.gz enso-project bundle into a temporary directory */
81+
export async function unpackBundle(
82+
bundle: stream.Readable,
83+
targetDirectory: string,
84+
): Promise<string> {
85+
return new Promise((resolve, reject) => {
86+
bundle
87+
.pipe(
88+
tar.x({
89+
C: targetDirectory,
90+
}),
91+
)
92+
.on('end', () => resolve(targetDirectory))
93+
.on('error', (err) => reject(err))
94+
})
95+
}
96+
6397
// ================
6498
// === Metadata ===
6599
// ================

app/gui/src/dashboard/components/Devtools/EnsoDevtoolsImpl.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,15 @@ export function EnsoDevtools() {
280280
setFeatureFlag('enableAdvancedProjectExecutionOptions', value)
281281
}}
282282
/>
283+
<ariaComponents.Switch
284+
form={form}
285+
name="enableHybridExecution"
286+
label="Enable Hybrid Execution"
287+
description="Enable Hybrid Execution"
288+
onChange={(value) => {
289+
setFeatureFlag('enableHybridExecution', value)
290+
}}
291+
/>
283292
</>
284293
)}
285294
</ariaComponents.Form>

app/gui/src/dashboard/hooks/projectHooks.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,22 @@ export function useCloseProjectMutation() {
276276

277277
void client.cancelQueries({ queryKey })
278278
},
279-
onSuccess: async (_, { type, id, parentId }) => {
279+
onSuccess: async (_, { type, id, parentId, cloudProjectId }) => {
280280
await client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) })
281281
setProjectAsset(type, id, parentId, (asset) => ({
282282
...asset,
283283
projectState: { ...asset.projectState, type: backendModule.ProjectState.closed },
284284
}))
285+
286+
// If the project runs in hybrid execution mode
287+
// TODO: implement proper handling of upload failures
288+
if (cloudProjectId) {
289+
invariant(localBackend != null, 'LocalBackend is null')
290+
291+
await remoteBackend.uploadProject(cloudProjectId, parentId)
292+
293+
await localBackend.deleteAsset(parentId, { force: true }, null)
294+
}
285295
},
286296
onError: async (_, { type, id, parentId }) => {
287297
await client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) })

app/gui/src/dashboard/layouts/AssetContextMenu.tsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @file The context menu for an arbitrary {@link backendModule.Asset}. */
22
import * as React from 'react'
3+
import invariant from 'tiny-invariant'
34

45
import * as reactQuery from '@tanstack/react-query'
56
import * as toast from 'react-toastify'
@@ -38,6 +39,7 @@ import { useBackendQuery, useNewProject } from '#/hooks/backendHooks'
3839
import { useUploadFileWithToastMutation } from '#/hooks/backendUploadFilesHooks'
3940
import { useGetAsset } from '#/layouts/Drive/assetsTableItemsHooks'
4041
import { usePasteData } from '#/providers/DriveProvider'
42+
import * as featureFlagsProvider from '#/providers/FeatureFlagsProvider'
4143
import { computeFullRemotePath } from '#/services/RemoteBackend'
4244
import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths'
4345
import { normalizePath } from '#/utilities/fileInfo'
@@ -87,7 +89,6 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
8789
const restoreAssetsMutation = reactQuery.useMutation(restoreAssetsMutationOptions(backend))
8890
const copyAssetsMutation = reactQuery.useMutation(copyAssetsMutationOptions(backend))
8991
const downloadAssetsMutation = reactQuery.useMutation(downloadAssetsMutationOptions(backend))
90-
const openProjectMutation = projectHooks.useOpenProjectMutation()
9192
const self = permissions.tryFindSelfPermission(user, asset.permissions)
9293
const isCloud = categoryModule.isCloudCategory(category)
9394
const pathComputed =
@@ -170,6 +171,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
170171
asset.projectState.openedBy != null &&
171172
asset.projectState.openedBy !== user.email
172173

174+
const enableHybridExecution = featureFlagsProvider.useFeatureFlag('enableHybridExecution')
175+
173176
const pasteMenuEntry = hasPasteData && canPaste && (
174177
<ContextMenuEntry
175178
hidden={hidden}
@@ -245,19 +248,31 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
245248
}}
246249
/>
247250
)}
248-
{asset.type === backendModule.AssetType.project && isCloud && (
251+
{asset.type === backendModule.AssetType.project && isCloud && enableHybridExecution && (
249252
<ContextMenuEntry
250-
hidden={hidden}
253+
hidden={hidden || localBackend == null}
251254
action="run"
252255
isDisabled={!canOpenProjects}
253256
tooltip={disabledTooltip}
254-
doAction={() => {
255-
openProjectMutation.mutate({
256-
id: asset.id,
257-
title: asset.title,
258-
parentId: asset.parentId,
259-
type: state.backend.type,
260-
inBackground: true,
257+
doAction={async () => {
258+
invariant(localBackend != null, 'Local Backend is null')
259+
const parentId = await remoteBackend.downloadProject(asset.id)
260+
const assets = await localBackend.listDirectory({
261+
parentId: parentId,
262+
filterBy: null,
263+
labels: null,
264+
recentProjects: false,
265+
})
266+
const project = assets
267+
.filter((item) => item.type === backendModule.AssetType.project)
268+
.at(0)
269+
invariant(project, 'Downloaded cloud project does not exist.')
270+
openProject({
271+
id: project.id,
272+
title: project.title,
273+
parentId: project.parentId,
274+
type: backendModule.BackendType.local,
275+
cloudProjectId: asset.id,
261276
})
262277
}}
263278
/>

app/gui/src/dashboard/providers/FeatureFlagsProvider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const FEATURE_FLAGS_SCHEMA = z.object({
3131
enableCloudExecution: z.boolean(),
3232
enableAsyncExecution: z.boolean(),
3333
enableAdvancedProjectExecutionOptions: z.boolean(),
34+
enableHybridExecution: z.boolean(),
3435
})
3536

3637
/** Feature flags. */
@@ -56,6 +57,7 @@ const flagsStore = createStore<FeatureFlagsStore>()(
5657
enableCloudExecution: IS_DEV_MODE || isOnElectron(),
5758
enableAsyncExecution: true,
5859
enableAdvancedProjectExecutionOptions: false,
60+
enableHybridExecution: IS_DEV_MODE,
5961
},
6062
setFeatureFlag: (key, value) => {
6163
set(({ featureFlags }) => ({ featureFlags: { ...featureFlags, [key]: value } }))

app/gui/src/dashboard/providers/ProjectsProvider.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const PROJECT_SCHEMA = z
3333
),
3434
title: z.string(),
3535
type: z.nativeEnum(backendModule.BackendType),
36+
cloudProjectId: z.optional(
37+
z.custom<backendModule.ProjectId>((x) => typeof x === 'string' && x.startsWith('project-')),
38+
),
3639
})
3740
.readonly()
3841
const LAUNCHED_PROJECT_SCHEMA = z.array(PROJECT_SCHEMA).readonly()

app/gui/src/dashboard/services/RemoteBackend.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,6 +1527,38 @@ export default class RemoteBackend extends Backend {
15271527
}
15281528
}
15291529

1530+
/** Download the project to a temporary location. */
1531+
async downloadProject(id: backend.ProjectId): Promise<DirectoryId> {
1532+
const details = await this.getProjectDetails(id, true)
1533+
1534+
invariant(details.url != null, 'The download URL of the project must be present.')
1535+
1536+
const queryString = new URLSearchParams({
1537+
downloadUrl: details.url,
1538+
projectId: id,
1539+
})
1540+
1541+
const response = await this.client.get(`./api/cloud/download-project?${queryString}`)
1542+
const path = await response.text()
1543+
1544+
if (!response.ok) {
1545+
return await this.throw(response, 'resolveProjectAssetPathBackendError')
1546+
}
1547+
1548+
return DirectoryId(`directory-${path}` as const)
1549+
}
1550+
1551+
/** Upload the project. */
1552+
async uploadProject(id: backend.ProjectId, directoryId: backend.DirectoryId): Promise<void> {
1553+
const uploadPath = remoteBackendPaths.getProjectUploadPath(id)
1554+
const queryString = new URLSearchParams({
1555+
uploadUrl: `${$config.API_URL}/${uploadPath}`,
1556+
directory: extractIdFromDirectoryId(directoryId),
1557+
})
1558+
1559+
await this.client.get(`./api/cloud/upload-project?${queryString}`)
1560+
}
1561+
15301562
/** Fetch the URL of the customer portal. */
15311563
override async createCustomerPortalSession() {
15321564
const response = await this.post<backend.CreateCustomerPortalSessionResponse>(

app/gui/src/dashboard/services/remoteBackendPaths.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ export function getProjectContentPath(
109109
export function getProjectAssetPath(projectId: backend.ProjectId, relativePath: string) {
110110
return `projects/${projectId}/files/${relativePath.replace('./', '')}`
111111
}
112+
/** Relative HTTP path to the upload project endpoint of the Cloud backend API. */
113+
export function getProjectUploadPath(projectId: backend.ProjectId) {
114+
return `projects/${projectId}/upload`
115+
}
112116

113117
/** Relative HTTP path to the "update asset" endpoint of the Cloud backend API. */
114118
export function updateAssetPath(assetId: backend.AssetId) {

0 commit comments

Comments
 (0)