Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions deploy/workflow-cfd-studio.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,10 @@ permissions:
type: string
optional: true
tooltip: |
Comma-separated names of platform workflows to offer as tiles on the
Workflows tab, in order. Each viewer runs them under their own account.
Comma-separated workflows to offer as tiles on the Workflows tab, in
order: account workflow names, marketplace/<slug>, or
github.com/<owner>/<repo>[/path][@ref]. Each viewer runs them under
their own account.
Administrators can also pick them in Settings, Workflows, from the
workflows on the account this Studio is deployed under.
build_gufi:
Expand Down
6 changes: 4 additions & 2 deletions deploy/workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,10 @@ permissions:
type: string
optional: true
tooltip: |
Comma-separated names of platform workflows to offer as tiles on the
Workflows tab, in order. Each viewer runs them under their own account.
Comma-separated workflows to offer as tiles on the Workflows tab, in
order: account workflow names, marketplace/<slug>, or
github.com/<owner>/<repo>[/path][@ref]. Each viewer runs them under
their own account.
Administrators can also pick them in Settings, Workflows, from the
workflows on the account this Studio is deployed under.
build_gufi:
Expand Down
21 changes: 16 additions & 5 deletions docs/WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ The tab appears only when the Studio can reach a platform, through the viewer's

## Choosing the set

An administrator picks workflows in Settings, Workflows. The list to pick from is every workflow on the platform account the Studio is deployed under, so a site deployment offers from that site's workflows. Order in the list is the order of the tiles. A name that is no longer on the account is flagged there and shown as an unavailable tile.
An entry is one of three kinds:

| Entry | Runs |
|---|---|
| `name` | a workflow saved on the account; a viewer without it can add a copy from its tile |
| `marketplace/<slug>` | the marketplace workflow, by slug, with no copy in anyone's account |
| `github.com/<owner>/<repo>[/path][@ref]` | the workflow.yaml in that repository (or directory, or named file), with no copy in anyone's account |

The form for a GitHub entry is read from the repository's raw file, so it shows for public repositories; a private repository still runs, through the platform's connected account.

An administrator picks workflows in Settings, Workflows, from the workflows on the platform account the Studio is deployed under (a site deployment offers from that site's workflows), from the marketplace, or by adding a GitHub repository, which is checked before it is added. Order in the list is the order of the tiles. A name that is no longer on the account is flagged there and shown as an unavailable tile.

At deploy time, the Offered Workflows input (environment variable `STUDIO_WORKFLOWS`, comma-separated names) sets the initial list. A list saved in Settings takes its place; clearing it in Settings returns to the deploy-time list.

Expand Down Expand Up @@ -36,9 +46,10 @@ Recent runs from the tab are listed below the tiles with their state.
| Route | Purpose |
|---|---|
| `GET /api/workflows/collection` | the tiles, as the viewer has them |
| `GET /api/workflows/catalog` | the deployment account's workflows, for Settings |
| `GET /api/workflows/:name/form` | the converted form and saved configurations |
| `POST /api/workflows/:name/run` | `{inputs, dryRun}`; validate or submit |
| `POST /api/workflows/:name/install` | copy the deployment's definition into the viewer's account |
| `GET /api/workflows/catalog` | the deployment account's workflows and the marketplace's, for Settings |
| `GET /api/workflows/item/form?w=<entry>` | the converted form and saved configurations |
| `GET /api/workflows/item/icon?w=<entry>` | the tile's icon |
| `POST /api/workflows/item/run?w=<entry>` | `{inputs, dryRun}`; validate or submit |
| `POST /api/workflows/item/install?w=<entry>` | copy the deployment's definition of an account workflow into the viewer's account |
| `GET /api/workflows/runs` | recent runs |
| `GET /api/platform/clusters`, `/partitions?cluster=`, `/buckets` | data for the pickers |
3 changes: 2 additions & 1 deletion server/src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'node:fs'
import { parseWorkflowEntry } from './workflowEntries.js'
import fsp from 'node:fs/promises'
import path from 'node:path'
import type { FastifyInstance } from 'fastify'
Expand Down Expand Up @@ -188,7 +189,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
if (body.kbLabel !== undefined) next.kbLabel = String(body.kbLabel).slice(0, 60) || undefined
if (body.sections !== undefined) next.sections = Array.isArray(body.sections) && body.sections.length ? body.sections.map(String).slice(0, 20) : undefined
if (body.workflowCollection !== undefined) {
const list = Array.isArray(body.workflowCollection) ? body.workflowCollection.map(String).filter(n => /^[A-Za-z0-9_.-]{1,120}$/.test(n)) : []
const list = Array.isArray(body.workflowCollection) ? body.workflowCollection.map(n => parseWorkflowEntry(String(n))?.entry).filter((n): n is string => !!n) : []
next.workflowCollection = list.length ? [...new Set(list)].slice(0, 60) : undefined
}
if (body.theme !== undefined) next.theme = body.theme === 'dark' ? 'dark' : 'light'
Expand Down
56 changes: 56 additions & 0 deletions server/src/workflowEntries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* An entry in a Studio's workflow collection is one of:
*
* name a workflow saved on the account
* marketplace/<slug> a marketplace workflow, run by slug
* github.com/<owner>/<repo>[/path][@ref] a workflow.yaml in a GitHub repository
*
* The last two run without being added to anyone's account; `pw workflows
* run` accepts both forms as they are written here.
*/

export type WorkflowEntry =
| { kind: 'account'; entry: string; name: string }
| { kind: 'marketplace'; entry: string; slug: string }
| { kind: 'github'; entry: string; owner: string; repo: string; path: string; ref: string | null }

const NAME = /^[A-Za-z0-9_.-]{1,120}$/
const SEG = '[A-Za-z0-9_.-]+'
const GITHUB = new RegExp(`^github\\.com/(${SEG})/(${SEG})((?:/${SEG})*)(?:@([A-Za-z0-9_./-]{1,120}))?$`)

export function parseWorkflowEntry(raw: string): WorkflowEntry | null {
const s = String(raw ?? '').trim()
if (!s || s.length > 300 || s.includes('..')) return null
if (NAME.test(s)) return { kind: 'account', entry: s, name: s }
const mp = /^marketplace\/([A-Za-z0-9_.-]{1,120})$/.exec(s)
if (mp) return { kind: 'marketplace', entry: s, slug: mp[1] }
const gh = GITHUB.exec(s.replace(/^https?:\/\//, ''))
if (gh) {
const entry = s.replace(/^https?:\/\//, '')
return { kind: 'github', entry, owner: gh[1], repo: gh[2], path: gh[3].replace(/^\//, ''), ref: gh[4] ?? null }
}
return null
}

/** The raw file URLs to try for a GitHub entry: the named file, or
* workflow.yaml then workflow.yml in the named directory. */
export function githubYamlUrls(e: Extract<WorkflowEntry, { kind: 'github' }>): string[] {
const base = `https://raw.githubusercontent.com/${e.owner}/${e.repo}/${e.ref ?? 'HEAD'}`
if (/\.ya?ml$/.test(e.path)) return [`${base}/${e.path}`]
const dir = e.path ? `${base}/${e.path}` : base
return [`${dir}/workflow.yaml`, `${dir}/workflow.yml`]
}

/** Where a GitHub workflow's thumbnail would be, by the platform's convention. */
export function githubThumbnail(e: Extract<WorkflowEntry, { kind: 'github' }>): string {
const dir = /\.ya?ml$/.test(e.path) ? e.path.replace(/\/?[^/]+$/, '') : e.path
return `https://raw.githubusercontent.com/${e.owner}/${e.repo}/${e.ref ?? 'HEAD'}/${dir ? `${dir}/` : ''}thumbnail.png`
}

/** A readable default title: the repository, or its last path segment. */
export function entryTitle(e: WorkflowEntry): string {
if (e.kind === 'account') return e.name
if (e.kind === 'marketplace') return e.slug
const tail = e.path.replace(/\/?workflow\.ya?ml$/, '').split('/').filter(Boolean).pop()
return tail || e.repo
}
Loading
Loading