Skip to content

Commit

Permalink
Enable saving of Studio access token (#3235)
Browse files Browse the repository at this point in the history
* add prototype for very simple setup of a Studio token

* wire up new webview

* wire up new actions

* validate studio token input

* switch to settings page if studio access token is saved

* validate dvc.studioAccessToken via package json properties

* add context value which hides view from tree when token is added

* hide show webview command when studio is connected

* switch from setting to using secrets store

* revert changes in config key

* fix enum

* fix story

* restructure connect class

* add connect app tests

* add integration tests for Connect

* disallow direct access to secrets

* create add studio access token command

* apply review feedback
  • Loading branch information
mattseddon authored Feb 16, 2023
1 parent 716f569 commit a68ceba
Show file tree
Hide file tree
Showing 33 changed files with 639 additions and 13 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"featurize",
"hardlinks",
"Interactors",
"isat",
"isdir",
"isempty",
"isequal",
Expand Down
37 changes: 37 additions & 0 deletions extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@
"category": "DVC",
"icon": "$(star-full)"
},
{
"title": "%command.addStudioAccessToken%",
"command": "dvc.addStudioAccessToken",
"category": "DVC"
},
{
"title": "%command.addTarget%",
"command": "dvc.addTarget",
Expand Down Expand Up @@ -307,6 +312,11 @@
"category": "DVC",
"icon": "$(close-all)"
},
{
"title": "%command.removeStudioAccessToken%",
"command": "dvc.removeStudioAccessToken",
"category": "DVC"
},
{
"title": "%command.removeTarget%",
"command": "dvc.removeTarget",
Expand Down Expand Up @@ -384,6 +394,11 @@
"command": "dvc.showCommands",
"category": "DVC"
},
{
"title": "%command.showConnect%",
"command": "dvc.showConnect",
"category": "DVC"
},
{
"title": "%command.showExperiments%",
"command": "dvc.showExperiments",
Expand Down Expand Up @@ -622,6 +637,10 @@
"command": "dvc.addStarredExperimentsTableSort",
"when": "dvc.commands.available && dvc.project.available"
},
{
"command": "dvc.addStudioAccessToken",
"when": "dvc.commands.available && dvc.project.available"
},
{
"command": "dvc.addTarget",
"when": "false"
Expand Down Expand Up @@ -750,6 +769,10 @@
"command": "dvc.removeExperimentsTableSorts",
"when": "dvc.commands.available && dvc.project.available"
},
{
"command": "dvc.removeStudioAccessToken",
"when": "dvc.commands.available && dvc.project.available && dvc.studio.connected"
},
{
"command": "dvc.removeTarget",
"when": "false"
Expand Down Expand Up @@ -786,6 +809,10 @@
"command": "dvc.stopQueuedExperiments",
"when": "dvc.commands.available && dvc.project.available"
},
{
"command": "dvc.showConnect",
"when": "dvc.commands.available && dvc.project.available && !dvc.studio.connected"
},
{
"command": "dvc.selectForCompare",
"when": "false"
Expand Down Expand Up @@ -1319,6 +1346,11 @@
"name": "Actions",
"when": "true"
},
{
"id": "dvc.views.studio",
"name": "Studio",
"when": "!dvc.studio.connected"
},
{
"id": "dvc.views.experimentsColumnsTree",
"name": "Columns",
Expand Down Expand Up @@ -1355,6 +1387,11 @@
"view": "dvc.views.actions",
"contents": "[$(beaker) Show Experiments](command:dvc.showExperiments)\n[$(graph-scatter) Show Plots](command:dvc.showPlots)\n[$(play) Run Experiment](command:dvc.runExperiment)"
},
{
"view": "dvc.views.studio",
"contents": "[$(plug) Connect](command:dvc.showConnect)",
"when": "!dvc.studio.connected"
},
{
"view": "dvc.views.welcome",
"contents": "New to the extension?\n[Show Walkthrough](command:dvc.getStarted)\n\nThe extension is currently unable to initialize.\n[Show Setup](command:dvc.showSetup)",
Expand Down
3 changes: 3 additions & 0 deletions extension/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"command.addExperimentsTableSort": "Add Or Update Sort On Experiments Table",
"command.addStarredExperimentsTableFilter": "Filter Experiments Table to Starred",
"command.addStarredExperimentsTableSort": "Sort Experiments Table by Starred",
"command.addStudioAccessToken": "Add Studio Access Token",
"command.addTarget": "Add Target",
"command.applyExperiment": "Apply Experiment To Workspace",
"command.branchExperiment": "Create New Branch From Experiment",
Expand Down Expand Up @@ -39,6 +40,7 @@
"command.removeExperimentQueue": "Remove All Queued Experiments",
"command.removeExperimentsTableFilters": "Remove Filter(s) From Experiments Table",
"command.removeExperimentsTableSorts": "Remove Sort(s) From Experiments Table",
"command.removeStudioAccessToken": "Remove Studio Access Token",
"command.removeTarget": "Remove",
"command.renameTarget": "Rename",
"command.runExperiment": "Run Experiment",
Expand All @@ -50,6 +52,7 @@
"command.shareExperimentAsBranch": "Share Experiment as Branch",
"command.shareExperimentAsCommit": "Commit and Share Experiment",
"command.showCommands": "Show Commands",
"command.showConnect": "Connect to Studio",
"command.showExperiments": "Show Experiments",
"command.showExperimentsAndPlots": "Show Experiments and Plots",
"command.showOutput": "Show DVC Output",
Expand Down
6 changes: 5 additions & 1 deletion extension/src/commands/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,9 @@ export enum RegisteredCommands {
TRACKED_EXPLORER_SELECT_FOR_COMPARE = 'dvc.selectForCompare',

SETUP_SHOW = 'dvc.showSetup',
SELECT_FOCUSED_PROJECTS = 'dvc.selectFocusedProjects'
SELECT_FOCUSED_PROJECTS = 'dvc.selectFocusedProjects',

CONNECT_SHOW = 'dvc.showConnect',
ADD_STUDIO_ACCESS_TOKEN = 'dvc.addStudioAccessToken',
REMOVE_STUDIO_ACCESS_TOKEN = 'dvc.removeStudioAccessToken'
}
124 changes: 124 additions & 0 deletions extension/src/connect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { commands, ExtensionContext, SecretStorage } from 'vscode'
import { validateTokenInput } from './inputBox'
import { STUDIO_ACCESS_TOKEN_KEY, isStudioAccessToken } from './token'
import { STUDIO_URL } from './webview/contract'
import { Resource } from '../resourceLocator'
import { ViewKey } from '../webview/constants'
import { MessageFromWebview, MessageFromWebviewType } from '../webview/contract'
import { BaseRepository } from '../webview/repository'
import { Logger } from '../common/logger'
import { getInput, getValidInput } from '../vscode/inputBox'
import { Title } from '../vscode/title'
import { openUrl } from '../vscode/external'
import { ContextKey, setContextValue } from '../vscode/context'
import { RegisteredCommands } from '../commands/external'
import { showInformation } from '../vscode/modal'

export class Connect extends BaseRepository<undefined> {
public readonly viewKey = ViewKey.CONNECT

private readonly secrets: SecretStorage

constructor(context: ExtensionContext, webviewIcon: Resource) {
super('', webviewIcon)

this.secrets = context.secrets

this.dispose.track(
this.onDidReceivedWebviewMessage(message =>
this.handleMessageFromWebview(message)
)
)

void this.setContext().then(() => this.deferred.resolve())

this.dispose.track(
context.secrets.onDidChange(e => {
if (e.key !== STUDIO_ACCESS_TOKEN_KEY) {
return
}
return this.setContext()
})
)
}

public sendInitialWebviewData(): void {}

public removeStudioAccessToken() {
return this.removeSecret(STUDIO_ACCESS_TOKEN_KEY)
}

public async saveStudioAccessToken() {
const token = await getValidInput(
Title.ENTER_STUDIO_TOKEN,
validateTokenInput,
{ password: true }
)
if (!token) {
return
}

return this.storeSecret(STUDIO_ACCESS_TOKEN_KEY, token)
}

private handleMessageFromWebview(message: MessageFromWebview) {
switch (message.type) {
case MessageFromWebviewType.OPEN_STUDIO:
return this.openStudio()
case MessageFromWebviewType.OPEN_STUDIO_PROFILE:
return this.openStudioProfile()
case MessageFromWebviewType.SAVE_STUDIO_TOKEN:
return commands.executeCommand(
RegisteredCommands.ADD_STUDIO_ACCESS_TOKEN
)
default:
Logger.error(`Unexpected message: ${JSON.stringify(message)}`)
}
}

private openStudio() {
return openUrl(STUDIO_URL)
}

private async openStudioProfile() {
const username = await getInput(Title.ENTER_STUDIO_USERNAME)
if (!username) {
return
}
return openUrl(`${STUDIO_URL}/user/${username}/profile`)
}

private async setContext() {
const storedToken = await this.getSecret(STUDIO_ACCESS_TOKEN_KEY)
if (isStudioAccessToken(storedToken)) {
if (this.deferred.state === 'resolved') {
void showInformation(
'Studio is now connected. Use the "Share to Studio" command from an experiment\'s context menu to share experiments.'
)
}
this.webview?.dispose()
return setContextValue(ContextKey.STUDIO_CONNECTED, true)
}

return setContextValue(ContextKey.STUDIO_CONNECTED, false)
}

private getSecret(key: string) {
const secrets = this.getSecrets()
return secrets.get(key)
}

private storeSecret(key: string, value: string) {
const secrets = this.getSecrets()
return secrets.store(key, value)
}

private removeSecret(key: string) {
const secrets = this.getSecrets()
return secrets.delete(key)
}

private getSecrets() {
return this.secrets
}
}
20 changes: 20 additions & 0 deletions extension/src/connect/inputBox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { validateTokenInput } from './inputBox'

describe('validateTokenInput', () => {
const mockedStudioAccessToken =
'isat_1Z4T0zVHvq9Cu03XEe9Zjvx2vkBihfGPdY7FfmEMAagOXfQx'
it('should return the warning if the input is not valid', () => {
expect(
validateTokenInput(mockedStudioAccessToken.slice(0, -1))
).not.toBeNull()
expect(
validateTokenInput(
mockedStudioAccessToken.slice(1, mockedStudioAccessToken.length)
)
).not.toBeNull()
})

it('should return null if the input is valid', () => {
expect(validateTokenInput(mockedStudioAccessToken)).toBeNull()
})
})
8 changes: 8 additions & 0 deletions extension/src/connect/inputBox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { isStudioAccessToken } from './token'

export const validateTokenInput = (input: string | undefined) => {
if (!isStudioAccessToken(input)) {
return 'please enter a valid Studio access token'
}
return null
}
23 changes: 23 additions & 0 deletions extension/src/connect/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Connect } from '.'
import { RegisteredCommands } from '../commands/external'
import { InternalCommands } from '../commands/internal'

export const registerConnectCommands = (
connect: Connect,
internalCommands: InternalCommands
): void => {
internalCommands.registerExternalCommand(
RegisteredCommands.CONNECT_SHOW,
() => connect.showWebview()
)

internalCommands.registerExternalCommand(
RegisteredCommands.ADD_STUDIO_ACCESS_TOKEN,
() => connect.saveStudioAccessToken()
)

internalCommands.registerExternalCommand(
RegisteredCommands.REMOVE_STUDIO_ACCESS_TOKEN,
() => connect.removeStudioAccessToken()
)
}
8 changes: 8 additions & 0 deletions extension/src/connect/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const STUDIO_ACCESS_TOKEN_KEY = 'dvc.studioAccessToken'

export const isStudioAccessToken = (text?: string): boolean => {
if (!text) {
return false
}
return text.startsWith('isat_') && text.length >= 53
}
1 change: 1 addition & 0 deletions extension/src/connect/webview/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const STUDIO_URL = 'https://studio.iterative.ai'
10 changes: 9 additions & 1 deletion extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { DvcExecutor } from './cli/dvc/executor'
import { DvcRunner } from './cli/dvc/runner'
import { DvcReader } from './cli/dvc/reader'
import { Config } from './config'
import { Connect } from './connect'
import { registerConnectCommands } from './connect/register'
import { Context } from './context'
import { WorkspaceExperiments } from './experiments/workspace'
import { registerExperimentCommands } from './experiments/commands/register'
Expand Down Expand Up @@ -52,12 +54,12 @@ import { stopProcesses } from './processExecution'
import { Flag } from './cli/dvc/constants'
import { LanguageClient } from './languageClient'
import { collectRunningExperimentPids } from './experiments/processExecution/collect'

export class Extension extends Disposable {
protected readonly internalCommands: InternalCommands

private readonly resourceLocator: ResourceLocator
private readonly repositories: WorkspaceRepositories
private readonly connect: Connect
private readonly experiments: WorkspaceExperiments
private readonly plots: WorkspacePlots
private readonly setup: Setup
Expand Down Expand Up @@ -85,6 +87,10 @@ export class Extension extends Disposable {

const config = this.dispose.track(new Config())

this.connect = this.dispose.track(
new Connect(context, this.resourceLocator.dvcIcon)
)

this.dvcExecutor = this.dispose.track(new DvcExecutor(config))
this.dvcReader = this.dispose.track(new DvcReader(config))
this.dvcRunner = this.dispose.track(new DvcRunner(config))
Expand Down Expand Up @@ -185,6 +191,8 @@ export class Extension extends Disposable {
)
)

registerConnectCommands(this.connect, this.internalCommands)

registerExperimentCommands(
this.experiments,
this.internalCommands,
Expand Down
12 changes: 12 additions & 0 deletions extension/src/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export const EventName = Object.assign(
EXTENSION_EXECUTION_DETAILS_CHANGED: 'extension.executionDetails.changed',
EXTENSION_LOAD: 'extension.load',

VIEWS_CONNECT_CLOSED: 'views.connect.closed',
VIEWS_CONNECT_CREATED: 'views.connect.created',
VIEWS_CONNECT_FOCUS_CHANGED: 'views.connect.focusChanged',

VIEWS_EXPERIMENTS_TABLE_CLOSED: 'views.experimentsTable.closed',
VIEWS_EXPERIMENTS_TABLE_COLUMNS_REORDERED:
'views.experimentsTable.columnsReordered',
Expand Down Expand Up @@ -208,6 +212,10 @@ export interface IEventNamePropertyMapping {
[EventName.EXTENSION_SHOW_COMMANDS]: undefined
[EventName.EXTENSION_SHOW_OUTPUT]: undefined

[EventName.VIEWS_CONNECT_CLOSED]: undefined
[EventName.VIEWS_CONNECT_CREATED]: undefined
[EventName.VIEWS_CONNECT_FOCUS_CHANGED]: undefined

[EventName.VIEWS_EXPERIMENTS_TREE_OPENED]: DvcRootCount
[EventName.VIEWS_EXPERIMENTS_FILTER_BY_TREE_OPENED]: DvcRootCount
[EventName.VIEWS_EXPERIMENTS_METRICS_AND_PARAMS_TREE_OPENED]: DvcRootCount
Expand Down Expand Up @@ -273,4 +281,8 @@ export interface IEventNamePropertyMapping {

[EventName.SETUP_SHOW]: undefined
[EventName.SELECT_FOCUSED_PROJECTS]: undefined

[EventName.CONNECT_SHOW]: undefined
[EventName.ADD_STUDIO_ACCESS_TOKEN]: undefined
[EventName.REMOVE_STUDIO_ACCESS_TOKEN]: undefined
}
Loading

0 comments on commit a68ceba

Please sign in to comment.