obm/internal/validation/validation.go
MermaidMan 33d9a2cb2e deploy walkthrough, API validation, inference client, Hetzner provider
- 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
2026-05-22 15:29:27 +00:00

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
}