- Interactive deploy command with 8-step walkthrough: framework → provider → token → SSH → server → inference → tailscale → discord - .env file generation from walkthrough config - DeploymentConfig struct with framework-aware defaults - Inference API client with validation for Venice, OpenRouter, OpenAI, Anthropic - Hetzner Cloud provider: token validation, SSH key listing - DotEnv parser/writer with schema validation - Destroy command with confirmation prompt - Validation subcommand for checking existing .env files - All tests passing, go vet clean
280 lines
8.2 KiB
Go
280 lines
8.2 KiB
Go
// Package validation provides a framework for validating cloud provider
|
|
// configurations and API credentials. It defines structured check results,
|
|
// a Check interface that providers implement, and a Runner that orchestrates
|
|
// checks and produces reports.
|
|
//
|
|
// Usage:
|
|
//
|
|
// runner := validation.NewRunner(provider)
|
|
// report := runner.Run(ctx)
|
|
// fmt.Println(report.Format())
|
|
package validation
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Status represents the outcome of a single validation check.
|
|
type Status string
|
|
|
|
const (
|
|
// Pass means the check succeeded.
|
|
Pass Status = "PASS"
|
|
// Fail means the check failed — configuration is invalid or credentials are bad.
|
|
Fail Status = "FAIL"
|
|
// Warn means the check passed but with a non-blocking issue (e.g. quota near limit).
|
|
Warn Status = "WARN"
|
|
// Skip means the check was not applicable (e.g. no token provided for a secondary provider).
|
|
Skip Status = "SKIP"
|
|
// Error means the check could not complete due to an unexpected error (network, etc.).
|
|
Error Status = "ERROR"
|
|
)
|
|
|
|
// CheckCategory groups related checks for organized output.
|
|
type CheckCategory string
|
|
|
|
const (
|
|
CategoryCredentials CheckCategory = "Credentials"
|
|
CategoryConnectivity CheckCategory = "Connectivity"
|
|
CategorySSH CheckCategory = "SSH Keys"
|
|
CategoryServer CheckCategory = "Server Config"
|
|
CategoryQuota CheckCategory = "Quotas"
|
|
CategoryAccount CheckCategory = "Account"
|
|
)
|
|
|
|
// CheckResult is the outcome of a single validation check.
|
|
type CheckResult struct {
|
|
// Name is a short identifier for the check (e.g. "token-auth", "ssh-keys").
|
|
Name string
|
|
// Category groups this check with related checks.
|
|
Category CheckCategory
|
|
// Status is the outcome.
|
|
Status Status
|
|
// Message is a human-readable description of the result.
|
|
Message string
|
|
// Detail is optional extra info (e.g. SSH key fingerprints found, quota numbers).
|
|
Detail string
|
|
// Duration tracks how long the check took (useful for API call timing).
|
|
Duration time.Duration
|
|
}
|
|
|
|
// Passed returns true if the status is Pass or Warn.
|
|
func (r CheckResult) Passed() bool {
|
|
return r.Status == Pass || r.Status == Warn
|
|
}
|
|
|
|
// Icon returns a terminal-friendly icon for the status.
|
|
func (r CheckResult) Icon() string {
|
|
switch r.Status {
|
|
case Pass:
|
|
return "✓"
|
|
case Fail:
|
|
return "✗"
|
|
case Warn:
|
|
return "!"
|
|
case Skip:
|
|
return "—"
|
|
case Error:
|
|
return "⚠"
|
|
default:
|
|
return "?"
|
|
}
|
|
}
|
|
|
|
// Check is a single validation step. Provider implementations register checks
|
|
// via their Checks() method. Each check should be independent — checks run
|
|
// concurrently and the failure of one should not affect others.
|
|
type Check interface {
|
|
// Name returns a short kebab-case identifier (e.g. "token-format").
|
|
Name() string
|
|
// Category returns the check's grouping category.
|
|
Category() CheckCategory
|
|
// Run executes the check and returns the result.
|
|
Run(ctx context.Context) CheckResult
|
|
}
|
|
|
|
// CheckFunc is a convenience type for creating checks from functions.
|
|
type CheckFunc struct {
|
|
CategoryField CheckCategory
|
|
NameField string
|
|
RunFunc func(ctx context.Context) CheckResult
|
|
}
|
|
|
|
func (c CheckFunc) Name() string { return c.NameField }
|
|
func (c CheckFunc) Category() CheckCategory { return c.CategoryField }
|
|
func (c CheckFunc) Run(ctx context.Context) CheckResult { return c.RunFunc(ctx) }
|
|
|
|
// Report is the aggregate result of all validation checks for a provider.
|
|
type Report struct {
|
|
// Provider is the name of the cloud provider that was validated.
|
|
Provider string
|
|
// Results contains the outcome of each check, in the order they were run.
|
|
Results []CheckResult
|
|
// TotalDuration is the wall-clock time for all checks combined.
|
|
TotalDuration time.Duration
|
|
}
|
|
|
|
// Summary returns pass/fail/warn/skip/error counts.
|
|
func (r *Report) Summary() map[Status]int {
|
|
counts := map[Status]int{
|
|
Pass: 0, Fail: 0, Warn: 0, Skip: 0, Error: 0,
|
|
}
|
|
for _, res := range r.Results {
|
|
counts[res.Status]++
|
|
}
|
|
return counts
|
|
}
|
|
|
|
// HasFailures returns true if any check failed or errored.
|
|
func (r *Report) HasFailures() bool {
|
|
for _, res := range r.Results {
|
|
if res.Status == Fail || res.Status == Error {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AllPassed returns true if every check passed or was skipped.
|
|
func (r *Report) AllPassed() bool {
|
|
return !r.HasFailures()
|
|
}
|
|
|
|
// ByCategory returns results grouped by category, preserving order within each group.
|
|
func (r *Report) ByCategory() map[CheckCategory][]CheckResult {
|
|
out := make(map[CheckCategory][]CheckResult)
|
|
for _, res := range r.Results {
|
|
out[res.Category] = append(out[res.Category], res)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Format produces a human-readable terminal output of the report.
|
|
func (r *Report) Format() string {
|
|
var b strings.Builder
|
|
|
|
summary := r.Summary()
|
|
fmt.Fprintf(&b, "\n Provider Validation: %s\n", r.Provider)
|
|
fmt.Fprintf(&b, " %s\n", strings.Repeat("─", 50))
|
|
|
|
// Group by category
|
|
categories := []CheckCategory{
|
|
CategoryCredentials, CategoryConnectivity, CategorySSH,
|
|
CategoryServer, CategoryQuota, CategoryAccount,
|
|
}
|
|
seen := make(map[CheckCategory]bool)
|
|
for _, cat := range categories {
|
|
results := r.ByCategory()[cat]
|
|
if len(results) == 0 {
|
|
continue
|
|
}
|
|
seen[cat] = true
|
|
fmt.Fprintf(&b, "\n [%s]\n", cat)
|
|
for _, res := range results {
|
|
fmt.Fprintf(&b, " %s %-25s %s\n", res.Icon(), res.Name, res.Message)
|
|
if res.Detail != "" {
|
|
fmt.Fprintf(&b, " %s\n", res.Detail)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Print any results in uncategorized groups
|
|
for _, res := range r.Results {
|
|
if !seen[res.Category] && len(r.ByCategory()[res.Category]) > 0 {
|
|
fmt.Fprintf(&b, "\n [%s]\n", res.Category)
|
|
for _, r2 := range r.ByCategory()[res.Category] {
|
|
fmt.Fprintf(&b, " %s %-25s %s\n", r2.Icon(), r2.Name, r2.Message)
|
|
if r2.Detail != "" {
|
|
fmt.Fprintf(&b, " %s\n", r2.Detail)
|
|
}
|
|
}
|
|
seen[res.Category] = true
|
|
}
|
|
}
|
|
|
|
// Summary line
|
|
fmt.Fprintf(&b, "\n %s\n", strings.Repeat("─", 50))
|
|
fmt.Fprintf(&b, " Total: %d checks in %s", len(r.Results), r.TotalDuration.Round(time.Millisecond))
|
|
fmt.Fprintf(&b, " | ✓%d ✗%d !%d —%d ⚠%d\n",
|
|
summary[Pass], summary[Fail], summary[Warn], summary[Skip], summary[Error])
|
|
|
|
if r.HasFailures() {
|
|
fmt.Fprintf(&b, "\n Result: FAIL — fix the issues above before deploying\n")
|
|
} else {
|
|
fmt.Fprintf(&b, "\n Result: OK — ready to deploy\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// Validatable is the interface that cloud provider implementations must satisfy
|
|
// to participate in the validation framework. It extends the basic Provider
|
|
// interface with structured validation support.
|
|
type Validatable interface {
|
|
// ProviderName returns the display name of the provider (e.g. "Hetzner Cloud").
|
|
ProviderName() string
|
|
// Checks returns all validation checks for this provider.
|
|
// The provider should inspect its own config to determine which checks
|
|
// are applicable (e.g. skip SSH key checks if no token is set).
|
|
Checks(ctx context.Context) []Check
|
|
}
|
|
|
|
// Runner executes all validation checks for a provider and produces a Report.
|
|
type Runner struct {
|
|
provider Validatable
|
|
timeout time.Duration
|
|
}
|
|
|
|
// NewRunner creates a validation runner for the given provider.
|
|
func NewRunner(provider Validatable) *Runner {
|
|
return &Runner{
|
|
provider: provider,
|
|
timeout: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
// SetTimeout configures the per-check timeout. Default is 30s.
|
|
func (r *Runner) SetTimeout(d time.Duration) {
|
|
r.timeout = d
|
|
}
|
|
|
|
// Run executes all checks and returns the aggregate report.
|
|
// Checks run sequentially for predictable output ordering.
|
|
// Each check respects the configured timeout.
|
|
func (r *Runner) Run(ctx context.Context) *Report {
|
|
start := time.Now()
|
|
|
|
// Collect checks
|
|
checks := r.provider.Checks(ctx)
|
|
|
|
report := &Report{
|
|
Provider: r.provider.ProviderName(),
|
|
Results: make([]CheckResult, 0, len(checks)),
|
|
}
|
|
|
|
for _, check := range checks {
|
|
// Per-check timeout
|
|
checkCtx, cancel := context.WithTimeout(ctx, r.timeout)
|
|
|
|
checkStart := time.Now()
|
|
result := check.Run(checkCtx)
|
|
result.Duration = time.Since(checkStart)
|
|
|
|
// Ensure the result has its name/category set from the check
|
|
if result.Name == "" {
|
|
result.Name = check.Name()
|
|
}
|
|
if result.Category == "" {
|
|
result.Category = check.Category()
|
|
}
|
|
|
|
cancel()
|
|
report.Results = append(report.Results, result)
|
|
}
|
|
|
|
report.TotalDuration = time.Since(start)
|
|
return report
|
|
}
|