obm/internal/provider/hetzner/hetzner.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

347 lines
No EOL
9.5 KiB
Go

// Package hetzner implements the Hetzner Cloud provider for obm.
// It provides API credential validation and SSH key management.
package hetzner
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"github.com/openboatmobile/obm/internal/provider"
"github.com/openboatmobile/obm/internal/validation"
)
const (
// DefaultBaseURL is the Hetzner Cloud API endpoint.
DefaultBaseURL = "https://api.hetzner.cloud/v1"
// TokenEnvKey is the environment variable for the Hetzner API token.
TokenEnvKey = "HCLOUD_TOKEN"
)
// HetznerProvider implements the Provider interface for Hetzner Cloud.
type HetznerProvider struct {
provider.BaseProvider
// HTTP client and base URL (injectable for testing).
client *http.Client
baseURL string
once sync.Once
}
// ClientOption configures the Hetzner provider client.
type ClientOption func(*HetznerProvider)
// WithHTTPClient sets a custom HTTP client (for testing with mock servers).
func WithHTTPClient(client *http.Client) ClientOption {
return func(h *HetznerProvider) {
h.client = client
}
}
// WithBaseURL sets a custom base URL (for testing).
func WithBaseURL(url string) ClientOption {
return func(h *HetznerProvider) {
h.baseURL = url
}
}
// New creates a new Hetzner provider with the given options.
func New(opts ...ClientOption) *HetznerProvider {
h := &HetznerProvider{
BaseProvider: provider.BaseProvider{
DisplayName: "Hetzner Cloud",
Identifier: "hetzner",
TokenKey: TokenEnvKey,
},
client: http.DefaultClient,
baseURL: DefaultBaseURL,
}
for _, opt := range opts {
opt(h)
}
return h
}
func init() {
provider.Register("hetzner", func() provider.Provider {
return New()
})
}
// getClient returns the HTTP client, initializing once if needed.
func (h *HetznerProvider) getClient() *http.Client {
h.once.Do(func() {
if h.client == nil {
h.client = http.DefaultClient
}
})
return h.client
}
// Validate performs a quick credential check by calling the Hetzner API.
// Returns nil if credentials are valid, or an error describing the problem.
func (h *HetznerProvider) Validate(ctx context.Context) error {
if h.GetToken() == "" {
return fmt.Errorf("Hetzner Cloud: no API token configured (set %s)", TokenEnvKey)
}
// Quick API call to verify token
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.baseURL+"/server_types", nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+h.GetToken())
resp, err := h.getClient().Do(req)
if err != nil {
return fmt.Errorf("API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("invalid API token (401 Unauthorized)")
}
if resp.StatusCode >= 400 {
return fmt.Errorf("API returned status %d", resp.StatusCode)
}
return nil
}
// Checks returns all validation checks for the Hetzner provider.
func (h *HetznerProvider) Checks(ctx context.Context) []validation.Check {
token := h.GetToken()
if token == "" {
// If no token is configured, return a single skip check
return []validation.Check{
validation.CheckFunc{
NameField: "token-config",
CategoryField: validation.CategoryCredentials,
RunFunc: func(ctx context.Context) validation.CheckResult {
return validation.CheckResult{
Status: validation.Skip,
Message: fmt.Sprintf("No API token configured (set %s)", TokenEnvKey),
}
},
},
}
}
return []validation.Check{
// Token format validation
validation.CheckFunc{
NameField: "token-format",
CategoryField: validation.CategoryCredentials,
RunFunc: h.checkTokenFormat(ctx, token),
},
// Token authentication
validation.CheckFunc{
NameField: "token-auth",
CategoryField: validation.CategoryCredentials,
RunFunc: h.checkTokenAuth(ctx),
},
// SSH keys
validation.CheckFunc{
NameField: "ssh-keys",
CategoryField: validation.CategorySSH,
RunFunc: h.checkSSHKeys(ctx),
},
}
}
// checkTokenFormat validates the token format without making an API call.
func (h *HetznerProvider) checkTokenFormat(ctx context.Context, token string) func(context.Context) validation.CheckResult {
return func(ctx context.Context) validation.CheckResult {
// Hetzner tokens are 64-character alphanumeric strings
if len(token) < 10 {
return validation.CheckResult{
Status: validation.Fail,
Message: "Token is too short (expected at least 10 characters)",
}
}
if len(token) > 128 {
return validation.CheckResult{
Status: validation.Fail,
Message: "Token is too long (expected at most 128 characters)",
}
}
// Check for valid characters (alphanumeric)
for _, c := range token {
if !isAlphanumeric(c) {
return validation.CheckResult{
Status: validation.Fail,
Message: "Token contains invalid characters (expected alphanumeric)",
}
}
}
return validation.CheckResult{
Status: validation.Pass,
Message: fmt.Sprintf("Token format valid (%d characters)", len(token)),
}
}
}
// checkTokenAuth verifies the token against the Hetzner API.
func (h *HetznerProvider) checkTokenAuth(ctx context.Context) func(context.Context) validation.CheckResult {
return func(ctx context.Context) validation.CheckResult {
token := h.GetToken()
if token == "" {
return validation.CheckResult{
Status: validation.Skip,
Message: "No token configured",
}
}
// Call Hetzner API to verify token
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.baseURL+"/server_types", nil)
if err != nil {
return validation.CheckResult{
Status: validation.Error,
Message: "Failed to create API request",
Detail: err.Error(),
}
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := h.getClient().Do(req)
if err != nil {
return validation.CheckResult{
Status: validation.Error,
Message: "API request failed",
Detail: err.Error(),
}
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return validation.CheckResult{
Status: validation.Pass,
Message: "Token authenticated successfully",
}
case http.StatusUnauthorized:
return validation.CheckResult{
Status: validation.Fail,
Message: "Invalid API token (401 Unauthorized)",
Detail: "The token was rejected by the Hetzner API. Check that your token is correct and has not expired.",
}
case http.StatusForbidden:
return validation.CheckResult{
Status: validation.Fail,
Message: "Token lacks required permissions",
Detail: "The token exists but does not have permission to list server types.",
}
default:
return validation.CheckResult{
Status: validation.Error,
Message: fmt.Sprintf("API returned unexpected status %d", resp.StatusCode),
}
}
}
}
// SSHKey represents a Hetzner SSH key.
type SSHKey struct {
ID int `json:"id"`
Name string `json:"name"`
Fingerprint string `json:"fingerprint"`
}
// SSHKeysResponse is the API response for listing SSH keys.
type SSHKeysResponse struct {
SSHKeys []SSHKey `json:"ssh_keys"`
Meta struct {
Pagination struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalEntries int `json:"total_entries"`
} `json:"pagination"`
} `json:"meta"`
}
// checkSSHKeys lists and validates SSH keys in the Hetzner account.
func (h *HetznerProvider) checkSSHKeys(ctx context.Context) func(context.Context) validation.CheckResult {
return func(ctx context.Context) validation.CheckResult {
token := h.GetToken()
if token == "" {
return validation.CheckResult{
Status: validation.Skip,
Message: "No token configured",
}
}
// Fetch SSH keys from Hetzner API
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.baseURL+"/ssh_keys", nil)
if err != nil {
return validation.CheckResult{
Status: validation.Error,
Message: "Failed to create API request",
Detail: err.Error(),
}
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := h.getClient().Do(req)
if err != nil {
return validation.CheckResult{
Status: validation.Error,
Message: "API request failed",
Detail: err.Error(),
}
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return validation.CheckResult{
Status: validation.Fail,
Message: "Authentication failed (token may be invalid)",
}
}
if resp.StatusCode != http.StatusOK {
return validation.CheckResult{
Status: validation.Error,
Message: fmt.Sprintf("API returned status %d", resp.StatusCode),
}
}
// Parse response
var keysResp SSHKeysResponse
if err := json.NewDecoder(resp.Body).Decode(&keysResp); err != nil {
return validation.CheckResult{
Status: validation.Error,
Message: "Failed to parse API response",
Detail: err.Error(),
}
}
keys := keysResp.SSHKeys
if len(keys) == 0 {
return validation.CheckResult{
Status: validation.Fail,
Message: "No SSH keys registered in account",
Detail: "Add an SSH key via the Hetzner Cloud Console or API before deploying servers.",
}
}
// Build details string
var keyNames []string
for _, key := range keys {
keyNames = append(keyNames, key.Name)
}
detail := fmt.Sprintf("Keys: %s", strings.Join(keyNames, ", "))
return validation.CheckResult{
Status: validation.Pass,
Message: fmt.Sprintf("%d SSH key(s) found", len(keys)),
Detail: detail,
}
}
}
// isAlphanumeric checks if a rune is a letter or digit.
func isAlphanumeric(c rune) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}