diff --git a/.fiberplane/templates/p1.jsonnet b/.fiberplane/templates/p1.jsonnet new file mode 100644 index 0000000..35a8d52 --- /dev/null +++ b/.fiberplane/templates/p1.jsonnet @@ -0,0 +1,17 @@ +// For documentation on Fiberplane Templates, see: https://docs.fiberplane.com/templates +local fp = import 'fiberplane.libsonnet'; +local c = fp.cell; +local fmt = fp.format; + +function( + title='WE GOT P1 PPL' +) + fp.notebook + .new(title) + .setTimeRangeRelative(minutes=60) + .addLabels({}) + .addCells([ + c.h1('This is a section'), + c.text('You can add any types of cells and pre-fill content'), + ]) + diff --git a/.fiberplane/templates/p2.jsonnet b/.fiberplane/templates/p2.jsonnet new file mode 100644 index 0000000..242c4cc --- /dev/null +++ b/.fiberplane/templates/p2.jsonnet @@ -0,0 +1,17 @@ +// For documentation on Fiberplane Templates, see: https://docs.fiberplane.com/templates +local fp = import 'fiberplane.libsonnet'; +local c = fp.cell; +local fmt = fp.format; + +function( + title='AAAND IT P2' +) + fp.notebook + .new(title) + .setTimeRangeRelative(minutes=60) + .addLabels({}) + .addCells([ + c.h1('This is a section'), + c.text('You can add any types of cells and pre-fill content'), + ]) + diff --git a/.fiberplane/templates/random_json.json b/.fiberplane/templates/random_json.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.fiberplane/templates/random_json.json @@ -0,0 +1 @@ +{} diff --git a/.fiberplane/templates/template3.jsonnet b/.fiberplane/templates/template3.jsonnet new file mode 100644 index 0000000..6e5e94f --- /dev/null +++ b/.fiberplane/templates/template3.jsonnet @@ -0,0 +1,17 @@ +// For documentation on Fiberplane Templates, see: https://docs.fiberplane.com/templates +local fp = import 'fiberplane.libsonnet'; +local c = fp.cell; +local fmt = fp.format; + +function( + title='TEST 3' +) + fp.notebook + .new(title) + .setTimeRangeRelative(minutes=60) + .addLabels({}) + .addCells([ + c.h1('This is a section'), + c.text('You can add any types of cells and pre-fill content'), + ]) + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4b1f008 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: Test the GitHub Action + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + + - uses: actions/checkout@v4 + + - name: Integration test + uses: ./ # fiberplane/sync-templates@v1 + id: integration-test-1 + with: + api-token: ${{ secrets.FP_TOKEN }} + workspace-id: wTTZcPV5Q8qqQ-340fAEWQ + diff --git a/README.md b/README.md index 2a8ca3c..1834c06 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ # sync-templates + +[![Continuous Integration](https://github.com/fiberplane/sync-templates/actions/workflows/ci.yml/badge.svg)](https://github.com/fiberplane/sync-templates/actions/workflows/ci.yml) + +The `fiberplane/sync-templates` action is a utility that syncs your Fiberplane Templates from a designated directory in your repository with your Fiberplane Workspace. + +## Usage +This action can be run on `ubuntu-latest` and `macos-latest` GitHub Actions runners. + +A minimum working example of the action: +```yaml +steps: +- uses: fiberplane/sync-templates@v1 + with: + api-token: ${{ secrets.FP_TOKEN }} # it is best practice to keep your secrets in GitHub Secrets + workspace-id: # you can look it up with: fp workspaces list +``` + +The `sync-templates` action accepts the following inputs: +- `api-token` (**required**) - the Fiberplane API token used to access the workspace +- `workspace-id` (**required**) - the ID of the workspace where the Templates should be sync'ed to +- `templates-directory` (optional) - directory where valid Templates `*.jsonnet` files are located, default: `.fiberplane/templates/` +- `fp-version` (optional) - explicit version of the `fp` CLI that should be used in the action, default: `latest` +- `fp-base-url`(optional) - the base URL of the Fiberplane API, default `studio.fiberplane.com` + +When run the action will: +1. Download, setup, and cache the Deno runtime and the Fiberplane CLI (`fp`). +2. Validate that the intended Templates are syntactically correct. +3. Create and/or upload the intended Templates to a Fiberplane Workspace diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..a69280e --- /dev/null +++ b/action.yml @@ -0,0 +1,99 @@ +name: Sync Templates +description: Synchronizes Templates between your repository and Fiberplane +branding: + icon: tag + color: gray-dark + +inputs: + api-token: + description: API token used to access the Fiberplane API with + required: true + workspace-id: + description: ID of the workspace to which the templates should be uploaded to + required: true + fp-base-url: + description: Base URL of the Fiberplane API + default: https://studio.fiberplane.com + templates-directory: + description: "Custom directory that should be monitored for Template JSONNET files (default: .fiberplane/templates/)" + default: .fiberplane/templates/ + fp-version: + description: Version of the Fiberplane CLI to use (latest by default) + default: latest + +runs: + using: composite + steps: + + - uses: denoland/setup-deno@v1 + with: + deno-version: v1.x + + - name: Set up Fiberplane CLI + shell: bash + id: download + env: + FP_VERSION: ${{ inputs.fp-version }} + run: | + echo "== Setting up Fiberplane CLI ==" + + echo "Running on ${{ runner.arch }}" + + if [ ${{ runner.arch }} = "ARM64" ]; then + FP_ARCH="aarch64" + elif [ ${{ runner.arch }} = "X86" ] || [ ${{ runner.arch }} = "X64" ]; then + FP_ARCH="x86_64" + else + echo "Fiberplane CLI supports only x86_64 and ARM64 CPU architectures" + exit 1 + fi + + echo "Running on ${{ runner.os }}" + + if [ ${{ runner.os }} = "Linux" ]; then + FP_OS="unknown-linux-gnu" + elif [ ${{ runner.os }} = "macOS" ]; then + FP_OS="apple-darwin" + else + echo "Fiberplane CLI supports only Linux and macOS" + exit 1 + fi + + if [ "$FP_VERSION" != "latest" ]; then + FP_VERSION="v${FP_VERSION}" + fi + + echo "Going to install: $FP_VERSION version of fp" + + URL="https://fp.dev/fp/${FP_VERSION}/${FP_ARCH}-${FP_OS}" + + EXPECTED_SHA256SUM="$(curl -H user-agent:fiberplane-github-action -s -L "${URL}/checksum.sha256" | grep fp | awk '{print $1;}')" + + curl -H user-agent:fiberplane-github-action -L "${URL}/fp" -o fp + + ACTUAL_SHA256SUM=$(sha256sum fp | awk '{print $1;}') + + echo "Expected sha256: $EXPECTED_SHA256SUM" + echo "Actual sha256: $ACTUAL_SHA256SUM" + + if [ "$ACTUAL_SHA256SUM" != "$EXPECTED_SHA256SUM" ]; then + exit 1 + fi + + chmod +x fp + sudo mv fp /usr/local/bin + + - name: Run template validate and sync + shell: bash + env: + API_TOKEN: ${{ inputs.api-token }} + WORKSPACE_ID: ${{ inputs.workspace-id }} + FP_BASE_URL: ${{ inputs.fp-base-url }} + TEMPLATES_DIRECTORY: ${{ inputs.templates-directory }} + run: | + deno run \ + --allow-read \ + --allow-env \ + --allow-run=/usr/local/bin/fp \ + --allow-net=${{ inputs.fp-base-url }} \ + ${{ github.action_path }}/main.ts diff --git a/api.ts b/api.ts new file mode 100644 index 0000000..fff034b --- /dev/null +++ b/api.ts @@ -0,0 +1,157 @@ +// functions for interacting with the fp-cli, should be replaced with a proper +// API client when we get around to it + +const FP = "/usr/local/bin/fp"; +const decoder = new TextDecoder(); + +type Template = { + id: string; + name: string; + description: string; + created_at: string; + updated_at: string; +}; + +export async function createTemplate( + templateFile: string, + templatesDirectory: string, + apiToken: string, + workspaceId: string, + fpBaseUrl: string, +) { + console.log(`Creating template ${templatesDirectory + templateFile}`); + + const command = new Deno.Command(FP, { + cwd: templatesDirectory, + args: [ + "templates", + "create", + templateFile, + "--template-name", + templateFile.replace(".jsonnet", ""), + "--description", + templateFile.replace(".jsonnet", ""), + "--create-trigger", + "false", + ], + clearEnv: true, + env: { + FP_TOKEN: apiToken, + API_BASE: fpBaseUrl, + WORKSPACE_ID: workspaceId, + }, + }); + + const { code, stdout: stdoutBuf, stderr: stderrBuf } = await command.output(); + + const stderr = decoder.decode(stderrBuf); + const stdout = decoder.decode(stdoutBuf); + + if (code !== 0) { + console.log(stdout + stderr); + throw new Error(stderr); + } + + console.log(stdout + stderr); +} + +export async function listTemplates( + apiToken: string, + workspaceId: string, + fpBaseUrl: string, +): Promise { + const command = new Deno.Command(FP, { + args: ["templates", "list", "--output", "json"], + clearEnv: true, + env: { + FP_TOKEN: apiToken, + API_BASE: fpBaseUrl, + WORKSPACE_ID: workspaceId, + }, + }); + + const { code, stdout: stdoutBuf, stderr: stderrBuf } = await command.output(); + + const stderr = decoder.decode(stderrBuf); + const stdout = decoder.decode(stdoutBuf); + + if (code !== 0) { + console.log(stdout + stderr); + throw new Error(stderr); + } + + let out; + + try { + out = JSON.parse(stdout); + } catch (err) { + console.log(stdout + stderr); + throw new Error(err); + } + + return out; +} + +export async function updateTemplate( + templateFile: string, + templatesDirectory: string, + apiToken: string, + workspaceId: string, + fpBaseUrl: string, +) { + console.log(`Updating template ${templatesDirectory + templateFile}`); + + const command = new Deno.Command(FP, { + cwd: templatesDirectory, + args: [ + "templates", + "update", + templateFile.replace(".jsonnet", ""), + "--template-path", + templateFile, + ], + clearEnv: true, + env: { + FP_TOKEN: apiToken, + API_BASE: fpBaseUrl, + WORKSPACE_ID: workspaceId, + }, + }); + + const { code, stdout: stdoutBuf, stderr: stderrBuf } = await command.output(); + + const stderr = decoder.decode(stderrBuf); + const stdout = decoder.decode(stdoutBuf); + + if (code !== 0) { + console.log(stdout + stderr); + throw new Error(stderr); + } + + console.log(stdout + stderr); +} + +export async function validateTemplate( + templateFile: string, + templatesDirectory: string, +) { + console.log(`Validating template ${templatesDirectory + templateFile}`); + + const command = new Deno.Command(FP, { + cwd: templatesDirectory, + args: ["templates", "validate", templateFile], + clearEnv: true, + }); + + const { code, stdout: stdoutBuf, stderr: stderrBuf } = await command.output(); + + const stderr = decoder.decode(stderrBuf); + const stdout = decoder.decode(stdoutBuf); + + if (code !== 0) { + console.log(stdout + stderr); + throw new Error(stderr); + } + + console.log(stdout + stderr); +} diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1 @@ +{} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..bdb0085 --- /dev/null +++ b/deno.lock @@ -0,0 +1,7 @@ +{ + "version": "3", + "redirects": { + "https://deno.land/std/cli/parse_args.ts": "https://deno.land/std@0.213.0/cli/parse_args.ts" + }, + "remote": {} +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..20c300b --- /dev/null +++ b/main.ts @@ -0,0 +1,108 @@ +import { + createTemplate, + listTemplates, + validateTemplate, + updateTemplate, +} from "./api.ts"; + +// set up consts +const API_TOKEN = Deno.env.get("API_TOKEN"); +const WORKSPACE_ID = Deno.env.get("WORKSPACE_ID"); +const FP_BASE_URL = Deno.env.get("FP_BASE_URL"); +const TEMPLATES_DIRECTORY = Deno.env.get("TEMPLATES_DIRECTORY"); + +// check all inputs +if (!API_TOKEN) { + console.log("api-token input is not set"); + Deno.exit(1); +} + +if (!WORKSPACE_ID) { + console.log("workspace-id input is not set"); + Deno.exit(1); +} + +if (!FP_BASE_URL) { + console.log( + "fp-base-url input is not set (this is likely a bug in the action as default should be https://studio.fiberplane.com)", + ); + Deno.exit(1); +} + +if (!TEMPLATES_DIRECTORY) { + console.log( + "templates-directory input is not set (this is likely a bug in the action as default should be .fiberplane/templates/)", + ); + Deno.exit(1); +} + +const validatedTemplates = []; + +const entriesToCreate = []; +const entriesToUpdate = []; + +// read and validate all templates +for await (const dirEntry of Deno.readDir(TEMPLATES_DIRECTORY)) { + if (dirEntry.isFile && dirEntry.name.endsWith(".jsonnet")) { + try { + await validateTemplate(dirEntry.name, TEMPLATES_DIRECTORY); + validatedTemplates.push(dirEntry); + } catch (err) { + console.log(err); + Deno.exit(1); + } + } +} + +const uploadedTemplates = await listTemplates( + API_TOKEN, + WORKSPACE_ID, + FP_BASE_URL, +); + +// the list of templates is not likely to be that long +// so we can just compare the two lists in a nested loop +for (const dirEntry of validatedTemplates) { + if ( + uploadedTemplates.find( + (template) => template.name === dirEntry.name.replace(".jsonnet", ""), + ) + ) { + entriesToUpdate.push(dirEntry); + } else { + entriesToCreate.push(dirEntry); + } +} + +for (const dirEntry of entriesToUpdate) { + try { + await updateTemplate( + dirEntry.name, + TEMPLATES_DIRECTORY, + API_TOKEN, + WORKSPACE_ID, + FP_BASE_URL, + ); + } catch (err) { + console.log(err); + Deno.exit(1); + } +} + +// upload all templates +for (const dirEntry of entriesToCreate) { + try { + await createTemplate( + dirEntry.name, + TEMPLATES_DIRECTORY, + API_TOKEN, + WORKSPACE_ID, + FP_BASE_URL, + ); + } catch (err) { + console.log(err); + Deno.exit(1); + } +} + +console.log("All templates created and updated successfully!");