From 5b04436249815178fc9e6990d2983a9247fe43fd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 2 Apr 2026 22:33:07 +0000 Subject: [PATCH 1/4] Add Clone Deployment GitHub workflow Adds a new reusable GitHub Actions workflow that clones an existing Fireworks deployment into a new deployment (optionally in a different account). Features: - Takes a source deployment ID and account as required inputs - Fetches the full source deployment config via Fireworks REST API - Creates a new deployment with the same configuration - Polls until the new deployment reaches READY state - Supports optional overrides: target account, deployment ID, display name, description, replica counts, accelerator type/count, region, precision, max context length, and deployment shape - Configurable wait timeout (default 30m) - Generates a GitHub Actions step summary with clone report - Available as both workflow_dispatch (manual) and workflow_call (reusable) Co-authored-by: Chuanying --- .github/workflows/clone-deployment.yaml | 481 ++++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 .github/workflows/clone-deployment.yaml diff --git a/.github/workflows/clone-deployment.yaml b/.github/workflows/clone-deployment.yaml new file mode 100644 index 0000000..fae9a61 --- /dev/null +++ b/.github/workflows/clone-deployment.yaml @@ -0,0 +1,481 @@ +name: Clone Deployment + +on: + workflow_dispatch: + inputs: + source_deployment: + description: "Source deployment ID to clone (e.g. my-deployment)" + required: true + type: string + source_account: + description: "Account ID where the source deployment lives" + required: true + type: string + target_account: + description: "Account ID to create the clone in (defaults to source_account)" + required: false + type: string + deployment_id: + description: "ID for the new deployment (auto-generated if empty)" + required: false + type: string + display_name: + description: "Display name for the cloned deployment" + required: false + type: string + description: + description: "Description for the cloned deployment" + required: false + type: string + min_replica_count: + description: "Minimum replica count override" + required: false + type: string + max_replica_count: + description: "Maximum replica count override" + required: false + type: string + accelerator_type: + description: "Accelerator type override (e.g. NVIDIA_H100_80GB, NVIDIA_A100_80GB)" + required: false + type: string + accelerator_count: + description: "Number of accelerators per replica override" + required: false + type: string + region: + description: "Placement region override (e.g. us-iowa-1). Use 'global' for multi-region." + required: false + type: string + precision: + description: "Model precision override (e.g. FP16, FP8, BF16)" + required: false + type: string + max_context_length: + description: "Maximum context length override" + required: false + type: string + deployment_shape: + description: "Deployment shape override" + required: false + type: string + wait_timeout: + description: "How long to wait for the deployment to become healthy (default: 30m)" + required: false + type: string + default: "30m" + + workflow_call: + inputs: + source_deployment: + description: "Source deployment ID to clone" + required: true + type: string + source_account: + description: "Account ID where the source deployment lives" + required: true + type: string + target_account: + description: "Account ID to create the clone in (defaults to source_account)" + required: false + type: string + deployment_id: + description: "ID for the new deployment (auto-generated if empty)" + required: false + type: string + display_name: + description: "Display name for the cloned deployment" + required: false + type: string + description: + description: "Description for the cloned deployment" + required: false + type: string + min_replica_count: + description: "Minimum replica count override" + required: false + type: string + max_replica_count: + description: "Maximum replica count override" + required: false + type: string + accelerator_type: + description: "Accelerator type override" + required: false + type: string + accelerator_count: + description: "Number of accelerators per replica override" + required: false + type: string + region: + description: "Placement region override" + required: false + type: string + precision: + description: "Model precision override" + required: false + type: string + max_context_length: + description: "Maximum context length override" + required: false + type: string + deployment_shape: + description: "Deployment shape override" + required: false + type: string + wait_timeout: + description: "How long to wait for the deployment to become healthy" + required: false + type: string + default: "30m" + secrets: + FIREWORKS_API_KEY: + description: "Fireworks API key with permissions on both source and target accounts" + required: true + outputs: + deployment_id: + description: "The ID of the newly created deployment" + value: ${{ jobs.clone.outputs.deployment_id }} + deployment_name: + description: "The full resource name of the new deployment" + value: ${{ jobs.clone.outputs.deployment_name }} + deployment_state: + description: "The final observed state of the new deployment" + value: ${{ jobs.clone.outputs.deployment_state }} + +jobs: + clone: + name: Clone Deployment + runs-on: ubuntu-latest + outputs: + deployment_id: ${{ steps.create.outputs.deployment_id }} + deployment_name: ${{ steps.create.outputs.deployment_name }} + deployment_state: ${{ steps.wait.outputs.deployment_state }} + + env: + FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} + FIREWORKS_API_BASE: "https://api.fireworks.ai/v1" + + steps: + - name: Validate inputs + run: | + if [ -z "${{ inputs.source_deployment }}" ]; then + echo "::error::source_deployment is required" + exit 1 + fi + if [ -z "${{ inputs.source_account }}" ]; then + echo "::error::source_account is required" + exit 1 + fi + if [ -z "${FIREWORKS_API_KEY}" ]; then + echo "::error::FIREWORKS_API_KEY secret is not set" + exit 1 + fi + + - name: Resolve target account + id: accounts + run: | + SOURCE_ACCOUNT="${{ inputs.source_account }}" + TARGET_ACCOUNT="${{ inputs.target_account }}" + if [ -z "${TARGET_ACCOUNT}" ]; then + TARGET_ACCOUNT="${SOURCE_ACCOUNT}" + fi + echo "source=${SOURCE_ACCOUNT}" >> "$GITHUB_OUTPUT" + echo "target=${TARGET_ACCOUNT}" >> "$GITHUB_OUTPUT" + echo "Source account: ${SOURCE_ACCOUNT}" + echo "Target account: ${TARGET_ACCOUNT}" + + - name: Fetch source deployment config + id: fetch + run: | + SOURCE_ACCOUNT="${{ steps.accounts.outputs.source }}" + DEPLOYMENT_ID="${{ inputs.source_deployment }}" + + echo "Fetching deployment config: accounts/${SOURCE_ACCOUNT}/deployments/${DEPLOYMENT_ID}" + + HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: Bearer ${FIREWORKS_API_KEY}" \ + -H "Content-Type: application/json" \ + "${FIREWORKS_API_BASE}/accounts/${SOURCE_ACCOUNT}/deployments/${DEPLOYMENT_ID}") + + HTTP_CODE=$(echo "${HTTP_RESPONSE}" | tail -n1) + RESPONSE_BODY=$(echo "${HTTP_RESPONSE}" | sed '$d') + + if [ "${HTTP_CODE}" -ne 200 ]; then + echo "::error::Failed to fetch source deployment (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}" + exit 1 + fi + + echo "Successfully fetched source deployment config" + echo "${RESPONSE_BODY}" | jq '.' | head -40 + echo "..." + + # Write to file so downstream steps can use it + echo "${RESPONSE_BODY}" > /tmp/source_deployment.json + + - name: Build clone deployment payload + id: payload + run: | + SOURCE_CONFIG="/tmp/source_deployment.json" + + # Extract cloneable fields from source, stripping read-only fields + CLONE_PAYLOAD=$(jq '{ + baseModel, + minReplicaCount, + maxReplicaCount, + acceleratorType, + acceleratorCount, + precision, + placement, + maxContextLength, + deploymentShape, + deploymentTemplate, + enableAddons, + enableSessionAffinity, + autoscalingPolicy, + draftModel, + draftTokenCount, + ngramSpeculationLength, + autoTune, + enableMtp, + enableHotLoad, + hotLoadBucketType, + hotLoadBucketUrl, + enableHotReloadLatestAddon + } | with_entries(select(.value != null and .value != ""))' "${SOURCE_CONFIG}") + + # Apply overrides from inputs + if [ -n "${{ inputs.display_name }}" ]; then + CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${{ inputs.display_name }}" '. + {displayName: $v}') + fi + + if [ -n "${{ inputs.description }}" ]; then + CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${{ inputs.description }}" '. + {description: $v}') + fi + + if [ -n "${{ inputs.min_replica_count }}" ]; then + CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --argjson v "${{ inputs.min_replica_count }}" '. + {minReplicaCount: $v}') + fi + + if [ -n "${{ inputs.max_replica_count }}" ]; then + CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --argjson v "${{ inputs.max_replica_count }}" '. + {maxReplicaCount: $v}') + fi + + if [ -n "${{ inputs.accelerator_type }}" ]; then + CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${{ inputs.accelerator_type }}" '. + {acceleratorType: $v}') + fi + + if [ -n "${{ inputs.accelerator_count }}" ]; then + CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --argjson v "${{ inputs.accelerator_count }}" '. + {acceleratorCount: $v}') + fi + + if [ -n "${{ inputs.precision }}" ]; then + CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${{ inputs.precision }}" '. + {precision: $v}') + fi + + if [ -n "${{ inputs.max_context_length }}" ]; then + CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --argjson v "${{ inputs.max_context_length }}" '. + {maxContextLength: $v}') + fi + + if [ -n "${{ inputs.deployment_shape }}" ]; then + CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${{ inputs.deployment_shape }}" '. + {deploymentShape: $v}') + fi + + if [ -n "${{ inputs.region }}" ]; then + # Convert human-friendly region to API format and set placement + REGION_UPPER=$(echo "${{ inputs.region }}" | tr '[:lower:]' '[:upper:]' | tr '-' '_') + if [ "${REGION_UPPER}" = "GLOBAL" ]; then + CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq '. + {placement: {multiRegion: "GLOBAL"}}') + else + CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${REGION_UPPER}" '. + {placement: {region: $v}}') + fi + fi + + echo "Clone payload:" + echo "${CLONE_PAYLOAD}" | jq '.' + echo "${CLONE_PAYLOAD}" > /tmp/clone_payload.json + + - name: Create cloned deployment + id: create + run: | + TARGET_ACCOUNT="${{ steps.accounts.outputs.target }}" + DEPLOYMENT_ID="${{ inputs.deployment_id }}" + + CREATE_URL="${FIREWORKS_API_BASE}/accounts/${TARGET_ACCOUNT}/deployments" + if [ -n "${DEPLOYMENT_ID}" ]; then + CREATE_URL="${CREATE_URL}?deploymentId=${DEPLOYMENT_ID}" + fi + + echo "Creating deployment in account: ${TARGET_ACCOUNT}" + + HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST \ + -H "Authorization: Bearer ${FIREWORKS_API_KEY}" \ + -H "Content-Type: application/json" \ + -d @/tmp/clone_payload.json \ + "${CREATE_URL}") + + HTTP_CODE=$(echo "${HTTP_RESPONSE}" | tail -n1) + RESPONSE_BODY=$(echo "${HTTP_RESPONSE}" | sed '$d') + + if [ "${HTTP_CODE}" -ne 200 ]; then + echo "::error::Failed to create deployment (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}" + exit 1 + fi + + DEPLOYMENT_NAME=$(echo "${RESPONSE_BODY}" | jq -r '.name') + NEW_DEPLOYMENT_ID=$(echo "${DEPLOYMENT_NAME}" | awk -F'/' '{print $NF}') + STATE=$(echo "${RESPONSE_BODY}" | jq -r '.state') + + echo "deployment_id=${NEW_DEPLOYMENT_ID}" >> "$GITHUB_OUTPUT" + echo "deployment_name=${DEPLOYMENT_NAME}" >> "$GITHUB_OUTPUT" + + echo "Created deployment: ${DEPLOYMENT_NAME} (state: ${STATE})" + echo "${RESPONSE_BODY}" | jq '.' + + - name: Wait for deployment to become healthy + id: wait + run: | + TARGET_ACCOUNT="${{ steps.accounts.outputs.target }}" + DEPLOYMENT_ID="${{ steps.create.outputs.deployment_id }}" + TIMEOUT="${{ inputs.wait_timeout }}" + if [ -z "${TIMEOUT}" ]; then + TIMEOUT="30m" + fi + + # Parse timeout to seconds + TIMEOUT_SECONDS=0 + if echo "${TIMEOUT}" | grep -qE '^[0-9]+s$'; then + TIMEOUT_SECONDS=$(echo "${TIMEOUT}" | sed 's/s$//') + elif echo "${TIMEOUT}" | grep -qE '^[0-9]+m$'; then + TIMEOUT_SECONDS=$(($(echo "${TIMEOUT}" | sed 's/m$//') * 60)) + elif echo "${TIMEOUT}" | grep -qE '^[0-9]+h$'; then + TIMEOUT_SECONDS=$(($(echo "${TIMEOUT}" | sed 's/h$//') * 3600)) + else + TIMEOUT_SECONDS=1800 + fi + + echo "Waiting up to ${TIMEOUT} (${TIMEOUT_SECONDS}s) for deployment ${DEPLOYMENT_ID} to become healthy..." + + START_TIME=$(date +%s) + POLL_INTERVAL=15 + LAST_STATE="" + + while true; do + ELAPSED=$(( $(date +%s) - START_TIME )) + if [ "${ELAPSED}" -ge "${TIMEOUT_SECONDS}" ]; then + echo "::error::Timed out after ${TIMEOUT} waiting for deployment to become healthy (last state: ${LAST_STATE})" + echo "deployment_state=${LAST_STATE}" >> "$GITHUB_OUTPUT" + exit 1 + fi + + HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: Bearer ${FIREWORKS_API_KEY}" \ + -H "Content-Type: application/json" \ + "${FIREWORKS_API_BASE}/accounts/${TARGET_ACCOUNT}/deployments/${DEPLOYMENT_ID}") + + HTTP_CODE=$(echo "${HTTP_RESPONSE}" | tail -n1) + RESPONSE_BODY=$(echo "${HTTP_RESPONSE}" | sed '$d') + + if [ "${HTTP_CODE}" -ne 200 ]; then + echo "Warning: Failed to poll deployment status (HTTP ${HTTP_CODE}), will retry..." + sleep "${POLL_INTERVAL}" + continue + fi + + STATE=$(echo "${RESPONSE_BODY}" | jq -r '.state') + READY_REPLICAS=$(echo "${RESPONSE_BODY}" | jq -r '.replicaStats.readyReplicaCount // 0') + STATUS_CODE=$(echo "${RESPONSE_BODY}" | jq -r '.status.code // "UNKNOWN"') + STATUS_MSG=$(echo "${RESPONSE_BODY}" | jq -r '.status.message // ""') + + if [ "${STATE}" != "${LAST_STATE}" ]; then + echo "State changed: ${LAST_STATE:-initial} -> ${STATE} (ready replicas: ${READY_REPLICAS})" + LAST_STATE="${STATE}" + fi + + if [ "${STATE}" = "READY" ]; then + echo "Deployment is healthy! (ready replicas: ${READY_REPLICAS})" + echo "deployment_state=READY" >> "$GITHUB_OUTPUT" + break + fi + + if [ "${STATE}" = "FAILED" ]; then + echo "::error::Deployment entered FAILED state: ${STATUS_MSG}" + echo "deployment_state=FAILED" >> "$GITHUB_OUTPUT" + exit 1 + fi + + if [ "${STATE}" = "DELETED" ] || [ "${STATE}" = "DELETING" ]; then + echo "::error::Deployment was unexpectedly deleted (state: ${STATE})" + echo "deployment_state=${STATE}" >> "$GITHUB_OUTPUT" + exit 1 + fi + + REMAINING=$(( TIMEOUT_SECONDS - ELAPSED )) + MINUTES_LEFT=$(( REMAINING / 60 )) + echo " State: ${STATE} | Ready replicas: ${READY_REPLICAS} | Elapsed: ${ELAPSED}s | Remaining: ~${MINUTES_LEFT}m" + sleep "${POLL_INTERVAL}" + done + + - name: Generate summary + if: always() + run: | + DEPLOYMENT_ID="${{ steps.create.outputs.deployment_id }}" + DEPLOYMENT_NAME="${{ steps.create.outputs.deployment_name }}" + DEPLOYMENT_STATE="${{ steps.wait.outputs.deployment_state }}" + SOURCE_ACCOUNT="${{ steps.accounts.outputs.source }}" + TARGET_ACCOUNT="${{ steps.accounts.outputs.target }}" + SOURCE_DEPLOYMENT="${{ inputs.source_deployment }}" + + if [ "${DEPLOYMENT_STATE}" = "READY" ]; then + STATUS_EMOJI="white_check_mark" + STATUS_TEXT="Healthy" + elif [ "${DEPLOYMENT_STATE}" = "FAILED" ]; then + STATUS_EMOJI="x" + STATUS_TEXT="Failed" + else + STATUS_EMOJI="warning" + STATUS_TEXT="${DEPLOYMENT_STATE:-Unknown}" + fi + + { + echo "## Deployment Clone Report" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| **Status** | :${STATUS_EMOJI}: ${STATUS_TEXT} |" + echo "| **Source** | \`accounts/${SOURCE_ACCOUNT}/deployments/${SOURCE_DEPLOYMENT}\` |" + echo "| **Clone** | \`${DEPLOYMENT_NAME}\` |" + echo "| **Clone ID** | \`${DEPLOYMENT_ID}\` |" + echo "| **Target Account** | \`${TARGET_ACCOUNT}\` |" + echo "| **Final State** | \`${DEPLOYMENT_STATE}\` |" + echo "" + echo "### Overrides Applied" + } >> "$GITHUB_STEP_SUMMARY" + + HAS_OVERRIDES=false + for INPUT_NAME in target_account deployment_id display_name description min_replica_count max_replica_count accelerator_type accelerator_count region precision max_context_length deployment_shape; do + VALUE="" + case "${INPUT_NAME}" in + target_account) VALUE="${{ inputs.target_account }}" ;; + deployment_id) VALUE="${{ inputs.deployment_id }}" ;; + display_name) VALUE="${{ inputs.display_name }}" ;; + description) VALUE="${{ inputs.description }}" ;; + min_replica_count) VALUE="${{ inputs.min_replica_count }}" ;; + max_replica_count) VALUE="${{ inputs.max_replica_count }}" ;; + accelerator_type) VALUE="${{ inputs.accelerator_type }}" ;; + accelerator_count) VALUE="${{ inputs.accelerator_count }}" ;; + region) VALUE="${{ inputs.region }}" ;; + precision) VALUE="${{ inputs.precision }}" ;; + max_context_length) VALUE="${{ inputs.max_context_length }}" ;; + deployment_shape) VALUE="${{ inputs.deployment_shape }}" ;; + esac + if [ -n "${VALUE}" ]; then + echo "| \`${INPUT_NAME}\` | \`${VALUE}\` |" >> "$GITHUB_STEP_SUMMARY" + HAS_OVERRIDES=true + fi + done + + if [ "${HAS_OVERRIDES}" = "false" ]; then + echo "_No overrides — exact clone of source deployment._" >> "$GITHUB_STEP_SUMMARY" + fi From 0c91301ec5dc90e8510338e121a8ec7396a4ca0d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Apr 2026 01:16:02 +0000 Subject: [PATCH 2/4] Rewrite workflow to use firectl CLI for deployment cloning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the raw REST API curl approach with firectl CLI: - Installs firectl from the public stable channel - Uses 'firectl deployment get -o json' to fetch source config - Uses 'firectl deployment create --wait' to create the clone - Carries all source config (model, accelerator, replicas, shape, precision, addons, context length) into the new deployment - Supports override inputs for target_account, deployment_id, display_name, replica counts, accelerator_type, region - Adds extra_args input for passing arbitrary firectl flags - Adds firectl_version input to choose the download channel - Authenticates via FIREWORKS_API_KEY secret using 'firectl set-api-key' When firectl-admin becomes available on a public download channel, switching to 'firectl-admin deployment clone' only requires changing the download URL (via firectl_version input) — the workflow's auth model (API key) remains the same. Co-authored-by: Chuanying --- .github/workflows/clone-deployment.yaml | 415 ++++++++++-------------- 1 file changed, 167 insertions(+), 248 deletions(-) diff --git a/.github/workflows/clone-deployment.yaml b/.github/workflows/clone-deployment.yaml index fae9a61..d547637 100644 --- a/.github/workflows/clone-deployment.yaml +++ b/.github/workflows/clone-deployment.yaml @@ -4,11 +4,11 @@ on: workflow_dispatch: inputs: source_deployment: - description: "Source deployment ID to clone (e.g. my-deployment)" + description: "Source deployment to clone (ID or full resource name)" required: true type: string source_account: - description: "Account ID where the source deployment lives" + description: "Account ID that owns the source deployment" required: true type: string target_account: @@ -23,10 +23,6 @@ on: description: "Display name for the cloned deployment" required: false type: string - description: - description: "Description for the cloned deployment" - required: false - type: string min_replica_count: description: "Minimum replica count override" required: false @@ -36,43 +32,36 @@ on: required: false type: string accelerator_type: - description: "Accelerator type override (e.g. NVIDIA_H100_80GB, NVIDIA_A100_80GB)" - required: false - type: string - accelerator_count: - description: "Number of accelerators per replica override" + description: "Accelerator type override (e.g. NVIDIA_H100_80GB)" required: false type: string region: - description: "Placement region override (e.g. us-iowa-1). Use 'global' for multi-region." + description: "Placement region override (e.g. us-iowa-1)" required: false type: string - precision: - description: "Model precision override (e.g. FP16, FP8, BF16)" - required: false - type: string - max_context_length: - description: "Maximum context length override" - required: false - type: string - deployment_shape: - description: "Deployment shape override" + extra_args: + description: "Additional flags passed verbatim to the create/clone command" required: false type: string wait_timeout: - description: "How long to wait for the deployment to become healthy (default: 30m)" + description: "How long to wait for healthy state (default: 30m)" required: false type: string default: "30m" + firectl_version: + description: "firectl download channel (default: stable)" + required: false + type: string + default: "stable" workflow_call: inputs: source_deployment: - description: "Source deployment ID to clone" + description: "Source deployment to clone (ID or full resource name)" required: true type: string source_account: - description: "Account ID where the source deployment lives" + description: "Account ID that owns the source deployment" required: true type: string target_account: @@ -87,10 +76,6 @@ on: description: "Display name for the cloned deployment" required: false type: string - description: - description: "Description for the cloned deployment" - required: false - type: string min_replica_count: description: "Minimum replica count override" required: false @@ -100,47 +85,40 @@ on: required: false type: string accelerator_type: - description: "Accelerator type override" - required: false - type: string - accelerator_count: - description: "Number of accelerators per replica override" + description: "Accelerator type override (e.g. NVIDIA_H100_80GB)" required: false type: string region: - description: "Placement region override" - required: false - type: string - precision: - description: "Model precision override" - required: false - type: string - max_context_length: - description: "Maximum context length override" + description: "Placement region override (e.g. us-iowa-1)" required: false type: string - deployment_shape: - description: "Deployment shape override" + extra_args: + description: "Additional flags passed verbatim to the create/clone command" required: false type: string wait_timeout: - description: "How long to wait for the deployment to become healthy" + description: "How long to wait for healthy state (default: 30m)" required: false type: string default: "30m" + firectl_version: + description: "firectl download channel (default: stable)" + required: false + type: string + default: "stable" secrets: FIREWORKS_API_KEY: - description: "Fireworks API key with permissions on both source and target accounts" + description: "Fireworks API key with permissions on source and target accounts" required: true outputs: deployment_id: description: "The ID of the newly created deployment" value: ${{ jobs.clone.outputs.deployment_id }} deployment_name: - description: "The full resource name of the new deployment" + description: "Full resource name of the new deployment" value: ${{ jobs.clone.outputs.deployment_name }} deployment_state: - description: "The final observed state of the new deployment" + description: "Final observed state of the new deployment" value: ${{ jobs.clone.outputs.deployment_state }} jobs: @@ -148,13 +126,12 @@ jobs: name: Clone Deployment runs-on: ubuntu-latest outputs: - deployment_id: ${{ steps.create.outputs.deployment_id }} - deployment_name: ${{ steps.create.outputs.deployment_name }} + deployment_id: ${{ steps.clone.outputs.deployment_id }} + deployment_name: ${{ steps.clone.outputs.deployment_name }} deployment_state: ${{ steps.wait.outputs.deployment_state }} env: FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} - FIREWORKS_API_BASE: "https://api.fireworks.ai/v1" steps: - name: Validate inputs @@ -172,7 +149,21 @@ jobs: exit 1 fi - - name: Resolve target account + - name: Install firectl + id: install + run: | + CHANNEL="${{ inputs.firectl_version || 'stable' }}" + URL="https://storage.googleapis.com/fireworks-public/firectl/${CHANNEL}/linux-amd64.gz" + + echo "Downloading firectl from ${URL} ..." + wget -qO firectl.gz "${URL}" + gunzip firectl.gz + sudo install -o root -g root -m 0755 firectl /usr/local/bin/firectl + echo "Installed firectl $(firectl version)" + + firectl set-api-key "${FIREWORKS_API_KEY}" + + - name: Resolve accounts id: accounts run: | SOURCE_ACCOUNT="${{ inputs.source_account }}" @@ -185,257 +176,189 @@ jobs: echo "Source account: ${SOURCE_ACCOUNT}" echo "Target account: ${TARGET_ACCOUNT}" - - name: Fetch source deployment config + - name: Fetch source deployment id: fetch run: | SOURCE_ACCOUNT="${{ steps.accounts.outputs.source }}" - DEPLOYMENT_ID="${{ inputs.source_deployment }}" - - echo "Fetching deployment config: accounts/${SOURCE_ACCOUNT}/deployments/${DEPLOYMENT_ID}" - - HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" \ - -H "Authorization: Bearer ${FIREWORKS_API_KEY}" \ - -H "Content-Type: application/json" \ - "${FIREWORKS_API_BASE}/accounts/${SOURCE_ACCOUNT}/deployments/${DEPLOYMENT_ID}") - - HTTP_CODE=$(echo "${HTTP_RESPONSE}" | tail -n1) - RESPONSE_BODY=$(echo "${HTTP_RESPONSE}" | sed '$d') + SOURCE_DEPLOYMENT="${{ inputs.source_deployment }}" - if [ "${HTTP_CODE}" -ne 200 ]; then - echo "::error::Failed to fetch source deployment (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}" - exit 1 - fi + echo "Fetching: accounts/${SOURCE_ACCOUNT}/deployments/${SOURCE_DEPLOYMENT}" - echo "Successfully fetched source deployment config" - echo "${RESPONSE_BODY}" | jq '.' | head -40 - echo "..." + firectl -a "${SOURCE_ACCOUNT}" deployment get "${SOURCE_DEPLOYMENT}" -o json \ + > /tmp/source_deployment.json - # Write to file so downstream steps can use it - echo "${RESPONSE_BODY}" > /tmp/source_deployment.json + echo "Source deployment:" + jq '{name, state, baseModel, acceleratorType, acceleratorCount, minReplicaCount, maxReplicaCount, precision, placement, deploymentShape}' \ + /tmp/source_deployment.json - - name: Build clone deployment payload - id: payload + - name: Clone deployment + id: clone run: | + SOURCE_ACCOUNT="${{ steps.accounts.outputs.source }}" + TARGET_ACCOUNT="${{ steps.accounts.outputs.target }}" + SOURCE_DEPLOYMENT="${{ inputs.source_deployment }}" SOURCE_CONFIG="/tmp/source_deployment.json" - # Extract cloneable fields from source, stripping read-only fields - CLONE_PAYLOAD=$(jq '{ - baseModel, - minReplicaCount, - maxReplicaCount, - acceleratorType, - acceleratorCount, - precision, - placement, - maxContextLength, - deploymentShape, - deploymentTemplate, - enableAddons, - enableSessionAffinity, - autoscalingPolicy, - draftModel, - draftTokenCount, - ngramSpeculationLength, - autoTune, - enableMtp, - enableHotLoad, - hotLoadBucketType, - hotLoadBucketUrl, - enableHotReloadLatestAddon - } | with_entries(select(.value != null and .value != ""))' "${SOURCE_CONFIG}") - - # Apply overrides from inputs - if [ -n "${{ inputs.display_name }}" ]; then - CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${{ inputs.display_name }}" '. + {displayName: $v}') + BASE_MODEL=$(jq -r '.baseModel' "${SOURCE_CONFIG}") + + # Assemble flags — carry source config, let overrides win + FLAGS="" + + DEPLOYMENT_ID="${{ inputs.deployment_id }}" + if [ -n "${DEPLOYMENT_ID}" ]; then + FLAGS="${FLAGS} --deployment-id=${DEPLOYMENT_ID}" fi - if [ -n "${{ inputs.description }}" ]; then - CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${{ inputs.description }}" '. + {description: $v}') + DISPLAY_NAME="${{ inputs.display_name }}" + if [ -n "${DISPLAY_NAME}" ]; then + FLAGS="${FLAGS} --display-name=${DISPLAY_NAME}" fi - if [ -n "${{ inputs.min_replica_count }}" ]; then - CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --argjson v "${{ inputs.min_replica_count }}" '. + {minReplicaCount: $v}') + # Replica counts + MIN_REPLICAS="${{ inputs.min_replica_count }}" + if [ -z "${MIN_REPLICAS}" ]; then + MIN_REPLICAS=$(jq -r '.minReplicaCount // empty' "${SOURCE_CONFIG}") + fi + if [ -n "${MIN_REPLICAS}" ]; then + FLAGS="${FLAGS} --min-replica-count=${MIN_REPLICAS}" fi - if [ -n "${{ inputs.max_replica_count }}" ]; then - CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --argjson v "${{ inputs.max_replica_count }}" '. + {maxReplicaCount: $v}') + MAX_REPLICAS="${{ inputs.max_replica_count }}" + if [ -z "${MAX_REPLICAS}" ]; then + MAX_REPLICAS=$(jq -r '.maxReplicaCount // empty' "${SOURCE_CONFIG}") + fi + if [ -n "${MAX_REPLICAS}" ]; then + FLAGS="${FLAGS} --max-replica-count=${MAX_REPLICAS}" fi - if [ -n "${{ inputs.accelerator_type }}" ]; then - CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${{ inputs.accelerator_type }}" '. + {acceleratorType: $v}') + # Accelerator + ACCEL_TYPE="${{ inputs.accelerator_type }}" + if [ -z "${ACCEL_TYPE}" ]; then + ACCEL_TYPE=$(jq -r '.acceleratorType // empty' "${SOURCE_CONFIG}") + fi + if [ -n "${ACCEL_TYPE}" ] && [ "${ACCEL_TYPE}" != "ACCELERATOR_TYPE_UNSPECIFIED" ]; then + FLAGS="${FLAGS} --accelerator-type=${ACCEL_TYPE}" fi - if [ -n "${{ inputs.accelerator_count }}" ]; then - CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --argjson v "${{ inputs.accelerator_count }}" '. + {acceleratorCount: $v}') + ACCEL_COUNT=$(jq -r '.acceleratorCount // empty' "${SOURCE_CONFIG}") + if [ -n "${ACCEL_COUNT}" ] && [ "${ACCEL_COUNT}" != "0" ]; then + FLAGS="${FLAGS} --accelerator-count=${ACCEL_COUNT}" fi - if [ -n "${{ inputs.precision }}" ]; then - CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${{ inputs.precision }}" '. + {precision: $v}') + # Region + REGION="${{ inputs.region }}" + if [ -n "${REGION}" ]; then + FLAGS="${FLAGS} --region=${REGION}" fi - if [ -n "${{ inputs.max_context_length }}" ]; then - CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --argjson v "${{ inputs.max_context_length }}" '. + {maxContextLength: $v}') + # Precision + SRC_PRECISION=$(jq -r '.precision // empty' "${SOURCE_CONFIG}") + if [ -n "${SRC_PRECISION}" ] && [ "${SRC_PRECISION}" != "PRECISION_UNSPECIFIED" ]; then + FLAGS="${FLAGS} --precision=${SRC_PRECISION}" fi - if [ -n "${{ inputs.deployment_shape }}" ]; then - CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${{ inputs.deployment_shape }}" '. + {deploymentShape: $v}') + # Deployment shape + SRC_SHAPE=$(jq -r '.deploymentShape // empty' "${SOURCE_CONFIG}") + if [ -n "${SRC_SHAPE}" ]; then + FLAGS="${FLAGS} --deployment-shape=${SRC_SHAPE}" fi - if [ -n "${{ inputs.region }}" ]; then - # Convert human-friendly region to API format and set placement - REGION_UPPER=$(echo "${{ inputs.region }}" | tr '[:lower:]' '[:upper:]' | tr '-' '_') - if [ "${REGION_UPPER}" = "GLOBAL" ]; then - CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq '. + {placement: {multiRegion: "GLOBAL"}}') - else - CLONE_PAYLOAD=$(echo "${CLONE_PAYLOAD}" | jq --arg v "${REGION_UPPER}" '. + {placement: {region: $v}}') - fi + # Context length + SRC_CTX=$(jq -r '.maxContextLength // empty' "${SOURCE_CONFIG}") + if [ -n "${SRC_CTX}" ] && [ "${SRC_CTX}" != "0" ]; then + FLAGS="${FLAGS} --max-context-length=${SRC_CTX}" fi - echo "Clone payload:" - echo "${CLONE_PAYLOAD}" | jq '.' - echo "${CLONE_PAYLOAD}" > /tmp/clone_payload.json + # Addons + SRC_ADDONS=$(jq -r '.enableAddons // empty' "${SOURCE_CONFIG}") + if [ "${SRC_ADDONS}" = "true" ]; then + FLAGS="${FLAGS} --enable-addons" + fi - - name: Create cloned deployment - id: create - run: | - TARGET_ACCOUNT="${{ steps.accounts.outputs.target }}" - DEPLOYMENT_ID="${{ inputs.deployment_id }}" + # Wait for the deployment to come up healthy + FLAGS="${FLAGS} --wait --wait-timeout=${{ inputs.wait_timeout || '30m' }}" - CREATE_URL="${FIREWORKS_API_BASE}/accounts/${TARGET_ACCOUNT}/deployments" - if [ -n "${DEPLOYMENT_ID}" ]; then - CREATE_URL="${CREATE_URL}?deploymentId=${DEPLOYMENT_ID}" + # Extra flags from the caller + EXTRA_ARGS="${{ inputs.extra_args }}" + if [ -n "${EXTRA_ARGS}" ]; then + FLAGS="${FLAGS} ${EXTRA_ARGS}" fi - echo "Creating deployment in account: ${TARGET_ACCOUNT}" + echo "=== Clone summary ===" + echo "Source : accounts/${SOURCE_ACCOUNT}/deployments/${SOURCE_DEPLOYMENT}" + echo "Target : account=${TARGET_ACCOUNT}" + echo "Model : ${BASE_MODEL}" + echo "Flags : ${FLAGS}" + echo "=====================" + + # shellcheck disable=SC2086 + OUTPUT=$(firectl -a "${TARGET_ACCOUNT}" deployment create \ + "${BASE_MODEL}" \ + ${FLAGS} 2>&1) || { + echo "::error::firectl deployment create failed" + echo "${OUTPUT}" + exit 1 + } - HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" \ - -X POST \ - -H "Authorization: Bearer ${FIREWORKS_API_KEY}" \ - -H "Content-Type: application/json" \ - -d @/tmp/clone_payload.json \ - "${CREATE_URL}") + echo "${OUTPUT}" - HTTP_CODE=$(echo "${HTTP_RESPONSE}" | tail -n1) - RESPONSE_BODY=$(echo "${HTTP_RESPONSE}" | sed '$d') + # Extract deployment name from the output (supports both text and json) + DEPLOYMENT_NAME=$(echo "${OUTPUT}" | jq -r '.name // empty' 2>/dev/null) + if [ -z "${DEPLOYMENT_NAME}" ]; then + DEPLOYMENT_NAME=$(echo "${OUTPUT}" | grep -oP '(?<=Name:\s)\S+' || true) + fi + NEW_ID=$(echo "${DEPLOYMENT_NAME}" | awk -F'/' '{print $NF}') - if [ "${HTTP_CODE}" -ne 200 ]; then - echo "::error::Failed to create deployment (HTTP ${HTTP_CODE}): ${RESPONSE_BODY}" + if [ -z "${NEW_ID}" ]; then + echo "::error::Could not extract deployment ID from output" exit 1 fi - DEPLOYMENT_NAME=$(echo "${RESPONSE_BODY}" | jq -r '.name') - NEW_DEPLOYMENT_ID=$(echo "${DEPLOYMENT_NAME}" | awk -F'/' '{print $NF}') - STATE=$(echo "${RESPONSE_BODY}" | jq -r '.state') - - echo "deployment_id=${NEW_DEPLOYMENT_ID}" >> "$GITHUB_OUTPUT" + echo "deployment_id=${NEW_ID}" >> "$GITHUB_OUTPUT" echo "deployment_name=${DEPLOYMENT_NAME}" >> "$GITHUB_OUTPUT" + echo "Created deployment: ${DEPLOYMENT_NAME}" - echo "Created deployment: ${DEPLOYMENT_NAME} (state: ${STATE})" - echo "${RESPONSE_BODY}" | jq '.' - - - name: Wait for deployment to become healthy + - name: Verify deployment health id: wait run: | TARGET_ACCOUNT="${{ steps.accounts.outputs.target }}" - DEPLOYMENT_ID="${{ steps.create.outputs.deployment_id }}" - TIMEOUT="${{ inputs.wait_timeout }}" - if [ -z "${TIMEOUT}" ]; then - TIMEOUT="30m" - fi + DEPLOYMENT_ID="${{ steps.clone.outputs.deployment_id }}" - # Parse timeout to seconds - TIMEOUT_SECONDS=0 - if echo "${TIMEOUT}" | grep -qE '^[0-9]+s$'; then - TIMEOUT_SECONDS=$(echo "${TIMEOUT}" | sed 's/s$//') - elif echo "${TIMEOUT}" | grep -qE '^[0-9]+m$'; then - TIMEOUT_SECONDS=$(($(echo "${TIMEOUT}" | sed 's/m$//') * 60)) - elif echo "${TIMEOUT}" | grep -qE '^[0-9]+h$'; then - TIMEOUT_SECONDS=$(($(echo "${TIMEOUT}" | sed 's/h$//') * 3600)) - else - TIMEOUT_SECONDS=1800 + if [ -z "${DEPLOYMENT_ID}" ]; then + echo "::error::No deployment ID from clone step" + echo "deployment_state=UNKNOWN" >> "$GITHUB_OUTPUT" + exit 1 fi - echo "Waiting up to ${TIMEOUT} (${TIMEOUT_SECONDS}s) for deployment ${DEPLOYMENT_ID} to become healthy..." + STATE=$(firectl -a "${TARGET_ACCOUNT}" deployment get "${DEPLOYMENT_ID}" -o json \ + | jq -r '.state') - START_TIME=$(date +%s) - POLL_INTERVAL=15 - LAST_STATE="" + echo "deployment_state=${STATE}" >> "$GITHUB_OUTPUT" + echo "Deployment state: ${STATE}" - while true; do - ELAPSED=$(( $(date +%s) - START_TIME )) - if [ "${ELAPSED}" -ge "${TIMEOUT_SECONDS}" ]; then - echo "::error::Timed out after ${TIMEOUT} waiting for deployment to become healthy (last state: ${LAST_STATE})" - echo "deployment_state=${LAST_STATE}" >> "$GITHUB_OUTPUT" - exit 1 - fi - - HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" \ - -H "Authorization: Bearer ${FIREWORKS_API_KEY}" \ - -H "Content-Type: application/json" \ - "${FIREWORKS_API_BASE}/accounts/${TARGET_ACCOUNT}/deployments/${DEPLOYMENT_ID}") - - HTTP_CODE=$(echo "${HTTP_RESPONSE}" | tail -n1) - RESPONSE_BODY=$(echo "${HTTP_RESPONSE}" | sed '$d') - - if [ "${HTTP_CODE}" -ne 200 ]; then - echo "Warning: Failed to poll deployment status (HTTP ${HTTP_CODE}), will retry..." - sleep "${POLL_INTERVAL}" - continue - fi - - STATE=$(echo "${RESPONSE_BODY}" | jq -r '.state') - READY_REPLICAS=$(echo "${RESPONSE_BODY}" | jq -r '.replicaStats.readyReplicaCount // 0') - STATUS_CODE=$(echo "${RESPONSE_BODY}" | jq -r '.status.code // "UNKNOWN"') - STATUS_MSG=$(echo "${RESPONSE_BODY}" | jq -r '.status.message // ""') - - if [ "${STATE}" != "${LAST_STATE}" ]; then - echo "State changed: ${LAST_STATE:-initial} -> ${STATE} (ready replicas: ${READY_REPLICAS})" - LAST_STATE="${STATE}" - fi - - if [ "${STATE}" = "READY" ]; then - echo "Deployment is healthy! (ready replicas: ${READY_REPLICAS})" - echo "deployment_state=READY" >> "$GITHUB_OUTPUT" - break - fi - - if [ "${STATE}" = "FAILED" ]; then - echo "::error::Deployment entered FAILED state: ${STATUS_MSG}" - echo "deployment_state=FAILED" >> "$GITHUB_OUTPUT" - exit 1 - fi - - if [ "${STATE}" = "DELETED" ] || [ "${STATE}" = "DELETING" ]; then - echo "::error::Deployment was unexpectedly deleted (state: ${STATE})" - echo "deployment_state=${STATE}" >> "$GITHUB_OUTPUT" - exit 1 - fi + if [ "${STATE}" != "READY" ]; then + echo "::warning::Deployment not READY (state: ${STATE})" + exit 1 + fi - REMAINING=$(( TIMEOUT_SECONDS - ELAPSED )) - MINUTES_LEFT=$(( REMAINING / 60 )) - echo " State: ${STATE} | Ready replicas: ${READY_REPLICAS} | Elapsed: ${ELAPSED}s | Remaining: ~${MINUTES_LEFT}m" - sleep "${POLL_INTERVAL}" - done + echo "Deployment is healthy!" - - name: Generate summary + - name: Summary if: always() run: | - DEPLOYMENT_ID="${{ steps.create.outputs.deployment_id }}" - DEPLOYMENT_NAME="${{ steps.create.outputs.deployment_name }}" + DEPLOYMENT_ID="${{ steps.clone.outputs.deployment_id }}" + DEPLOYMENT_NAME="${{ steps.clone.outputs.deployment_name }}" DEPLOYMENT_STATE="${{ steps.wait.outputs.deployment_state }}" SOURCE_ACCOUNT="${{ steps.accounts.outputs.source }}" TARGET_ACCOUNT="${{ steps.accounts.outputs.target }}" SOURCE_DEPLOYMENT="${{ inputs.source_deployment }}" if [ "${DEPLOYMENT_STATE}" = "READY" ]; then - STATUS_EMOJI="white_check_mark" - STATUS_TEXT="Healthy" + STATUS_EMOJI="white_check_mark"; STATUS_TEXT="Healthy" elif [ "${DEPLOYMENT_STATE}" = "FAILED" ]; then - STATUS_EMOJI="x" - STATUS_TEXT="Failed" + STATUS_EMOJI="x"; STATUS_TEXT="Failed" else - STATUS_EMOJI="warning" - STATUS_TEXT="${DEPLOYMENT_STATE:-Unknown}" + STATUS_EMOJI="warning"; STATUS_TEXT="${DEPLOYMENT_STATE:-Unknown}" fi { @@ -454,21 +377,17 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" HAS_OVERRIDES=false - for INPUT_NAME in target_account deployment_id display_name description min_replica_count max_replica_count accelerator_type accelerator_count region precision max_context_length deployment_shape; do + for INPUT_NAME in target_account deployment_id display_name min_replica_count max_replica_count accelerator_type region extra_args; do VALUE="" case "${INPUT_NAME}" in target_account) VALUE="${{ inputs.target_account }}" ;; deployment_id) VALUE="${{ inputs.deployment_id }}" ;; display_name) VALUE="${{ inputs.display_name }}" ;; - description) VALUE="${{ inputs.description }}" ;; min_replica_count) VALUE="${{ inputs.min_replica_count }}" ;; max_replica_count) VALUE="${{ inputs.max_replica_count }}" ;; accelerator_type) VALUE="${{ inputs.accelerator_type }}" ;; - accelerator_count) VALUE="${{ inputs.accelerator_count }}" ;; region) VALUE="${{ inputs.region }}" ;; - precision) VALUE="${{ inputs.precision }}" ;; - max_context_length) VALUE="${{ inputs.max_context_length }}" ;; - deployment_shape) VALUE="${{ inputs.deployment_shape }}" ;; + extra_args) VALUE="${{ inputs.extra_args }}" ;; esac if [ -n "${VALUE}" ]; then echo "| \`${INPUT_NAME}\` | \`${VALUE}\` |" >> "$GITHUB_STEP_SUMMARY" From 974562e320e5f4fddee0a33adb9da03eb596ee2d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Apr 2026 03:32:12 +0000 Subject: [PATCH 3/4] Use firectl-admin deployment clone for native cloning Replace the manual get-then-create approach with firectl-admin's native 'deployment clone' command, which handles all config replication internally. Changes: - Install firectl-admin from GCS (firectl-admin/stable/linux-amd64.gz) - Authenticate via 'firectl-admin set-api-key' with FIREWORKS_API_KEY - Use 'firectl-admin deployment clone' with --wait for health check - Pass full resource name (accounts/{src}/deployments/{id}) to support cross-account cloning via -a {target_account} - Expose native clone flags: --deployment-id, --description, --min-replica-count, --max-replica-count, --region, --image-tag, --image-tag-reason, plus extra_args for any other flags - Remove all manual config extraction / payload building logic Co-authored-by: Chuanying --- .github/workflows/clone-deployment.yaml | 217 +++++++++--------------- 1 file changed, 77 insertions(+), 140 deletions(-) diff --git a/.github/workflows/clone-deployment.yaml b/.github/workflows/clone-deployment.yaml index d547637..222fc98 100644 --- a/.github/workflows/clone-deployment.yaml +++ b/.github/workflows/clone-deployment.yaml @@ -16,11 +16,11 @@ on: required: false type: string deployment_id: - description: "ID for the new deployment (auto-generated if empty)" + description: "ID for the cloned deployment (auto-generated if empty)" required: false type: string - display_name: - description: "Display name for the cloned deployment" + description: + description: "Description for the cloned deployment" required: false type: string min_replica_count: @@ -31,16 +31,20 @@ on: description: "Maximum replica count override" required: false type: string - accelerator_type: - description: "Accelerator type override (e.g. NVIDIA_H100_80GB)" + region: + description: "Region override (e.g. us-iowa-1, us, global)" required: false type: string - region: - description: "Placement region override (e.g. us-iowa-1)" + image_tag: + description: "Serving image tag override" + required: false + type: string + image_tag_reason: + description: "Reason for setting a specific image tag" required: false type: string extra_args: - description: "Additional flags passed verbatim to the create/clone command" + description: "Additional flags passed verbatim to firectl-admin deployment clone" required: false type: string wait_timeout: @@ -48,11 +52,6 @@ on: required: false type: string default: "30m" - firectl_version: - description: "firectl download channel (default: stable)" - required: false - type: string - default: "stable" workflow_call: inputs: @@ -69,11 +68,11 @@ on: required: false type: string deployment_id: - description: "ID for the new deployment (auto-generated if empty)" + description: "ID for the cloned deployment (auto-generated if empty)" required: false type: string - display_name: - description: "Display name for the cloned deployment" + description: + description: "Description for the cloned deployment" required: false type: string min_replica_count: @@ -84,16 +83,20 @@ on: description: "Maximum replica count override" required: false type: string - accelerator_type: - description: "Accelerator type override (e.g. NVIDIA_H100_80GB)" + region: + description: "Region override (e.g. us-iowa-1, us, global)" required: false type: string - region: - description: "Placement region override (e.g. us-iowa-1)" + image_tag: + description: "Serving image tag override" + required: false + type: string + image_tag_reason: + description: "Reason for setting a specific image tag" required: false type: string extra_args: - description: "Additional flags passed verbatim to the create/clone command" + description: "Additional flags passed verbatim to firectl-admin deployment clone" required: false type: string wait_timeout: @@ -101,11 +104,6 @@ on: required: false type: string default: "30m" - firectl_version: - description: "firectl download channel (default: stable)" - required: false - type: string - default: "stable" secrets: FIREWORKS_API_KEY: description: "Fireworks API key with permissions on source and target accounts" @@ -130,9 +128,6 @@ jobs: deployment_name: ${{ steps.clone.outputs.deployment_name }} deployment_state: ${{ steps.wait.outputs.deployment_state }} - env: - FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} - steps: - name: Validate inputs run: | @@ -144,24 +139,24 @@ jobs: echo "::error::source_account is required" exit 1 fi + + - name: Install firectl-admin + run: | + wget -qO firectl-admin.gz \ + "https://storage.googleapis.com/fireworks-public/firectl-admin/stable/linux-amd64.gz" + gunzip firectl-admin.gz + sudo install -o root -g root -m 0755 firectl-admin /usr/local/bin/firectl-admin + echo "Installed: $(firectl-admin version)" + + - name: Authenticate firectl-admin + env: + FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} + run: | if [ -z "${FIREWORKS_API_KEY}" ]; then echo "::error::FIREWORKS_API_KEY secret is not set" exit 1 fi - - - name: Install firectl - id: install - run: | - CHANNEL="${{ inputs.firectl_version || 'stable' }}" - URL="https://storage.googleapis.com/fireworks-public/firectl/${CHANNEL}/linux-amd64.gz" - - echo "Downloading firectl from ${URL} ..." - wget -qO firectl.gz "${URL}" - gunzip firectl.gz - sudo install -o root -g root -m 0755 firectl /usr/local/bin/firectl - echo "Installed firectl $(firectl version)" - - firectl set-api-key "${FIREWORKS_API_KEY}" + firectl-admin set-api-key "${FIREWORKS_API_KEY}" - name: Resolve accounts id: accounts @@ -176,20 +171,12 @@ jobs: echo "Source account: ${SOURCE_ACCOUNT}" echo "Target account: ${TARGET_ACCOUNT}" - - name: Fetch source deployment - id: fetch + - name: Show source deployment run: | SOURCE_ACCOUNT="${{ steps.accounts.outputs.source }}" SOURCE_DEPLOYMENT="${{ inputs.source_deployment }}" - - echo "Fetching: accounts/${SOURCE_ACCOUNT}/deployments/${SOURCE_DEPLOYMENT}" - - firectl -a "${SOURCE_ACCOUNT}" deployment get "${SOURCE_DEPLOYMENT}" -o json \ - > /tmp/source_deployment.json - - echo "Source deployment:" - jq '{name, state, baseModel, acceleratorType, acceleratorCount, minReplicaCount, maxReplicaCount, precision, placement, deploymentShape}' \ - /tmp/source_deployment.json + echo "Source: accounts/${SOURCE_ACCOUNT}/deployments/${SOURCE_DEPLOYMENT}" + firectl-admin -a "${SOURCE_ACCOUNT}" deployment get "${SOURCE_DEPLOYMENT}" - name: Clone deployment id: clone @@ -197,118 +184,68 @@ jobs: SOURCE_ACCOUNT="${{ steps.accounts.outputs.source }}" TARGET_ACCOUNT="${{ steps.accounts.outputs.target }}" SOURCE_DEPLOYMENT="${{ inputs.source_deployment }}" - SOURCE_CONFIG="/tmp/source_deployment.json" - BASE_MODEL=$(jq -r '.baseModel' "${SOURCE_CONFIG}") + # Build the full resource name so clone works across accounts + SOURCE_REF="accounts/${SOURCE_ACCOUNT}/deployments/${SOURCE_DEPLOYMENT}" - # Assemble flags — carry source config, let overrides win FLAGS="" - DEPLOYMENT_ID="${{ inputs.deployment_id }}" - if [ -n "${DEPLOYMENT_ID}" ]; then - FLAGS="${FLAGS} --deployment-id=${DEPLOYMENT_ID}" - fi - - DISPLAY_NAME="${{ inputs.display_name }}" - if [ -n "${DISPLAY_NAME}" ]; then - FLAGS="${FLAGS} --display-name=${DISPLAY_NAME}" + if [ -n "${{ inputs.deployment_id }}" ]; then + FLAGS="${FLAGS} --deployment-id=${{ inputs.deployment_id }}" fi - # Replica counts - MIN_REPLICAS="${{ inputs.min_replica_count }}" - if [ -z "${MIN_REPLICAS}" ]; then - MIN_REPLICAS=$(jq -r '.minReplicaCount // empty' "${SOURCE_CONFIG}") - fi - if [ -n "${MIN_REPLICAS}" ]; then - FLAGS="${FLAGS} --min-replica-count=${MIN_REPLICAS}" + if [ -n "${{ inputs.description }}" ]; then + FLAGS="${FLAGS} --description=${{ inputs.description }}" fi - MAX_REPLICAS="${{ inputs.max_replica_count }}" - if [ -z "${MAX_REPLICAS}" ]; then - MAX_REPLICAS=$(jq -r '.maxReplicaCount // empty' "${SOURCE_CONFIG}") - fi - if [ -n "${MAX_REPLICAS}" ]; then - FLAGS="${FLAGS} --max-replica-count=${MAX_REPLICAS}" - fi - - # Accelerator - ACCEL_TYPE="${{ inputs.accelerator_type }}" - if [ -z "${ACCEL_TYPE}" ]; then - ACCEL_TYPE=$(jq -r '.acceleratorType // empty' "${SOURCE_CONFIG}") - fi - if [ -n "${ACCEL_TYPE}" ] && [ "${ACCEL_TYPE}" != "ACCELERATOR_TYPE_UNSPECIFIED" ]; then - FLAGS="${FLAGS} --accelerator-type=${ACCEL_TYPE}" + if [ -n "${{ inputs.min_replica_count }}" ]; then + FLAGS="${FLAGS} --min-replica-count=${{ inputs.min_replica_count }}" fi - ACCEL_COUNT=$(jq -r '.acceleratorCount // empty' "${SOURCE_CONFIG}") - if [ -n "${ACCEL_COUNT}" ] && [ "${ACCEL_COUNT}" != "0" ]; then - FLAGS="${FLAGS} --accelerator-count=${ACCEL_COUNT}" + if [ -n "${{ inputs.max_replica_count }}" ]; then + FLAGS="${FLAGS} --max-replica-count=${{ inputs.max_replica_count }}" fi - # Region - REGION="${{ inputs.region }}" - if [ -n "${REGION}" ]; then - FLAGS="${FLAGS} --region=${REGION}" + if [ -n "${{ inputs.region }}" ]; then + FLAGS="${FLAGS} --region=${{ inputs.region }}" fi - # Precision - SRC_PRECISION=$(jq -r '.precision // empty' "${SOURCE_CONFIG}") - if [ -n "${SRC_PRECISION}" ] && [ "${SRC_PRECISION}" != "PRECISION_UNSPECIFIED" ]; then - FLAGS="${FLAGS} --precision=${SRC_PRECISION}" + if [ -n "${{ inputs.image_tag }}" ]; then + FLAGS="${FLAGS} --image-tag=${{ inputs.image_tag }}" fi - # Deployment shape - SRC_SHAPE=$(jq -r '.deploymentShape // empty' "${SOURCE_CONFIG}") - if [ -n "${SRC_SHAPE}" ]; then - FLAGS="${FLAGS} --deployment-shape=${SRC_SHAPE}" + if [ -n "${{ inputs.image_tag_reason }}" ]; then + FLAGS="${FLAGS} --image-tag-reason=${{ inputs.image_tag_reason }}" fi - # Context length - SRC_CTX=$(jq -r '.maxContextLength // empty' "${SOURCE_CONFIG}") - if [ -n "${SRC_CTX}" ] && [ "${SRC_CTX}" != "0" ]; then - FLAGS="${FLAGS} --max-context-length=${SRC_CTX}" - fi - - # Addons - SRC_ADDONS=$(jq -r '.enableAddons // empty' "${SOURCE_CONFIG}") - if [ "${SRC_ADDONS}" = "true" ]; then - FLAGS="${FLAGS} --enable-addons" - fi - - # Wait for the deployment to come up healthy FLAGS="${FLAGS} --wait --wait-timeout=${{ inputs.wait_timeout || '30m' }}" - # Extra flags from the caller - EXTRA_ARGS="${{ inputs.extra_args }}" - if [ -n "${EXTRA_ARGS}" ]; then - FLAGS="${FLAGS} ${EXTRA_ARGS}" + if [ -n "${{ inputs.extra_args }}" ]; then + FLAGS="${FLAGS} ${{ inputs.extra_args }}" fi - echo "=== Clone summary ===" - echo "Source : accounts/${SOURCE_ACCOUNT}/deployments/${SOURCE_DEPLOYMENT}" - echo "Target : account=${TARGET_ACCOUNT}" - echo "Model : ${BASE_MODEL}" - echo "Flags : ${FLAGS}" - echo "=====================" + echo "Cloning ${SOURCE_REF} into account ${TARGET_ACCOUNT}" + echo "Flags: ${FLAGS}" + # -a sets the target account; the source ref includes the source account # shellcheck disable=SC2086 - OUTPUT=$(firectl -a "${TARGET_ACCOUNT}" deployment create \ - "${BASE_MODEL}" \ + OUTPUT=$(firectl-admin -a "${TARGET_ACCOUNT}" deployment clone \ + "${SOURCE_REF}" \ ${FLAGS} 2>&1) || { - echo "::error::firectl deployment create failed" + echo "::error::firectl-admin deployment clone failed" echo "${OUTPUT}" exit 1 } echo "${OUTPUT}" - # Extract deployment name from the output (supports both text and json) - DEPLOYMENT_NAME=$(echo "${OUTPUT}" | jq -r '.name // empty' 2>/dev/null) + # Parse deployment name from text output (e.g. "Name: accounts/.../deployments/xxx") + DEPLOYMENT_NAME=$(echo "${OUTPUT}" | grep -oP '(?<=Name:\s{1,10})accounts/\S+' | head -1 || true) if [ -z "${DEPLOYMENT_NAME}" ]; then - DEPLOYMENT_NAME=$(echo "${OUTPUT}" | grep -oP '(?<=Name:\s)\S+' || true) + DEPLOYMENT_NAME=$(echo "${OUTPUT}" | jq -r '.name // empty' 2>/dev/null || true) fi - NEW_ID=$(echo "${DEPLOYMENT_NAME}" | awk -F'/' '{print $NF}') + NEW_ID=$(echo "${DEPLOYMENT_NAME}" | awk -F'/' '{print $NF}') if [ -z "${NEW_ID}" ]; then echo "::error::Could not extract deployment ID from output" exit 1 @@ -316,7 +253,7 @@ jobs: echo "deployment_id=${NEW_ID}" >> "$GITHUB_OUTPUT" echo "deployment_name=${DEPLOYMENT_NAME}" >> "$GITHUB_OUTPUT" - echo "Created deployment: ${DEPLOYMENT_NAME}" + echo "Cloned deployment: ${DEPLOYMENT_NAME}" - name: Verify deployment health id: wait @@ -330,17 +267,17 @@ jobs: exit 1 fi - STATE=$(firectl -a "${TARGET_ACCOUNT}" deployment get "${DEPLOYMENT_ID}" -o json \ - | jq -r '.state') + echo "Verifying deployment ${DEPLOYMENT_ID} in account ${TARGET_ACCOUNT}..." + firectl-admin -a "${TARGET_ACCOUNT}" deployment get "${DEPLOYMENT_ID}" + STATE=$(firectl-admin -a "${TARGET_ACCOUNT}" deployment get "${DEPLOYMENT_ID}" -o json \ + | jq -r '.state') echo "deployment_state=${STATE}" >> "$GITHUB_OUTPUT" - echo "Deployment state: ${STATE}" if [ "${STATE}" != "READY" ]; then echo "::warning::Deployment not READY (state: ${STATE})" exit 1 fi - echo "Deployment is healthy!" - name: Summary @@ -377,16 +314,16 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" HAS_OVERRIDES=false - for INPUT_NAME in target_account deployment_id display_name min_replica_count max_replica_count accelerator_type region extra_args; do + for INPUT_NAME in target_account deployment_id description min_replica_count max_replica_count region image_tag extra_args; do VALUE="" case "${INPUT_NAME}" in target_account) VALUE="${{ inputs.target_account }}" ;; deployment_id) VALUE="${{ inputs.deployment_id }}" ;; - display_name) VALUE="${{ inputs.display_name }}" ;; + description) VALUE="${{ inputs.description }}" ;; min_replica_count) VALUE="${{ inputs.min_replica_count }}" ;; max_replica_count) VALUE="${{ inputs.max_replica_count }}" ;; - accelerator_type) VALUE="${{ inputs.accelerator_type }}" ;; region) VALUE="${{ inputs.region }}" ;; + image_tag) VALUE="${{ inputs.image_tag }}" ;; extra_args) VALUE="${{ inputs.extra_args }}" ;; esac if [ -n "${VALUE}" ]; then From 27778c64bfefff54eb7e6d2a2ac0f46ee1ab18ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 3 Apr 2026 03:52:35 +0000 Subject: [PATCH 4/4] Simplify inputs: keep only target_account, deployment_id, and extra_args Remove individual optional params (description, min/max_replica_count, region, image_tag, image_tag_reason, wait_timeout). Users pass any firectl-admin flags they need via the catch-all extra_args input. Co-authored-by: Chuanying --- .github/workflows/clone-deployment.yaml | 126 +++--------------------- 1 file changed, 12 insertions(+), 114 deletions(-) diff --git a/.github/workflows/clone-deployment.yaml b/.github/workflows/clone-deployment.yaml index 222fc98..90b1eda 100644 --- a/.github/workflows/clone-deployment.yaml +++ b/.github/workflows/clone-deployment.yaml @@ -19,39 +19,10 @@ on: description: "ID for the cloned deployment (auto-generated if empty)" required: false type: string - description: - description: "Description for the cloned deployment" - required: false - type: string - min_replica_count: - description: "Minimum replica count override" - required: false - type: string - max_replica_count: - description: "Maximum replica count override" - required: false - type: string - region: - description: "Region override (e.g. us-iowa-1, us, global)" - required: false - type: string - image_tag: - description: "Serving image tag override" - required: false - type: string - image_tag_reason: - description: "Reason for setting a specific image tag" - required: false - type: string extra_args: - description: "Additional flags passed verbatim to firectl-admin deployment clone" - required: false - type: string - wait_timeout: - description: "How long to wait for healthy state (default: 30m)" + description: "Extra flags for firectl-admin deployment clone (e.g. --region=us-iowa-1 --min-replica-count=1)" required: false type: string - default: "30m" workflow_call: inputs: @@ -71,39 +42,10 @@ on: description: "ID for the cloned deployment (auto-generated if empty)" required: false type: string - description: - description: "Description for the cloned deployment" - required: false - type: string - min_replica_count: - description: "Minimum replica count override" - required: false - type: string - max_replica_count: - description: "Maximum replica count override" - required: false - type: string - region: - description: "Region override (e.g. us-iowa-1, us, global)" - required: false - type: string - image_tag: - description: "Serving image tag override" - required: false - type: string - image_tag_reason: - description: "Reason for setting a specific image tag" - required: false - type: string extra_args: - description: "Additional flags passed verbatim to firectl-admin deployment clone" - required: false - type: string - wait_timeout: - description: "How long to wait for healthy state (default: 30m)" + description: "Extra flags for firectl-admin deployment clone (e.g. --region=us-iowa-1 --min-replica-count=1)" required: false type: string - default: "30m" secrets: FIREWORKS_API_KEY: description: "Fireworks API key with permissions on source and target accounts" @@ -184,42 +126,14 @@ jobs: SOURCE_ACCOUNT="${{ steps.accounts.outputs.source }}" TARGET_ACCOUNT="${{ steps.accounts.outputs.target }}" SOURCE_DEPLOYMENT="${{ inputs.source_deployment }}" - - # Build the full resource name so clone works across accounts SOURCE_REF="accounts/${SOURCE_ACCOUNT}/deployments/${SOURCE_DEPLOYMENT}" - FLAGS="" + FLAGS="--wait" if [ -n "${{ inputs.deployment_id }}" ]; then FLAGS="${FLAGS} --deployment-id=${{ inputs.deployment_id }}" fi - if [ -n "${{ inputs.description }}" ]; then - FLAGS="${FLAGS} --description=${{ inputs.description }}" - fi - - if [ -n "${{ inputs.min_replica_count }}" ]; then - FLAGS="${FLAGS} --min-replica-count=${{ inputs.min_replica_count }}" - fi - - if [ -n "${{ inputs.max_replica_count }}" ]; then - FLAGS="${FLAGS} --max-replica-count=${{ inputs.max_replica_count }}" - fi - - if [ -n "${{ inputs.region }}" ]; then - FLAGS="${FLAGS} --region=${{ inputs.region }}" - fi - - if [ -n "${{ inputs.image_tag }}" ]; then - FLAGS="${FLAGS} --image-tag=${{ inputs.image_tag }}" - fi - - if [ -n "${{ inputs.image_tag_reason }}" ]; then - FLAGS="${FLAGS} --image-tag-reason=${{ inputs.image_tag_reason }}" - fi - - FLAGS="${FLAGS} --wait --wait-timeout=${{ inputs.wait_timeout || '30m' }}" - if [ -n "${{ inputs.extra_args }}" ]; then FLAGS="${FLAGS} ${{ inputs.extra_args }}" fi @@ -227,7 +141,6 @@ jobs: echo "Cloning ${SOURCE_REF} into account ${TARGET_ACCOUNT}" echo "Flags: ${FLAGS}" - # -a sets the target account; the source ref includes the source account # shellcheck disable=SC2086 OUTPUT=$(firectl-admin -a "${TARGET_ACCOUNT}" deployment clone \ "${SOURCE_REF}" \ @@ -239,7 +152,6 @@ jobs: echo "${OUTPUT}" - # Parse deployment name from text output (e.g. "Name: accounts/.../deployments/xxx") DEPLOYMENT_NAME=$(echo "${OUTPUT}" | grep -oP '(?<=Name:\s{1,10})accounts/\S+' | head -1 || true) if [ -z "${DEPLOYMENT_NAME}" ]; then DEPLOYMENT_NAME=$(echo "${OUTPUT}" | jq -r '.name // empty' 2>/dev/null || true) @@ -309,29 +221,15 @@ jobs: echo "| **Clone ID** | \`${DEPLOYMENT_ID}\` |" echo "| **Target Account** | \`${TARGET_ACCOUNT}\` |" echo "| **Final State** | \`${DEPLOYMENT_STATE}\` |" - echo "" - echo "### Overrides Applied" } >> "$GITHUB_STEP_SUMMARY" - HAS_OVERRIDES=false - for INPUT_NAME in target_account deployment_id description min_replica_count max_replica_count region image_tag extra_args; do - VALUE="" - case "${INPUT_NAME}" in - target_account) VALUE="${{ inputs.target_account }}" ;; - deployment_id) VALUE="${{ inputs.deployment_id }}" ;; - description) VALUE="${{ inputs.description }}" ;; - min_replica_count) VALUE="${{ inputs.min_replica_count }}" ;; - max_replica_count) VALUE="${{ inputs.max_replica_count }}" ;; - region) VALUE="${{ inputs.region }}" ;; - image_tag) VALUE="${{ inputs.image_tag }}" ;; - extra_args) VALUE="${{ inputs.extra_args }}" ;; - esac - if [ -n "${VALUE}" ]; then - echo "| \`${INPUT_NAME}\` | \`${VALUE}\` |" >> "$GITHUB_STEP_SUMMARY" - HAS_OVERRIDES=true - fi - done - - if [ "${HAS_OVERRIDES}" = "false" ]; then - echo "_No overrides — exact clone of source deployment._" >> "$GITHUB_STEP_SUMMARY" + EXTRA_ARGS="${{ inputs.extra_args }}" + if [ -n "${EXTRA_ARGS}" ]; then + { + echo "" + echo "### Extra Args" + echo "\`\`\`" + echo "${EXTRA_ARGS}" + echo "\`\`\`" + } >> "$GITHUB_STEP_SUMMARY" fi