diff --git a/.vscode/launch.json b/.vscode/launch.json index 8880465..17e58dd 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,7 +15,7 @@ "outFiles": [ "${workspaceFolder}/out/**/*.js" ], - "preLaunchTask": "${defaultBuildTask}" + "preLaunchTask": "tsc: watch" } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 30bf8c2..be647ae 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,8 @@ "out": true // set this to false to include "out" folder in search results }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off" + "typescript.tsc.autoDetect": "off", + "cSpell.words": [ + "ttdbg" + ] } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 6974b02..3b8da82 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,21 +1,53 @@ -// See https://go.microsoft.com/fwlink/?LinkId=733558 -// for the documentation about the tasks.json format { "version": "2.0.0", "tasks": [ { + "label": "tsc: build", + "type": "typescript", + "group": { + "kind": "build", + "isDefault": true + }, + "tsconfig": "tsconfig.json", + "problemMatcher": "$tsc", + "presentation": { + "reveal": "silent" + } + }, + { + "label": "tsc: watch", + "type": "typescript", + "group": "build", + "tsconfig": "tsconfig.json", + "option": "watch", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "reveal": "silent" + } + }, + { + "label": "npm: watch", "type": "npm", + "group": "build", "script": "watch", "problemMatcher": "$esbuild-watch", "isBackground": true, "presentation": { "reveal": "never", "revealProblems": "onProblem" - }, - "group": { - "kind": "build", - "isDefault": true + } + }, + { + "label": "npm: compile", + "type": "npm", + "group": "build", + "script": "compile", + "problemMatcher": "$esbuild", + "presentation": { + "reveal": "never", + "revealProblems": "onProblem" } } ] -} +} \ No newline at end of file diff --git a/package.json b/package.json index 2a09110..58fc885 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,12 @@ "category": "DBOS", "icon": "$(log-in)" }, + { + "command": "dbos-ttdbg.refresh-domain", + "title": "Refresh DBOS Cloud Resources", + "category": "DBOS", + "icon": "$(refresh)" + }, { "command": "dbos-ttdbg.delete-domain-credentials", "title": "Delete Stored DBOS Cloud Credentials", @@ -52,6 +58,23 @@ "command": "dbos-ttdbg.shutdown-debug-proxy", "title": "Shutdown Debug Proxy", "category": "DBOS" + }, + { + "command": "dbos-ttdbg.update-debug-proxy", + "title": "Update Debug Proxy", + "category": "DBOS" + }, + { + "command": "dbos-ttdbg.launch-debug-proxy", + "title": "Launch Debug Proxy", + "category": "DBOS", + "icon": "$(debug)" + }, + { + "command": "dbos-ttdbg.launch-dashboard", + "title": "Launch DBOS Dashboard", + "category": "DBOS", + "icon": "$(server)" } ], "configuration": { @@ -85,6 +108,16 @@ "when": "view == dbos-ttdbg.views.resources && viewItem == cloudDomain", "group": "inline" }, + { + "command": "dbos-ttdbg.launch-dashboard", + "when": "view == dbos-ttdbg.views.resources && (viewItem == cloudDomain || viewItem == cloudApp)", + "group": "inline" + }, + { + "command": "dbos-ttdbg.refresh-domain", + "when": "view == dbos-ttdbg.views.resources && viewItem == cloudDomain", + "group": "inline" + }, { "command": "dbos-ttdbg.delete-domain-credentials", "when": "view == dbos-ttdbg.views.resources && viewItem == cloudDomain" @@ -92,6 +125,11 @@ { "command": "dbos-ttdbg.delete-app-db-password", "when": "view == dbos-ttdbg.views.resources && viewItem == cloudApp" + }, + { + "command": "dbos-ttdbg.launch-debug-proxy", + "when": "view == dbos-ttdbg.views.resources && viewItem == cloudApp", + "group": "inline" } ] }, diff --git a/src/CloudDataProvider.ts b/src/CloudDataProvider.ts index bd00e66..8247daa 100644 --- a/src/CloudDataProvider.ts +++ b/src/CloudDataProvider.ts @@ -1,83 +1,94 @@ import * as vscode from 'vscode'; -import { DbosCloudApp, DbosCloudDatabase, DbosCloudCredentials, listApps, listDatabases, getCloudDomain, authenticate, isTokenExpired, DbosCloudDomain } from './dbosCloudApi'; +import { DbosCloudApp, getCloudDomain, DbosCloudDbInstance, listApps, listDbInstances, isUnauthorized } from './dbosCloudApi'; import { config } from './extension'; +import { validateCredentials } from './validateCredentials'; export interface CloudDomainNode { kind: "cloudDomain"; domain: string; - credentials?: DbosCloudCredentials; } -interface CloudServiceTypeNode { - kind: "cloudServiceType"; - type: "Applications" | "Databases"; +interface CloudResourceTypeNode { + kind: "cloudResourceType"; + type: "apps" | "dbInstances"; domain: string; - credentials?: DbosCloudCredentials; }; export interface CloudAppNode { kind: "cloudApp"; + domain: string; app: DbosCloudApp; - credentials: DbosCloudCredentials; }; -export interface CloudDatabaseNode { - kind: "cloudDatabase"; - database: DbosCloudDatabase; - credentials: DbosCloudCredentials; +export interface CloudDbInstanceNode { + kind: "cloudDbInstance"; + domain: string; + dbInstance: DbosCloudDbInstance; } -type CloudProviderNode = CloudDomainNode | CloudServiceTypeNode | CloudAppNode | CloudDatabaseNode; +type CloudProviderNode = CloudDomainNode | CloudResourceTypeNode | CloudAppNode | CloudDbInstanceNode; export class CloudDataProvider implements vscode.TreeDataProvider { private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; - private readonly domains = new Set(); + private readonly domains: Array; + private readonly apps = new Map(); + private readonly dbInstances = new Map(); constructor() { const { cloudDomain } = getCloudDomain(); - this.domains.add(cloudDomain); + this.domains = [{ kind: "cloudDomain", domain: cloudDomain }]; } - async #getCredentials(domain?: string | DbosCloudDomain): Promise { - const storedCredentials = await config.getStoredCloudCredentials(domain); - if (storedCredentials) { - return storedCredentials; + async refresh(domain: string) { + this.apps.delete(domain); + this.dbInstances.delete(domain); + + const node = this.domains.find(d => d.domain === domain); + if (node) { + this.onDidChangeTreeDataEmitter.fire(node); } - return await config.cloudLogin(domain); } async getChildren(element?: CloudProviderNode | undefined): Promise { if (element === undefined) { - const children = new Array(); - for (const domain of this.domains) { - const credentials = await config.getStoredCloudCredentials(domain); - children.push({ kind: 'cloudDomain', domain, credentials }); - } - return children; + return this.domains; } if (element.kind === "cloudDomain") { - return [ - { kind: 'cloudServiceType', domain: element.domain, credentials: element.credentials, type: "Applications" }, - { kind: 'cloudServiceType', domain: element.domain, credentials: element.credentials, type: "Databases" }, - ]; + if (!this.apps.has(element.domain) || !this.dbInstances.has(element.domain)) { + const credentials = await config.getCredentials(element.domain); + if (!validateCredentials(credentials)) { return []; } + + const [apps, dbInstances] = await Promise.all([listApps(credentials), listDbInstances(credentials)]); + if (isUnauthorized(apps)) { + this.apps.delete(element.domain); + } else { + this.apps.set(element.domain, apps.map(a => ({ kind: "cloudApp", domain: element.domain, app: a }))); + } + if (isUnauthorized(dbInstances)) { + this.dbInstances.delete(element.domain); + } else { + this.dbInstances.set(element.domain, dbInstances.map(dbi => ({ kind: "cloudDbInstance", domain: element.domain, dbInstance: dbi }))); + } + return [ + { kind: "cloudResourceType", type: "apps", domain: element.domain }, + { kind: "cloudResourceType", type: "dbInstances", domain: element.domain }, + ]; + } } - if (element.kind === "cloudServiceType") { - const credentials = element.credentials ?? await this.#getCredentials(element.domain); - if (!credentials) { return []; } + if (element.kind === "cloudResourceType") { switch (element.type) { - case "Applications": { - const apps = await listApps(credentials); - return apps.map(app => ({ kind: "cloudApp", app, credentials })); + case "apps": { + return this.apps.get(element.domain) ?? []; } - case "Databases": { - const dbs = await listDatabases(credentials); - return dbs.map(database => ({ kind: "cloudDatabase", database, credentials })); + case "dbInstances": { + return this.dbInstances.get(element.domain) ?? []; } default: + const _: never = element.type; throw new Error(`Unknown service type: ${element.type}`); } } @@ -86,38 +97,64 @@ export class CloudDataProvider implements vscode.TreeDataProvider { - if (element.kind === 'cloudDomain') { - return { - label: element.domain, - collapsibleState: vscode.TreeItemCollapsibleState.Expanded, - contextValue: element.kind, - }; - } - - if (element.kind === 'cloudServiceType') { - return { - label: element.type, - collapsibleState: vscode.TreeItemCollapsibleState.Expanded, - contextValue: element.kind, - }; - } - - if (element.kind === 'cloudApp') { - return { - label: element.app.Name, - collapsibleState: vscode.TreeItemCollapsibleState.None, - contextValue: element.kind, - }; - } - - if (element.kind === 'cloudDatabase') { - return { - label: element.database.PostgresInstanceName, - collapsibleState: vscode.TreeItemCollapsibleState.None, - contextValue: element.kind, - }; + const { kind } = element; + switch (kind) { + case "cloudDomain": { + return { + label: element.domain, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + contextValue: element.kind, + }; + } + case 'cloudResourceType': { + let label: string; + switch (element.type) { + case "apps": label = "Applications"; break; + case "dbInstances": label = "Database Instances"; break; + default: + const _: never = element.type; + throw new Error(`Unknown service type: ${element.type}`); + } + return { + label, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + contextValue: element.kind, + }; + } + case 'cloudApp': { + const { app } = element; + const tooltip = ` +Database Instance: ${app.PostgresInstanceName}\n +Database Name: ${app.ApplicationDatabaseName}\n +Status: ${app.Status}\n +Version: ${app.Version}\n +Application URL: ${app.AppURL}`; + + return { + label: app.Name, + collapsibleState: vscode.TreeItemCollapsibleState.None, + contextValue: element.kind, + tooltip: new vscode.MarkdownString(tooltip) + }; + } + case 'cloudDbInstance': { + const { dbInstance: dbi } = element; + const tooltip = ` +Host Name: ${dbi.HostName}\n +Port: ${dbi.Port}\n +Username: ${dbi.DatabaseUsername}\n +Status: ${dbi.Status}`; + + return { + label: element.dbInstance.PostgresInstanceName, + collapsibleState: vscode.TreeItemCollapsibleState.None, + contextValue: element.kind, + tooltip: new vscode.MarkdownString(tooltip) + }; + } + default: + const _: never = kind; + throw new Error(`Unknown service type: ${kind}`); } - - throw new Error(`Unknown element type: ${JSON.stringify(element)}`); } } diff --git a/src/CloudStorage.ts b/src/CloudStorage.ts index 6883394..a845592 100644 --- a/src/CloudStorage.ts +++ b/src/CloudStorage.ts @@ -2,7 +2,30 @@ import * as vscode from 'vscode'; import { GetObjectCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3"; import { HttpHandlerOptions } from "@aws-sdk/types"; import * as semver from "semver"; -import { PLATFORM, ARCHITECTURE } from './utils'; + +const PLATFORM = function () { + switch (process.platform) { + case "linux": + return "linux"; + case "darwin": + return "macos"; + case "win32": + return "windows"; + default: + throw new Error(`Unsupported platform: ${process.platform}`); + } +}(); + +const ARCHITECTURE = function () { + switch (process.arch) { + case "arm64": + return "arm64"; + case "x64": + return "x64"; + default: + throw new Error(`Unsupported architecture: ${process.arch}`); + } +}(); export interface CloudObject { asByteArray(): Promise; @@ -13,12 +36,6 @@ export interface CloudStorage { getVersions(token?: vscode.CancellationToken): AsyncGenerator; } -export interface S3CloudStorageOptions { - region?: string; - bucket?: string; - releaseName?: string; -} - export class S3CloudStorage implements CloudStorage { private readonly bucket: string; private readonly region: string; @@ -26,7 +43,11 @@ export class S3CloudStorage implements CloudStorage { private readonly s3: S3Client; private readonly regexVersion: RegExp; - constructor(options?: S3CloudStorageOptions) { + constructor(options?: { + region?: string; + bucket?: string; + releaseName?: string; + }) { this.bucket = options?.bucket ?? "dbos-releases"; this.region = options?.region ?? "us-east-2"; this.releaseName = options?.releaseName ?? "debug-proxy"; diff --git a/src/sourceParser.ts b/src/CodeLensProvider.ts similarity index 60% rename from src/sourceParser.ts rename to src/CodeLensProvider.ts index 70ce989..cd4109e 100644 --- a/src/sourceParser.ts +++ b/src/CodeLensProvider.ts @@ -1,19 +1,73 @@ +import * as vscode from 'vscode'; import ts from 'typescript'; +import { startDebuggingCodeLensCommandName } from './commands'; +import { logger } from './extension'; -export interface ImportInfo { +export type DbosMethodType = "Workflow" | "Transaction" | "Communicator"; + +export type DbosMethodInfo = { name: string; type: DbosMethodType }; + +export function getDbosWorkflowName(name: string, $type: DbosMethodType): string { + switch ($type) { + case "Workflow": return name; + case "Transaction": return `temp_workflow-transaction-${name}`; + case "Communicator": return `temp_workflow-external-${name}`; + default: throw new Error(`Unsupported DbosMethodType: ${$type}`); + } +} + +export class CodeLensProvider implements vscode.CodeLensProvider { + provideCodeLenses(document: vscode.TextDocument, _token: vscode.CancellationToken): vscode.ProviderResult { + try { + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!folder) { return; } + + const text = document.getText(); + const file = ts.createSourceFile( + document.fileName, + text, + ts.ScriptTarget.Latest + ); + + return parse(file) + .map(methodInfo => { + const methodType = getDbosMethodType(methodInfo.decorators); + if (!methodType) { return undefined; } + + const start = methodInfo.node.getStart(file); + const end = methodInfo.node.getEnd(); + const range = new vscode.Range( + document.positionAt(start), + document.positionAt(end) + ); + + return new vscode.CodeLens(range, { + title: '⏳ Time Travel Debug', + tooltip: `Debug ${methodInfo.name} with the DBOS Time Travel Debugger`, + command: startDebuggingCodeLensCommandName, + arguments: [folder, { name: methodInfo.name, type: methodType }] + }); + }) + .filter((x?: T): x is T => !!x); + } catch (e) { + logger.error("provideCodeLenses", e); + } + } +} + + +interface ImportInfo { readonly name: string; readonly module: string; }; -export interface MethodInfo { +interface MethodInfo { readonly node: ts.MethodDeclaration; readonly name: string; readonly decorators: readonly ImportInfo[]; } -export type DbosMethodType = "Workflow" | "Transaction" | "Communicator"; - -export function getDbosMethodType(decorators: readonly ImportInfo[]): DbosMethodType | undefined { +function getDbosMethodType(decorators: readonly ImportInfo[]): DbosMethodType | undefined { for (const d of decorators) { if (d.module !== '@dbos-inc/dbos-sdk') { continue; } if (d.name === "Workflow") { return "Workflow"; } @@ -23,16 +77,8 @@ export function getDbosMethodType(decorators: readonly ImportInfo[]): DbosMethod return undefined; } -export function getDbosWorkflowName(name: string, $type: DbosMethodType): string { - switch ($type) { - case "Workflow": return name; - case "Transaction": return `temp_workflow-transaction-${name}`; - case "Communicator": return `temp_workflow-external-${name}`; - default: throw new Error(`Unsupported DbosMethodType: ${$type}`); - } -} -export function parse(file: ts.SourceFile): readonly MethodInfo[] { +function parse(file: ts.SourceFile): readonly MethodInfo[] { if (file.isDeclarationFile) { return []; } diff --git a/src/configuration.ts b/src/Configuration.ts similarity index 77% rename from src/configuration.ts rename to src/Configuration.ts index 9109330..78ba946 100644 --- a/src/configuration.ts +++ b/src/Configuration.ts @@ -1,8 +1,8 @@ import * as vscode from 'vscode'; -import { getPackageName } from './utils'; +import { exists } from './utility'; import { logger } from './extension'; -import { DbosCloudApp, DbosCloudCredentials, DbosCloudDomain, authenticate, getAppInfo, getCloudDomain, getDatabaseInfo } from './dbosCloudApi'; -import { validateCredentials } from './userFlows'; +import { type DbosCloudApp, type DbosCloudCredentials, type DbosCloudDomain, authenticate, getApp, getCloudDomain, getDbInstance, isUnauthorized } from './dbosCloudApi'; +import { validateCredentials } from './validateCredentials'; const TTDBG_CONFIG_SECTION = "dbos-ttdbg"; const PROV_DB_HOST = "prov_db_host"; @@ -24,8 +24,13 @@ export interface DbosDebugConfig { export async function getDebugConfigFromDbosCloud(app: string | DbosCloudApp, credentials: DbosCloudCredentials): Promise | undefined> { if (!validateCredentials(credentials)) { return undefined; } - if (typeof app === 'string') { app = await getAppInfo(app, credentials); } - const db = await getDatabaseInfo(app.PostgresInstanceName, credentials); + if (typeof app === 'string') { + const $app = await getApp(app, credentials); + if (isUnauthorized($app)) { return undefined; } + app = $app; + } + const db = await getDbInstance(app.PostgresInstanceName, credentials); + if (isUnauthorized(db)) { return undefined; } const cloudConfig = { host: db.HostName, port: db.Port, @@ -89,11 +94,23 @@ export class Configuration { return credentials; } + async getCredentials(domain?: string | DbosCloudDomain) { + const { cloudDomain } = getCloudDomain(domain); + const storedCredentials = await this.getStoredCloudCredentials(cloudDomain); + return storedCredentials ?? await this.cloudLogin(cloudDomain); + } + async deleteStoredCloudCredentials(domain?: string | DbosCloudDomain) { const { cloudDomain } = getCloudDomain(domain); const secretKey = domainSecretKey(cloudDomain); - await this.secrets.delete(secretKey); - logger.debug("Deleted DBOS Cloud credentials", { cloudDomain }); + const json = await this.secrets.get(secretKey); + if (json) { + await this.secrets.delete(secretKey); + logger.debug("Deleted DBOS Cloud credentials", { cloudDomain }); + return true; + } else { + return false; + } } async getDebugConfig(folder: vscode.WorkspaceFolder, credentials: DbosCloudCredentials): Promise { @@ -119,7 +136,7 @@ export class Configuration { if (cloudConfig) { logger.debug("getCloudConfig", { folder: folder.uri.fsPath, cloudConfig }); - return { ...cloudConfig, password: () => this.#getAppDatabasePassword(cloudConfig) }; + return { ...cloudConfig, password: () => this.getAppDatabasePassword(cloudConfig) }; } else { throw new Error("Invalid CloudConfig", { cause: { folder: folder.uri.fsPath, credentials } }); } @@ -148,7 +165,7 @@ export class Configuration { await this.secrets.store(databaseSetKey, JSON.stringify(Array.from(set))); } - async #getAppDatabasePassword(debugConfig: Pick): Promise { + async getAppDatabasePassword(debugConfig: Pick): Promise { const key = databaseSecretKey(debugConfig); let password = await this.secrets.get(key); if (!password) { @@ -180,4 +197,19 @@ export class Configuration { } await this.secrets.delete(databaseSetKey); } -} \ No newline at end of file +} + +async function getPackageName(folder: vscode.WorkspaceFolder): Promise { + const packageJsonUri = vscode.Uri.joinPath(folder.uri, "package.json"); + if (!await exists(packageJsonUri)) { return undefined; } + + try { + const packageJsonBuffer = await vscode.workspace.fs.readFile(packageJsonUri); + const packageJsonText = new TextDecoder().decode(packageJsonBuffer); + const packageJson = JSON.parse(packageJsonText); + return packageJson.name; + } catch (e) { + logger.error("getPackageName", e); + return undefined; + } +} diff --git a/src/DebugProxy.ts b/src/DebugProxy.ts deleted file mode 100644 index eb8da23..0000000 --- a/src/DebugProxy.ts +++ /dev/null @@ -1,225 +0,0 @@ -import * as vscode from 'vscode'; -import { ChildProcessWithoutNullStreams as ChildProcess, spawn } from "child_process"; -import jszip from 'jszip'; -import * as fs from 'node:fs/promises'; -import * as semver from 'semver'; -import { CloudStorage } from './CloudStorage'; -import { config, logger } from './extension'; -import { execFile, exists, hashClientConfig } from './utils'; -import { DbosDebugConfig } from './configuration'; - -const IS_WINDOWS = process.platform === "win32"; -const EXE_FILE_NAME = `debug-proxy${IS_WINDOWS ? ".exe" : ""}`; - -function exeFileName(storageUri: vscode.Uri) { - return vscode.Uri.joinPath(storageUri, EXE_FILE_NAME); -} - -function throwOnCancelled(token?: vscode.CancellationToken) { - if (token?.isCancellationRequested) { - const abortError = new Error("Request aborted"); - abortError.name = "AbortError"; - throw abortError; - } -} - -export class DebugProxy { - private _outChannel: vscode.LogOutputChannel; - private _proxyProcesses: Map = new Map(); - - constructor(private readonly cloudStorage: CloudStorage, private readonly storageUri: vscode.Uri) { - this._outChannel = vscode.window.createOutputChannel("DBOS Debug Proxy", { log: true }); - } - - dispose() { - this.shutdown(); - } - - shutdown() { - for (const [key, process] of this._proxyProcesses.entries()) { - this._proxyProcesses.delete(key); - logger.info(`Debug Proxy shutting down`, { folder: key, pid: process.pid }); - process.stdout.removeAllListeners(); - process.stderr.removeAllListeners(); - process.removeAllListeners(); - process.kill(); - } - } - - async launch(clientConfig: DbosDebugConfig, folder: vscode.WorkspaceFolder): Promise { - const configHash = hashClientConfig(clientConfig); - - if (!configHash) { throw new Error("Invalid configuration"); } - if (this._proxyProcesses.has(configHash)) { return true; } - - const exeUri = exeFileName(this.storageUri); - const exeExists = await exists(exeUri); - if (!exeExists) { - throw new Error("Debug proxy not installed"); - } - - const proxy_port = config.getProxyPort(folder); - let { host, port, database, user, password } = clientConfig; - if (typeof password === "function") { - const $password = await password(); - if ($password) { - password = $password; - } else { - return false; - } - } - - const args = [ - "-json", - "-host", host, - "-db", database, - "-user", user, - ]; - if (port) { - args.push("-port", `${port}`); - } - if (proxy_port) { - args.push("-listen", `${proxy_port}`); - } - - const proxyProcess = spawn( - exeUri.fsPath, - args, - { - env: { - "PGPASSWORD": password - } - } - ); - logger.info(`Debug Proxy launched`, { port: proxy_port, pid: proxyProcess.pid, database }); - this._proxyProcesses.set(configHash, proxyProcess); - - proxyProcess.stdout.on("data", (data: Buffer) => { - const { time, level, msg, ...properties } = JSON.parse(data.toString()) as { time: string, level: string, msg: string, [key: string]: unknown }; - const $properties = { ...properties, database }; - switch (level.toLowerCase()) { - case "debug": - this._outChannel.debug(msg, $properties); - break; - case "info": - this._outChannel.info(msg, $properties); - break; - case "warn": - this._outChannel.warn(msg, $properties); - break; - case "error": - this._outChannel.error(msg, $properties); - break; - default: - this._outChannel.appendLine(`${time} [${level}] ${msg} ${JSON.stringify($properties)}`); - break; - } - }); - - proxyProcess.on("error", e => { - this._outChannel.error(e, { database }); - }); - - proxyProcess.on('close', (code, signal) => { - this._proxyProcesses.delete(configHash); - this._outChannel.info(`Debug Proxy closed with exit code ${code}`, { database }); - }); - - proxyProcess.on("exit", (code, _signal) => { - this._proxyProcesses.delete(configHash); - this._outChannel.info(`Debug Proxy exited with exit code ${code}`, { database }); - }); - - return true; - } - - async getVersion() { - const localVersion = await this._getLocalVersion(); - const msg = localVersion ? `Debug Proxy v${localVersion} installed` : "Debug Proxy not installed"; - logger.info(msg); - await vscode.window.showInformationMessage(msg); - } - - async update() { - const remoteVersion = await this._getRemoteVersion(); - if (remoteVersion === undefined) { - logger.error("Failed to get the latest version of Debug Proxy."); - return; - } - logger.info(`Debug Proxy remote version v${remoteVersion}.`); - - const localVersion = await this._getLocalVersion(); - if (localVersion && semver.valid(localVersion) !== null) { - logger.info(`Debug Proxy local version v${localVersion}.`); - if (semver.satisfies(localVersion, `>=${remoteVersion}`, { includePrerelease: true })) { - return; - } - } - - const msg = localVersion - ? `Updating DBOS Debug Proxy to v${remoteVersion}.` - : `Installing DBOS Debug Proxy v${remoteVersion}.`; - logger.info(msg); - - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - cancellable: true - }, async (progress, token) => { - progress.report({ message: msg }); - await this._downloadRemoteVersion(remoteVersion, token); - logger.info(`Debug Proxy updated to v${remoteVersion}.`); - }); - } - - async _getLocalVersion() { - const exeUri = exeFileName(this.storageUri); - if (!(await exists(exeUri))) { - return Promise.resolve(undefined); - } - - try { - const { stdout } = await execFile(exeUri.fsPath, ["-version"]); - return stdout.trim(); - } catch (e) { - logger.error("Failed to get local debug proxy version", e); - return undefined; - } - } - - async _getRemoteVersion(includePrerelease?: boolean, token?: vscode.CancellationToken) { - includePrerelease = includePrerelease ?? false; - let latestVersion: string | undefined = undefined; - for await (const version of this.cloudStorage.getVersions(token)) { - if (semver.prerelease(version) && !includePrerelease) { - continue; - } - if (latestVersion === undefined || semver.gt(version, latestVersion)) { - latestVersion = version; - } - } - return latestVersion; - } - - async _downloadRemoteVersion(version: string, token?: vscode.CancellationToken) { - if (!(await exists(this.storageUri))) { - await vscode.workspace.fs.createDirectory(this.storageUri); - } - - const response = await this.cloudStorage.downloadVersion(version, token); - if (!response) { throw new Error(`Failed to download version ${version}`); } - throwOnCancelled(token); - - const zipFile = await jszip.loadAsync(response.asByteArray()); - throwOnCancelled(token); - - const files = Object.keys(zipFile.files); - if (files.length !== 1) { throw new Error(`Expected 1 file, got ${files.length}`); } - - const exeUri = exeFileName(this.storageUri); - const exeBuffer = await zipFile.files[files[0]].async("uint8array"); - throwOnCancelled(token); - - await vscode.workspace.fs.writeFile(exeUri, exeBuffer); - await fs.chmod(exeUri.fsPath, 0o755); - } -} \ No newline at end of file diff --git a/src/ProvenanceDatabase.ts b/src/ProvenanceDatabase.ts deleted file mode 100644 index 72d1b23..0000000 --- a/src/ProvenanceDatabase.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Client } from 'pg'; -import { logger } from './extension'; -import { DbosMethodType, getDbosWorkflowName } from './sourceParser'; -import { hashClientConfig } from './utils'; -import { DbosDebugConfig } from './configuration'; - -export interface workflow_status { - workflow_uuid: string; - status: string; - name: string; - authenticated_user: string; - output: string; - error: string; - assumed_role: string; - authenticated_roles: string; // Serialized list of roles. - request: string; // Serialized HTTPRequest - executor_id: string; // Set to "local" for local deployment, set to microVM ID for cloud deployment. - created_at: string; - updated_at: string; -} - -export type DbosMethodInfo = { name: string; type: DbosMethodType }; - -export class ProvenanceDatabase { - private _databases: Map = new Map(); - - dispose() { - for (const db of this._databases.values()) { - db.end(e => logger.error(e)); - } - } - - private async connect(dbConfig: DbosDebugConfig): Promise { - const configHash = hashClientConfig(dbConfig); - if (!configHash) { throw new Error("Invalid configuration"); } - const existingDB = this._databases.get(configHash); - if (existingDB) { return existingDB; } - - const password = typeof dbConfig.password === "function" ? await dbConfig.password() : dbConfig.password; - if (!password) { throw new Error("Invalid password"); } - - const db = new Client({ - host: dbConfig.host, - port: dbConfig.port, - database: dbConfig.database, - user: dbConfig.user, - password, - ssl: { rejectUnauthorized: false } - }); - await db.connect(); - this._databases.set(configHash, db); - return db; - } - - async getWorkflowStatuses(clientConfig: DbosDebugConfig, method?: DbosMethodInfo): Promise { - const db = await this.connect(clientConfig); - const results = method - ? await db.query('SELECT * FROM dbos.workflow_status WHERE name = $1 ORDER BY created_at DESC LIMIT 10', [getDbosWorkflowName(method.name, method.type)]) - : await db.query('SELECT * FROM dbos.workflow_status ORDER BY created_at DESC LIMIT 10'); - return results.rows; - } - - async getWorkflowStatus(clientConfig: DbosDebugConfig, workflowID: string): Promise { - const db = await this.connect(clientConfig); - const results = await db.query('SELECT * FROM dbos.workflow_status WHERE workflow_uuid = $1', [workflowID]); - if (results.rows.length > 1) { throw new Error(`Multiple workflow status records found for workflow ID ${workflowID}`); } - return results.rows.length === 1 ? results.rows[0] : undefined; - } -} diff --git a/src/uriHandler.ts b/src/UriHandler.ts similarity index 86% rename from src/uriHandler.ts rename to src/UriHandler.ts index bb53d89..0f6779d 100644 --- a/src/uriHandler.ts +++ b/src/UriHandler.ts @@ -2,9 +2,9 @@ import * as vscode from 'vscode'; import { logger } from './extension'; import { startDebuggingUriCommandName } from './commands'; -export class TTDbgUriHandler implements vscode.UriHandler { +export class UriHandler implements vscode.UriHandler { async handleUri(uri: vscode.Uri): Promise { - logger.debug(`TTDbgUriHandler.handleUri`, { uri: uri.toString() }); + logger.debug(`UriHandler.handleUri`, { uri: uri.toString() }); switch (uri.path) { case '/start-debugging': const searchParams = new URLSearchParams(uri.query); diff --git a/src/codeLensProvider.ts b/src/codeLensProvider.ts deleted file mode 100644 index daaf5f7..0000000 --- a/src/codeLensProvider.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as vscode from 'vscode'; -import ts from 'typescript'; -import { startDebuggingCodeLensCommandName } from './commands'; -import { logger } from './extension'; -import { getDbosMethodType, parse } from './sourceParser'; -import { DbosMethodInfo } from './ProvenanceDatabase'; - -export class TTDbgCodeLensProvider implements vscode.CodeLensProvider { - provideCodeLenses(document: vscode.TextDocument, _token: vscode.CancellationToken): vscode.ProviderResult { - try { - const folder = vscode.workspace.getWorkspaceFolder(document.uri); - if (!folder) { return; } - - const text = document.getText(); - const file = ts.createSourceFile( - document.fileName, - text, - ts.ScriptTarget.Latest - ); - - return parse(file) - .map(methodInfo => { - const methodType = getDbosMethodType(methodInfo.decorators); - if (!methodType) { return undefined; } - - const start = methodInfo.node.getStart(file); - const end = methodInfo.node.getEnd(); - const range = new vscode.Range( - document.positionAt(start), - document.positionAt(end) - ); - - return new vscode.CodeLens(range, { - title: '⏳ Time Travel Debug', - tooltip: `Debug ${methodInfo.name} with the DBOS Time Travel Debugger`, - command: startDebuggingCodeLensCommandName, - arguments: [folder, { name: methodInfo.name, type: methodType }] - }); - }) - .filter((x?: T): x is T => !!x); - } catch (e) { - logger.error("provideCodeLenses", e); - } - } -} - diff --git a/src/commands.ts b/src/commands.ts deleted file mode 100644 index 9fc367c..0000000 --- a/src/commands.ts +++ /dev/null @@ -1,128 +0,0 @@ -import * as vscode from 'vscode'; -import { logger, debugProxy, config } from './extension'; -import { getDebugConfigFolder, getWorkspaceFolder } from './utils'; -import { DbosMethodInfo } from './ProvenanceDatabase'; -import { startDebugging, showWorkflowPick, validateCredentials } from './userFlows'; -import { DbosCloudDomain, getCloudDomain } from './dbosCloudApi'; -import { CloudAppNode } from './CloudDataProvider'; -import { getDebugConfigFromDbosCloud } from './configuration'; - -export const cloudLoginCommandName = "dbos-ttdbg.cloud-login"; -export async function cloudLogin(host?: string) { - logger.debug("cloudLogin", { host }); - try { - await config.cloudLogin(host); - } catch (e) { - logger.error("cloudLogin", e); - } -} - -export const shutdownDebugProxyCommandName = "dbos-ttdbg.shutdown-debug-proxy"; -export function shutdownDebugProxy() { - logger.debug("shutdownDebugProxy"); - try { - debugProxy.shutdown(); - } catch (e) { - logger.error("shutdownDebugProxy", e); - } -} - -export const deleteDomainCredentialsCommandName = "dbos-ttdbg.delete-domain-credentials"; -export async function deleteDomainCredentials(domain?: string | DbosCloudDomain) { - logger.debug("deleteDomainCredentials", { domain: domain ?? null }); - - try { - domain = getCloudDomain(domain); - logger.info("deleteDomainCredentials", domain); - await config.deleteStoredCloudCredentials(domain); - } catch (e) { - logger.error("shutdownDebugProxy", { domain: domain ?? null, error: e }); - } -} - -export const deleteAppDatabasePasswordCommandName = "dbos-ttdbg.delete-app-db-password"; -export async function deleteAppDatabasePassword(node?: CloudAppNode) { - logger.debug("deleteAppDatabasePassword", { node: node ?? null }); - if (node) { - const debugConfig = await getDebugConfigFromDbosCloud(node.app, node.credentials); - if (debugConfig) { - try { - await config.deleteStoredAppDatabasePassword(debugConfig); - } catch (e) { - logger.error("deleteAppDatabasePassword", e); - } - } - } -} - -export const deleteStoredPasswordsCommandName = "dbos-ttdbg.delete-stored-passwords"; -export async function deleteStoredPasswords() { - logger.debug("deleteStoredPasswords"); - try { - await config.deletePasswords(); - } catch (e) { - logger.error("deleteProvenanceDatabasePasswords", e); - } -} - -export const getProxyUrlCommandName = "dbos-ttdbg.get-proxy-url"; -export async function getProxyUrl(cfg?: vscode.DebugConfiguration & { rootPath?: string }) { - try { - const folder = getDebugConfigFolder(cfg); - const credentials = await config.getStoredCloudCredentials(); - if (!validateCredentials(credentials)) { return undefined; } - const cloudConfig = await config.getDebugConfig(folder, credentials); - - const proxyLaunched = await debugProxy.launch(cloudConfig, folder); - if (!proxyLaunched) { - throw new Error("Failed to launch debug proxy", { cause: { folder: folder.uri.fsPath, cloudConfig } }); - } - - return `http://localhost:${config.getProxyPort(folder)}`; - } catch (e) { - logger.error("getProxyUrl", e); - vscode.window.showErrorMessage(`Failed to get proxy URL`); - } -} - -export const pickWorkflowIdCommandName = "dbos-ttdbg.pick-workflow-id"; -export async function pickWorkflowId(cfg?: vscode.DebugConfiguration) { - try { - const folder = getDebugConfigFolder(cfg); - const credentials = await config.getStoredCloudCredentials(); - if (!validateCredentials(credentials)) { return undefined; } - const cloudConfig = await config.getDebugConfig(folder, credentials); - - return await showWorkflowPick(folder, { cloudConfig }); - } catch (e) { - logger.error("pickWorkflowId", e); - vscode.window.showErrorMessage("Failed to get workflow ID"); - } -} - -export const startDebuggingUriCommandName = "dbos-ttdbg.start-debugging-uri"; -export async function startDebuggingFromUri(workflowID: string) { - try { - const folder = await getWorkspaceFolder(); - if (!folder) { return; } - - logger.info(`startDebuggingFromUri`, { folder: folder.uri.fsPath, workflowID }); - await startDebugging(folder, async () => { return workflowID; }); - } catch (e) { - logger.error("startDebuggingFromUri", e); - vscode.window.showErrorMessage(`Failed to debug ${workflowID} workflow`); - } -} - -export const startDebuggingCodeLensCommandName = "dbos-ttdbg.start-debugging-code-lens"; -export async function startDebuggingFromCodeLens(folder: vscode.WorkspaceFolder, method: DbosMethodInfo) { - try { - logger.info(`startDebuggingFromCodeLens`, { folder: folder.uri.fsPath, method }); - await startDebugging(folder, async (cloudConfig) => { - return await showWorkflowPick(folder, { cloudConfig, method }); - }); - } catch (e) { - logger.error("startDebuggingFromCodeLens", e); - vscode.window.showErrorMessage(`Failed to debug ${method.name} method`); - } -} diff --git a/src/commands/cloudLogin.ts b/src/commands/cloudLogin.ts new file mode 100644 index 0000000..d9c9b3b --- /dev/null +++ b/src/commands/cloudLogin.ts @@ -0,0 +1,17 @@ +import { config, logger } from '../extension'; +import type { CloudDomainNode } from '../CloudDataProvider'; +import { validateCredentials } from '../validateCredentials'; + +export function getCloudLoginCommand(refresh: (domain: string) => Promise) { + return async function (node?: CloudDomainNode) { + logger.debug("cloudLogin", { domain: node ?? null }); + if (node) { + const domain = node.domain; + const credentials = await config.getStoredCloudCredentials(domain); + if (!validateCredentials(credentials)) { return; } + + await config.cloudLogin(domain); + await refresh(domain); + } + }; +} diff --git a/src/commands/deleteAppDatabasePassword.ts b/src/commands/deleteAppDatabasePassword.ts new file mode 100644 index 0000000..77ddcdf --- /dev/null +++ b/src/commands/deleteAppDatabasePassword.ts @@ -0,0 +1,19 @@ +import { logger, config } from '../extension'; +import type { CloudAppNode } from '../CloudDataProvider'; +import { getDebugConfigFromDbosCloud } from '../Configuration'; +import { isTokenExpired } from '../validateCredentials'; + +export async function deleteAppDatabasePassword(node?: CloudAppNode) { + logger.debug("deleteAppDatabasePassword", { node: node ?? null }); + if (node) { + const credentials = await config.getStoredCloudCredentials(node.domain); + if (!credentials || isTokenExpired(credentials.token)) { return; } + const debugConfig = await getDebugConfigFromDbosCloud(node.app, credentials); + if (!debugConfig) { return; } + try { + await config.deleteStoredAppDatabasePassword(debugConfig); + } catch (e) { + logger.error("deleteAppDatabasePassword", e); + } + } +} diff --git a/src/commands/deleteDomainCredentials.ts b/src/commands/deleteDomainCredentials.ts new file mode 100644 index 0000000..b9df7ae --- /dev/null +++ b/src/commands/deleteDomainCredentials.ts @@ -0,0 +1,15 @@ +import { config, logger } from '../extension'; +import type { CloudDomainNode } from '../CloudDataProvider'; + +export function getDeleteDomainCredentialsCommand(refresh: (domain: string) => Promise) { + return async function (node?: CloudDomainNode) { + logger.debug("deleteDomainCredentials", { domain: node ?? null }); + if (node) { + const domain = node.domain; + const changed = await config.deleteStoredCloudCredentials(domain); + if (changed) { + await refresh(domain); + } + } + }; +} \ No newline at end of file diff --git a/src/commands/deleteStoredPasswords.ts b/src/commands/deleteStoredPasswords.ts new file mode 100644 index 0000000..58c7343 --- /dev/null +++ b/src/commands/deleteStoredPasswords.ts @@ -0,0 +1,10 @@ +import { logger, config } from '../extension'; + +export async function deleteStoredPasswords() { + logger.debug("deleteStoredPasswords"); + try { + await config.deletePasswords(); + } catch (e) { + logger.error("deleteProvenanceDatabasePasswords", e); + } +} diff --git a/src/commands/getProxyUrl.ts b/src/commands/getProxyUrl.ts new file mode 100644 index 0000000..8304281 --- /dev/null +++ b/src/commands/getProxyUrl.ts @@ -0,0 +1,23 @@ +import * as vscode from 'vscode'; +import { logger, config } from '../extension'; +import { getDebugConfigFolder } from '../utility'; +import { validateCredentials } from '../validateCredentials'; +import { launchDebugProxyCommandName } from '.'; + +export async function getProxyUrl(cfg?: vscode.DebugConfiguration & { rootPath?: string; }) { + try { + const folder = getDebugConfigFolder(cfg); + const credentials = await config.getStoredCloudCredentials(); + if (!validateCredentials(credentials)) { return; } + + const debugConfig = await config.getDebugConfig(folder, credentials); + const proxyLaunched = await vscode.commands.executeCommand(launchDebugProxyCommandName, debugConfig); + if (!proxyLaunched) { + throw new Error("Failed to launch debug proxy", { cause: { folder: folder.uri.fsPath, debugConfig } }); + } + return `http://localhost:${config.getProxyPort(folder)}`; + } catch (e) { + logger.error("getProxyUrl", e); + vscode.window.showErrorMessage(`Failed to get proxy URL`); + } +} diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..b085cd0 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,50 @@ +import * as vscode from 'vscode'; +import type { CloudStorage } from '../CloudStorage'; +import { getCloudLoginCommand } from './cloudLogin'; +import { deleteAppDatabasePassword } from './deleteAppDatabasePassword'; +import { getDeleteDomainCredentialsCommand, } from './deleteDomainCredentials'; +import { deleteStoredPasswords } from './deleteStoredPasswords'; +import { getProxyUrl } from './getProxyUrl'; +import { launchDashboard } from './launchDashboard'; +import { getLaunchDebugProxyCommand } from './launchDebugProxy'; +import { pickWorkflowId } from './pickWorkflowId'; + +import { shutdownDebugProxy } from './shutdownDebugProxy'; +import { startDebuggingFromCodeLens } from './startDebuggingFromCodeLens'; +import { startDebuggingFromUri } from './startDebuggingFromUri'; +import { getUpdateDebugProxyCommand } from './updateDebugProxy'; +import { getRefreshDomainCommand } from './refreshDomain'; + +export const cloudLoginCommandName = "dbos-ttdbg.cloud-login"; +export const deleteAppDatabasePasswordCommandName = "dbos-ttdbg.delete-app-db-password"; +export const deleteDomainCredentialsCommandName = "dbos-ttdbg.delete-domain-credentials"; +export const deleteStoredPasswordsCommandName = "dbos-ttdbg.delete-stored-passwords"; +export const getProxyUrlCommandName = "dbos-ttdbg.get-proxy-url"; +export const launchDashboardCommandName = "dbos-ttdbg.launch-dashboard"; +export const launchDebugProxyCommandName = "dbos-ttdbg.launch-debug-proxy"; +export const pickWorkflowIdCommandName = "dbos-ttdbg.pick-workflow-id"; +export const refreshDomainCommandName = "dbos-ttdbg.refresh-domain"; +export const shutdownDebugProxyCommandName = "dbos-ttdbg.shutdown-debug-proxy"; +export const startDebuggingCodeLensCommandName = "dbos-ttdbg.start-debugging-code-lens"; +export const startDebuggingUriCommandName = "dbos-ttdbg.start-debugging-uri"; +export const updateDebugProxyCommandName = "dbos-ttdbg.update-debug-proxy"; + +export function registerCommands(cloudStorage: CloudStorage, storageUri: vscode.Uri, refresh: (domain: string) => Promise) { + const disposables = [ + vscode.commands.registerCommand(cloudLoginCommandName, getCloudLoginCommand(refresh)), + vscode.commands.registerCommand(deleteAppDatabasePasswordCommandName, deleteAppDatabasePassword), + vscode.commands.registerCommand(deleteDomainCredentialsCommandName, getDeleteDomainCredentialsCommand(refresh)), + vscode.commands.registerCommand(deleteStoredPasswordsCommandName, deleteStoredPasswords), + vscode.commands.registerCommand(getProxyUrlCommandName, getProxyUrl), + vscode.commands.registerCommand(launchDashboardCommandName, launchDashboard), + vscode.commands.registerCommand(launchDebugProxyCommandName, getLaunchDebugProxyCommand(storageUri)), + vscode.commands.registerCommand(pickWorkflowIdCommandName, pickWorkflowId), + vscode.commands.registerCommand(refreshDomainCommandName, getRefreshDomainCommand(refresh)), + vscode.commands.registerCommand(shutdownDebugProxyCommandName, shutdownDebugProxy), + vscode.commands.registerCommand(startDebuggingCodeLensCommandName, startDebuggingFromCodeLens), + vscode.commands.registerCommand(startDebuggingUriCommandName, startDebuggingFromUri), + vscode.commands.registerCommand(updateDebugProxyCommandName, getUpdateDebugProxyCommand(cloudStorage, storageUri)), + ]; + + return disposables; +} \ No newline at end of file diff --git a/src/commands/launchDashboard.ts b/src/commands/launchDashboard.ts new file mode 100644 index 0000000..8e4bbbc --- /dev/null +++ b/src/commands/launchDashboard.ts @@ -0,0 +1,59 @@ +import * as vscode from 'vscode'; +import { logger, config } from '../extension'; +import type { CloudAppNode, CloudDomainNode } from '../CloudDataProvider'; +import { createDashboard, getCloudDomain, getDashboard, isUnauthorized } from '../dbosCloudApi'; +import { validateCredentials } from '../validateCredentials'; +import type { DbosMethodInfo } from '../CodeLensProvider'; + +export async function launchDashboard(node?: string | CloudDomainNode | CloudAppNode, method?: DbosMethodInfo) { + logger.debug("launchDashboard", { node: node ?? null }); + if (!node) { return; } + + let domain: string; + if (typeof node === 'string') { + const { cloudDomain } = getCloudDomain(); + domain = cloudDomain; + } else { + domain = node.domain; + } + + const credentials = await config.getStoredCloudCredentials(domain); + if (!validateCredentials(credentials)) { return; } + + let dashboardUrl = await getDashboard(credentials); + if (isUnauthorized(dashboardUrl)) { return; } + + if (!dashboardUrl) { + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Window, + cancellable: false, + title: "Creating DBOS dashboard" + }, async () => { await createDashboard(credentials); }); + + const $dashboardUrl = await getDashboard(credentials); + dashboardUrl = isUnauthorized($dashboardUrl) ? undefined : $dashboardUrl; + } + + if (!dashboardUrl) { + vscode.window.showErrorMessage("Failed to create DBOS dashboard"); + return; + } + + const params = new URLSearchParams(); + if (typeof node === 'string') { + params.append("var-app_name", node); + } else if (node.kind === 'cloudApp') { + params.append("var-app_name", node.app.Name); + } + if (method) { + params.append("var-operation_name", method.name); + params.append("var-operation_type", method.type.toLowerCase()); + } + + const dashboardQueryUrl = `${dashboardUrl}?${params}`; + logger.info(`launchDashboardCommand uri`, { uri: dashboardQueryUrl }); + const openResult = await vscode.env.openExternal(vscode.Uri.parse(dashboardQueryUrl)); + if (!openResult) { + throw new Error(`failed to open dashboard URL: ${dashboardQueryUrl}`); + } +} diff --git a/src/commands/launchDebugProxy.ts b/src/commands/launchDebugProxy.ts new file mode 100644 index 0000000..ac88140 --- /dev/null +++ b/src/commands/launchDebugProxy.ts @@ -0,0 +1,50 @@ +import * as vscode from 'vscode'; +import { logger, config } from '../extension'; +import type { CloudAppNode } from '../CloudDataProvider'; +import { launchDebugProxy } from '../debugProxy'; +import { getDbInstance, isUnauthorized } from '../dbosCloudApi'; +import { validateCredentials } from '../validateCredentials'; +import { DbosDebugConfig } from '../Configuration'; + +function isDebugConfig(node: CloudAppNode | DbosDebugConfig): node is DbosDebugConfig { + return (node as DbosDebugConfig).host !== undefined; +} + +export function getLaunchDebugProxyCommand(storageUri: vscode.Uri) { + + return async function (arg?: CloudAppNode | DbosDebugConfig): Promise { + logger.debug("launchDebugProxy", { app: arg ?? null }); + if (!arg) { return false; } + + let debugConfig: DbosDebugConfig; + if (isDebugConfig(arg)) { + debugConfig = arg; + } else { + const credentials = await config.getStoredCloudCredentials(arg.domain); + if (!validateCredentials(credentials)) { return false; } + + const { PostgresInstanceName, ApplicationDatabaseName } = arg.app; + const dbInstance = await getDbInstance(PostgresInstanceName, credentials); + if (isUnauthorized(dbInstance)) { return false; } + + debugConfig = { + host: dbInstance.HostName, + database: ApplicationDatabaseName + "_dbos_prov", + user: dbInstance.DatabaseUsername, + port: dbInstance.Port, + password: "", + }; + const password = await config.getAppDatabasePassword(debugConfig); + if (!password) { return false; } + debugConfig.password = password; + } + + await launchDebugProxy(storageUri, debugConfig) + .catch(e => { + logger.error("launchDebugProxy", e); + vscode.window.showErrorMessage("Failed to launch debug proxy"); + }); + + return true; + }; +} diff --git a/src/commands/pickWorkflowId.ts b/src/commands/pickWorkflowId.ts new file mode 100644 index 0000000..efd74fd --- /dev/null +++ b/src/commands/pickWorkflowId.ts @@ -0,0 +1,19 @@ +import * as vscode from 'vscode'; +import { logger, config } from '../extension'; +import { getDebugConfigFolder } from '../utility'; +import { showWorkflowPick } from '../showWorkflowPick'; +import { validateCredentials } from '../validateCredentials'; + +export async function pickWorkflowId(cfg?: vscode.DebugConfiguration) { + try { + const folder = getDebugConfigFolder(cfg); + const credentials = await config.getStoredCloudCredentials(); + if (!validateCredentials(credentials)) { return undefined; } + const cloudConfig = await config.getDebugConfig(folder, credentials); + + return await showWorkflowPick(folder, { cloudConfig }); + } catch (e) { + logger.error("pickWorkflowId", e); + vscode.window.showErrorMessage("Failed to get workflow ID"); + } +} diff --git a/src/commands/refreshDomain.ts b/src/commands/refreshDomain.ts new file mode 100644 index 0000000..0c1f07f --- /dev/null +++ b/src/commands/refreshDomain.ts @@ -0,0 +1,11 @@ +import { logger } from '../extension'; +import type { CloudDomainNode } from '../CloudDataProvider'; + +export function getRefreshDomainCommand(refresh: (domain: string) => Promise) { + return function (node?: CloudDomainNode) { + logger.debug("refreshDomain", { domain: node ?? null }); + if (node) { + refresh(node.domain).catch(e => logger.error("refreshDomain", e)); + } + }; +} diff --git a/src/commands/shutdownDebugProxy.ts b/src/commands/shutdownDebugProxy.ts new file mode 100644 index 0000000..e1b8b10 --- /dev/null +++ b/src/commands/shutdownDebugProxy.ts @@ -0,0 +1,7 @@ +import { logger } from '../extension'; +import * as DebugProxy from '../debugProxy'; + +export function shutdownDebugProxy() { + logger.debug("shutdownDebugProxy"); + DebugProxy.shutdownDebugProxy(); +} diff --git a/src/commands/startDebuggingFromCodeLens.ts b/src/commands/startDebuggingFromCodeLens.ts new file mode 100644 index 0000000..557ee98 --- /dev/null +++ b/src/commands/startDebuggingFromCodeLens.ts @@ -0,0 +1,17 @@ +import * as vscode from 'vscode'; +import { logger } from '../extension'; +import { startDebugging } from '../startDebugging'; +import { showWorkflowPick } from '../showWorkflowPick'; +import type { DbosMethodInfo } from '../CodeLensProvider'; + +export async function startDebuggingFromCodeLens(folder: vscode.WorkspaceFolder, method: DbosMethodInfo) { + try { + logger.info(`startDebuggingFromCodeLens`, { folder: folder.uri.fsPath, method }); + await startDebugging(folder, async (cloudConfig) => { + return await showWorkflowPick(folder, { cloudConfig, method }); + }); + } catch (e) { + logger.error("startDebuggingFromCodeLens", e); + vscode.window.showErrorMessage(`Failed to debug ${method.name} method`); + } +} diff --git a/src/commands/startDebuggingFromUri.ts b/src/commands/startDebuggingFromUri.ts new file mode 100644 index 0000000..ffff1e0 --- /dev/null +++ b/src/commands/startDebuggingFromUri.ts @@ -0,0 +1,43 @@ +import * as vscode from 'vscode'; +import { logger } from '../extension'; +import { startDebugging } from '../startDebugging'; + +export async function startDebuggingFromUri(workflowID: string) { + try { + const folder = await getWorkspaceFolder(); + if (!folder) { return; } + + logger.info(`startDebuggingFromUri`, { folder: folder.uri.fsPath, workflowID }); + await startDebugging(folder, async () => { return workflowID; }); + } catch (e) { + logger.error("startDebuggingFromUri", e); + vscode.window.showErrorMessage(`Failed to debug ${workflowID} workflow`); + } +} + +async function getWorkspaceFolder(rootPath?: string | vscode.Uri) { + if (rootPath) { + if (typeof rootPath === "string") { + rootPath = vscode.Uri.file(rootPath); + } + const folder = vscode.workspace.getWorkspaceFolder(rootPath); + if (folder) { + return folder; + } + } + + const folders = vscode.workspace.workspaceFolders ?? []; + if (folders.length === 1) { + return folders[0]; + } + + if (vscode.window.activeTextEditor) { + const folder = vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri); + if (folder) { + return folder; + } + } + + return await vscode.window.showWorkspaceFolderPick(); +} + diff --git a/src/commands/updateDebugProxy.ts b/src/commands/updateDebugProxy.ts new file mode 100644 index 0000000..fee00b4 --- /dev/null +++ b/src/commands/updateDebugProxy.ts @@ -0,0 +1,14 @@ +import * as vscode from 'vscode'; +import { logger } from '../extension'; +import { updateDebugProxy } from '../debugProxy'; +import type { CloudStorage } from '../CloudStorage'; + +export function getUpdateDebugProxyCommand(s3: CloudStorage, storageUri: vscode.Uri) { + return async function () { + logger.debug("updateDebugProxy"); + updateDebugProxy(s3, storageUri).catch(e => { + logger.error("updateDebugProxy", e); + vscode.window.showErrorMessage("Failed to update debug proxy"); + }); + }; +} diff --git a/src/dbosCloudApi.ts b/src/dbosCloudApi.ts index d0aa189..517ad24 100644 --- a/src/dbosCloudApi.ts +++ b/src/dbosCloudApi.ts @@ -2,7 +2,12 @@ import * as vscode from 'vscode'; import jwt, { JwtPayload } from 'jsonwebtoken'; import jwksClient from 'jwks-rsa'; import { logger } from './extension'; -import { cancellableFetch } from './utils'; + +export type Unauthorized = { status: "unauthorized" }; + +export function isUnauthorized(obj: any): obj is Unauthorized { + return obj?.status === "unauthorized"; +} export interface DbosCloudCredentials { token: string; @@ -20,7 +25,7 @@ export interface DbosCloudApp { AppURL: string; } -export interface DbosCloudDatabase { +export interface DbosCloudDbInstance { PostgresInstanceName: string; HostName: string; Status: string; @@ -130,17 +135,6 @@ async function getAuthToken(deviceCodeResponse: DeviceCodeResponse, domain?: str return undefined; } -export function isTokenExpired(authToken: string | AuthTokenResponse): boolean { - const $authToken = typeof authToken === 'string' ? authToken : authToken.access_token; - try { - const { exp } = jwt.decode($authToken) as jwt.JwtPayload; - if (!exp) { return false; } - return Date.now() >= exp * 1000; - } catch (error) { - return true; - } -} - export async function verifyToken(authToken: string | AuthTokenResponse, domain?: string | DbosCloudDomain, token?: vscode.CancellationToken): Promise { const $authToken = typeof authToken === 'string' ? authToken : authToken.access_token; @@ -235,83 +229,47 @@ async function getUser(accessToken: string, domain?: string | DbosCloudDomain, t return body; } -export async function listApps({ domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken) { +export async function listApps({ domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken): Promise { const url = `https://${domain}/v1alpha1/${userName}/applications`; const request = { method: 'GET', headers: { 'authorization': `Bearer ${accessToken}` } }; - const response = await cancellableFetch(url, request, token); - if (!response.ok) { - throw new Error(`listApps request failed`, { - cause: { url, status: response.status, statusText: response.statusText } - }); - } - - const body = await response.json() as DbosCloudApp[]; - logger.debug("listApps", { url, status: response.status, body }); - return body; + return fetchHelper('listApps', url, request, async (response) => await response.json() as DbosCloudApp[], token); } -export async function getAppInfo(appName: string, { domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken) { +export async function getApp(appName: string, { domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken): Promise { const url = `https://${domain}/v1alpha1/${userName}/applications/${appName}`; const request = { method: 'GET', headers: { 'authorization': `Bearer ${accessToken}` } }; - const response = await cancellableFetch(url, request, token); - if (!response.ok) { - throw new Error(`getAppInfo request failed`, { - cause: { url, status: response.status, statusText: response.statusText } - }); - } - - const body = await response.json() as DbosCloudApp; - logger.debug("getAppInfo", { url, status: response.status, body }); - return body; + return fetchHelper('getApp', url, request, async (response) => await response.json() as DbosCloudApp, token); } -export async function listDatabases({ domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken) { +export async function listDbInstances({ domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken): Promise { const url = `https://${domain}/v1alpha1/${userName}/databases`; const request = { method: 'GET', headers: { 'authorization': `Bearer ${accessToken}` } }; - const response = await cancellableFetch(url, request, token); - if (!response.ok) { - throw new Error(`listDatabases request failed`, { - cause: { url, status: response.status, statusText: response.statusText } - }); - } - - const body = await response.json() as DbosCloudDatabase[]; - logger.debug("listDatabases", { url, status: response.status, body }); - return body; + return fetchHelper('listDatabaseInstances', url, request, async (response) => await response.json() as DbosCloudDbInstance[], token); } -export async function getDatabaseInfo(dbName: string, { domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken) { +export async function getDbInstance(dbName: string, { domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken): Promise { const url = `https://${domain}/v1alpha1/${userName}/databases/userdb/info/${dbName}`; const request = { method: 'GET', headers: { 'authorization': `Bearer ${accessToken}` } }; - const response = await cancellableFetch(url, request, token); - if (!response.ok) { - throw new Error(`getDatabaseInfo request failed`, { - cause: { url, status: response.status, statusText: response.statusText } - }); - } - - const body = await response.json() as DbosCloudDatabase; - logger.debug("getDatabaseInfo", { url, status: response.status, body }); - return body; + return fetchHelper('listDatabaseInstances', url, request, async (response) => await response.json() as DbosCloudDbInstance, token); } -export async function createDashboard({ domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken) { +export async function createDashboard({ domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken): Promise { const url = `https://${domain}/v1alpha1/${userName}/dashboard`; const request = { @@ -319,31 +277,52 @@ export async function createDashboard({ domain, token: accessToken, userName }: headers: { 'authorization': `Bearer ${accessToken}` } }; - const response = await cancellableFetch(url, request, token); - if (!response.ok) { - throw new Error(`createDashboard request failed`, { - cause: { url, status: response.status, statusText: response.statusText } - }); - } - const body = await response.text(); - logger.debug("createDashboard", { url, status: response.status, body }); - return body; + return fetchHelper('createDashboard', url, request, (response) => response.text(), token); } -export async function getDashboard({ domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken) { +export async function getDashboard({ domain, token: accessToken, userName }: DbosCloudCredentials, token?: vscode.CancellationToken): Promise { const url = `https://${domain}/v1alpha1/${userName}/dashboard`; const request = { method: 'GET', headers: { 'authorization': `Bearer ${accessToken}` } }; const response = await cancellableFetch(url, request, token); - if (!response.ok && response.status !== 500) { + logger.debug("getDashboard", { url, status: response.status }); + if (response.ok || response.status === 500) { + const body = response.ok ? await response.text() : undefined;; + logger.debug("getDashboard", { body: body ?? null }); + return body; + } else if (response.status === 401) { + return { status: "unauthorized" }; + } else { throw new Error(`getDashboard request failed`, { cause: { url, status: response.status, statusText: response.statusText } }); } +} - const body = response.status === 500 ? undefined : await response.text(); - logger.debug("getDashboard", { url, status: response.status, body }); - return body; -} \ No newline at end of file +async function fetchHelper(name: string, url: string, request: Omit, onOk: (resp: Response) => Promise, token?: vscode.CancellationToken): Promise { + const response = await cancellableFetch(url, request, token); + logger.debug(name, { url, status: response.status }); + if (response.ok) { + const body = await onOk(response); + logger.debug(name, { body }); + return body; + } else if (response.status === 401) { + return { status: "unauthorized" }; + } else { + throw new Error(`${name} request failed`, { + cause: { url, status: response.status, statusText: response.statusText } + }); + } +} + +async function cancellableFetch(url: string, request: Omit, token?: vscode.CancellationToken) { + const abort = new AbortController(); + const tokenListener = token?.onCancellationRequested(reason => { abort.abort(reason); }); + try { + return await fetch(url, { ...request, signal: abort.signal }); + } finally { + tokenListener?.dispose(); + } +} diff --git a/src/debugProxy.ts b/src/debugProxy.ts new file mode 100644 index 0000000..0768e33 --- /dev/null +++ b/src/debugProxy.ts @@ -0,0 +1,295 @@ +import * as vscode from 'vscode'; +import { ChildProcessWithoutNullStreams as ChildProcess, spawn } from "child_process"; +import jszip from 'jszip'; +import * as fs from 'node:fs/promises'; +import { execFile as cpExecFile } from "node:child_process"; +import { promisify } from 'node:util'; +import * as semver from 'semver'; +import { CloudStorage } from './CloudStorage'; +import { logger } from './extension'; +import { exists } from './utility'; +import { DbosDebugConfig } from './Configuration'; + +const IS_WINDOWS = process.platform === "win32"; +const EXE_FILE_NAME = `debug-proxy${IS_WINDOWS ? ".exe" : ""}`; + +function exeFileName(storageUri: vscode.Uri) { + return vscode.Uri.joinPath(storageUri, EXE_FILE_NAME); +} + +const execFile = promisify(cpExecFile); + +async function getLocalVersion(storageUri: vscode.Uri) { + const exeUri = exeFileName(storageUri); + if (!(await exists(exeUri))) { + return Promise.resolve(undefined); + } + + try { + const { stdout } = await execFile(exeUri.fsPath, ["-version"]); + return stdout.trim(); + } catch (e) { + logger.error("Failed to get local debug proxy version", e); + return undefined; + } +} + +async function getRemoteVersion(s3: CloudStorage, options?: UpdateDebugProxyOptions) { + const includePrerelease = options?.includePrerelease ?? false; + let latestVersion: string | undefined = undefined; + for await (const version of s3.getVersions(options?.token)) { + if (semver.prerelease(version) && !includePrerelease) { + continue; + } + if (latestVersion === undefined || semver.gt(version, latestVersion)) { + latestVersion = version; + } + } + return latestVersion; +} + +async function downloadRemoteVersion(s3: CloudStorage, storageUri: vscode.Uri, version: string, token?: vscode.CancellationToken) { + if (!(await exists(storageUri))) { + await vscode.workspace.fs.createDirectory(storageUri); + } + + const response = await s3.downloadVersion(version, token); + if (!response) { throw new Error(`Failed to download version ${version}`); } + if (token?.isCancellationRequested) { throw new vscode.CancellationError(); } + + const zipFile = await jszip.loadAsync(response.asByteArray()); + if (token?.isCancellationRequested) { throw new vscode.CancellationError(); } + + const files = Object.keys(zipFile.files); + if (files.length !== 1) { throw new Error(`Expected 1 file, got ${files.length}`); } + + const exeUri = exeFileName(storageUri); + const exeBuffer = await zipFile.files[files[0]].async("uint8array"); + if (token?.isCancellationRequested) { throw new vscode.CancellationError(); } + + await vscode.workspace.fs.writeFile(exeUri, exeBuffer); + await fs.chmod(exeUri.fsPath, 0o755); +} + +interface UpdateDebugProxyOptions { + includePrerelease?: boolean; + token?: vscode.CancellationToken; +} + +export async function updateDebugProxy(s3: CloudStorage, storageUri: vscode.Uri, options?: UpdateDebugProxyOptions) { + logger.debug("updateDebugProxy", { storageUri: storageUri.fsPath, includePrerelease: options?.includePrerelease ?? false }); + + const remoteVersion = await getRemoteVersion(s3, options); + if (remoteVersion === undefined) { + logger.error("Failed to get the latest version of Debug Proxy."); + return; + } + logger.info(`Debug Proxy remote version v${remoteVersion}.`); + + const localVersion = await getLocalVersion(storageUri); + if (localVersion && semver.valid(localVersion) !== null) { + logger.info(`Debug Proxy local version v${localVersion}.`); + if (semver.satisfies(localVersion, `>=${remoteVersion}`, { includePrerelease: true })) { + return; + } + } + + const message = localVersion + ? `Updating DBOS Debug Proxy to v${remoteVersion}.` + : `Installing DBOS Debug Proxy v${remoteVersion}.`; + logger.info(message); + + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: message, + cancellable: true + }, async (_, token) => { + // create a CTS so we can cancel via withProgress token or options token + const cts = new vscode.CancellationTokenSource(); + const disposables = new Array(); + try { + token.onCancellationRequested(() => cts.cancel(), undefined, disposables); + options?.token?.onCancellationRequested(() => cts.cancel(), undefined, disposables); + + // progress.report({ message: message }); + await downloadRemoteVersion(s3, storageUri, remoteVersion, cts.token); + logger.info(`Debug Proxy updated to v${remoteVersion}.`); + } finally { + disposables.forEach(d => d.dispose()); + cts.dispose(); + } + }); +} + +let terminal: DebugProxyTerminal | undefined = undefined; + +export function shutdownDebugProxy() { + if (terminal) { + const $terminal = terminal; + terminal = undefined; + $terminal.dispose(); + } +} + +export async function launchDebugProxy(storageUri: vscode.Uri, options: DbosDebugConfig & { proxyPort?: number }) { + + const exeUri = exeFileName(storageUri); + logger.debug("launchDebugProxy", { exeUri, launchOptions: options }); + if (!(await exists(exeUri))) { + throw new Error("debug proxy doesn't exist", { cause: { path: exeUri.fsPath } }); + } + + if (terminal?.matches(options)) { return; } + shutdownDebugProxy(); + + const password = typeof options.password === 'function' ? await options.password() : options.password; + if (!password) { throw new Error("Invalid password"); } + + terminal = new DebugProxyTerminal(exeUri, { ...options, password }); + terminal.show(); +} + +class DebugProxyTerminal implements vscode.Disposable { + + private readonly pty: DebugProxyPseudoterminal; + private readonly terminal: vscode.Terminal; + + constructor( + exeUri: vscode.Uri, + private readonly options: DebugProxyTerminalOptions, + ) { + this.pty = new DebugProxyPseudoterminal(exeUri, options); + this.terminal = vscode.window.createTerminal({ + name: "DBOS Debug Proxy", + pty: this.pty, + iconPath: new vscode.ThemeIcon('debug'), + }); + } + + get isRunning() { + return this.pty.isRunning; + } + + dispose() { + this.terminal.dispose(); + } + + show() { + this.terminal.show(); + } + + matches(config: DbosDebugConfig) { + if (!this.isRunning) { return false; } + return this.options.host === config.host + && this.options.database === config.database + && this.options.user === config.user + && this.options.port === config.port; + } +} + +const ansiReset = "\x1b[0m"; +const ansiRed = "\x1b[31m"; +const ansiGreen = "\x1b[32m"; +const ansiYellow = "\x1b[33m"; +const ansiBlue = "\x1b[34m"; +const ansiMagenta = "\x1b[35m"; +const ansiCyan = "\x1b[36m"; +const ansiWhite = "\x1b[37m"; + +function getEscapeCode(level: string) { + switch (level.toLowerCase()) { + case "error": return ansiMagenta; + case "warn": return ansiYellow; + case "info": return ansiWhite; + case "debug": return ansiCyan; + default: return ansiBlue; + } +} + +interface DebugProxyTerminalOptions { + host: string; + port?: number; + database: string; + user: string; + password: string; + proxyPort?: number; +} + +class DebugProxyPseudoterminal implements vscode.Pseudoterminal { + private readonly onDidWriteEmitter = new vscode.EventEmitter; + private readonly onDidCloseEmitter = new vscode.EventEmitter; + private process: ChildProcess | undefined; + + readonly onDidWrite = this.onDidWriteEmitter.event; + readonly onDidClose = this.onDidCloseEmitter.event; + + constructor( + private readonly exeUri: vscode.Uri, + private readonly options: DebugProxyTerminalOptions + ) { } + + get isRunning() { return !!this.process && !this.process.exitCode; } + + open(_: vscode.TerminalDimensions | undefined): void { + const { host, database, password, user, port, proxyPort } = this.options; + logger.info("Launching Debug Proxy", { path: this.exeUri.fsPath, launchOptions: { host, database, user, port, proxyPort } }); + + const args = [ + "-json", + "-host", host, + "-db", database, + "-user", user, + ]; + if (port) { args.push("-port", `${port}`); } + if (proxyPort) { args.push("-listen", `${proxyPort}`); } + + const options = { env: { "PGPASSWORD": password } }; + + this.process = spawn(this.exeUri.fsPath, args, options); + + this.process.stdout.on("data", (data: Buffer) => { + type DebugProxyStdOut = { + time: string; + level: string; + msg: string; + [key: string]: unknown; + }; + + const { time, level, msg, ...properties } = JSON.parse(data.toString()) as DebugProxyStdOut; + const escapeCode = getEscapeCode(level); + this.onDidWriteEmitter.fire("\r\n" + escapeCode + msg.trim()); + for (const [key, value] of Object.entries(properties)) { + const $value = typeof value === 'object' ? JSON.stringify(value) : `${value}`; + this.onDidWriteEmitter.fire(`\r\n ${key}: ${$value}`); + } + this.onDidWriteEmitter.fire(ansiReset); + }); + this.process.stderr.on("data", (data: Buffer) => { + this.onDidWriteEmitter.fire("\r\n" + ansiRed + data.toString().trim() + "\x1b[0m"); + }); + this.process.on("close", (code) => { + let message = "DBOS Proxy has exited" + (code ? ` with code ${code}.` : "."); + this.onDidWriteEmitter.fire("\r\n\r\n" + ansiGreen + `${message}\r\nPress any key to close this terminal` + ansiReset); + this.process = undefined; + }); + this.process.on("error", () => { + this.onDidWriteEmitter.fire("\r\n\r\n" + ansiRed + `DBOS Debug Proxy has encountered an error.\r\nPress any key to close this terminal` + ansiReset); + this.process = undefined; + }); + } + + handleInput(data: string): void { + if (data.charCodeAt(0) === 3) { + // Ctrl+C + this.close(); + } else if (this.process?.exitCode === null) { + this.process.send(data); + } else { + this.onDidCloseEmitter.fire(); + } + } + + close(): void { + this.process?.kill(); + } +} diff --git a/src/extension.ts b/src/extension.ts index 6536c43..811afbe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,18 +1,16 @@ import * as vscode from 'vscode'; import { S3CloudStorage } from './CloudStorage'; -import { TTDbgCodeLensProvider } from './codeLensProvider'; -import { deleteStoredPasswords, deleteStoredPasswordsCommandName, shutdownDebugProxyCommandName, shutdownDebugProxy, cloudLoginCommandName, cloudLogin, startDebuggingCodeLensCommandName, startDebuggingFromCodeLens, startDebuggingFromUri, startDebuggingUriCommandName, getProxyUrl, getProxyUrlCommandName, pickWorkflowIdCommandName, pickWorkflowId, deleteDomainCredentials, deleteDomainCredentialsCommandName, deleteAppDatabasePassword, deleteAppDatabasePasswordCommandName } from './commands'; -import { Configuration } from './configuration'; -import { DebugProxy, } from './DebugProxy'; +import { CodeLensProvider } from './CodeLensProvider'; +import { registerCommands, updateDebugProxyCommandName, } from './commands'; +import { Configuration } from './Configuration'; import { LogOutputChannelTransport, Logger, createLogger } from './logger'; -import { ProvenanceDatabase } from './ProvenanceDatabase'; -import { TTDbgUriHandler } from './uriHandler'; +import { UriHandler } from './UriHandler'; import { CloudDataProvider } from './CloudDataProvider'; +import { shutdownProvenanceDbConnectionPool } from './provenanceDb'; +import { shutdownDebugProxy } from './debugProxy'; export let logger: Logger; export let config: Configuration; -export let provDB: ProvenanceDatabase; -export let debugProxy: DebugProxy; export async function activate(context: vscode.ExtensionContext) { @@ -22,42 +20,28 @@ export async function activate(context: vscode.ExtensionContext) { config = new Configuration(context.secrets); - provDB = new ProvenanceDatabase(); - context.subscriptions.push(provDB); - const cloudStorage = new S3CloudStorage(); context.subscriptions.push(cloudStorage); - debugProxy = new DebugProxy(cloudStorage, context.globalStorageUri); - context.subscriptions.push(debugProxy); + const cloudDataProvider = new CloudDataProvider(); context.subscriptions.push( - vscode.commands.registerCommand(cloudLoginCommandName, cloudLogin), - vscode.commands.registerCommand(deleteDomainCredentialsCommandName, deleteDomainCredentials), - vscode.commands.registerCommand(deleteAppDatabasePasswordCommandName, deleteAppDatabasePassword), - vscode.commands.registerCommand(deleteStoredPasswordsCommandName, deleteStoredPasswords), - vscode.commands.registerCommand(shutdownDebugProxyCommandName, shutdownDebugProxy), - vscode.commands.registerCommand(startDebuggingCodeLensCommandName, startDebuggingFromCodeLens), - vscode.commands.registerCommand(startDebuggingUriCommandName, startDebuggingFromUri), - - vscode.commands.registerCommand(getProxyUrlCommandName, getProxyUrl), - vscode.commands.registerCommand(pickWorkflowIdCommandName, pickWorkflowId), + ...registerCommands(cloudStorage, context.globalStorageUri, (domain: string) => cloudDataProvider.refresh(domain)), + { dispose() { shutdownProvenanceDbConnectionPool(); } }, + { dispose() { shutdownDebugProxy(); } }, - vscode.window.registerTreeDataProvider( - "dbos-ttdbg.views.resources", - new CloudDataProvider()), + vscode.window.registerTreeDataProvider("dbos-ttdbg.views.resources", cloudDataProvider), vscode.languages.registerCodeLensProvider( { scheme: 'file', language: 'typescript' }, - new TTDbgCodeLensProvider()), + new CodeLensProvider()), - vscode.window.registerUriHandler(new TTDbgUriHandler()) + vscode.window.registerUriHandler(new UriHandler()) ); - await debugProxy.update().catch(e => { - logger.error("Debug Proxy Update Failed", e); - vscode.window.showErrorMessage(`Debug Proxy Update Failed`); - }); + vscode.commands.executeCommand(updateDebugProxyCommandName); } export function deactivate() { } + + diff --git a/src/provenanceDb.ts b/src/provenanceDb.ts new file mode 100644 index 0000000..e3dd125 --- /dev/null +++ b/src/provenanceDb.ts @@ -0,0 +1,84 @@ +import * as vscode from 'vscode'; +import { Client, ClientConfig, Pool } from 'pg'; +import { getDbosWorkflowName, type DbosMethodInfo } from './CodeLensProvider'; +import type { DbosDebugConfig } from './Configuration'; +import { logger } from './extension'; + +export interface workflow_status { + workflow_uuid: string; + status: string; + name: string; + authenticated_user: string; + output: string; + error: string; + assumed_role: string; + authenticated_roles: string; // Serialized list of roles. + request: string; // Serialized HTTPRequest + executor_id: string; // Set to "local" for local deployment, set to microVM ID for cloud deployment. + created_at: string; + updated_at: string; +} + +function getClientConfig(debugConfig: DbosDebugConfig): ClientConfig { + const { password } = debugConfig; + const pgPassword = typeof password === 'string' ? password : async () => { + const pwd = await password(); + if (!pwd) { throw new Error("Invalid password"); } + return pwd; + }; + + return { + host: debugConfig.host, + port: debugConfig.port, + database: debugConfig.database, + user: debugConfig.user, + password: pgPassword, + ssl: { rejectUnauthorized: false } + }; +} + +const connections = new Map(); + +function getConnectionString(debugConfig: DbosDebugConfig): string { + const { host, port, database, user } = debugConfig; + return `postgres://${user}:@${host}:${port}/${database}`; +} + +function getPool(debugConfig: DbosDebugConfig): Pool { + const key = getConnectionString(debugConfig); + let pool = connections.get(key); + if (!pool) { + pool = new Pool(getClientConfig(debugConfig)); + connections.set(key, pool); + } + return pool; +} + +export function shutdownProvenanceDbConnectionPool() { + for (const pool of connections.values()) { + pool.end().catch(e => logger.error("Failed to end pool", e)); + } +} + +export async function getWorkflowStatuses(debugConfig: DbosDebugConfig, method?: DbosMethodInfo): Promise { + const client = await getPool(debugConfig).connect(); + try { + const results = method + ? await client.query('SELECT * FROM dbos.workflow_status WHERE name = $1 ORDER BY created_at DESC LIMIT 10', [getDbosWorkflowName(method.name, method.type)]) + : await client.query('SELECT * FROM dbos.workflow_status ORDER BY created_at DESC LIMIT 10'); + return results.rows; + } finally { + client.release(); + } +} + +export async function getWorkflowStatus(debugConfig: DbosDebugConfig, workflowID: string): Promise { + const client = await getPool(debugConfig).connect(); + try { + const results = await client.query('SELECT * FROM dbos.workflow_status WHERE workflow_uuid = $1', [workflowID]); + if (results.rows.length > 1) { throw new Error(`Multiple workflow status records found for workflow ID ${workflowID}`); } + return results.rows.length === 1 ? results.rows[0] : undefined; + } finally { + client.release(); + } +} diff --git a/src/showWorkflowPick.ts b/src/showWorkflowPick.ts new file mode 100644 index 0000000..3beb616 --- /dev/null +++ b/src/showWorkflowPick.ts @@ -0,0 +1,91 @@ +import * as vscode from 'vscode'; +import { logger, config } from './extension'; +import type { DbosDebugConfig } from './Configuration'; +import { validateCredentials } from './validateCredentials'; +import type { DbosMethodInfo } from './CodeLensProvider'; +import { getWorkflowStatuses } from './provenanceDb'; +import { launchDashboardCommandName } from './commands'; + +export async function showWorkflowPick( + folder: vscode.WorkspaceFolder, + options?: { + method?: DbosMethodInfo; + cloudConfig?: DbosDebugConfig; + } +): Promise { + let cloudConfig = options?.cloudConfig; + if (!cloudConfig) { + const credentials = await config.getStoredCloudCredentials(); + if (!validateCredentials(credentials)) { + logger.warn("showWorkflowPick: config.getStoredCloudCredentials returned undefined"); + return undefined; + } + cloudConfig = await config.getDebugConfig(folder, credentials); + } + + const statuses = await getWorkflowStatuses(cloudConfig, options?.method); + const items = statuses.map(status => { + label: new Date(parseInt(status.created_at)).toLocaleString(), + description: `${status.status}${status.authenticated_user.length !== 0 ? ` (${status.authenticated_user})` : ""}`, + detail: status.workflow_uuid, + }); + + const editButton: vscode.QuickInputButton = { + iconPath: new vscode.ThemeIcon("edit"), + tooltip: "Specify workflow id directly" + }; + + const dashboardButton: vscode.QuickInputButton = { + iconPath: new vscode.ThemeIcon("server"), + tooltip: "Select workflow via DBOS User Dashboard" + }; + + const disposables: { dispose(): any; }[] = []; + try { + const result = await new Promise(resolve => { + const input = vscode.window.createQuickPick(); + input.title = "Select a workflow ID to debug"; + input.canSelectMany = false; + input.items = items; + input.buttons = [editButton, dashboardButton]; + let selectedItem: vscode.QuickPickItem | undefined = undefined; + disposables.push( + input.onDidAccept(() => { + logger.debug("showWorkflowPick.onDidAccept", { selectedItem }); + resolve(selectedItem); + input.dispose(); + }), + input.onDidHide(() => { + logger.debug("showWorkflowPick.onDidHide", { selectedItem }); + resolve(undefined); + input.dispose(); + }), + input.onDidChangeSelection(items => { + logger.debug("showWorkflowPick.onDidChangeSelection", { items }); + selectedItem = items.length === 0 ? undefined : items[0]; + }), + input.onDidTriggerButton(button => { + logger.debug("showWorkflowPick.onDidTriggerButton", { button }); + resolve(button); + input.dispose(); + }) + ); + input.show(); + }); + if (result === undefined) { return undefined; } + if ("label" in result) { + return result.detail; + } + if (result === editButton) { + return await vscode.window.showInputBox({ prompt: "Enter the workflow ID" }); + } else if (result === dashboardButton) { + vscode.commands.executeCommand(launchDashboardCommandName, cloudConfig.appName, options?.method) + .then(undefined, e => logger.error(launchDashboardCommandName, e)); + return undefined; + } else { + throw new Error(`Unexpected button: ${result.tooltip ?? ""}`); + } + } finally { + disposables.forEach(d => d.dispose()); + } +} diff --git a/src/startDebugging.ts b/src/startDebugging.ts new file mode 100644 index 0000000..e3329e4 --- /dev/null +++ b/src/startDebugging.ts @@ -0,0 +1,78 @@ +import * as vscode from 'vscode'; +import { logger, config } from './extension'; +import type { DbosDebugConfig } from './Configuration'; +import { validateCredentials } from './validateCredentials'; +import { getWorkflowStatus } from './provenanceDb'; +import { launchDebugProxyCommandName } from './commands'; + +export async function startDebugging(folder: vscode.WorkspaceFolder, getWorkflowID: (debugConfig: DbosDebugConfig) => Promise) { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: "Launching DBOS Time Travel Debugger", + }, + async () => { + const credentials = await config.getStoredCloudCredentials(); + if (!validateCredentials(credentials)) { + logger.warn("startDebugging: getWorkflowID returned invalid credentials", { folder: folder.uri.fsPath, credentials: credentials ?? null }); + return undefined; + } + + const debugConfig = await config.getDebugConfig(folder, credentials); + const workflowID = await getWorkflowID(debugConfig); + if (!workflowID) { + logger.warn("startDebugging: getWorkflowID returned undefined", { folder: folder.uri.fsPath, debugConfig }); + return undefined; + } + + const workflowStatus = await getWorkflowStatus(debugConfig, workflowID); + if (!workflowStatus) { + logger.error(`startDebugging: Workflow ID ${workflowID} not found`, { folder: folder.uri.fsPath, debugConfig }); + vscode.window.showErrorMessage(`Workflow ID ${workflowID} not found`); + return undefined; + } + + const proxyLaunched = await vscode.commands.executeCommand(launchDebugProxyCommandName, debugConfig); + if (!proxyLaunched) { + logger.warn("startDebugging: launchDebugProxy returned false", { folder: folder.uri.fsPath, debugConfig, workflowID }); + return undefined; + } + + const launchConfig = getDebugLaunchConfig(folder, workflowID); + logger.info(`startDebugging`, { folder: folder.uri.fsPath, debugConfig, launchConfig }); + + const debuggerStarted = await vscode.debug.startDebugging(folder, launchConfig); + if (!debuggerStarted) { + throw new Error("startDebugging: Debugger failed to start", { + cause: { + folder: folder.uri.fsPath, + debugConfig, + workflowID, + launchConfig, + } + }); + } + }); +} + + +function getDebugLaunchConfig(folder: vscode.WorkspaceFolder, workflowID: string): vscode.DebugConfiguration { + const debugConfigs = vscode.workspace.getConfiguration("launch", folder).get('configurations') as ReadonlyArray | undefined; + for (const config of debugConfigs ?? []) { + const command = config["command"] as string | undefined; + if (command && command.includes("npx dbos-sdk debug")) { + const newCommand = command.replace("${command:dbos-ttdbg.pick-workflow-id}", `${workflowID}`); + return { ...config, command: newCommand }; + } + } + + const preLaunchTask = config.getPreLaunchTask(folder); + const proxyPort = config.getProxyPort(folder); + return { + name: `Time-Travel Debug ${workflowID}`, + type: 'node-terminal', + request: 'launch', + command: `npx dbos-sdk debug -x http://localhost:${proxyPort} -u ${workflowID}`, + preLaunchTask, + }; +} \ No newline at end of file diff --git a/src/userFlows.ts b/src/userFlows.ts deleted file mode 100644 index 0293444..0000000 --- a/src/userFlows.ts +++ /dev/null @@ -1,233 +0,0 @@ -import * as vscode from 'vscode'; -import { logger, config, provDB, debugProxy } from './extension'; -import { DbosDebugConfig } from './configuration'; -import { DbosMethodInfo } from './ProvenanceDatabase'; -import { DbosCloudCredentials, createDashboard, getDashboard, isTokenExpired } from './dbosCloudApi'; - -function getDebugLaunchConfig(folder: vscode.WorkspaceFolder, workflowID: string): vscode.DebugConfiguration { - const debugConfigs = vscode.workspace.getConfiguration("launch", folder).get('configurations') as ReadonlyArray | undefined; - for (const config of debugConfigs ?? []) { - const command = config["command"] as string | undefined; - if (command && command.includes("npx dbos-sdk debug")) { - const newCommand = command.replace("${command:dbos-ttdbg.pick-workflow-id}", `${workflowID}`); - return { ...config, command: newCommand }; - } - } - - const preLaunchTask = config.getPreLaunchTask(folder); - const proxyPort = config.getProxyPort(folder); - return { - name: `Time-Travel Debug ${workflowID}`, - type: 'node-terminal', - request: 'launch', - command: `npx dbos-sdk debug -x http://localhost:${proxyPort} -u ${workflowID}`, - preLaunchTask, - }; - } - -export async function startDebugging(folder: vscode.WorkspaceFolder, getWorkflowID: (cloudConfig: DbosDebugConfig) => Promise) { - await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Window, - title: "Launching DBOS Time Travel Debugger", - }, - async () => { - const credentials = await config.getStoredCloudCredentials(); - if (!validateCredentials(credentials)) { - logger.warn("startDebugging: getWorkflowID returned invalid credentials", { folder: folder.uri.fsPath, credentials: credentials ?? null }); - return undefined; - } - - const cloudConfig = await config.getDebugConfig(folder, credentials); - const workflowID = await getWorkflowID(cloudConfig); - if (!workflowID) { - logger.warn("startDebugging: getWorkflowID returned undefined", { folder: folder.uri.fsPath, cloudConfig }); - return undefined; - } - - const workflowStatus = await provDB.getWorkflowStatus(cloudConfig, workflowID); - if (!workflowStatus) { - logger.error(`startDebugging: Workflow ID ${workflowID} not found`, { folder: folder.uri.fsPath, cloudConfig }); - vscode.window.showErrorMessage(`Workflow ID ${workflowID} not found`); - return undefined; - } - - const proxyLaunched = await debugProxy.launch(cloudConfig, folder); - if (!proxyLaunched) { - logger.warn("startDebugging: debugProxy.launch returned false", { folder: folder.uri.fsPath, cloudConfig, workflowID }); - return undefined; - } - - const launchConfig = getDebugLaunchConfig(folder, workflowID); - logger.info(`startDebugging`, { folder: folder.uri.fsPath, database: cloudConfig, debugConfig: launchConfig }); - - const debuggerStarted = await vscode.debug.startDebugging(folder, launchConfig); - if (!debuggerStarted) { - throw new Error("startDebugging: Debugger failed to start", { - cause: { - folder: folder.uri.fsPath, - cloudConfig, - workflowID, - launchConfig, - } - }); - } - }); -} - -export async function showWorkflowPick( - folder: vscode.WorkspaceFolder, - options?: { - method?: DbosMethodInfo; - cloudConfig?: DbosDebugConfig; - } -): Promise { - let cloudConfig = options?.cloudConfig; - if (!cloudConfig) { - const credentials = await config.getStoredCloudCredentials(); - if (!validateCredentials(credentials)) { - logger.warn("showWorkflowPick: config.getStoredCloudCredentials returned undefined"); - return undefined; - } - cloudConfig = await config.getDebugConfig(folder, credentials); - } - - const statuses = await provDB.getWorkflowStatuses(cloudConfig, options?.method); - const items = statuses.map(status => { - label: new Date(parseInt(status.created_at)).toLocaleString(), - description: `${status.status}${status.authenticated_user.length !== 0 ? ` (${status.authenticated_user})` : ""}`, - detail: status.workflow_uuid, - }); - - const editButton: vscode.QuickInputButton = { - iconPath: new vscode.ThemeIcon("edit"), - tooltip: "Specify workflow id directly" - }; - - const dashboardButton: vscode.QuickInputButton = { - iconPath: new vscode.ThemeIcon("server"), - tooltip: "Select workflow via DBOS User Dashboard" - }; - - const disposables: { dispose(): any; }[] = []; - try { - const result = await new Promise(resolve => { - const input = vscode.window.createQuickPick(); - input.title = "Select a workflow ID to debug"; - input.canSelectMany = false; - input.items = items; - input.buttons = [editButton, dashboardButton]; - let selectedItem: vscode.QuickPickItem | undefined = undefined; - disposables.push( - input.onDidAccept(() => { - logger.debug("showWorkflowPick.onDidAccept", { selectedItem }); - resolve(selectedItem); - input.dispose(); - }), - input.onDidHide(() => { - logger.debug("showWorkflowPick.onDidHide", { selectedItem }); - resolve(undefined); - input.dispose(); - }), - input.onDidChangeSelection(items => { - logger.debug("showWorkflowPick.onDidChangeSelection", { items }); - selectedItem = items.length === 0 ? undefined : items[0]; - }), - input.onDidTriggerButton(button => { - logger.debug("showWorkflowPick.onDidTriggerButton", { button }); - resolve(button); - input.dispose(); - }), - ); - input.show(); - }); - if (result === undefined) { return undefined; } - if ("label" in result) { - return result.detail; - } - if (result === editButton) { - return await vscode.window.showInputBox({ prompt: "Enter the workflow ID" }); - } else if (result === dashboardButton) { - startOpenDashboardFlow(cloudConfig.appName, options?.method) - .catch(e => logger.error("startOpenDashboard", e)); - return undefined; - } else { - throw new Error(`Unexpected button: ${result.tooltip ?? ""}`); - } - } finally { - disposables.forEach(d => d.dispose()); - } -} - -async function startOpenDashboardFlow(appName: string | undefined, method?: DbosMethodInfo): Promise { - logger.debug(`startOpenDashboardFlow enter`, { appName: appName ?? null, method: method ?? null }); - const credentials = await config.getStoredCloudCredentials(); - if (!validateCredentials(credentials)) { - logger.warn("startOpenDashboardFlow: config.getStoredCloudCredentials returned invalid credentials", { credentials }); - return undefined; - } - - let dashboardUrl = await getDashboard(credentials); - if (!dashboardUrl) { - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Window, - cancellable: false, - title: "Creating DBOS dashboard" - }, async () => { await createDashboard(credentials); }); - - dashboardUrl = await getDashboard(credentials); - if (!dashboardUrl) { - vscode.window.showErrorMessage("Failed to create DBOS dashboard"); - return; - } - } - - const params = new URLSearchParams(); - if (method) { - params.append("var-operation_name", method.name); - params.append("var-operation_type", method.type.toLowerCase()); - } - if (appName) { - params.append("var-app_name", appName); - } - const dashboardQueryUrl = `${dashboardUrl}?${params}`; - logger.info(`startOpenDashboardFlow uri`, { uri: dashboardQueryUrl }); - const openResult = await vscode.env.openExternal(vscode.Uri.parse(dashboardQueryUrl)); - if (!openResult) { - throw new Error(`failed to open dashboard URL: ${dashboardQueryUrl}`); - } -} - -export function validateCredentials(credentials?: DbosCloudCredentials): credentials is DbosCloudCredentials { - if (!credentials) { - startInvalidCredentialsFlow(credentials) - .catch(e => logger.error("startInvalidCredentialsFlow", e)); - return false; - } - - if (isTokenExpired(credentials.token)) { - startInvalidCredentialsFlow(credentials) - .catch(e => logger.error("startInvalidCredentialsFlow", e)); - return false; - } - - return true; -} - -export async function startInvalidCredentialsFlow(credentials?: DbosCloudCredentials): Promise { - const message = credentials - ? "DBOS Cloud credentials have expired. Please login again." - : "You need to login to DBOS Cloud."; - - const items = ["Login", "Cancel"]; - - // TODO: Register support - // if (!credentials) { items.unshift("Register"); } - const result = await vscode.window.showWarningMessage(message, ...items); - switch (result) { - // case "Register": break; - case "Login": - await config.cloudLogin(); - break; - } -} diff --git a/src/utility.ts b/src/utility.ts new file mode 100644 index 0000000..85a91bf --- /dev/null +++ b/src/utility.ts @@ -0,0 +1,14 @@ +import * as vscode from 'vscode'; + +export async function exists(uri: vscode.Uri): Promise { + return await vscode.workspace.fs.stat(uri) + .then(_value => true, () => false); +} + +export function getDebugConfigFolder(cfg?: vscode.DebugConfiguration): vscode.WorkspaceFolder { + const rootPath = cfg?.rootPath; + if (!rootPath) { throw new Error("getDebugConfigFolder: Invalid rootPath", { cause: cfg }); } + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.parse(rootPath)); + if (!folder) { throw new Error("getDebugConfigFolder: getWorkspaceFolder failed", { cause: cfg }); } + return folder; +} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 65afdc8..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,111 +0,0 @@ -import * as vscode from 'vscode'; -import { execFile as cpExecFile } from "child_process"; -import util from 'util'; -import { fast1a32 } from 'fnv-plus'; -import { ClientConfig } from 'pg'; -import { DbosDebugConfig } from './configuration'; -import { logger } from './extension'; - -export const PLATFORM = function () { - switch (process.platform) { - case "linux": - return "linux"; - case "darwin": - return "macos"; - case "win32": - return "windows"; - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } -}(); - -export const ARCHITECTURE = function () { - switch (process.arch) { - case "arm64": - return "arm64"; - case "x64": - return "x64"; - default: - throw new Error(`Unsupported architecture: ${process.arch}`); - } -}(); - -export function stringify(obj: unknown): string { - if (typeof obj === 'string') { return obj; } - if (obj instanceof Error) { return obj.message; } - if (typeof obj === 'object') { return JSON.stringify(obj); } - return (obj as any).toString(); -} - -export async function exists(uri: vscode.Uri): Promise { - return await vscode.workspace.fs.stat(uri) - .then(_value => true, () => false); -} - -export async function getPackageName(folder: vscode.WorkspaceFolder): Promise { - const packageJsonUri = vscode.Uri.joinPath(folder.uri, "package.json"); - if (!await exists(packageJsonUri)) { return undefined; } - - try { - const packageJsonBuffer = await vscode.workspace.fs.readFile(packageJsonUri); - const packageJsonText = new TextDecoder().decode(packageJsonBuffer); - const packageJson = JSON.parse(packageJsonText); - return packageJson.name; - } catch (e) { - logger.error("getPackageName", e); - return undefined; - } -} - -export const execFile = util.promisify(cpExecFile); - -export function hashClientConfig(clientConfig: ClientConfig | DbosDebugConfig) { - const { host, port, database, user } = clientConfig; - return host && port && database && user - ? fast1a32(`${host}:${port}:${database}:${user}`) - : undefined; -} - -export async function getWorkspaceFolder(rootPath?: string | vscode.Uri) { - if (rootPath) { - if (typeof rootPath === "string") { - rootPath = vscode.Uri.file(rootPath); - } - const folder = vscode.workspace.getWorkspaceFolder(rootPath); - if (folder) { - return folder; - } - } - - const folders = vscode.workspace.workspaceFolders ?? []; - if (folders.length === 1) { - return folders[0]; - } - - if (vscode.window.activeTextEditor) { - const folder = vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri); - if (folder) { - return folder; - } - } - - return await vscode.window.showWorkspaceFolderPick(); -} - -export function getDebugConfigFolder(cfg?: vscode.DebugConfiguration): vscode.WorkspaceFolder { - const rootPath = cfg?.rootPath; - if (!rootPath) { throw new Error("getDebugConfigFolder: Invalid rootPath", { cause: cfg }); } - const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.parse(rootPath)); - if (!folder) { throw new Error("getDebugConfigFolder: getWorkspaceFolder failed", { cause: cfg }); } - return folder; -} - -export async function cancellableFetch(url: string, request: Omit, token?: vscode.CancellationToken) { - const abort = new AbortController(); - const tokenListener = token?.onCancellationRequested(reason => { abort.abort(reason); }); - try { - return await fetch(url, { ...request, signal: abort.signal }); - } finally { - tokenListener?.dispose(); - } -} diff --git a/src/validateCredentials.ts b/src/validateCredentials.ts new file mode 100644 index 0000000..258733d --- /dev/null +++ b/src/validateCredentials.ts @@ -0,0 +1,48 @@ +import * as vscode from 'vscode'; +import { logger, config } from './extension'; +import { DbosCloudCredentials } from './dbosCloudApi'; +import jwt from 'jsonwebtoken'; + +export function isTokenExpired(authToken: string): boolean { + try { + const { exp } = jwt.decode(authToken) as jwt.JwtPayload; + if (!exp) { return false; } + return Date.now() >= exp * 1000; + } catch (error) { + return true; + } +} + +export function validateCredentials(credentials?: DbosCloudCredentials): credentials is DbosCloudCredentials { + if (!credentials) { + startInvalidCredentialsFlow(credentials) + .catch(e => logger.error("startInvalidCredentialsFlow", e)); + return false; + } + + if (isTokenExpired(credentials.token)) { + startInvalidCredentialsFlow(credentials) + .catch(e => logger.error("startInvalidCredentialsFlow", e)); + return false; + } + + return true; + + async function startInvalidCredentialsFlow(credentials?: DbosCloudCredentials): Promise { + const message = credentials + ? "DBOS Cloud credentials have expired. Please login again." + : "You need to login to DBOS Cloud."; + + const items = ["Login", "Cancel"]; + + // TODO: Register support + // if (!credentials) { items.unshift("Register"); } + const result = await vscode.window.showWarningMessage(message, ...items); + switch (result) { + // case "Register": break; + case "Login": + await config.cloudLogin(); + break; + } + } +}