From 63505d5fa7219017e4153cfd381875a15e85fb13 Mon Sep 17 00:00:00 2001 From: Phil Austin Date: Thu, 13 Nov 2025 13:21:34 -0800 Subject: [PATCH 1/2] Update docs --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 2225d99..f10e579 100644 --- a/README.md +++ b/README.md @@ -297,9 +297,7 @@ This is a beta release and we'd love your feedback! For comprehensive documentation: -- **[API Reference](https://api.versioner.io/docs)** - Interactive OpenAPI documentation -- **[Web Dashboard](https://app.versioner.io)** - View your deployment history -- **[CLI Integration Guide](https://github.com/versioner-io/versioner-docs/blob/main/features/cli-integration.md)** - Complete feature documentation and roadmap +- See [Versioner docs](https://docs.versioner.io) ### Repository-Specific Docs From 2012571fce8ce8e38879ff43e38334a73bd0dda1 Mon Sep 17 00:00:00 2001 From: Phil Austin Date: Fri, 21 Nov 2025 13:49:32 -0800 Subject: [PATCH 2/2] feat: add preflight checks integration - Add --skip-preflight-checks flag to deployment command - Implement comprehensive error handling for 409/423/428 responses - Add specific handlers for FLOW_VIOLATION, INSUFFICIENT_SOAK_TIME, QUALITY_APPROVAL_REQUIRED, APPROVAL_REQUIRED - Add generic fallback handler for future error codes with full details JSON - Implement exit code 5 for preflight failures (0=success, 1=general, 4=API, 5=preflight) - Update command help text with preflight checks documentation and exit codes - Add comprehensive README section with examples, error types, and CI/CD integration patterns - Match error output format from GitHub Action for consistency Closes preflight checks client integration task --- README.md | 197 +++++++++++++++++++++++++++++++ internal/api/client.go | 50 +++++++- internal/api/deployment.go | 41 ++++--- internal/cmd/track_deployment.go | 138 ++++++++++++++++++++-- 4 files changed, 395 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f10e579..1f37419 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,203 @@ Both build and deployment events support these statuses: Aliases like `success`, `in_progress`, `cancelled`, etc. are automatically normalized. +## Preflight Checks + +When tracking a deployment with `--status=started`, the API automatically runs **preflight checks** to validate the deployment before it proceeds. These checks help enforce deployment policies and prevent common issues. + +### What Gets Checked + +Preflight checks validate: + +1. **Concurrent Deployments** - Prevents multiple simultaneous deployments to the same environment +2. **No-Deploy Windows** - Blocks deployments during scheduled blackout periods (e.g., Friday afternoons) +3. **Flow Requirements** - Ensures versions are deployed to prerequisite environments first (e.g., staging before production) +4. **Soak Time** - Requires versions to run in an environment for a minimum duration before promoting +5. **Quality Approvals** - Requires QA/security sign-off from prerequisite environments +6. **Release Approvals** - Requires manager/lead approval before deploying to sensitive environments + +### Exit Codes + +The CLI uses specific exit codes to indicate different failure types: + +- **0** - Success (deployment allowed) +- **1** - General error (network issues, invalid arguments) +- **4** - API error (authentication, validation) +- **5** - Preflight check failure (deployment blocked) + +### Error Types and Responses + +#### 409 - Deployment Conflict + +Another deployment is already in progress: + +``` +⚠️ Deployment Conflict + +Another deployment to production is already in progress +Another deployment is in progress. Please wait and retry. +``` + +**Action:** Wait for the current deployment to complete, then retry. + +#### 423 - Schedule Block + +Deployment blocked by a no-deploy window: + +``` +🔒 Deployment Blocked by Schedule + +Rule: Production Freeze - Friday Afternoons +Deployment blocked by no-deploy window + +Retry after: 2025-11-21T18:00:00-08:00 + +To skip checks (emergency only), add: + --skip-preflight-checks +``` + +**Action:** Wait until the blackout window ends, or use `--skip-preflight-checks` for emergencies. + +#### 428 - Precondition Failed + +Missing required prerequisites: + +**Flow Violation:** +``` +❌ Deployment Precondition Failed + +Error: FLOW_VIOLATION +Rule: Staging Required Before Production +Version must be deployed to staging first + +Deploy to required environments first, then retry. +``` + +**Insufficient Soak Time:** +``` +❌ Deployment Precondition Failed + +Error: INSUFFICIENT_SOAK_TIME +Rule: 24hr Staging Soak +Version must soak in staging for at least 24 hours + +Retry after: 2025-11-22T10:00:00Z + +Wait for soak time to complete, then retry. +``` + +**Approval Required:** +``` +❌ Deployment Precondition Failed + +Error: APPROVAL_REQUIRED +Rule: Prod Needs 2 Approvals +production deployment requires 2 release approval(s) + +Approval required before deployment can proceed. +Obtain approval via Versioner UI, then retry. +``` + +### Emergency Override + +For production incidents or hotfixes, you can skip preflight checks: + +```bash +versioner track deployment \ + --product=api-service \ + --environment=production \ + --version=1.2.3-hotfix \ + --status=started \ + --skip-preflight-checks +``` + +**⚠️ Warning:** Only use `--skip-preflight-checks` for: +- Production incidents requiring immediate fixes +- Approved emergency changes +- When deployment rules are temporarily misconfigured + +Always document why checks were skipped in your deployment logs. + +### Full Deployment Workflow + +```bash +# 1. Start deployment (triggers preflight checks) +versioner track deployment \ + --product=api-service \ + --environment=production \ + --version=1.2.3 \ + --status=started + +# 2. If checks pass (exit code 0), proceed with actual deployment +if [ $? -eq 0 ]; then + # Your deployment commands here + kubectl apply -f deployment.yaml + + # 3. Report completion + versioner track deployment \ + --product=api-service \ + --environment=production \ + --version=1.2.3 \ + --status=completed +fi +``` + +### CI/CD Integration + +**GitHub Actions:** +```yaml +- name: Start Deployment + id: preflight + run: | + versioner track deployment \ + --product=api-service \ + --environment=production \ + --version=${{ github.sha }} \ + --status=started + env: + VERSIONER_API_KEY: ${{ secrets.VERSIONER_API_KEY }} + continue-on-error: true + +- name: Deploy Application + if: steps.preflight.outcome == 'success' + run: | + # Your deployment commands + kubectl apply -f k8s/ + +- name: Report Completion + if: steps.preflight.outcome == 'success' + run: | + versioner track deployment \ + --product=api-service \ + --environment=production \ + --version=${{ github.sha }} \ + --status=completed + env: + VERSIONER_API_KEY: ${{ secrets.VERSIONER_API_KEY }} +``` + +**GitLab CI:** +```yaml +deploy:production: + script: + # Preflight check + - | + versioner track deployment \ + --product=api \ + --environment=production \ + --version=$CI_COMMIT_SHA \ + --status=started + # Deploy if checks pass + - kubectl apply -f k8s/ + # Report completion + - | + versioner track deployment \ + --product=api \ + --environment=production \ + --version=$CI_COMMIT_SHA \ + --status=completed +``` + ## CI/CD Auto-Detection The CLI automatically detects your CI/CD environment and extracts relevant metadata. Supported systems: diff --git a/internal/api/client.go b/internal/api/client.go index 9047f2b..8a8593b 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -135,13 +135,23 @@ func handleResponse(resp *http.Response, result interface{}) error { } // Error response - var apiError APIError - if err := json.Unmarshal(body, &apiError); err != nil { + var errorResponse struct { + Detail interface{} `json:"detail"` + } + if err := json.Unmarshal(body, &errorResponse); err != nil { // Fallback if error response doesn't match expected format - return fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(body)) + return &APIError{ + StatusCode: resp.StatusCode, + Detail: string(body), + } + } + + apiError := &APIError{ + StatusCode: resp.StatusCode, + Detail: errorResponse.Detail, } - return &apiError + return apiError } // APIError represents an error response from the API @@ -164,3 +174,35 @@ func (e *APIError) Error() string { return fmt.Sprintf("API error: %v", detail) } } + +// IsPreflightError checks if this is a preflight check failure (409, 423, 428) +func (e *APIError) IsPreflightError() bool { + return e.StatusCode == 409 || e.StatusCode == 423 || e.StatusCode == 428 +} + +// GetPreflightDetails extracts structured preflight error details +func (e *APIError) GetPreflightDetails() (errorType, message, code, retryAfter string, details map[string]interface{}, ok bool) { + detailMap, ok := e.Detail.(map[string]interface{}) + if !ok { + return + } + + if errType, exists := detailMap["error"].(string); exists { + errorType = errType + } + if msg, exists := detailMap["message"].(string); exists { + message = msg + } + if c, exists := detailMap["code"].(string); exists { + code = c + } + if retry, exists := detailMap["retry_after"].(string); exists { + retryAfter = retry + } + if det, exists := detailMap["details"].(map[string]interface{}); exists { + details = det + } + + ok = true + return +} diff --git a/internal/api/deployment.go b/internal/api/deployment.go index 1b7d40c..c5fd835 100644 --- a/internal/api/deployment.go +++ b/internal/api/deployment.go @@ -4,21 +4,22 @@ import "time" // DeploymentEventCreate represents the request payload for creating a deployment event type DeploymentEventCreate struct { - ProductName string `json:"product_name"` - Version string `json:"version"` - EnvironmentName string `json:"environment_name"` - Status string `json:"status"` - SourceSystem string `json:"source_system,omitempty"` - BuildNumber string `json:"build_number,omitempty"` - SCMSha string `json:"scm_sha,omitempty"` - SCMRepository string `json:"scm_repository,omitempty"` - BuildURL string `json:"build_url,omitempty"` - InvokeID string `json:"invoke_id,omitempty"` - DeployedBy string `json:"deployed_by,omitempty"` - DeployedByEmail string `json:"deployed_by_email,omitempty"` - DeployedByName string `json:"deployed_by_name,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - ExtraMetadata map[string]interface{} `json:"extra_metadata,omitempty"` + ProductName string `json:"product_name"` + Version string `json:"version"` + EnvironmentName string `json:"environment_name"` + Status string `json:"status"` + SourceSystem string `json:"source_system,omitempty"` + BuildNumber string `json:"build_number,omitempty"` + SCMSha string `json:"scm_sha,omitempty"` + SCMRepository string `json:"scm_repository,omitempty"` + BuildURL string `json:"build_url,omitempty"` + InvokeID string `json:"invoke_id,omitempty"` + DeployedBy string `json:"deployed_by,omitempty"` + DeployedByEmail string `json:"deployed_by_email,omitempty"` + DeployedByName string `json:"deployed_by_name,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + SkipPreflightChecks bool `json:"skip_preflight_checks,omitempty"` + ExtraMetadata map[string]interface{} `json:"extra_metadata,omitempty"` } // DeploymentResponse represents the response from creating a deployment event @@ -31,6 +32,16 @@ type DeploymentResponse struct { DeployedAt *time.Time `json:"deployed_at,omitempty"` } +// PreflightError represents a preflight check failure with detailed information +type PreflightError struct { + StatusCode int + Error string `json:"error"` + Message string `json:"message"` + Code string `json:"code"` + Details map[string]interface{} `json:"details"` + RetryAfter string `json:"retry_after,omitempty"` +} + // CreateDeploymentEvent sends a deployment event to the API func (c *Client) CreateDeploymentEvent(event *DeploymentEventCreate) (*DeploymentResponse, error) { resp, err := c.doRequest("POST", "/deployment-events/", event) diff --git a/internal/cmd/track_deployment.go b/internal/cmd/track_deployment.go index e4fd5f2..0cab014 100644 --- a/internal/cmd/track_deployment.go +++ b/internal/cmd/track_deployment.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "fmt" "os" "time" @@ -16,22 +17,39 @@ var deploymentCmd = &cobra.Command{ Use: "deployment", Short: "Track a deployment event", Long: `Track a deployment lifecycle event with the Versioner API. -This command sends deployment information to track deployments to environments.`, - Example: ` # Track a successful deployment +This command sends deployment information to track deployments to environments. + +When status=started, the API automatically runs preflight checks to validate: +- No concurrent deployments (409 Conflict) +- No-deploy windows/schedules (423 Locked) +- Flow requirements, soak time, approvals (428 Precondition Required) + +Exit codes: + 0 - Success + 1 - General error (network, invalid arguments) + 4 - API error (validation, authentication) + 5 - Preflight check failure (deployment blocked)`, + Example: ` # Track a deployment start (triggers preflight checks) versioner track deployment \ --product=api-service \ --environment=production \ --version=1.2.3 \ - --status=success + --status=started - # Track a deployment with additional metadata + # Track deployment completion versioner track deployment \ --product=api-service \ - --environment=staging \ + --environment=production \ --version=1.2.3 \ - --status=success \ - --scm-sha=abc123 \ - --build-number=456`, + --status=completed + + # Emergency deployment (skip preflight checks) + versioner track deployment \ + --product=api-service \ + --environment=production \ + --version=1.2.3-hotfix \ + --status=started \ + --skip-preflight-checks`, RunE: runDeploymentTrack, } @@ -56,6 +74,7 @@ func init() { deploymentCmd.Flags().String("deployed-by-name", "", "User display name") deploymentCmd.Flags().String("completed-at", "", "Deployment completion timestamp (ISO 8601 format)") deploymentCmd.Flags().String("extra-metadata", "", "Additional metadata as JSON object (max 100KB)") + deploymentCmd.Flags().Bool("skip-preflight-checks", false, "Skip preflight checks (emergency use only)") // Bind flags to viper _ = viper.BindPFlag("product", deploymentCmd.Flags().Lookup("product")) @@ -185,6 +204,10 @@ func runDeploymentTrack(cmd *cobra.Command, args []string) error { // Merge metadata (user values take precedence) event.ExtraMetadata = MergeMetadata(autoMetadata, userMetadata) + // Get skip-preflight-checks flag + skipPreflightChecks, _ := cmd.Flags().GetBool("skip-preflight-checks") + event.SkipPreflightChecks = skipPreflightChecks + if verbose { fmt.Fprintf(os.Stderr, "Tracking deployment event:\n") if detected.System != cicd.SystemUnknown { @@ -211,13 +234,18 @@ func runDeploymentTrack(cmd *cobra.Command, args []string) error { resp, err := client.CreateDeploymentEvent(event) if err != nil { if apiErr, ok := err.(*api.APIError); ok { - // API error - exit code 2 + // Check if this is a preflight check failure + if apiErr.IsPreflightError() { + handlePreflightError(apiErr) + os.Exit(5) // Exit code 5 for preflight failures + } + // Other API error - exit code 4 fmt.Fprintf(os.Stderr, "API error: %s\n", apiErr.Error()) - os.Exit(2) + os.Exit(4) } - // Network or other error - exit code 2 + // Network or other error - exit code 1 fmt.Fprintf(os.Stderr, "Error: %s\n", err.Error()) - os.Exit(2) + os.Exit(1) } // Success @@ -231,3 +259,89 @@ func runDeploymentTrack(cmd *cobra.Command, args []string) error { return nil } + +// handlePreflightError formats and displays preflight check errors +func handlePreflightError(apiErr *api.APIError) { + _, message, code, retryAfter, details, ok := apiErr.GetPreflightDetails() + if !ok { + // Fallback if we can't parse the error structure + fmt.Fprintf(os.Stderr, "❌ Deployment Failed (HTTP %d)\n\n", apiErr.StatusCode) + fmt.Fprintf(os.Stderr, "%s\n", apiErr.Error()) + return + } + + // Get rule name from details if available + ruleName := "" + if details != nil { + if name, exists := details["rule_name"].(string); exists { + ruleName = name + } + } + + // Format output based on status code and error code + switch apiErr.StatusCode { + case 409: + // Deployment Conflict + fmt.Fprintf(os.Stderr, "⚠️ Deployment Conflict\n\n") + fmt.Fprintf(os.Stderr, "%s\n", message) + fmt.Fprintf(os.Stderr, "Another deployment is in progress. Please wait and retry.\n") + + case 423: + // Schedule Block + fmt.Fprintf(os.Stderr, "🔒 Deployment Blocked by Schedule\n\n") + if ruleName != "" { + fmt.Fprintf(os.Stderr, "Rule: %s\n", ruleName) + } + fmt.Fprintf(os.Stderr, "%s\n", message) + if retryAfter != "" { + fmt.Fprintf(os.Stderr, "\nRetry after: %s\n", retryAfter) + } + fmt.Fprintf(os.Stderr, "\nTo skip checks (emergency only), add:\n") + fmt.Fprintf(os.Stderr, " --skip-preflight-checks\n") + + case 428: + // Precondition Failed + fmt.Fprintf(os.Stderr, "❌ Deployment Precondition Failed\n\n") + fmt.Fprintf(os.Stderr, "Error: %s\n", code) + if ruleName != "" { + fmt.Fprintf(os.Stderr, "Rule: %s\n", ruleName) + } + fmt.Fprintf(os.Stderr, "%s\n", message) + + // Specific guidance based on error code + switch code { + case "FLOW_VIOLATION": + fmt.Fprintf(os.Stderr, "\nDeploy to required environments first, then retry.\n") + + case "INSUFFICIENT_SOAK_TIME": + if retryAfter != "" { + fmt.Fprintf(os.Stderr, "\nRetry after: %s\n", retryAfter) + } + fmt.Fprintf(os.Stderr, "\nWait for soak time to complete, then retry.\n") + fmt.Fprintf(os.Stderr, "\nTo skip checks (emergency only), add:\n") + fmt.Fprintf(os.Stderr, " --skip-preflight-checks\n") + + case "QUALITY_APPROVAL_REQUIRED", "APPROVAL_REQUIRED": + fmt.Fprintf(os.Stderr, "\nApproval required before deployment can proceed.\n") + fmt.Fprintf(os.Stderr, "Obtain approval via Versioner UI, then retry.\n") + + default: + // Unknown error code - provide generic guidance + if retryAfter != "" { + fmt.Fprintf(os.Stderr, "\nRetry after: %s\n", retryAfter) + } + fmt.Fprintf(os.Stderr, "\nResolve the issue described above, then retry.\n") + fmt.Fprintf(os.Stderr, "\nTo skip checks (emergency only), add:\n") + fmt.Fprintf(os.Stderr, " --skip-preflight-checks\n") + } + } + + // Always print full details for debugging + if details != nil { + fmt.Fprintf(os.Stderr, "\nDetails:\n") + detailsJSON, err := json.MarshalIndent(details, " ", " ") + if err == nil { + fmt.Fprintf(os.Stderr, " %s\n", string(detailsJSON)) + } + } +}