In my day job, we often need to use port forwarding to test our applications during development, as our GKE cluster is not exposed to the public internet and it can be tedious to test your application through the Kong Gateway (particularly when the application is not yet available in production).
Typically, I like to use Google zx to create this kind of script, but it would still require setup on the end user's machine.
So I thought I'd give Deno 2.0 a shot to see how it compares.
Problem
When our application is deployed to a GKE cluster, we need to port forward to it to test it locally, to avoid the need to go through the Kong Gateway quite yet.
Currently, when we need to port forward a Deno application, we need to follow these steps:
bash# 1. Start the VPN # 2. Authenticate with Google Cloud SDK gcloud auth login # 3. Authenticate with GKE gcloud container clusters get-credentials [cluster-name] --region [region] --project [project-id] # 4. Configure kubectl to use the correct cluster kubectl config use-context [cluster] # 5. Find your pod name kubectl get pods # 6. Port forward your pod to your local machine kubectl port-forward [pod-name] [local-port]:[remote-port]
While this is not the end of the world, it can be a bit tedious to type all of this every time we need to port forward.. Especially since we also use various clusters, in different regions, and with different project IDs, etc.
Solution
The solution is pretty simple: let's use Deno 2.0 to create a small script that will handle all of this for us. As mentioned above, I typically use Google zx for this kind of script, but I thought I'd give Deno 2.0 a shot since it just came out.
Install Deno
First, we need to install Deno:
shcurl -fsSL https://deno.land/install.sh | sh
Additional installation options can be found here. After installation, you should have the deno executable available on your system path. You can verify the installation by running:
shdeno --version
Create the script
I created a new file called port-forwarding.ts
, with the following content:
tipFor this script to later work on all platforms, I had to add the
checkCommand()
function to check the OS.
noteI also added
ensureCommandsInstalled()
to ensure the required commands are available on the system path (gcloud
andkubectl
).
typescript// This script requires Deno 1.32.0 or later import { parse } from 'std/flags/mod.ts'; async function checkCommand(command: string): Promise<boolean> { const cmd = Deno.build.os === 'windows' ? 'where' : 'which'; try { const process = new Deno.Command(cmd, { args: [command] }); const { success } = await process.output(); return success; } catch { return false; } } async function ensureCommandsInstalled() { const commands = ['gcloud', 'kubectl']; for (const cmd of commands) { if (!(await checkCommand(cmd))) { console.error(`Error: ${cmd} is not found in the system PATH.`); console.error(`Please install ${cmd} and make sure it's in your system's PATH.`); Deno.exit(1); } } } async function runCommand(cmd: string[]): Promise<void> { const command = new Deno.Command(cmd[0], { args: cmd.slice(1) }); const { success } = await command.output(); if (!success) { throw new Error(`Command failed: ${cmd.join(' ')}`); } } async function getKubectlPodName(namespace: string, appSelector: string): Promise<string> { const cmd = [ 'kubectl', 'get', 'pod', '--namespace', namespace, `--selector=app=${appSelector}`, '--output=jsonpath={.items[0].metadata.name}', ]; const command = new Deno.Command(cmd[0], { args: cmd.slice(1), stdout: 'piped', }); const { stdout } = await command.output(); return new TextDecoder().decode(stdout).trim(); } async function runPortForward( cluster: string, region: string, project: string, namespace: string, appSelector: string, localPort: number, remotePort: number ) { try { console.log( `gcloud container clusters get-credentials ${cluster} --region ${region} --project ${project}` ); await runCommand([ 'gcloud', 'container', 'clusters', 'get-credentials', cluster, '--region', region, '--project', project, ]); const podName = await getKubectlPodName(namespace, appSelector); console.log(`Port forwarding for pod: ${podName}`); await runCommand([ 'kubectl', 'port-forward', '--namespace', namespace, podName, `${localPort}:${remotePort}`, ]); } catch (error: any) { console.error('An error occurred:', error.message); Deno.exit(1); } } async function promptForValue(prompt: string): Promise<string> { const buf = new Uint8Array(1024); await Deno.stdout.write(new TextEncoder().encode(prompt)); const n = await Deno.stdin.read(buf); return new TextDecoder().decode(buf.subarray(0, n!)).trim(); } const clusterOptions = [ { project: 'some-gcp-project', cluster: 'some-gke-cluster', region: 'some-region', }, // ... ]; async function promptForCluster(): Promise<{ project: string; cluster: string; region: string; }> { console.log('Available clusters:'); clusterOptions.forEach((option, index) => { console.log( `${index + 1}. Project: ${option.project}, Cluster: ${option.cluster}, Region: ${option.region}` ); }); let selection: number; do { const input = await promptForValue('Enter the number of your selection: '); selection = parseInt(input) - 1; } while (selection < 0 || selection >= clusterOptions.length || isNaN(selection)); return clusterOptions[selection]; } function printHelp() { console.log(` Port Forwarding Script Usage: deno run --allow-run --allow-env port-forwarding.ts [options] Options: --help, -h Show this help message --cluster <name> Specify the cluster name --region <name> Specify the region --project <name> Specify the project --namespace <name> Specify the namespace (default: my-namespace) --app-selector <name> Specify the app selector (default: my-pod-selector) --local-port <number> Specify the local port (default: 4000) --remote-port <number> Specify the remote port (default: 4000) If cluster, region, and project are not provided, you will be prompted to select from available options. For other options, if not provided, you will be prompted to enter values. `); } async function main() { await ensureCommandsInstalled(); Deno.env.set('HTTP_PROXY', 'http://127.0.0.1:8080'); Deno.env.set('HTTPS_PROXY', 'http://127.0.0.1:8080'); const args = parse(Deno.args, { string: [ 'cluster', 'region', 'project', 'namespace', 'app-selector', 'local-port', 'remote-port', ], boolean: ['help'], alias: { h: 'help' }, }); if (args.help) { printHelp(); Deno.exit(0); } let clusterInfo: { project: string; cluster: string; region: string }; if (args.cluster && args.region && args.project) { clusterInfo = { project: args.project, cluster: args.cluster, region: args.region, }; } else { clusterInfo = await promptForCluster(); } const config = { cluster: clusterInfo.cluster, region: clusterInfo.region, project: clusterInfo.project, namespace: args.namespace || (await promptForValue('Enter namespace (default: my-namespace): ')) || 'my-namespace', 'app-selector': args['app-selector'] || (await promptForValue('Enter app selector (default: my-pod-selector): ')) || 'my-pod-selector', 'local-port': args['local-port'] || parseInt(await promptForValue('Enter local port (default: 4000): ')) || 4000, 'remote-port': args['remote-port'] || parseInt(await promptForValue('Enter remote port (default: 4000): ')) || 4000, }; await runPortForward( config.cluster, config.region, config.project, config.namespace, config['app-selector'], Number(config['local-port']), Number(config['remote-port']) ); } main();
Deno Configuration
In order to compile the script, we do need to create a deno.json
file with a few configurations:
json{ "compilerOptions": { "lib": ["deno.ns", "deno.window"], "strict": true }, "lint": { "files": { "include": ["./"] }, "rules": { "tags": ["recommended"] } }, "fmt": { "files": { "include": ["./"] }, "options": { "useTabs": false, "lineWidth": 120, "indentWidth": 2, "singleQuote": true, "proseWrap": "preserve" } }, "tasks": { "build": "deno compile --allow-run --allow-env --allow-net --config deno.json --output build/port-forwarding port-forwarding.ts", "start": "deno run --allow-run --allow-env --allow-net port-forwarding.ts" }, "importMap": "import_map.json", "compile": { "allow": ["run", "env", "net"] } }
Let's break down the different sections of this configuration file:
compilerOptions
- Specifies the compiler options for TypeScript.
lib
: Includes the Deno namespace and window APIs.strict
: Enables strict type-checking.
lint
- Configures the linter.
- Includes all files in the current directory for linting.
- Uses recommended linting rules.
fmt
- Configures the code formatter.
- Includes all files in the current directory for formatting.
- Sets formatting options like using spaces instead of tabs, line width, indent width, single quotes, and preserving prose wrap.
tasks
- Defines custom tasks that can be run using deno task.
build
: Compiles the port-forwarding.ts file into an executable with specific permissions.start
: Runs the port-forwarding.ts file with specific permissions.
importMap
- Specifies the location of the import map file, which is used to control how module specifiers are resolved.
compile
- Specifies the permissions allowed when compiling the project.
- Allows running subprocesses, accessing environment variables, and network access.
Running the script
To run the script, we can use the deno run
command:
bashdeno run --allow-run --allow-env --allow-net port-forwarding.ts
tipWe can also use Deno Tasks to run the
build
command, as defined in thedeno.json
file: bashdeno task start
Build the script as an executable
I didn't want to force users to install Deno on their machine to run the script, so I used the deno compile
command to compile the script into an executable:
bashdeno compile --allow-run --allow-env --allow-net --config deno.json --output build/port-forwarding port-forwarding.ts
tipJust like the build, we can also use Deno Tasks to run the
compile
command, as defined in thedeno.json
file: bashdeno task build
Github Actions
Now that we can run and compile the script, we can add a Github Actions workflow to:
- Test the build on every pull request
- Compile the script on every push to the main branch
- Create a release with the compiled executables
We use Conventional Commits to automatically generate the changelog and version number.
yamlname: Build Executables and Release on: push: branches: [main] pull_request: branches: [main] jobs: build: runs-on: ubuntu-latest strategy: matrix: target: [ x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, x86_64-apple-darwin, aarch64-apple-darwin, x86_64-pc-windows-msvc, ] steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Deno uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Get version id: get_version run: | git fetch --tags LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") if [ "$LATEST_TAG" = "v0.0.0" ]; then NEW_VERSION="v1.0.0" else IFS='.' read -ra VERSION_PARTS <<< "${LATEST_TAG#v}" MAJOR=${VERSION_PARTS[0]} MINOR=${VERSION_PARTS[1]} PATCH=${VERSION_PARTS[2]} # Get commit messages since last tag COMMITS=$(git log ${LATEST_TAG}..HEAD --pretty=format:"%s") # Check commit messages for conventional commit keywords if echo "$COMMITS" | grep -qE '^BREAKING CHANGE:'; then MAJOR=$((MAJOR + 1)) MINOR=0 PATCH=0 elif echo "$COMMITS" | grep -qE '^feat(\([^)]+\))?:'; then MINOR=$((MINOR + 1)) PATCH=0 else PATCH=$((PATCH + 1)) fi NEW_VERSION="v$MAJOR.$MINOR.$PATCH" fi echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT - name: Compile run: | deno compile --allow-run --allow-env --allow-net --target ${{ matrix.target }} --output port-forwarding-${{ matrix.target }} port-forwarding.ts - name: Upload artifact uses: actions/upload-artifact@v4 with: name: port-forwarding-${{ matrix.target }} path: port-forwarding-${{ matrix.target }}* release: needs: build if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Get version id: get_version run: | git fetch --tags LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") if [ "$LATEST_TAG" = "v0.0.0" ]; then NEW_VERSION="v1.0.0" else IFS='.' read -ra VERSION_PARTS <<< "${LATEST_TAG#v}" MAJOR=${VERSION_PARTS[0]} MINOR=${VERSION_PARTS[1]} PATCH=${VERSION_PARTS[2]} # Get commit messages since last tag COMMITS=$(git log ${LATEST_TAG}..HEAD --pretty=format:"%s") # Check commit messages for conventional commit keywords if echo "$COMMITS" | grep -qE '^BREAKING CHANGE:'; then MAJOR=$((MAJOR + 1)) MINOR=0 PATCH=0 elif echo "$COMMITS" | grep -qE '^feat(\([^)]+\))?:'; then MINOR=$((MINOR + 1)) PATCH=0 else PATCH=$((PATCH + 1)) fi NEW_VERSION="v$MAJOR.$MINOR.$PATCH" fi echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT - name: Generate Changelog id: generate_changelog run: | LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") if [ -z "$LATEST_TAG" ]; then COMMITS=$(git log --pretty=format:"- %s" --reverse) else COMMITS=$(git log ${LATEST_TAG}..HEAD --pretty=format:"- %s" --reverse) fi REPO_URL="https://github.com/${{ github.repository }}" VERSION="${{ steps.get_version.outputs.VERSION }}" echo "# [$VERSION]($REPO_URL/releases/tag/$VERSION)" > CHANGELOG.md echo "" >> CHANGELOG.md echo "## Features" >> CHANGELOG.md echo "$COMMITS" | grep "^- feat" >> CHANGELOG.md || echo "No new features" >> CHANGELOG.md echo "" >> CHANGELOG.md echo "## Bug Fixes" >> CHANGELOG.md echo "$COMMITS" | grep "^- fix" >> CHANGELOG.md || echo "No bug fixes" >> CHANGELOG.md echo "" >> CHANGELOG.md echo "## Other Changes" >> CHANGELOG.md echo "$COMMITS" | grep -vE "^- (feat|fix)" >> CHANGELOG.md || echo "No other changes" >> CHANGELOG.md CHANGELOG_CONTENT=$(cat CHANGELOG.md) echo "CHANGELOG<<EOF" >> $GITHUB_OUTPUT echo "$CHANGELOG_CONTENT" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Create Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create ${{ steps.get_version.outputs.VERSION }} \ --title "${{ steps.get_version.outputs.VERSION }}" \ --notes "${{ steps.generate_changelog.outputs.CHANGELOG }}" - name: Download artifacts uses: actions/download-artifact@v4 - name: Upload Release Assets env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | for file in port-forwarding-*/*; do gh release upload ${{ steps.get_version.outputs.VERSION }} "$file" done - name: Push new tag run: | git config user.name github-actions git config user.email github-actions@github.com git tag ${{ steps.get_version.outputs.VERSION }} git push origin ${{ steps.get_version.outputs.VERSION }}
End-user Experience
For the end-user, they can simply download the executable for their platform, rename it to port-forwarding
if needed, and run it:
bash./port-forwarding
And it looks something like this:
Takeaways
This experiment with Deno 2.0 demonstrates how powerful and convenient it can be for building small, efficient utilities like our port forwarding script. Deno's built-in TypeScript support, robust standard library, and straightforward compilation process make it an excellent choice for developers looking to create cross-platform command-line tools.
The ability to easily compile our script into standalone executables for various platforms, coupled with Deno's security-first approach and modern JavaScript features, showcases why Deno 2.0 is gaining traction in the developer community. It provides a streamlined development experience while maintaining the flexibility and performance needed for practical, everyday tools.
Whether you're a seasoned developer or just getting started, Deno 2.0 offers a refreshing and efficient way to build utilities that can simplify your workflow. As we've seen with our port forwarding tool, even complex tasks can be made more accessible and manageable with Deno's ecosystem.
- Deno 2.0
- Kubernetes
- Google Cloud