From c16a99cba6e61f3a34a83ad866f7e44b901b60d5 Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Thu, 11 Sep 2025 20:55:14 -0500 Subject: [PATCH 01/21] direct connect via ip --- agent/index.js | 18 ++++++++++++++++-- agent/lib/sandbox.js | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/agent/index.js b/agent/index.js index e7126d4b..1e43feb8 100755 --- a/agent/index.js +++ b/agent/index.js @@ -1712,8 +1712,22 @@ ${regression} ); } - // Only attempt to connect to existing sandbox if not in CI mode and not creating new - if (this.sandboxId && !this.config.CI && !createNew) { + if (true) { + + let instance = await this.sandbox.send({ + type: "direct", + resolution: this.config.TD_RESOLUTION, + ci: this.config.CI, + ip: '3.15.159.245' + }); + + await this.renderSandbox(instance.instance, headless); + await this.newSession(); + + return; + + } else if (this.sandboxId && !this.config.CI && !createNew) { + // Only attempt to connect to existing sandbox if not in CI mode and not creating new // Attempt to connect to known instance this.emitter.emit( events.log.narration, diff --git a/agent/lib/sandbox.js b/agent/lib/sandbox.js index 82ca9b3f..0f295609 100644 --- a/agent/lib/sandbox.js +++ b/agent/lib/sandbox.js @@ -76,6 +76,21 @@ const createSandbox = (emitter, analytics) => { return reply.sandbox; } + // connect to non-sandbox instance + async direct(ip = "3.15.159.245") { + + let reply = await this.send({ + type: "direct" + }); + + if (reply.success) { + this.instanceSocketConnected = true; + emitter.emit(events.sandbox.connected); + } + + return reply.sandbox; + } + async boot(apiRoot) { return new Promise((resolve, reject) => { this.socket = new WebSocket(apiRoot.replace("https://", "wss://")); From 65d7439a23197dd118aaa0e71414b8dd3e48c159 Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Thu, 11 Sep 2025 21:05:42 -0500 Subject: [PATCH 02/21] direct instance --- .github/workflows/self-hosted.yml | 97 +++++++++++++++ agent/index.js | 29 +++-- agent/interface.js | 6 + agent/lib/sandbox.js | 4 +- aws-setup.sh | 189 ++++++++++++++++++++++++++++++ docs/tutorials/self-hosting.mdx | 15 +++ 6 files changed, 323 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/self-hosted.yml create mode 100644 aws-setup.sh create mode 100644 docs/tutorials/self-hosting.mdx diff --git a/.github/workflows/self-hosted.yml b/.github/workflows/self-hosted.yml new file mode 100644 index 00000000..45e5708b --- /dev/null +++ b/.github/workflows/self-hosted.yml @@ -0,0 +1,97 @@ +name: Computer-Use Acceptance + +on: + workflow_dispatch: + push: + +jobs: + gather: + name: Gather Test Files + runs-on: ubuntu-latest + outputs: + test_files: ${{ steps.test_list.outputs.files }} + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Find all test files + id: test_list + run: | + FILES=$(ls ./testdriver/acceptance/*.yaml) + FILENAMES=$(basename -a $FILES) + FILES_JSON=$(echo "$FILENAMES" | jq -R -s -c 'split("\n")[:-1]') + echo "files=$FILES_JSON" >> $GITHUB_OUTPUT + + test: + needs: gather + runs-on: ubuntu-latest + strategy: + matrix: + test: ${{ fromJson(needs.gather.outputs.test_files) }} + fail-fast: false + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + # only needed for `act` + - name: Install AWS CLI + run: | + apt-get update + apt-get install curl unzip -y + curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" + unzip awscliv2.zip + ./aws/install + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + - name: Install dependencies + run: NODE_ENV=production npm ci + - name: Setup AWS Instance + id: aws-setup + run: | + OUTPUT=$(./aws-setup.sh | tee /dev/stderr) # Capture and display output + echo "$OUTPUT" + PUBLIC_IP=$(echo "$OUTPUT" | grep "PUBLIC_IP=" | cut -d'=' -f2) + INSTANCE_ID=$(echo "$OUTPUT" | grep "INSTANCE_ID=" | cut -d'=' -f2) + AWS_REGION=$(echo "$OUTPUT" | grep "AWS_REGION=" | cut -d'=' -f2) + echo "public-ip=$PUBLIC_IP" >> $GITHUB_OUTPUT + echo "instance-id=$INSTANCE_ID" >> $GITHUB_OUTPUT + echo "aws-region=$AWS_REGION" >> $GITHUB_OUTPUT + env: + FORCE_COLOR: 3 + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_LAUNCH_TEMPLATE_ID: lt-067e8bc85566dd5d6 + AMI_ID: ami-085f872ca0cd80fed + - name: Run TestDriver + run: node bin/testdriverai.js run testdriver/acceptance/${{ matrix.test }} --ip="${{ steps.aws-setup.outputs.public-ip }}" --junit=out.xml + env: + TD_API_KEY: ${{ secrets.TD_API_KEY }} + TD_WEBSITE: https://testdriver-sandbox.vercel.app + TD_THIS_FILE: ${{ matrix.test }} + - name: Upload TestDriver AI CLI logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: testdriverai-cli-logs-${{ matrix.test }} + path: /tmp/testdriverai-cli-*.log + if-no-files-found: warn + retention-days: 30 + - name: Upload test results as artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.test }} + path: out.xml + retention-days: 30 + - name: Shutdown AWS Instance + if: always() + run: aws ec2 terminate-instances --region "$AWS_REGION" --instance-ids "$INSTANCE_ID" + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: ${{ steps.aws-setup.outputs.aws-region }} + INSTANCE_ID: ${{ steps.aws-setup.outputs.instance-id }} diff --git a/agent/index.js b/agent/index.js index 1e43feb8..342316fb 100755 --- a/agent/index.js +++ b/agent/index.js @@ -70,6 +70,7 @@ class TestDriverAgent extends EventEmitter2 { this.sandboxId = flags["sandbox-id"] || null; this.sandboxAmi = flags["sandbox-ami"] || null; this.sandboxInstance = flags["sandbox-instance"] || null; + this.ip = flags.ip || null; this.workingDir = flags.workingDir || process.cwd(); // Resolve thisFile to absolute path with proper extension @@ -1699,20 +1700,7 @@ ${regression} const recentId = createNew ? null : this.getRecentSandboxId(); // Set sandbox ID for reconnection (only if not creating new and recent ID exists) - if (!createNew && recentId) { - this.emitter.emit( - events.log.narration, - theme.dim(`using recent sandbox: ${recentId}`), - ); - this.sandboxId = recentId; - } else if (!createNew) { - this.emitter.emit( - events.log.narration, - theme.dim(`no recent sandbox found, creating a new one.`), - ); - } - - if (true) { + if (this.ip) { let instance = await this.sandbox.send({ type: "direct", @@ -1726,7 +1714,18 @@ ${regression} return; - } else if (this.sandboxId && !this.config.CI && !createNew) { + } else if (!createNew && recentId) { + this.emitter.emit( + events.log.narration, + theme.dim(`using recent sandbox: ${recentId}`), + ); + this.sandboxId = recentId; + } else if (!createNew) { + this.emitter.emit( + events.log.narration, + theme.dim(`no recent sandbox found, creating a new one.`), + ); + } else if (this.sandboxId && !this.config.CI) { // Only attempt to connect to existing sandbox if not in CI mode and not creating new // Attempt to connect to known instance this.emitter.emit( diff --git a/agent/interface.js b/agent/interface.js index 22b92141..6813cbec 100644 --- a/agent/interface.js +++ b/agent/interface.js @@ -55,6 +55,9 @@ function createCommandDefinitions(agent) { "sandbox-instance": Flags.string({ description: "Specify EC2 instance type for sandbox (e.g., i3.metal)", }), + ip: Flags.string({ + description: "Connect directly to a sandbox at the specified IP address", + }), summary: Flags.string({ description: "Specify output file for summarize results", }), @@ -129,6 +132,9 @@ function createCommandDefinitions(agent) { "sandbox-instance": Flags.string({ description: "Specify EC2 instance type for sandbox (e.g., i3.metal)", }), + ip: Flags.string({ + description: "Connect directly to a sandbox at the specified IP address", + }), summary: Flags.string({ description: "Specify output file for summarize results", }), diff --git a/agent/lib/sandbox.js b/agent/lib/sandbox.js index 0f295609..61b7ba1a 100644 --- a/agent/lib/sandbox.js +++ b/agent/lib/sandbox.js @@ -77,8 +77,8 @@ const createSandbox = (emitter, analytics) => { } // connect to non-sandbox instance - async direct(ip = "3.15.159.245") { - + async direct(ip) { + let reply = await this.send({ type: "direct" }); diff --git a/aws-setup.sh b/aws-setup.sh new file mode 100644 index 00000000..78219d7c --- /dev/null +++ b/aws-setup.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --- Config (reads from env) --- +: "${AWS_REGION:?Set AWS_REGION}" +: "${AMI_ID:?Set AMI_ID (TestDriver Ami)}" +: "${AWS_LAUNCH_TEMPLATE_ID:?Set AWS_LAUNCH_TEMPLATE_ID}" +: "${AWS_LAUNCH_TEMPLATE_VERSION:=\$Latest}" +: "${AWS_TAG_PREFIX:=td}" +: "${RUNNER_CLASS_ID:=default}" + +TAG_NAME="${AWS_TAG_PREFIX}-"$(date +%s) +WS_CONFIG_PATH='C:\Windows\Temp\pyautogui-ws.json' + +echo "Launching AWS Instance..." + +# --- 1) Launch instance --- +RUN_JSON=$(aws ec2 run-instances \ + --region "$AWS_REGION" \ + --image-id "$AMI_ID" \ + --launch-template "LaunchTemplateId=$AWS_LAUNCH_TEMPLATE_ID,Version=$AWS_LAUNCH_TEMPLATE_VERSION" \ + --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=${TAG_NAME}},{Key=Class,Value=${RUNNER_CLASS_ID}}]" \ + --output json) + +INSTANCE_ID=$(jq -r '.Instances[0].InstanceId' <<<"$RUN_JSON") + +echo "Launched: $INSTANCE_ID" +echo "Instance details:" +echo " Region: $AWS_REGION" +echo " AMI ID: $AMI_ID" +echo " Launch Template ID: $AWS_LAUNCH_TEMPLATE_ID" +echo " Launch Template Version: $AWS_LAUNCH_TEMPLATE_VERSION" + +echo "Waiting for instance to be running..." + +# --- 2) Wait for running + status checks --- +aws ec2 wait instance-running --region "$AWS_REGION" --instance-ids "$INSTANCE_ID" +echo "✓ Instance is now running" + +echo "Waiting for instance to pass status checks..." + +aws ec2 wait instance-status-ok --region "$AWS_REGION" --instance-ids "$INSTANCE_ID" +echo "✓ Instance passed all status checks" + +# Additional validation - check instance state details +echo "Validating instance readiness..." +INSTANCE_STATE=$(aws ec2 describe-instances --region "$AWS_REGION" --instance-ids "$INSTANCE_ID" \ + --query 'Reservations[0].Instances[0].{State:State.Name,StatusChecks:StateTransitionReason}' \ + --output json) +echo "Instance state details: $INSTANCE_STATE" + +# --- 3) Ensure SSM connectivity --- +echo "Waiting for SSM connectivity..." +echo "This can take several minutes for the SSM agent to be fully ready..." + +# First, check if the instance is registered with SSM +echo "Checking SSM instance registration..." +TRIES=0; MAX_TRIES=60 +while :; do + echo "Attempt $((TRIES+1))/$MAX_TRIES: Checking if instance is registered with SSM..." + + # Check if instance appears in SSM managed instances + if aws ssm describe-instance-information \ + --region "$AWS_REGION" \ + --filters "Key=InstanceIds,Values=$INSTANCE_ID" \ + --query 'InstanceInformationList[0].InstanceId' \ + --output text 2>/dev/null | grep -q "$INSTANCE_ID"; then + echo "✓ Instance is registered with SSM" + break + fi + + TRIES=$((TRIES+1)) + if [ $TRIES -ge $MAX_TRIES ]; then + echo "❌ SSM registration timeout - instance may not have proper IAM role or SSM agent" + echo "Checking instance details for debugging..." + aws ec2 describe-instances --region "$AWS_REGION" --instance-ids "$INSTANCE_ID" \ + --query 'Reservations[0].Instances[0].{State:State.Name,IAMProfile:IamInstanceProfile.Arn,SecurityGroups:SecurityGroups[].GroupId}' \ + --output table + exit 2 + fi + echo "Instance not yet registered with SSM, waiting..." + sleep 10 +done + +# Now test SSM command execution +echo "Testing SSM command execution..." +TRIES=0; MAX_TRIES=30 +while :; do + echo "Attempt $((TRIES+1))/$MAX_TRIES: Sending test SSM command..." + + if CMD_JSON=$(aws ssm send-command \ + --region "$AWS_REGION" \ + --targets "Key=instanceIds,Values=$INSTANCE_ID" \ + --document-name "AWS-RunPowerShellScript" \ + --parameters 'commands=["echo SSM connectivity test successful"]' \ + --output json 2>/dev/null); then + + COMMAND_ID=$(jq -r '.Command.CommandId' <<<"$CMD_JSON") + echo "✓ SSM command sent successfully (Command ID: $COMMAND_ID)" + + # Wait for command to complete and check status + echo "Waiting for command execution..." + if aws ssm wait command-executed --region "$AWS_REGION" --command-id "$COMMAND_ID" --instance-id "$INSTANCE_ID" 2>/dev/null; then + echo "✓ SSM connectivity confirmed" + break + else + echo "⚠ Command execution may have failed, checking status..." + CMD_STATUS=$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$COMMAND_ID" \ + --instance-id "$INSTANCE_ID" \ + --query 'Status' \ + --output text 2>/dev/null || echo "Unknown") + echo "Command status: $CMD_STATUS" + + if [ "$CMD_STATUS" = "Success" ]; then + echo "✓ Command actually succeeded" + break + fi + fi + else + echo "⚠ Failed to send SSM command" + fi + + TRIES=$((TRIES+1)) + if [ $TRIES -ge $MAX_TRIES ]; then + echo "❌ SSM command execution timeout" + echo "Final debugging information:" + + # Get SSM agent status + echo "SSM Agent status on instance:" + aws ssm describe-instance-information \ + --region "$AWS_REGION" \ + --filters "Key=InstanceIds,Values=$INSTANCE_ID" \ + --query 'InstanceInformationList[0].{PingStatus:PingStatus,LastPingDateTime:LastPingDateTime,AgentVersion:AgentVersion}' \ + --output table 2>/dev/null || echo "Could not retrieve SSM status" + + exit 2 + fi + echo "Retrying in 20 seconds..." + sleep 20 +done + +echo "Getting Public IP..." + +# # --- 4) Get instance Public IP --- +DESC_JSON=$(aws ec2 describe-instances --region "$AWS_REGION" --instance-ids "$INSTANCE_ID" --output json) +PUBLIC_IP=$(jq -r '.Reservations[0].Instances[0].PublicIpAddress // empty' <<<"$DESC_JSON") +[ -n "$PUBLIC_IP" ] || PUBLIC_IP="No public IP assigned" + +# echo "Getting Websocket Port..." + + +# --- 5) Read WebSocket config JSON --- +echo "Reading WebSocket configuration from: $WS_CONFIG_PATH" +READ_JSON=$(aws ssm send-command \ + --region "$AWS_REGION" \ + --instance-ids "$INSTANCE_ID" \ + --document-name "AWS-RunPowerShellScript" \ + --parameters "commands=[\"if (Test-Path '${WS_CONFIG_PATH}') { Get-Content -Raw '${WS_CONFIG_PATH}' } else { Write-Output 'Config file not found at ${WS_CONFIG_PATH}' }\"]" \ + --output json) + +READ_CMD_ID=$(jq -r '.Command.CommandId' <<<"$READ_JSON") +echo "WebSocket config read command ID: $READ_CMD_ID" + +echo "Waiting for WebSocket config command to complete..." +aws ssm wait command-executed --region "$AWS_REGION" --command-id "$READ_CMD_ID" --instance-id "$INSTANCE_ID" + +INVOC=$(aws ssm get-command-invocation \ + --region "$AWS_REGION" \ + --command-id "$READ_CMD_ID" \ + --instance-id "$INSTANCE_ID" \ + --output json) + +STDOUT=$(jq -r '.StandardOutputContent // ""' <<<"$INVOC") +STDERR=$(jq -r '.StandardErrorContent // ""' <<<"$INVOC") +CMD_STATUS=$(jq -r '.Status // ""' <<<"$INVOC") + +echo "WebSocket config command status: $CMD_STATUS" +if [ -n "$STDERR" ] && [ "$STDERR" != "null" ]; then + echo "WebSocket config stderr: $STDERR" +fi +echo "WebSocket config raw output: $STDOUT" + +# --- 6) Output results --- +echo "Setup complete!" +echo "PUBLIC_IP=$PUBLIC_IP" +echo "INSTANCE_ID=$INSTANCE_ID" +echo "AWS_REGION=$AWS_REGION" diff --git a/docs/tutorials/self-hosting.mdx b/docs/tutorials/self-hosting.mdx new file mode 100644 index 00000000..24dba25d --- /dev/null +++ b/docs/tutorials/self-hosting.mdx @@ -0,0 +1,15 @@ + +# Recommended Security +https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws + +# aws auth is up to you, make sure you do it + +``` + act --container-architecture=linux/amd64 -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-22.04 --secret-file .env -W .github/workflows/self-hosted.yml + ``` + +```sh +# Override the Launch Template +aws ec2 run-instances \ + --launch-template LaunchTemplateId=lt-xxxxxxxxx,Version=1,Overrides='[{ImageId=ami-0abcdef1234567890}]' +``` From 9e28dea3b315b91c354c9f1426cd1e2af43c9168 Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Thu, 11 Sep 2025 21:18:25 -0500 Subject: [PATCH 03/21] comment out stuff needed for act --- .github/workflows/self-hosted.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/self-hosted.yml b/.github/workflows/self-hosted.yml index 45e5708b..ee9824d0 100644 --- a/.github/workflows/self-hosted.yml +++ b/.github/workflows/self-hosted.yml @@ -35,13 +35,13 @@ jobs: with: fetch-depth: 0 # only needed for `act` - - name: Install AWS CLI - run: | - apt-get update - apt-get install curl unzip -y - curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" - unzip awscliv2.zip - ./aws/install + # - name: Install AWS CLI + # run: | + # apt-get update + # apt-get install curl unzip -y + # curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" + # unzip awscliv2.zip + # ./aws/install - name: Set up Node.js uses: actions/setup-node@v4 with: From 12bea135985c327a0a5a6e9533cc2ef443df9d2b Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Thu, 11 Sep 2025 21:28:05 -0500 Subject: [PATCH 04/21] aws setup chmod --- aws-setup.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 aws-setup.sh diff --git a/aws-setup.sh b/aws-setup.sh old mode 100644 new mode 100755 From c1d3f3e1e39db50c0de6874e0a9a3b216ee86428 Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Thu, 11 Sep 2025 21:32:58 -0500 Subject: [PATCH 05/21] add aws region --- .github/workflows/self-hosted.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/self-hosted.yml b/.github/workflows/self-hosted.yml index ee9824d0..0089220d 100644 --- a/.github/workflows/self-hosted.yml +++ b/.github/workflows/self-hosted.yml @@ -64,6 +64,7 @@ jobs: FORCE_COLOR: 3 AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: us-east-2 AWS_LAUNCH_TEMPLATE_ID: lt-067e8bc85566dd5d6 AMI_ID: ami-085f872ca0cd80fed - name: Run TestDriver From 79624af87febf33966399fc0ca8706493f57326a Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Thu, 11 Sep 2025 21:38:34 -0500 Subject: [PATCH 06/21] ensure vpc regions match --- cloudformation.yaml | 294 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 cloudformation.yaml diff --git a/cloudformation.yaml b/cloudformation.yaml new file mode 100644 index 00000000..09faf817 --- /dev/null +++ b/cloudformation.yaml @@ -0,0 +1,294 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: >- + Baseline artifacts (NO EC2 instance): Creates a dedicated VPC with public subnet, Security Group, + IAM Role/Profile, optional KeyPair, and an EC2 Launch Template so you can spawn many instances + programmatically. Writes handy IDs into SSM Parameter Store for easy lookup in scripts or automation. + +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: "Project Configuration" + Parameters: + - ProjectTag + - Label: + default: "Network Configuration" + Parameters: + - AllowedIngressCidr + - Label: + default: "Instance Configuration" + Parameters: + - InstanceType + - Label: + default: "Key Pair Configuration" + Parameters: + - CreateKeyPair + - ExistingKeyName + ParameterLabels: + ProjectTag: + default: "Project Tag" + InstanceType: + default: "Instance Type" + AllowedIngressCidr: + default: "Allowed Ingress CIDR" + CreateKeyPair: + default: "Create New Key Pair" + ExistingKeyName: + default: "Existing Key Name (only required if 'Create New Key Pair' is 'no')" + +Rules: + ValidateKeyPairConfiguration: + RuleCondition: !Equals [!Ref CreateKeyPair, 'no'] + Assertions: + - Assert: !Not [!Equals [!Ref ExistingKeyName, '']] + AssertDescription: "ExistingKeyName must be provided when CreateKeyPair is 'no'" + +Parameters: + ProjectTag: + Type: String + Default: testdriver + + InstanceType: + Type: String + Default: c5.xlarge + AllowedValues: + - c5.xlarge + - c5.2xlarge + - c5.4xlarge + - c5.9xlarge + - c5.12xlarge + - c5.18xlarge + - c5.24xlarge + - c5.metal + - i3.metal + Description: Instance type - only c5.xlarge or larger, plus c5.metal and i3.metal allowed + + AllowedIngressCidr: + Type: String + Default: 0.0.0.0/0 + Description: CIDR allowed to access inbound ports (0.0.0.0/0 means "anyone", we recommend tighening this in production). + + CreateKeyPair: + Type: String + AllowedValues: [yes, no] + Default: yes + Description: Create a new key pair for instance access? (If 'no', you must provide an existing key name) + ExistingKeyName: + Type: String + Default: '' + Description: Name of existing EC2 Key Pair (only required when CreateKeyPair is 'no') + +Conditions: + UseExistingKeyProvided: !Not [!Equals [!Ref ExistingKeyName, '']] + CreateKey: !Equals [!Ref CreateKeyPair, 'yes'] + +Resources: + # VPC for TestDriver + TestDriverVpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 + EnableDnsHostnames: true + EnableDnsSupport: true + Tags: + - { Key: Name, Value: !Sub '${AWS::StackName}-vpc' } + - { Key: Project, Value: !Ref ProjectTag } + + # Public subnet for EC2 instances + PublicSubnet: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref TestDriverVpc + CidrBlock: 10.0.1.0/24 + AvailabilityZone: !Select [0, !GetAZs ''] + MapPublicIpOnLaunch: true + Tags: + - { Key: Name, Value: !Sub '${AWS::StackName}-public-subnet' } + - { Key: Project, Value: !Ref ProjectTag } + + # Internet Gateway + InternetGateway: + Type: AWS::EC2::InternetGateway + Properties: + Tags: + - { Key: Name, Value: !Sub '${AWS::StackName}-igw' } + - { Key: Project, Value: !Ref ProjectTag } + + # Attach Internet Gateway to VPC + AttachGateway: + Type: AWS::EC2::VPCGatewayAttachment + Properties: + VpcId: !Ref TestDriverVpc + InternetGatewayId: !Ref InternetGateway + + # Route table for public subnet + PublicRouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref TestDriverVpc + Tags: + - { Key: Name, Value: !Sub '${AWS::StackName}-public-rt' } + - { Key: Project, Value: !Ref ProjectTag } + + # Route to Internet Gateway + PublicRoute: + Type: AWS::EC2::Route + DependsOn: AttachGateway + Properties: + RouteTableId: !Ref PublicRouteTable + DestinationCidrBlock: 0.0.0.0/0 + GatewayId: !Ref InternetGateway + + # Associate route table with public subnet + SubnetRouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PublicSubnet + RouteTableId: !Ref PublicRouteTable + + SecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: SG for QA desktop testing (RDP/HTTPS/NGINX/pyautogui + VNC) + VpcId: !Ref TestDriverVpc + SecurityGroupIngress: + - { IpProtocol: tcp, FromPort: 8765, ToPort: 8765, CidrIp: !Ref AllowedIngressCidr, Description: 'pyautogui-cli WebSockets' } + - { IpProtocol: tcp, FromPort: 8443, ToPort: 8443, CidrIp: !Ref AllowedIngressCidr, Description: 'Custom 8443' } + - { IpProtocol: tcp, FromPort: 8080, ToPort: 8080, CidrIp: !Ref AllowedIngressCidr, Description: 'NGINX 8080' } + - { IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: !Ref AllowedIngressCidr, Description: 'HTTP 80' } + - { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: !Ref AllowedIngressCidr, Description: 'HTTPS 443' } + - { IpProtocol: tcp, FromPort: 3389, ToPort: 3389, CidrIp: !Ref AllowedIngressCidr, Description: 'RDP 3389' } + - { IpProtocol: tcp, FromPort: 5900, ToPort: 5900, CidrIp: !Ref AllowedIngressCidr, Description: 'TightVNC 5900' } + - { IpProtocol: tcp, FromPort: 5901, ToPort: 5901, CidrIp: !Ref AllowedIngressCidr, Description: 'noVNC Websockify 5901' } + - { IpProtocol: tcp, FromPort: 6080, ToPort: 6080, CidrIp: !Ref AllowedIngressCidr, Description: 'noVNC HTTP 6080' } + SecurityGroupEgress: + - IpProtocol: -1 + CidrIp: 0.0.0.0/0 + Description: Allow all outbound + Tags: + - { Key: Project, Value: !Ref ProjectTag } + + InstanceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: { Service: ec2.amazonaws.com } + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore + Tags: + - { Key: Project, Value: !Ref ProjectTag } + + InstanceProfile: + Type: AWS::IAM::InstanceProfile + Properties: + Roles: [!Ref InstanceRole] + + KeyPair: + Type: AWS::EC2::KeyPair + Condition: CreateKey + Properties: + KeyName: !Sub '${AWS::StackName}-key' + KeyType: rsa + +LaunchTemplate: + Type: AWS::EC2::LaunchTemplate + Properties: + LaunchTemplateName: !Sub '${AWS::StackName}-lt' + LaunchTemplateData: + InstanceType: !Ref InstanceType + IamInstanceProfile: + Name: !Ref InstanceProfile + # Lock SG + Subnet to the same VPC + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref PublicSubnet + Groups: [ !Ref SecurityGroup ] + AssociatePublicIpAddress: true + # If you prefer the old spot, remove SecurityGroupIds above and keep this: + # SecurityGroupIds: [ !Ref SecurityGroup ] + KeyName: !If + - CreateKey + - !Ref KeyPair + - !If + - UseExistingKeyProvided + - !Ref ExistingKeyName + - !Ref AWS::NoValue + TagSpecifications: + - ResourceType: instance + Tags: [ { Key: Project, Value: !Ref ProjectTag } ] + - ResourceType: volume + Tags: [ { Key: Project, Value: !Ref ProjectTag } ] + + + SsmParamSg: + Type: AWS::SSM::Parameter + Properties: + Name: /testdriver/infra/security-group-id + Type: String + Value: !Ref SecurityGroup + + SsmParamIp: + Type: AWS::SSM::Parameter + Properties: + Name: /testdriver/infra/instance-profile-name + Type: String + Value: !Ref InstanceProfile + + SsmParamLt: + Type: AWS::SSM::Parameter + Properties: + Name: /testdriver/infra/launch-template-id + Type: String + Value: !Ref LaunchTemplate + + SsmParamLtLatest: + Type: AWS::SSM::Parameter + Properties: + Name: /testdriver/infra/launch-template-latest-version + Type: String + Value: !GetAtt LaunchTemplate.LatestVersionNumber + + SsmParamVpc: + Type: AWS::SSM::Parameter + Properties: + Name: /testdriver/infra/vpc-id + Type: String + Value: !Ref TestDriverVpc + + SsmParamSubnet: + Type: AWS::SSM::Parameter + Properties: + Name: /testdriver/infra/subnet-id + Type: String + Value: !Ref PublicSubnet + +Outputs: + VpcId: + Value: !Ref TestDriverVpc + Description: VPC ID created for TestDriver + SubnetId: + Value: !Ref PublicSubnet + Description: Public subnet ID for TestDriver instances + SecurityGroupId: + Value: !Ref SecurityGroup + Description: Security Group for QA desktop testing + InstanceProfileName: + Value: !Ref InstanceProfile + Description: Instance Profile to attach to instances + LaunchTemplateId: + Value: !Ref LaunchTemplate + Description: EC2 Launch Template ID + LaunchTemplateLatestVersion: + Value: !GetAtt LaunchTemplate.LatestVersionNumber + Description: Latest Launch Template version + KeyPairSsmParam: + Condition: CreateKey + Value: !Sub '/ec2/keypair/${KeyPair.KeyPairId}' + Description: SSM parameter that stores the generated private key + SsmNamespaceUsed: + Value: /testdriver/infra + Description: Prefix where IDs are stored for discovery From 70c1e08d8923fc60d1f830bcc372ed966d3132ba Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Thu, 11 Sep 2025 21:39:34 -0500 Subject: [PATCH 07/21] ensure vpc regions match --- cloudformation.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudformation.yaml b/cloudformation.yaml index 09faf817..33869a55 100644 --- a/cloudformation.yaml +++ b/cloudformation.yaml @@ -194,7 +194,7 @@ Resources: KeyName: !Sub '${AWS::StackName}-key' KeyType: rsa -LaunchTemplate: + LaunchTemplate: Type: AWS::EC2::LaunchTemplate Properties: LaunchTemplateName: !Sub '${AWS::StackName}-lt' From 1206ad6d790cb63d0266fe4aac3d4eba6dce9434 Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Thu, 11 Sep 2025 21:54:50 -0500 Subject: [PATCH 08/21] new launchtemplate via vpc --- .github/workflows/self-hosted.yml | 2 +- cloudformation.yaml | 183 ++++++++++++++++++++++++------ 2 files changed, 150 insertions(+), 35 deletions(-) diff --git a/.github/workflows/self-hosted.yml b/.github/workflows/self-hosted.yml index 0089220d..9753d0d5 100644 --- a/.github/workflows/self-hosted.yml +++ b/.github/workflows/self-hosted.yml @@ -65,7 +65,7 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_REGION: us-east-2 - AWS_LAUNCH_TEMPLATE_ID: lt-067e8bc85566dd5d6 + AWS_LAUNCH_TEMPLATE_ID: lt-07c53ce8349b958d1 AMI_ID: ami-085f872ca0cd80fed - name: Run TestDriver run: node bin/testdriverai.js run testdriver/acceptance/${{ matrix.test }} --ip="${{ steps.aws-setup.outputs.public-ip }}" --junit=out.xml diff --git a/cloudformation.yaml b/cloudformation.yaml index 33869a55..623c6c85 100644 --- a/cloudformation.yaml +++ b/cloudformation.yaml @@ -7,6 +7,10 @@ Description: >- Metadata: AWS::CloudFormation::Interface: ParameterGroups: + - Label: + default: "Notification Configuration" + Parameters: + - NotificationEmail - Label: default: "Project Configuration" Parameters: @@ -35,6 +39,8 @@ Metadata: default: "Create New Key Pair" ExistingKeyName: default: "Existing Key Name (only required if 'Create New Key Pair' is 'no')" + NotificationEmail: + default: "Notification Email (optional)" Rules: ValidateKeyPairConfiguration: @@ -44,6 +50,11 @@ Rules: AssertDescription: "ExistingKeyName must be provided when CreateKeyPair is 'no'" Parameters: + NotificationEmail: + Type: String + Default: '' + Description: Email address to receive deployment completion notifications (optional) + ProjectTag: Type: String Default: testdriver @@ -81,6 +92,7 @@ Parameters: Conditions: UseExistingKeyProvided: !Not [!Equals [!Ref ExistingKeyName, '']] CreateKey: !Equals [!Ref CreateKeyPair, 'yes'] + SendNotification: !Not [!Equals [!Ref NotificationEmail, '']] Resources: # VPC for TestDriver @@ -195,74 +207,177 @@ Resources: KeyType: rsa LaunchTemplate: - Type: AWS::EC2::LaunchTemplate - Properties: - LaunchTemplateName: !Sub '${AWS::StackName}-lt' - LaunchTemplateData: - InstanceType: !Ref InstanceType - IamInstanceProfile: - Name: !Ref InstanceProfile - # Lock SG + Subnet to the same VPC - NetworkInterfaces: - - DeviceIndex: 0 - SubnetId: !Ref PublicSubnet - Groups: [ !Ref SecurityGroup ] - AssociatePublicIpAddress: true - # If you prefer the old spot, remove SecurityGroupIds above and keep this: - # SecurityGroupIds: [ !Ref SecurityGroup ] - KeyName: !If - - CreateKey - - !Ref KeyPair - - !If - - UseExistingKeyProvided - - !Ref ExistingKeyName - - !Ref AWS::NoValue - TagSpecifications: - - ResourceType: instance - Tags: [ { Key: Project, Value: !Ref ProjectTag } ] - - ResourceType: volume - Tags: [ { Key: Project, Value: !Ref ProjectTag } ] + Type: AWS::EC2::LaunchTemplate + Properties: + LaunchTemplateName: !Sub '${AWS::StackName}-lt' + LaunchTemplateData: + InstanceType: !Ref InstanceType + IamInstanceProfile: + Name: !Ref InstanceProfile + # Lock SG + Subnet to the same VPC + NetworkInterfaces: + - DeviceIndex: 0 + SubnetId: !Ref PublicSubnet + Groups: [ !Ref SecurityGroup ] + AssociatePublicIpAddress: true + KeyName: !If + - CreateKey + - !Ref KeyPair + - !If + - UseExistingKeyProvided + - !Ref ExistingKeyName + - !Ref AWS::NoValue + TagSpecifications: + - ResourceType: instance + Tags: [ { Key: Project, Value: !Ref ProjectTag } ] + - ResourceType: volume + Tags: [ { Key: Project, Value: !Ref ProjectTag } ] + + # SNS Topic for deployment notifications + DeploymentNotificationTopic: + Type: AWS::SNS::Topic + Condition: SendNotification + Properties: + TopicName: !Sub '${AWS::StackName}-deployment-notifications' + DisplayName: !Sub '${AWS::StackName} Deployment Notifications' + Tags: + - { Key: Project, Value: !Ref ProjectTag } + + # SNS Subscription for email notifications + EmailSubscription: + Type: AWS::SNS::Subscription + Condition: SendNotification + Properties: + Protocol: email + TopicArn: !Ref DeploymentNotificationTopic + Endpoint: !Ref NotificationEmail + + # Custom resource to send completion notification + DeploymentCompleteNotification: + Type: AWS::CloudFormation::CustomResource + Condition: SendNotification + Properties: + ServiceToken: !GetAtt NotificationLambda.Arn + StackName: !Ref AWS::StackName + TopicArn: !Ref DeploymentNotificationTopic + DependsOn: + - SsmParamSg + - SsmParamIp + - SsmParamLt + - SsmParamLtLatest + - SsmParamVpc + - SsmParamSubnet + # Lambda function to send the notification + NotificationLambda: + Type: AWS::Lambda::Function + Condition: SendNotification + Properties: + FunctionName: !Sub '${AWS::StackName}-deployment-notifier' + Runtime: python3.11 + Handler: index.lambda_handler + Role: !GetAtt NotificationLambdaRole.Arn + Code: + ZipFile: | + import boto3 + import json + import cfnresponse + + def lambda_handler(event, context): + try: + if event['RequestType'] == 'Create': + sns = boto3.client('sns') + stack_name = event['ResourceProperties']['StackName'] + topic_arn = event['ResourceProperties']['TopicArn'] + + message = f""" + TestDriver Infrastructure Deployment Complete! + + Stack Name: {stack_name} + Status: CREATE_COMPLETE + + Your TestDriver infrastructure is now ready to use. + Check the CloudFormation outputs for resource IDs and configuration details. + """ + + sns.publish( + TopicArn=topic_arn, + Subject=f'TestDriver Stack {stack_name} - Deployment Complete', + Message=message + ) + + cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) + except Exception as e: + print(f"Error: {e}") + cfnresponse.send(event, context, cfnresponse.FAILED, {}) + Tags: + - { Key: Project, Value: !Ref ProjectTag } + + # IAM Role for the notification Lambda + NotificationLambdaRole: + Type: AWS::IAM::Role + Condition: SendNotification + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: SNSPublishPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - sns:Publish + Resource: !Ref DeploymentNotificationTopic + Tags: + - { Key: Project, Value: !Ref ProjectTag } SsmParamSg: Type: AWS::SSM::Parameter Properties: - Name: /testdriver/infra/security-group-id + Name: !Sub '/testdriver/infra/${AWS::StackName}/security-group-id' Type: String Value: !Ref SecurityGroup SsmParamIp: Type: AWS::SSM::Parameter Properties: - Name: /testdriver/infra/instance-profile-name + Name: !Sub '/testdriver/infra/${AWS::StackName}/instance-profile-name' Type: String Value: !Ref InstanceProfile SsmParamLt: Type: AWS::SSM::Parameter Properties: - Name: /testdriver/infra/launch-template-id + Name: !Sub '/testdriver/infra/${AWS::StackName}/launch-template-id' Type: String Value: !Ref LaunchTemplate SsmParamLtLatest: Type: AWS::SSM::Parameter Properties: - Name: /testdriver/infra/launch-template-latest-version + Name: !Sub '/testdriver/infra/${AWS::StackName}/launch-template-latest-version' Type: String Value: !GetAtt LaunchTemplate.LatestVersionNumber SsmParamVpc: Type: AWS::SSM::Parameter Properties: - Name: /testdriver/infra/vpc-id + Name: !Sub '/testdriver/infra/${AWS::StackName}/testdriver-vpc-id' Type: String Value: !Ref TestDriverVpc SsmParamSubnet: Type: AWS::SSM::Parameter Properties: - Name: /testdriver/infra/subnet-id + Name: !Sub '/testdriver/infra/${AWS::StackName}/testdriver-public-subnet-id' Type: String Value: !Ref PublicSubnet @@ -290,5 +405,5 @@ Outputs: Value: !Sub '/ec2/keypair/${KeyPair.KeyPairId}' Description: SSM parameter that stores the generated private key SsmNamespaceUsed: - Value: /testdriver/infra + Value: !Sub '/testdriver/infra/${AWS::StackName}' Description: Prefix where IDs are stored for discovery From d8c3b2f4ee6c7c630b851c60e28087bb8c1d5713 Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Thu, 11 Sep 2025 22:04:19 -0500 Subject: [PATCH 09/21] dont forget to run provision --- agent/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/agent/index.js b/agent/index.js index 342316fb..30421151 100755 --- a/agent/index.js +++ b/agent/index.js @@ -1711,6 +1711,7 @@ ${regression} await this.renderSandbox(instance.instance, headless); await this.newSession(); + await this.runLifecycle("provision"); return; From a283b7eefe469347e3e4d64a875cb8146b82a75e Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Thu, 11 Sep 2025 22:37:30 -0500 Subject: [PATCH 10/21] lol all tests at same ip --- agent/index.js | 2 +- cloudformation.yaml | 1 + .../content/self-hosted/launchtemplateid.png | Bin 0 -> 104267 bytes docs/tutorials/self-hosting.mdx | 297 +++++++++++++++++- 4 files changed, 290 insertions(+), 10 deletions(-) create mode 100644 docs/images/content/self-hosted/launchtemplateid.png diff --git a/agent/index.js b/agent/index.js index 30421151..8433c89f 100755 --- a/agent/index.js +++ b/agent/index.js @@ -1706,7 +1706,7 @@ ${regression} type: "direct", resolution: this.config.TD_RESOLUTION, ci: this.config.CI, - ip: '3.15.159.245' + ip: this.ip }); await this.renderSandbox(instance.instance, headless); diff --git a/cloudformation.yaml b/cloudformation.yaml index 623c6c85..09f744a6 100644 --- a/cloudformation.yaml +++ b/cloudformation.yaml @@ -63,6 +63,7 @@ Parameters: Type: String Default: c5.xlarge AllowedValues: + - c5.xlarge - c5.2xlarge - c5.4xlarge diff --git a/docs/images/content/self-hosted/launchtemplateid.png b/docs/images/content/self-hosted/launchtemplateid.png new file mode 100644 index 0000000000000000000000000000000000000000..4a9999da94556186c2a1f34affc569086448dc14 GIT binary patch literal 104267 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%0A)aQufZQRq#*%o<%7 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 YaUZhsdX6sGkiZ`WS>*@C(nkLO2R8%kN&o-= literal 0 HcmV?d00001 diff --git a/docs/tutorials/self-hosting.mdx b/docs/tutorials/self-hosting.mdx index 24dba25d..c224e3af 100644 --- a/docs/tutorials/self-hosting.mdx +++ b/docs/tutorials/self-hosting.mdx @@ -1,15 +1,294 @@ -# Recommended Security -https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws +--- +title: "Self-Hosting TestDriver" +sidebarTitle: "Self-Hosting" +description: "Complete guide to self-hosting TestDriver instances on AWS" +icon: "server" +--- -# aws auth is up to you, make sure you do it +## Quick Start (TL;DR) +1. **Copy the workflow file**: Use `.github/workflows/self-hosted.yml` as your template +2. **Run CloudFormation**: Deploy our `cloudformation.yaml` to provision infrastructure +3. **Setup instances**: Use `aws-setup.sh` with your launch template ID +4. **Configure GitHub Actions**: Add AWS credentials to your repository secrets + +## Overview + +Self-hosting TestDriver gives you complete control over your test execution environment. You'll provision EC2 instances on AWS using our pre-configured AMI and infrastructure templates. + + + You must use our TestDriver AMI for proper functionality. Contact your account manager to get access to the shared AMI. + + +## Prerequisites + +- AWS account with appropriate permissions +- AWS CLI installed and configured +- Access to TestDriver's shared AMI (Enterprise customers) +- GitHub repository for your tests + +## Step 1: Infrastructure Setup + +### Deploy CloudFormation Stack + +Our `cloudformation.yaml` template creates: +- Dedicated VPC with public subnet +- Security group with proper port access +- IAM roles and instance profiles +- EC2 launch template for programmatic instance creation + +```bash +# Deploy the CloudFormation stack +aws cloudformation deploy \ + --template-file cloudformation.yaml \ + --stack-name testdriver-infrastructure \ + --parameter-overrides \ + ProjectTag=testdriver \ + AllowedIngressCidr=0.0.0.0/0 \ + InstanceType=t3.medium \ + CreateKeyPair=yes \ + --capabilities CAPABILITY_IAM +``` + + + **Security**: Replace `AllowedIngressCidr=0.0.0.0/0` with your specific IP ranges to lock down access to your VPC. + + +### Get Launch Template ID + +After CloudFormation completes, find the launch template ID in the stack outputs: + +```bash +aws cloudformation describe-stacks \ + --stack-name testdriver-infrastructure \ + --query 'Stacks[0].Outputs[?OutputKey==`LaunchTemplateId`].OutputValue' \ + --output text +``` + +Save this ID - you'll need it for the next step. + +## Step 2: Instance Management + +### Using aws-setup.sh + +Our `aws-setup.sh` script handles the complete instance lifecycle: +- Launches instances using your launch template +- Waits for proper initialization +- Returns instance details for CLI usage +- Handles cleanup and termination + +```bash +# Launch an instance +export AWS_REGION=us-east-2 +export AMI_ID=ami-085f872ca0cd80fed # Your TestDriver AMI +export AWS_LAUNCH_TEMPLATE_ID=lt-07c53ce8349b958d1 # From CloudFormation output + +./aws-setup.sh +``` + +The script outputs: +``` +PUBLIC_IP=1.2.3.4 +INSTANCE_ID=i-1234567890abcdef0 +AWS_REGION=us-east-2 +``` + +## Step 3: GitHub Actions Integration + +### Example Workflow + +Our `.github/workflows/self-hosted.yml` demonstrates the complete workflow: + +```yaml +name: TestDriver Self-Hosted + +on: + workflow_dispatch: + push: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup AWS Instance + id: aws-setup + run: | + OUTPUT=$(./aws-setup.sh | tee /dev/stderr) + PUBLIC_IP=$(echo "$OUTPUT" | grep "PUBLIC_IP=" | cut -d'=' -f2) + INSTANCE_ID=$(echo "$OUTPUT" | grep "INSTANCE_ID=" | cut -d'=' -f2) + echo "public-ip=$PUBLIC_IP" >> $GITHUB_OUTPUT + echo "instance-id=$INSTANCE_ID" >> $GITHUB_OUTPUT + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: us-east-2 + AWS_LAUNCH_TEMPLATE_ID: ${{ secrets.AWS_LAUNCH_TEMPLATE_ID }} + AMI_ID: ${{ secrets.AMI_ID }} + + - name: Run TestDriver + run: | + node bin/testdriverai.js run your-test.yaml \ + --ip="${{ steps.aws-setup.outputs.public-ip }}" \ + --junit=results.xml + env: + TD_API_KEY: ${{ secrets.TD_API_KEY }} + + - name: Shutdown AWS Instance + if: always() + run: | + aws ec2 terminate-instances \ + --region us-east-2 \ + --instance-ids ${{ steps.aws-setup.outputs.instance-id }} + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} ``` - act --container-architecture=linux/amd64 -P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-22.04 --secret-file .env -W .github/workflows/self-hosted.yml - ``` -```sh -# Override the Launch Template -aws ec2 run-instances \ - --launch-template LaunchTemplateId=lt-xxxxxxxxx,Version=1,Overrides='[{ImageId=ami-0abcdef1234567890}]' +### Required Secrets + +Configure these secrets in your GitHub repository: + +| Secret | Description | Example | +|--------|-------------|---------| +| `AWS_ACCESS_KEY_ID` | AWS access key | `AKIAIOSFODNN7EXAMPLE` | +| `AWS_SECRET_ACCESS_KEY` | AWS secret key | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` | +| `AWS_LAUNCH_TEMPLATE_ID` | Launch template from CloudFormation | `lt-07c53ce8349b958d1` | +| `AMI_ID` | TestDriver AMI ID | `ami-085f872ca0cd80fed` | +| `TD_API_KEY` | TestDriver API key | Your API key from dashboard | + +## Step 4: Running Tests + +### CLI Usage + +Once you have an instance IP, run tests directly: + +```bash +# Basic test execution +npx testdriverai run test.yaml --ip=1.2.3.4 + +# With custom outputs +npx testdriverai run test.yaml --ip=1.2.3.4 --junit=results.xml +``` + +### TestDriver Configuration + +The CLI only needs the instance IP address. All other configuration (AMI setup, networking, etc.) is handled by the infrastructure. + +## AMI Customization + +### Using the Base AMI + +Our AMI comes pre-configured with: +- Windows Server with desktop environment +- Required TestDriver dependencies +- Optimized settings for test execution + +### Modifying the AMI + +You can customize the AMI for your specific needs: + +1. **Launch an instance** from our base AMI +2. **Make your changes** (install software, configure settings) +3. **Create a new AMI** from your modified instance +4. **Update your workflow** to use the new AMI ID + +### Amazon Image Builder + +For automated AMI builds, use [Amazon EC2 Image Builder](https://aws.amazon.com/image-builder/): + +```yaml +# Example Image Builder pipeline +Components: + - Name: testdriver-base + Version: 1.0.0 + Platform: Windows + Type: BUILD + Data: | + name: TestDriver Custom Setup + description: Custom TestDriver AMI with additional software + schemaVersion: 1.0 + phases: + - name: build + steps: + - name: InstallSoftware + action: ExecutePowerShell + inputs: + commands: + - "# Your custom installation commands here" +``` + +## Security Considerations + +### Network Security + +1. **Restrict CIDR blocks**: Only allow access from your known IP ranges +2. **Use VPC endpoints**: For private communication with AWS services +3. **Enable VPC Flow Logs**: For network monitoring and debugging + +### AWS Authentication + +Use [OIDC for GitHub Actions](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) instead of long-term credentials: + +```yaml +permissions: + id-token: write + contents: read + +steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole + aws-region: us-east-2 +``` + +### Instance Security + +- **Terminate instances** immediately after use +- **Monitor costs** with AWS billing alerts +- **Use least-privilege IAM roles** for instance profiles +- **Enable CloudTrail** for audit logging + +## Troubleshooting + +### Common Issues + +**Instance not responding:** +- Check security group rules allow necessary ports +- Verify instance has passed all status checks +- Ensure AMI is compatible with selected instance type + +**Connection timeouts:** +- Verify network connectivity from runner to instance +- Check VPC routing and internet gateway configuration +- Confirm instance is in correct subnet + +**AWS CLI errors:** +- Validate AWS credentials and permissions +- Check AWS service quotas and limits +- Verify region consistency across all resources + +### Getting Help + +For enterprise customers: +- Contact your account manager for AMI access issues +- Use support channels for infrastructure questions +- Check the TestDriver documentation for CLI usage + +## Cost Optimization + +### Instance Management +- **Auto-terminate** instances after tests complete +- **Use spot instances** for cost savings (if compatible with your workflow) +- **Right-size instances** based on test requirements +- **Schedule tests** during off-peak hours for better pricing + +### Monitoring +```bash +# Set up billing alerts +aws budgets create-budget --account-id 123456789012 --budget file://budget.json ``` From a60ce06aeda9d800365a7b333ef3f643287a5b74 Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Thu, 11 Sep 2025 22:53:18 -0500 Subject: [PATCH 11/21] ai generated docs --- docs/docs.json | 3 ++- docs/{tutorials => getting-started}/self-hosting.mdx | 5 ----- 2 files changed, 2 insertions(+), 6 deletions(-) rename docs/{tutorials => getting-started}/self-hosting.mdx (98%) diff --git a/docs/docs.json b/docs/docs.json index beadedfe..d4a8091d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -50,7 +50,8 @@ } ] }, - "/getting-started/vscode" + "/getting-started/vscode", + "/getting-started/self-hosting" ] }, { diff --git a/docs/tutorials/self-hosting.mdx b/docs/getting-started/self-hosting.mdx similarity index 98% rename from docs/tutorials/self-hosting.mdx rename to docs/getting-started/self-hosting.mdx index c224e3af..051c8ddc 100644 --- a/docs/tutorials/self-hosting.mdx +++ b/docs/getting-started/self-hosting.mdx @@ -1,4 +1,3 @@ - --- title: "Self-Hosting TestDriver" sidebarTitle: "Self-Hosting" @@ -17,10 +16,6 @@ icon: "server" Self-hosting TestDriver gives you complete control over your test execution environment. You'll provision EC2 instances on AWS using our pre-configured AMI and infrastructure templates. - - You must use our TestDriver AMI for proper functionality. Contact your account manager to get access to the shared AMI. - - ## Prerequisites - AWS account with appropriate permissions From bc7d3798dcdb597d6a8706b21e1bf2311779fde5 Mon Sep 17 00:00:00 2001 From: Eric Clemmons Date: Fri, 12 Sep 2025 10:28:10 -0500 Subject: [PATCH 12/21] Fix typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cloudformation.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudformation.yaml b/cloudformation.yaml index 09f744a6..226ced03 100644 --- a/cloudformation.yaml +++ b/cloudformation.yaml @@ -78,7 +78,7 @@ Parameters: AllowedIngressCidr: Type: String Default: 0.0.0.0/0 - Description: CIDR allowed to access inbound ports (0.0.0.0/0 means "anyone", we recommend tighening this in production). + Description: CIDR allowed to access inbound ports (0.0.0.0/0 means "anyone", we recommend tightening this in production). CreateKeyPair: Type: String From e780573be6edf91adfe942e05a4db61aea085c4e Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Fri, 12 Sep 2025 10:35:54 -0500 Subject: [PATCH 13/21] this method is unused --- agent/lib/sandbox.js | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/agent/lib/sandbox.js b/agent/lib/sandbox.js index 61b7ba1a..82ca9b3f 100644 --- a/agent/lib/sandbox.js +++ b/agent/lib/sandbox.js @@ -76,21 +76,6 @@ const createSandbox = (emitter, analytics) => { return reply.sandbox; } - // connect to non-sandbox instance - async direct(ip) { - - let reply = await this.send({ - type: "direct" - }); - - if (reply.success) { - this.instanceSocketConnected = true; - emitter.emit(events.sandbox.connected); - } - - return reply.sandbox; - } - async boot(apiRoot) { return new Promise((resolve, reject) => { this.socket = new WebSocket(apiRoot.replace("https://", "wss://")); From fab4c0d26918c1c9007924ee4b26f0ee7ce7f175 Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Fri, 12 Sep 2025 11:31:52 -0500 Subject: [PATCH 14/21] Update agent/index.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- agent/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/index.js b/agent/index.js index 8433c89f..a56ba9b4 100755 --- a/agent/index.js +++ b/agent/index.js @@ -1726,7 +1726,7 @@ ${regression} events.log.narration, theme.dim(`no recent sandbox found, creating a new one.`), ); - } else if (this.sandboxId && !this.config.CI) { + } else if (this.sandboxId && !this.config.CI && !createNew) { // Only attempt to connect to existing sandbox if not in CI mode and not creating new // Attempt to connect to known instance this.emitter.emit( From fc36ddcaff95b661108717b5bb83786d5ae417c8 Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Fri, 12 Sep 2025 12:58:54 -0500 Subject: [PATCH 15/21] Update docs/getting-started/self-hosting.mdx Co-authored-by: Eric Clemmons --- docs/getting-started/self-hosting.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/getting-started/self-hosting.mdx b/docs/getting-started/self-hosting.mdx index 051c8ddc..4faf76cb 100644 --- a/docs/getting-started/self-hosting.mdx +++ b/docs/getting-started/self-hosting.mdx @@ -46,9 +46,9 @@ aws cloudformation deploy \ --capabilities CAPABILITY_IAM ``` - + **Security**: Replace `AllowedIngressCidr=0.0.0.0/0` with your specific IP ranges to lock down access to your VPC. - + ### Get Launch Template ID From 734b77899be3874b5549463681665c72cc381043 Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Tue, 23 Sep 2025 14:40:57 -0500 Subject: [PATCH 16/21] Apply suggestion from @ericclemmons Co-authored-by: Eric Clemmons --- .github/workflows/self-hosted.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/self-hosted.yml b/.github/workflows/self-hosted.yml index 9753d0d5..c58f1fc7 100644 --- a/.github/workflows/self-hosted.yml +++ b/.github/workflows/self-hosted.yml @@ -1,4 +1,4 @@ -name: Computer-Use Acceptance +name: AWS on: workflow_dispatch: From a345fdfd42ff4813a5d0cfc48fe24c6bf1274d7e Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Tue, 23 Sep 2025 16:07:50 -0500 Subject: [PATCH 17/21] docs update, and path refactor --- docs/docs.json | 2 +- docs/getting-started/self-hosting.mdx | 120 ++++++++++-------- .../aws/cloudformation.yaml | 0 aws-setup.sh => setup/aws/spawn-runner.sh | 0 4 files changed, 66 insertions(+), 56 deletions(-) rename cloudformation.yaml => setup/aws/cloudformation.yaml (100%) rename aws-setup.sh => setup/aws/spawn-runner.sh (100%) diff --git a/docs/docs.json b/docs/docs.json index d0eb27ca..c8b26e6c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -50,7 +50,7 @@ } ] }, - "/getting-started/self-hosting" + "/getting-started/self-hosting", "/getting-started/playwright", "/getting-started/vscode" ] diff --git a/docs/getting-started/self-hosting.mdx b/docs/getting-started/self-hosting.mdx index 4faf76cb..6e93d686 100644 --- a/docs/getting-started/self-hosting.mdx +++ b/docs/getting-started/self-hosting.mdx @@ -5,11 +5,26 @@ description: "Complete guide to self-hosting TestDriver instances on AWS" icon: "server" --- +```mermaid +graph LR + A[CLI] <--> B[api.testdriver.ai] + B <--> C[Your AWS EC2 Instance] +``` + +Self-hosting TestDriver allows you to run tests on your own infrastructure, giving you full control over the environment, security, and configurations. +This guide walks you through setting up and managing self-hosted TestDriver instances using AWS. + +## Why self host? + +- **Enhanced security**: Get complete control over ingress and egress rules. +- **Complete customization**: Modify the TestDriver Golden Image to include custom dependencies, software, and configurations at launch time. +- **Powerful Infrastructure**: Run tests on bare metal infrastructure that support emulators and simulators. + ## Quick Start (TL;DR) -1. **Copy the workflow file**: Use `.github/workflows/self-hosted.yml` as your template -2. **Run CloudFormation**: Deploy our `cloudformation.yaml` to provision infrastructure -3. **Setup instances**: Use `aws-setup.sh` with your launch template ID +1. **Copy the workflow file**: Use [`.github/workflows/self-hosted.yml`](https://github.com/testdriverai/cli/tree/main/.github/workflows/self-hosted.yml) as your template +2. **Run CloudFormation**: Deploy our [`setup/aws/cloudformation.yaml`](https://github.com/testdriverai/cli/tree/main/setup/aws/cloudformation.yaml) to provision infrastructure +3. **Setup instances**: Use [`setup/aws/spawn-runner.sh`](https://github.com/testdriverai/cli/tree/main/setup/aws/spawn-runner.sh) with your launch template ID 4. **Configure GitHub Actions**: Add AWS credentials to your repository secrets ## Overview @@ -19,30 +34,33 @@ Self-hosting TestDriver gives you complete control over your test execution envi ## Prerequisites - AWS account with appropriate permissions -- AWS CLI installed and configured -- Access to TestDriver's shared AMI (Enterprise customers) +- AWS CLI installed locally +- Access to TestDriver's shared AMI. [Contact us for access](https://form.typeform.com/to/UECf9rDx?typeform-source=testdriver.ai). - GitHub repository for your tests -## Step 1: Infrastructure Setup +## Step 1: Set Up AWS Infrastructure ### Deploy CloudFormation Stack -Our `cloudformation.yaml` template creates: +Our [`setup/aws/cloudformation.yaml`](https://github.com/testdriverai/cli/tree/main/setup/aws/cloudformation.yaml) template creates: + - Dedicated VPC with public subnet - Security group with proper port access - IAM roles and instance profiles - EC2 launch template for programmatic instance creation +This is a one-time setup used to generate a template ID for launching instances. + ```bash # Deploy the CloudFormation stack aws cloudformation deploy \ - --template-file cloudformation.yaml \ - --stack-name testdriver-infrastructure \ + --template-file setup/aws/cloudformation.yaml \ + --stack-name testdriver-infrastructure-11 \ --parameter-overrides \ ProjectTag=testdriver \ AllowedIngressCidr=0.0.0.0/0 \ - InstanceType=t3.medium \ - CreateKeyPair=yes \ + InstanceType=c5.xlarge \ + CreateKeyPair=true \ --capabilities CAPABILITY_IAM ``` @@ -56,28 +74,29 @@ After CloudFormation completes, find the launch template ID in the stack outputs ```bash aws cloudformation describe-stacks \ - --stack-name testdriver-infrastructure \ + --stack-name testdriver-infrastructure-11 \ --query 'Stacks[0].Outputs[?OutputKey==`LaunchTemplateId`].OutputValue' \ --output text ``` Save this ID - you'll need it for the next step. -## Step 2: Instance Management +## Step 2: Spawn a New TestDriver Runner ### Using aws-setup.sh -Our `aws-setup.sh` script handles the complete instance lifecycle: +Our [`setup/aws/spawn-runner.sh`](https://github.com/testdriverai/cli/tree/main/setup/aws/spawn-runner.sh) spawns and initializes instances: + - Launches instances using your launch template -- Waits for proper initialization +- Completes TestDriver handshake - Returns instance details for CLI usage -- Handles cleanup and termination + ```bash # Launch an instance export AWS_REGION=us-east-2 -export AMI_ID=ami-085f872ca0cd80fed # Your TestDriver AMI -export AWS_LAUNCH_TEMPLATE_ID=lt-07c53ce8349b958d1 # From CloudFormation output +export AMI_ID=ami-085f872ca0cd80fed # Your TestDriver AMI (contact us to get one) +export AWS_LAUNCH_TEMPLATE_ID=lt-00d02f31cfc602f27 # From CloudFormation output from step 1 ./aws-setup.sh ``` @@ -89,11 +108,35 @@ INSTANCE_ID=i-1234567890abcdef0 AWS_REGION=us-east-2 ``` +### CLI Usage + +Once you have an instance IP, run tests directly: + +```bash +# Basic test execution +npx testdriverai run test.yaml --ip=1.2.3.4 +``` + +You can use the `PUBLIC_IP` to target the instance you just spawned via `./setup/aws/spawn-runner.sh`: + +```sh +npx testdriverai@latest run testdriver/your-test.yaml \ + --ip="$PUBLIC_IP" \ +``` + +Note that the instance will remain running until you terminate it. You can do this manually via the AWS console, or programmatically in your CI workflow: + +```bash +aws ec2 terminate-instances \ + --region us-east-2 \ + --instance-ids $INSTANCE_ID +``` + ## Step 3: GitHub Actions Integration ### Example Workflow -Our `.github/workflows/self-hosted.yml` demonstrates the complete workflow: +Our [`.github/workflows/self-hosted.yml`](https://github.com/testdriverai/cli/tree/main/.github/workflows/self-hosted.yml) demonstrates the complete workflow: ```yaml name: TestDriver Self-Hosted @@ -112,7 +155,7 @@ jobs: - name: Setup AWS Instance id: aws-setup run: | - OUTPUT=$(./aws-setup.sh | tee /dev/stderr) + OUTPUT=$(./setup/aws/spawn-runner.sh | tee /dev/stderr) PUBLIC_IP=$(echo "$OUTPUT" | grep "PUBLIC_IP=" | cut -d'=' -f2) INSTANCE_ID=$(echo "$OUTPUT" | grep "INSTANCE_ID=" | cut -d'=' -f2) echo "public-ip=$PUBLIC_IP" >> $GITHUB_OUTPUT @@ -127,8 +170,7 @@ jobs: - name: Run TestDriver run: | node bin/testdriverai.js run your-test.yaml \ - --ip="${{ steps.aws-setup.outputs.public-ip }}" \ - --junit=results.xml + --ip="${{ steps.aws-setup.outputs.public-ip }}" env: TD_API_KEY: ${{ secrets.TD_API_KEY }} @@ -153,25 +195,7 @@ Configure these secrets in your GitHub repository: | `AWS_SECRET_ACCESS_KEY` | AWS secret key | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` | | `AWS_LAUNCH_TEMPLATE_ID` | Launch template from CloudFormation | `lt-07c53ce8349b958d1` | | `AMI_ID` | TestDriver AMI ID | `ami-085f872ca0cd80fed` | -| `TD_API_KEY` | TestDriver API key | Your API key from dashboard | - -## Step 4: Running Tests - -### CLI Usage - -Once you have an instance IP, run tests directly: - -```bash -# Basic test execution -npx testdriverai run test.yaml --ip=1.2.3.4 - -# With custom outputs -npx testdriverai run test.yaml --ip=1.2.3.4 --junit=results.xml -``` - -### TestDriver Configuration - -The CLI only needs the instance IP address. All other configuration (AMI setup, networking, etc.) is handled by the infrastructure. +| `TD_API_KEY` | TestDriver API key | Your API key from [the dashboard](https://app.testdriver.ai) | ## AMI Customization @@ -273,17 +297,3 @@ For enterprise customers: - Contact your account manager for AMI access issues - Use support channels for infrastructure questions - Check the TestDriver documentation for CLI usage - -## Cost Optimization - -### Instance Management -- **Auto-terminate** instances after tests complete -- **Use spot instances** for cost savings (if compatible with your workflow) -- **Right-size instances** based on test requirements -- **Schedule tests** during off-peak hours for better pricing - -### Monitoring -```bash -# Set up billing alerts -aws budgets create-budget --account-id 123456789012 --budget file://budget.json -``` diff --git a/cloudformation.yaml b/setup/aws/cloudformation.yaml similarity index 100% rename from cloudformation.yaml rename to setup/aws/cloudformation.yaml diff --git a/aws-setup.sh b/setup/aws/spawn-runner.sh similarity index 100% rename from aws-setup.sh rename to setup/aws/spawn-runner.sh From 8a3366f49736a9c91d8682be8e836222ba24582d Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Tue, 23 Sep 2025 16:14:54 -0500 Subject: [PATCH 18/21] small eslint fix --- agent/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/index.js b/agent/index.js index a56ba9b4..8433c89f 100755 --- a/agent/index.js +++ b/agent/index.js @@ -1726,7 +1726,7 @@ ${regression} events.log.narration, theme.dim(`no recent sandbox found, creating a new one.`), ); - } else if (this.sandboxId && !this.config.CI && !createNew) { + } else if (this.sandboxId && !this.config.CI) { // Only attempt to connect to existing sandbox if not in CI mode and not creating new // Attempt to connect to known instance this.emitter.emit( From aec9eec453f8f79c64dbab78341a6a3bfd6c1d36 Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Tue, 23 Sep 2025 16:21:56 -0500 Subject: [PATCH 19/21] small path fix for gh action --- .github/workflows/self-hosted.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/self-hosted.yml b/.github/workflows/self-hosted.yml index c58f1fc7..907e059a 100644 --- a/.github/workflows/self-hosted.yml +++ b/.github/workflows/self-hosted.yml @@ -52,7 +52,7 @@ jobs: - name: Setup AWS Instance id: aws-setup run: | - OUTPUT=$(./aws-setup.sh | tee /dev/stderr) # Capture and display output + OUTPUT=$(./setup/aws/spawn-runner.sh | tee /dev/stderr) # Capture and display output echo "$OUTPUT" PUBLIC_IP=$(echo "$OUTPUT" | grep "PUBLIC_IP=" | cut -d'=' -f2) INSTANCE_ID=$(echo "$OUTPUT" | grep "INSTANCE_ID=" | cut -d'=' -f2) From d50b8c1922a4cee6be63114f4991682c170cdbae Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Tue, 23 Sep 2025 16:31:55 -0500 Subject: [PATCH 20/21] use new launch template --- .github/workflows/self-hosted.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/self-hosted.yml b/.github/workflows/self-hosted.yml index 907e059a..3ee634a2 100644 --- a/.github/workflows/self-hosted.yml +++ b/.github/workflows/self-hosted.yml @@ -65,7 +65,7 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} AWS_REGION: us-east-2 - AWS_LAUNCH_TEMPLATE_ID: lt-07c53ce8349b958d1 + AWS_LAUNCH_TEMPLATE_ID: lt-00d02f31cfc602f27 AMI_ID: ami-085f872ca0cd80fed - name: Run TestDriver run: node bin/testdriverai.js run testdriver/acceptance/${{ matrix.test }} --ip="${{ steps.aws-setup.outputs.public-ip }}" --junit=out.xml From 5a9298497d97a23bf2db40fefca4b0c77bf9065a Mon Sep 17 00:00:00 2001 From: Ian Jennings Date: Tue, 23 Sep 2025 16:35:52 -0500 Subject: [PATCH 21/21] prettier --- agent/index.js | 8 +- agent/interface.js | 6 +- docs/getting-started/self-hosting.mdx | 32 +++--- setup/aws/cloudformation.yaml | 143 ++++++++++++++++++-------- 4 files changed, 124 insertions(+), 65 deletions(-) diff --git a/agent/index.js b/agent/index.js index 8433c89f..3b95c453 100755 --- a/agent/index.js +++ b/agent/index.js @@ -1701,20 +1701,18 @@ ${regression} // Set sandbox ID for reconnection (only if not creating new and recent ID exists) if (this.ip) { - let instance = await this.sandbox.send({ type: "direct", resolution: this.config.TD_RESOLUTION, ci: this.config.CI, - ip: this.ip + ip: this.ip, }); - + await this.renderSandbox(instance.instance, headless); await this.newSession(); await this.runLifecycle("provision"); - - return; + return; } else if (!createNew && recentId) { this.emitter.emit( events.log.narration, diff --git a/agent/interface.js b/agent/interface.js index 6813cbec..0b5e48d8 100644 --- a/agent/interface.js +++ b/agent/interface.js @@ -56,7 +56,8 @@ function createCommandDefinitions(agent) { description: "Specify EC2 instance type for sandbox (e.g., i3.metal)", }), ip: Flags.string({ - description: "Connect directly to a sandbox at the specified IP address", + description: + "Connect directly to a sandbox at the specified IP address", }), summary: Flags.string({ description: "Specify output file for summarize results", @@ -133,7 +134,8 @@ function createCommandDefinitions(agent) { description: "Specify EC2 instance type for sandbox (e.g., i3.metal)", }), ip: Flags.string({ - description: "Connect directly to a sandbox at the specified IP address", + description: + "Connect directly to a sandbox at the specified IP address", }), summary: Flags.string({ description: "Specify output file for summarize results", diff --git a/docs/getting-started/self-hosting.mdx b/docs/getting-started/self-hosting.mdx index 6e93d686..c9c9acf0 100644 --- a/docs/getting-started/self-hosting.mdx +++ b/docs/getting-started/self-hosting.mdx @@ -11,7 +11,7 @@ graph LR B <--> C[Your AWS EC2 Instance] ``` -Self-hosting TestDriver allows you to run tests on your own infrastructure, giving you full control over the environment, security, and configurations. +Self-hosting TestDriver allows you to run tests on your own infrastructure, giving you full control over the environment, security, and configurations. This guide walks you through setting up and managing self-hosted TestDriver instances using AWS. ## Why self host? @@ -65,7 +65,8 @@ aws cloudformation deploy \ ``` - **Security**: Replace `AllowedIngressCidr=0.0.0.0/0` with your specific IP ranges to lock down access to your VPC. + **Security**: Replace `AllowedIngressCidr=0.0.0.0/0` with your specific IP + ranges to lock down access to your VPC. ### Get Launch Template ID @@ -91,7 +92,6 @@ Our [`setup/aws/spawn-runner.sh`](https://github.com/testdriverai/cli/tree/main/ - Completes TestDriver handshake - Returns instance details for CLI usage - ```bash # Launch an instance export AWS_REGION=us-east-2 @@ -102,6 +102,7 @@ export AWS_LAUNCH_TEMPLATE_ID=lt-00d02f31cfc602f27 # From CloudFormation output ``` The script outputs: + ``` PUBLIC_IP=1.2.3.4 INSTANCE_ID=i-1234567890abcdef0 @@ -151,7 +152,7 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - + - name: Setup AWS Instance id: aws-setup run: | @@ -166,14 +167,14 @@ jobs: AWS_REGION: us-east-2 AWS_LAUNCH_TEMPLATE_ID: ${{ secrets.AWS_LAUNCH_TEMPLATE_ID }} AMI_ID: ${{ secrets.AMI_ID }} - + - name: Run TestDriver run: | node bin/testdriverai.js run your-test.yaml \ --ip="${{ steps.aws-setup.outputs.public-ip }}" env: TD_API_KEY: ${{ secrets.TD_API_KEY }} - + - name: Shutdown AWS Instance if: always() run: | @@ -189,19 +190,20 @@ jobs: Configure these secrets in your GitHub repository: -| Secret | Description | Example | -|--------|-------------|---------| -| `AWS_ACCESS_KEY_ID` | AWS access key | `AKIAIOSFODNN7EXAMPLE` | -| `AWS_SECRET_ACCESS_KEY` | AWS secret key | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` | -| `AWS_LAUNCH_TEMPLATE_ID` | Launch template from CloudFormation | `lt-07c53ce8349b958d1` | -| `AMI_ID` | TestDriver AMI ID | `ami-085f872ca0cd80fed` | -| `TD_API_KEY` | TestDriver API key | Your API key from [the dashboard](https://app.testdriver.ai) | +| Secret | Description | Example | +| ------------------------ | ----------------------------------- | ------------------------------------------------------------ | +| `AWS_ACCESS_KEY_ID` | AWS access key | `AKIAIOSFODNN7EXAMPLE` | +| `AWS_SECRET_ACCESS_KEY` | AWS secret key | `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY` | +| `AWS_LAUNCH_TEMPLATE_ID` | Launch template from CloudFormation | `lt-07c53ce8349b958d1` | +| `AMI_ID` | TestDriver AMI ID | `ami-085f872ca0cd80fed` | +| `TD_API_KEY` | TestDriver API key | Your API key from [the dashboard](https://app.testdriver.ai) | ## AMI Customization ### Using the Base AMI Our AMI comes pre-configured with: + - Windows Server with desktop environment - Required TestDriver dependencies - Optimized settings for test execution @@ -277,16 +279,19 @@ steps: ### Common Issues **Instance not responding:** + - Check security group rules allow necessary ports - Verify instance has passed all status checks - Ensure AMI is compatible with selected instance type **Connection timeouts:** + - Verify network connectivity from runner to instance - Check VPC routing and internet gateway configuration - Confirm instance is in correct subnet **AWS CLI errors:** + - Validate AWS credentials and permissions - Check AWS service quotas and limits - Verify region consistency across all resources @@ -294,6 +299,7 @@ steps: ### Getting Help For enterprise customers: + - Contact your account manager for AMI access issues - Use support channels for infrastructure questions - Check the TestDriver documentation for CLI usage diff --git a/setup/aws/cloudformation.yaml b/setup/aws/cloudformation.yaml index 226ced03..f8e0d196 100644 --- a/setup/aws/cloudformation.yaml +++ b/setup/aws/cloudformation.yaml @@ -1,4 +1,4 @@ -AWSTemplateFormatVersion: '2010-09-09' +AWSTemplateFormatVersion: "2010-09-09" Description: >- Baseline artifacts (NO EC2 instance): Creates a dedicated VPC with public subnet, Security Group, IAM Role/Profile, optional KeyPair, and an EC2 Launch Template so you can spawn many instances @@ -44,15 +44,15 @@ Metadata: Rules: ValidateKeyPairConfiguration: - RuleCondition: !Equals [!Ref CreateKeyPair, 'no'] + RuleCondition: !Equals [!Ref CreateKeyPair, "no"] Assertions: - - Assert: !Not [!Equals [!Ref ExistingKeyName, '']] + - Assert: !Not [!Equals [!Ref ExistingKeyName, ""]] AssertDescription: "ExistingKeyName must be provided when CreateKeyPair is 'no'" Parameters: NotificationEmail: Type: String - Default: '' + Default: "" Description: Email address to receive deployment completion notifications (optional) ProjectTag: @@ -63,7 +63,6 @@ Parameters: Type: String Default: c5.xlarge AllowedValues: - - c5.xlarge - c5.2xlarge - c5.4xlarge @@ -87,13 +86,13 @@ Parameters: Description: Create a new key pair for instance access? (If 'no', you must provide an existing key name) ExistingKeyName: Type: String - Default: '' + Default: "" Description: Name of existing EC2 Key Pair (only required when CreateKeyPair is 'no') Conditions: - UseExistingKeyProvided: !Not [!Equals [!Ref ExistingKeyName, '']] - CreateKey: !Equals [!Ref CreateKeyPair, 'yes'] - SendNotification: !Not [!Equals [!Ref NotificationEmail, '']] + UseExistingKeyProvided: !Not [!Equals [!Ref ExistingKeyName, ""]] + CreateKey: !Equals [!Ref CreateKeyPair, "yes"] + SendNotification: !Not [!Equals [!Ref NotificationEmail, ""]] Resources: # VPC for TestDriver @@ -104,7 +103,7 @@ Resources: EnableDnsHostnames: true EnableDnsSupport: true Tags: - - { Key: Name, Value: !Sub '${AWS::StackName}-vpc' } + - { Key: Name, Value: !Sub "${AWS::StackName}-vpc" } - { Key: Project, Value: !Ref ProjectTag } # Public subnet for EC2 instances @@ -113,10 +112,10 @@ Resources: Properties: VpcId: !Ref TestDriverVpc CidrBlock: 10.0.1.0/24 - AvailabilityZone: !Select [0, !GetAZs ''] + AvailabilityZone: !Select [0, !GetAZs ""] MapPublicIpOnLaunch: true Tags: - - { Key: Name, Value: !Sub '${AWS::StackName}-public-subnet' } + - { Key: Name, Value: !Sub "${AWS::StackName}-public-subnet" } - { Key: Project, Value: !Ref ProjectTag } # Internet Gateway @@ -124,7 +123,7 @@ Resources: Type: AWS::EC2::InternetGateway Properties: Tags: - - { Key: Name, Value: !Sub '${AWS::StackName}-igw' } + - { Key: Name, Value: !Sub "${AWS::StackName}-igw" } - { Key: Project, Value: !Ref ProjectTag } # Attach Internet Gateway to VPC @@ -140,7 +139,7 @@ Resources: Properties: VpcId: !Ref TestDriverVpc Tags: - - { Key: Name, Value: !Sub '${AWS::StackName}-public-rt' } + - { Key: Name, Value: !Sub "${AWS::StackName}-public-rt" } - { Key: Project, Value: !Ref ProjectTag } # Route to Internet Gateway @@ -165,15 +164,69 @@ Resources: GroupDescription: SG for QA desktop testing (RDP/HTTPS/NGINX/pyautogui + VNC) VpcId: !Ref TestDriverVpc SecurityGroupIngress: - - { IpProtocol: tcp, FromPort: 8765, ToPort: 8765, CidrIp: !Ref AllowedIngressCidr, Description: 'pyautogui-cli WebSockets' } - - { IpProtocol: tcp, FromPort: 8443, ToPort: 8443, CidrIp: !Ref AllowedIngressCidr, Description: 'Custom 8443' } - - { IpProtocol: tcp, FromPort: 8080, ToPort: 8080, CidrIp: !Ref AllowedIngressCidr, Description: 'NGINX 8080' } - - { IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: !Ref AllowedIngressCidr, Description: 'HTTP 80' } - - { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: !Ref AllowedIngressCidr, Description: 'HTTPS 443' } - - { IpProtocol: tcp, FromPort: 3389, ToPort: 3389, CidrIp: !Ref AllowedIngressCidr, Description: 'RDP 3389' } - - { IpProtocol: tcp, FromPort: 5900, ToPort: 5900, CidrIp: !Ref AllowedIngressCidr, Description: 'TightVNC 5900' } - - { IpProtocol: tcp, FromPort: 5901, ToPort: 5901, CidrIp: !Ref AllowedIngressCidr, Description: 'noVNC Websockify 5901' } - - { IpProtocol: tcp, FromPort: 6080, ToPort: 6080, CidrIp: !Ref AllowedIngressCidr, Description: 'noVNC HTTP 6080' } + - { + IpProtocol: tcp, + FromPort: 8765, + ToPort: 8765, + CidrIp: !Ref AllowedIngressCidr, + Description: "pyautogui-cli WebSockets", + } + - { + IpProtocol: tcp, + FromPort: 8443, + ToPort: 8443, + CidrIp: !Ref AllowedIngressCidr, + Description: "Custom 8443", + } + - { + IpProtocol: tcp, + FromPort: 8080, + ToPort: 8080, + CidrIp: !Ref AllowedIngressCidr, + Description: "NGINX 8080", + } + - { + IpProtocol: tcp, + FromPort: 80, + ToPort: 80, + CidrIp: !Ref AllowedIngressCidr, + Description: "HTTP 80", + } + - { + IpProtocol: tcp, + FromPort: 443, + ToPort: 443, + CidrIp: !Ref AllowedIngressCidr, + Description: "HTTPS 443", + } + - { + IpProtocol: tcp, + FromPort: 3389, + ToPort: 3389, + CidrIp: !Ref AllowedIngressCidr, + Description: "RDP 3389", + } + - { + IpProtocol: tcp, + FromPort: 5900, + ToPort: 5900, + CidrIp: !Ref AllowedIngressCidr, + Description: "TightVNC 5900", + } + - { + IpProtocol: tcp, + FromPort: 5901, + ToPort: 5901, + CidrIp: !Ref AllowedIngressCidr, + Description: "noVNC Websockify 5901", + } + - { + IpProtocol: tcp, + FromPort: 6080, + ToPort: 6080, + CidrIp: !Ref AllowedIngressCidr, + Description: "noVNC HTTP 6080", + } SecurityGroupEgress: - IpProtocol: -1 CidrIp: 0.0.0.0/0 @@ -185,7 +238,7 @@ Resources: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: - Version: '2012-10-17' + Version: "2012-10-17" Statement: - Effect: Allow Principal: { Service: ec2.amazonaws.com } @@ -204,13 +257,13 @@ Resources: Type: AWS::EC2::KeyPair Condition: CreateKey Properties: - KeyName: !Sub '${AWS::StackName}-key' + KeyName: !Sub "${AWS::StackName}-key" KeyType: rsa LaunchTemplate: Type: AWS::EC2::LaunchTemplate Properties: - LaunchTemplateName: !Sub '${AWS::StackName}-lt' + LaunchTemplateName: !Sub "${AWS::StackName}-lt" LaunchTemplateData: InstanceType: !Ref InstanceType IamInstanceProfile: @@ -219,7 +272,7 @@ Resources: NetworkInterfaces: - DeviceIndex: 0 SubnetId: !Ref PublicSubnet - Groups: [ !Ref SecurityGroup ] + Groups: [!Ref SecurityGroup] AssociatePublicIpAddress: true KeyName: !If - CreateKey @@ -230,17 +283,17 @@ Resources: - !Ref AWS::NoValue TagSpecifications: - ResourceType: instance - Tags: [ { Key: Project, Value: !Ref ProjectTag } ] + Tags: [{ Key: Project, Value: !Ref ProjectTag }] - ResourceType: volume - Tags: [ { Key: Project, Value: !Ref ProjectTag } ] + Tags: [{ Key: Project, Value: !Ref ProjectTag }] # SNS Topic for deployment notifications DeploymentNotificationTopic: Type: AWS::SNS::Topic Condition: SendNotification Properties: - TopicName: !Sub '${AWS::StackName}-deployment-notifications' - DisplayName: !Sub '${AWS::StackName} Deployment Notifications' + TopicName: !Sub "${AWS::StackName}-deployment-notifications" + DisplayName: !Sub "${AWS::StackName} Deployment Notifications" Tags: - { Key: Project, Value: !Ref ProjectTag } @@ -274,7 +327,7 @@ Resources: Type: AWS::Lambda::Function Condition: SendNotification Properties: - FunctionName: !Sub '${AWS::StackName}-deployment-notifier' + FunctionName: !Sub "${AWS::StackName}-deployment-notifier" Runtime: python3.11 Handler: index.lambda_handler Role: !GetAtt NotificationLambdaRole.Arn @@ -283,7 +336,7 @@ Resources: import boto3 import json import cfnresponse - + def lambda_handler(event, context): try: if event['RequestType'] == 'Create': @@ -293,10 +346,10 @@ Resources: message = f""" TestDriver Infrastructure Deployment Complete! - + Stack Name: {stack_name} Status: CREATE_COMPLETE - + Your TestDriver infrastructure is now ready to use. Check the CloudFormation outputs for resource IDs and configuration details. """ @@ -320,7 +373,7 @@ Resources: Condition: SendNotification Properties: AssumeRolePolicyDocument: - Version: '2012-10-17' + Version: "2012-10-17" Statement: - Effect: Allow Principal: @@ -331,7 +384,7 @@ Resources: Policies: - PolicyName: SNSPublishPolicy PolicyDocument: - Version: '2012-10-17' + Version: "2012-10-17" Statement: - Effect: Allow Action: @@ -343,42 +396,42 @@ Resources: SsmParamSg: Type: AWS::SSM::Parameter Properties: - Name: !Sub '/testdriver/infra/${AWS::StackName}/security-group-id' + Name: !Sub "/testdriver/infra/${AWS::StackName}/security-group-id" Type: String Value: !Ref SecurityGroup SsmParamIp: Type: AWS::SSM::Parameter Properties: - Name: !Sub '/testdriver/infra/${AWS::StackName}/instance-profile-name' + Name: !Sub "/testdriver/infra/${AWS::StackName}/instance-profile-name" Type: String Value: !Ref InstanceProfile SsmParamLt: Type: AWS::SSM::Parameter Properties: - Name: !Sub '/testdriver/infra/${AWS::StackName}/launch-template-id' + Name: !Sub "/testdriver/infra/${AWS::StackName}/launch-template-id" Type: String Value: !Ref LaunchTemplate SsmParamLtLatest: Type: AWS::SSM::Parameter Properties: - Name: !Sub '/testdriver/infra/${AWS::StackName}/launch-template-latest-version' + Name: !Sub "/testdriver/infra/${AWS::StackName}/launch-template-latest-version" Type: String Value: !GetAtt LaunchTemplate.LatestVersionNumber SsmParamVpc: Type: AWS::SSM::Parameter Properties: - Name: !Sub '/testdriver/infra/${AWS::StackName}/testdriver-vpc-id' + Name: !Sub "/testdriver/infra/${AWS::StackName}/testdriver-vpc-id" Type: String Value: !Ref TestDriverVpc SsmParamSubnet: Type: AWS::SSM::Parameter Properties: - Name: !Sub '/testdriver/infra/${AWS::StackName}/testdriver-public-subnet-id' + Name: !Sub "/testdriver/infra/${AWS::StackName}/testdriver-public-subnet-id" Type: String Value: !Ref PublicSubnet @@ -403,8 +456,8 @@ Outputs: Description: Latest Launch Template version KeyPairSsmParam: Condition: CreateKey - Value: !Sub '/ec2/keypair/${KeyPair.KeyPairId}' + Value: !Sub "/ec2/keypair/${KeyPair.KeyPairId}" Description: SSM parameter that stores the generated private key SsmNamespaceUsed: - Value: !Sub '/testdriver/infra/${AWS::StackName}' + Value: !Sub "/testdriver/infra/${AWS::StackName}" Description: Prefix where IDs are stored for discovery