- 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
347 lines
No EOL
9.5 KiB
Go
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')
|
|
} |