Compare commits
8 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 650a4581ec | |||
|
|
90a74d8f09 | ||
|
|
08651929a1 | ||
|
|
47910d0667 | ||
|
|
67dae9cc94 | ||
| 504003c47e | |||
|
|
1cecf00148 | ||
|
|
ec1a591b71 |
25 changed files with 1258 additions and 392 deletions
60
client.go
60
client.go
|
|
@ -1,8 +1,6 @@
|
||||||
package infra
|
package infra
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
|
@ -11,10 +9,11 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RetryConfig controls exponential backoff retry behaviour.
|
// RetryConfig controls exponential backoff retry behaviour.
|
||||||
|
// Usage: cfg := infra.RetryConfig{}
|
||||||
type RetryConfig struct {
|
type RetryConfig struct {
|
||||||
// MaxRetries is the maximum number of retry attempts (0 = no retries).
|
// MaxRetries is the maximum number of retry attempts (0 = no retries).
|
||||||
MaxRetries int
|
MaxRetries int
|
||||||
|
|
@ -25,6 +24,7 @@ type RetryConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRetryConfig returns sensible defaults: 3 retries, 100ms initial, 5s max.
|
// DefaultRetryConfig returns sensible defaults: 3 retries, 100ms initial, 5s max.
|
||||||
|
// Usage: cfg := infra.DefaultRetryConfig()
|
||||||
func DefaultRetryConfig() RetryConfig {
|
func DefaultRetryConfig() RetryConfig {
|
||||||
return RetryConfig{
|
return RetryConfig{
|
||||||
MaxRetries: 3,
|
MaxRetries: 3,
|
||||||
|
|
@ -36,39 +36,46 @@ func DefaultRetryConfig() RetryConfig {
|
||||||
// APIClient is a shared HTTP client with retry, rate-limit handling,
|
// APIClient is a shared HTTP client with retry, rate-limit handling,
|
||||||
// and configurable authentication. Provider-specific clients embed or
|
// and configurable authentication. Provider-specific clients embed or
|
||||||
// delegate to this struct.
|
// delegate to this struct.
|
||||||
|
// Usage: client := infra.NewAPIClient()
|
||||||
type APIClient struct {
|
type APIClient struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
retry RetryConfig
|
retry RetryConfig
|
||||||
authFn func(req *http.Request)
|
authFn func(req *http.Request)
|
||||||
prefix string // error prefix, e.g. "hcloud API"
|
prefix string // error prefix, e.g. "hcloud API"
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
blockedUntil time.Time // rate-limit window
|
blockedUntil time.Time // rate-limit window
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIClientOption configures an APIClient.
|
// APIClientOption configures an APIClient.
|
||||||
|
// Usage: client := infra.NewAPIClient(infra.WithPrefix("api"))
|
||||||
type APIClientOption func(*APIClient)
|
type APIClientOption func(*APIClient)
|
||||||
|
|
||||||
// WithHTTPClient sets a custom http.Client.
|
// WithHTTPClient sets a custom http.Client.
|
||||||
|
// Usage: client := infra.NewAPIClient(infra.WithHTTPClient(http.DefaultClient))
|
||||||
func WithHTTPClient(c *http.Client) APIClientOption {
|
func WithHTTPClient(c *http.Client) APIClientOption {
|
||||||
return func(a *APIClient) { a.client = c }
|
return func(a *APIClient) { a.client = c }
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithRetry sets the retry configuration.
|
// WithRetry sets the retry configuration.
|
||||||
|
// Usage: client := infra.NewAPIClient(infra.WithRetry(infra.DefaultRetryConfig()))
|
||||||
func WithRetry(cfg RetryConfig) APIClientOption {
|
func WithRetry(cfg RetryConfig) APIClientOption {
|
||||||
return func(a *APIClient) { a.retry = cfg }
|
return func(a *APIClient) { a.retry = cfg }
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithAuth sets the authentication function applied to every request.
|
// WithAuth sets the authentication function applied to every request.
|
||||||
|
// Usage: client := infra.NewAPIClient(infra.WithAuth(func(req *http.Request) {}))
|
||||||
func WithAuth(fn func(req *http.Request)) APIClientOption {
|
func WithAuth(fn func(req *http.Request)) APIClientOption {
|
||||||
return func(a *APIClient) { a.authFn = fn }
|
return func(a *APIClient) { a.authFn = fn }
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithPrefix sets the error message prefix (e.g. "hcloud API").
|
// WithPrefix sets the error message prefix (e.g. "hcloud API").
|
||||||
|
// Usage: client := infra.NewAPIClient(infra.WithPrefix("hcloud API"))
|
||||||
func WithPrefix(p string) APIClientOption {
|
func WithPrefix(p string) APIClientOption {
|
||||||
return func(a *APIClient) { a.prefix = p }
|
return func(a *APIClient) { a.prefix = p }
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPIClient creates a new APIClient with the given options.
|
// NewAPIClient creates a new APIClient with the given options.
|
||||||
|
// Usage: client := infra.NewAPIClient(infra.WithPrefix("cloudns API"))
|
||||||
func NewAPIClient(opts ...APIClientOption) *APIClient {
|
func NewAPIClient(opts ...APIClientOption) *APIClient {
|
||||||
a := &APIClient{
|
a := &APIClient{
|
||||||
client: &http.Client{Timeout: 30 * time.Second},
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
|
@ -84,6 +91,7 @@ func NewAPIClient(opts ...APIClientOption) *APIClient {
|
||||||
// Do executes an HTTP request with authentication, retry logic, and
|
// Do executes an HTTP request with authentication, retry logic, and
|
||||||
// rate-limit handling. If result is non-nil, the response body is
|
// rate-limit handling. If result is non-nil, the response body is
|
||||||
// JSON-decoded into it.
|
// JSON-decoded into it.
|
||||||
|
// Usage: err := client.Do(req, &result)
|
||||||
func (a *APIClient) Do(req *http.Request, result any) error {
|
func (a *APIClient) Do(req *http.Request, result any) error {
|
||||||
if a.authFn != nil {
|
if a.authFn != nil {
|
||||||
a.authFn(req)
|
a.authFn(req)
|
||||||
|
|
@ -107,7 +115,7 @@ func (a *APIClient) Do(req *http.Request, result any) error {
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = coreerr.E(a.prefix, "request failed", err)
|
lastErr = core.E(a.prefix, "request failed", err)
|
||||||
if attempt < attempts-1 {
|
if attempt < attempts-1 {
|
||||||
a.backoff(attempt, req)
|
a.backoff(attempt, req)
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +125,7 @@ func (a *APIClient) Do(req *http.Request, result any) error {
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = coreerr.E("client.Do", "read response", err)
|
lastErr = core.E("client.Do", "read response", err)
|
||||||
if attempt < attempts-1 {
|
if attempt < attempts-1 {
|
||||||
a.backoff(attempt, req)
|
a.backoff(attempt, req)
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +139,7 @@ func (a *APIClient) Do(req *http.Request, result any) error {
|
||||||
a.blockedUntil = time.Now().Add(retryAfter)
|
a.blockedUntil = time.Now().Add(retryAfter)
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
lastErr = coreerr.E(a.prefix, fmt.Sprintf("rate limited: HTTP %d", resp.StatusCode), nil)
|
lastErr = core.E(a.prefix, core.Sprintf("rate limited: HTTP %d", resp.StatusCode), nil)
|
||||||
if attempt < attempts-1 {
|
if attempt < attempts-1 {
|
||||||
select {
|
select {
|
||||||
case <-req.Context().Done():
|
case <-req.Context().Done():
|
||||||
|
|
@ -144,7 +152,7 @@ func (a *APIClient) Do(req *http.Request, result any) error {
|
||||||
|
|
||||||
// Server errors are retryable.
|
// Server errors are retryable.
|
||||||
if resp.StatusCode >= 500 {
|
if resp.StatusCode >= 500 {
|
||||||
lastErr = coreerr.E(a.prefix, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(data)), nil)
|
lastErr = core.E(a.prefix, core.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil)
|
||||||
if attempt < attempts-1 {
|
if attempt < attempts-1 {
|
||||||
a.backoff(attempt, req)
|
a.backoff(attempt, req)
|
||||||
}
|
}
|
||||||
|
|
@ -153,13 +161,13 @@ func (a *APIClient) Do(req *http.Request, result any) error {
|
||||||
|
|
||||||
// Client errors (4xx, except 429 handled above) are not retried.
|
// Client errors (4xx, except 429 handled above) are not retried.
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
return coreerr.E(a.prefix, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(data)), nil)
|
return core.E(a.prefix, core.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success — decode if requested.
|
// Success — decode if requested.
|
||||||
if result != nil {
|
if result != nil {
|
||||||
if err := json.Unmarshal(data, result); err != nil {
|
if r := core.JSONUnmarshal(data, result); !r.OK {
|
||||||
return coreerr.E("client.Do", "decode response", err)
|
return core.E("client.Do", "decode response", coreResultErr(r, "client.Do"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -170,6 +178,7 @@ func (a *APIClient) Do(req *http.Request, result any) error {
|
||||||
|
|
||||||
// DoRaw executes a request and returns the raw response body.
|
// DoRaw executes a request and returns the raw response body.
|
||||||
// Same retry/rate-limit logic as Do but without JSON decoding.
|
// Same retry/rate-limit logic as Do but without JSON decoding.
|
||||||
|
// Usage: body, err := client.DoRaw(req)
|
||||||
func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) {
|
func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) {
|
||||||
if a.authFn != nil {
|
if a.authFn != nil {
|
||||||
a.authFn(req)
|
a.authFn(req)
|
||||||
|
|
@ -193,7 +202,7 @@ func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) {
|
||||||
|
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = coreerr.E(a.prefix, "request failed", err)
|
lastErr = core.E(a.prefix, "request failed", err)
|
||||||
if attempt < attempts-1 {
|
if attempt < attempts-1 {
|
||||||
a.backoff(attempt, req)
|
a.backoff(attempt, req)
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +212,7 @@ func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) {
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = coreerr.E("client.DoRaw", "read response", err)
|
lastErr = core.E("client.DoRaw", "read response", err)
|
||||||
if attempt < attempts-1 {
|
if attempt < attempts-1 {
|
||||||
a.backoff(attempt, req)
|
a.backoff(attempt, req)
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +225,7 @@ func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) {
|
||||||
a.blockedUntil = time.Now().Add(retryAfter)
|
a.blockedUntil = time.Now().Add(retryAfter)
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
|
|
||||||
lastErr = coreerr.E(a.prefix, fmt.Sprintf("rate limited: HTTP %d", resp.StatusCode), nil)
|
lastErr = core.E(a.prefix, core.Sprintf("rate limited: HTTP %d", resp.StatusCode), nil)
|
||||||
if attempt < attempts-1 {
|
if attempt < attempts-1 {
|
||||||
select {
|
select {
|
||||||
case <-req.Context().Done():
|
case <-req.Context().Done():
|
||||||
|
|
@ -228,7 +237,7 @@ func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode >= 500 {
|
if resp.StatusCode >= 500 {
|
||||||
lastErr = coreerr.E(a.prefix, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(data)), nil)
|
lastErr = core.E(a.prefix, core.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil)
|
||||||
if attempt < attempts-1 {
|
if attempt < attempts-1 {
|
||||||
a.backoff(attempt, req)
|
a.backoff(attempt, req)
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +245,7 @@ func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
return nil, coreerr.E(a.prefix, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, string(data)), nil)
|
return nil, core.E(a.prefix, core.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
|
|
@ -261,6 +270,17 @@ func (a *APIClient) backoff(attempt int, req *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxErrBodyLen is the maximum number of bytes from a response body included in error messages.
|
||||||
|
const maxErrBodyLen = 256
|
||||||
|
|
||||||
|
// truncateBody limits response body length in error messages to prevent sensitive data leakage.
|
||||||
|
func truncateBody(data []byte, max int) string {
|
||||||
|
if len(data) <= max {
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
return string(data[:max]) + "...(truncated)"
|
||||||
|
}
|
||||||
|
|
||||||
// parseRetryAfter interprets the Retry-After header value.
|
// parseRetryAfter interprets the Retry-After header value.
|
||||||
// Supports seconds (integer) format. Falls back to 1 second.
|
// Supports seconds (integer) format. Falls back to 1 second.
|
||||||
func parseRetryAfter(val string) time.Duration {
|
func parseRetryAfter(val string) time.Duration {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import (
|
||||||
|
|
||||||
// --- Constructor ---
|
// --- Constructor ---
|
||||||
|
|
||||||
func TestNewAPIClient_Good_Defaults(t *testing.T) {
|
func TestClient_NewAPIClient_Defaults_Good(t *testing.T) {
|
||||||
c := NewAPIClient()
|
c := NewAPIClient()
|
||||||
assert.NotNil(t, c.client)
|
assert.NotNil(t, c.client)
|
||||||
assert.Equal(t, "api", c.prefix)
|
assert.Equal(t, "api", c.prefix)
|
||||||
|
|
@ -24,7 +24,7 @@ func TestNewAPIClient_Good_Defaults(t *testing.T) {
|
||||||
assert.Nil(t, c.authFn)
|
assert.Nil(t, c.authFn)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewAPIClient_Good_WithOptions(t *testing.T) {
|
func TestClient_NewAPIClient_WithOptions_Good(t *testing.T) {
|
||||||
custom := &http.Client{Timeout: 10 * time.Second}
|
custom := &http.Client{Timeout: 10 * time.Second}
|
||||||
authCalled := false
|
authCalled := false
|
||||||
|
|
||||||
|
|
@ -46,7 +46,7 @@ func TestNewAPIClient_Good_WithOptions(t *testing.T) {
|
||||||
assert.True(t, authCalled)
|
assert.True(t, authCalled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultRetryConfig_Good(t *testing.T) {
|
func TestClient_DefaultRetryConfig_Good(t *testing.T) {
|
||||||
cfg := DefaultRetryConfig()
|
cfg := DefaultRetryConfig()
|
||||||
assert.Equal(t, 3, cfg.MaxRetries)
|
assert.Equal(t, 3, cfg.MaxRetries)
|
||||||
assert.Equal(t, 100*time.Millisecond, cfg.InitialBackoff)
|
assert.Equal(t, 100*time.Millisecond, cfg.InitialBackoff)
|
||||||
|
|
@ -55,7 +55,7 @@ func TestDefaultRetryConfig_Good(t *testing.T) {
|
||||||
|
|
||||||
// --- Do method ---
|
// --- Do method ---
|
||||||
|
|
||||||
func TestAPIClient_Do_Good_Success(t *testing.T) {
|
func TestClient_APIClient_Do_Success_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"name":"test"}`))
|
_, _ = w.Write([]byte(`{"name":"test"}`))
|
||||||
|
|
@ -78,7 +78,7 @@ func TestAPIClient_Do_Good_Success(t *testing.T) {
|
||||||
assert.Equal(t, "test", result.Name)
|
assert.Equal(t, "test", result.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_Do_Good_NilResult(t *testing.T) {
|
func TestClient_APIClient_Do_NilResult_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}))
|
}))
|
||||||
|
|
@ -96,7 +96,7 @@ func TestAPIClient_Do_Good_NilResult(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_Do_Good_AuthApplied(t *testing.T) {
|
func TestClient_APIClient_Do_AuthApplied_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, "Bearer my-token", r.Header.Get("Authorization"))
|
assert.Equal(t, "Bearer my-token", r.Header.Get("Authorization"))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
@ -119,7 +119,7 @@ func TestAPIClient_Do_Good_AuthApplied(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_Do_Bad_ClientError(t *testing.T) {
|
func TestClient_APIClient_Do_ClientError_Bad(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
_, _ = w.Write([]byte(`not found`))
|
_, _ = w.Write([]byte(`not found`))
|
||||||
|
|
@ -141,7 +141,7 @@ func TestAPIClient_Do_Bad_ClientError(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_Do_Bad_DecodeError(t *testing.T) {
|
func TestClient_APIClient_Do_DecodeError_Bad(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`not json`))
|
_, _ = w.Write([]byte(`not json`))
|
||||||
|
|
@ -164,7 +164,7 @@ func TestAPIClient_Do_Bad_DecodeError(t *testing.T) {
|
||||||
|
|
||||||
// --- Retry logic ---
|
// --- Retry logic ---
|
||||||
|
|
||||||
func TestAPIClient_Do_Good_RetriesServerError(t *testing.T) {
|
func TestClient_APIClient_Do_RetriesServerError_Good(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -201,7 +201,7 @@ func TestAPIClient_Do_Good_RetriesServerError(t *testing.T) {
|
||||||
assert.Equal(t, int32(3), attempts.Load())
|
assert.Equal(t, int32(3), attempts.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_Do_Bad_ExhaustsRetries(t *testing.T) {
|
func TestClient_APIClient_Do_ExhaustsRetries_Bad(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -231,7 +231,7 @@ func TestAPIClient_Do_Bad_ExhaustsRetries(t *testing.T) {
|
||||||
assert.Equal(t, int32(3), attempts.Load())
|
assert.Equal(t, int32(3), attempts.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_Do_Good_NoRetryOn4xx(t *testing.T) {
|
func TestClient_APIClient_Do_NoRetryOn4xx_Good(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -259,7 +259,7 @@ func TestAPIClient_Do_Good_NoRetryOn4xx(t *testing.T) {
|
||||||
assert.Equal(t, int32(1), attempts.Load())
|
assert.Equal(t, int32(1), attempts.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_Do_Good_ZeroRetries(t *testing.T) {
|
func TestClient_APIClient_Do_ZeroRetries_Good(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -284,7 +284,7 @@ func TestAPIClient_Do_Good_ZeroRetries(t *testing.T) {
|
||||||
|
|
||||||
// --- Rate limiting ---
|
// --- Rate limiting ---
|
||||||
|
|
||||||
func TestAPIClient_Do_Good_RateLimitRetry(t *testing.T) {
|
func TestClient_APIClient_Do_RateLimitRetry_Good(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -326,7 +326,7 @@ func TestAPIClient_Do_Good_RateLimitRetry(t *testing.T) {
|
||||||
assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(900))
|
assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(900))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_Do_Bad_RateLimitExhausted(t *testing.T) {
|
func TestClient_APIClient_Do_RateLimitExhausted_Bad(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -355,7 +355,7 @@ func TestAPIClient_Do_Bad_RateLimitExhausted(t *testing.T) {
|
||||||
assert.Equal(t, int32(2), attempts.Load()) // 1 initial + 1 retry
|
assert.Equal(t, int32(2), attempts.Load()) // 1 initial + 1 retry
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_Do_Good_RateLimitNoRetryAfterHeader(t *testing.T) {
|
func TestClient_APIClient_Do_RateLimitNoRetryAfterHeader_Good(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -387,7 +387,7 @@ func TestAPIClient_Do_Good_RateLimitNoRetryAfterHeader(t *testing.T) {
|
||||||
assert.Equal(t, int32(2), attempts.Load())
|
assert.Equal(t, int32(2), attempts.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_Do_Ugly_ContextCancelled(t *testing.T) {
|
func TestClient_APIClient_Do_ContextCancelled_Ugly(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = w.Write([]byte(`fail`))
|
_, _ = w.Write([]byte(`fail`))
|
||||||
|
|
@ -415,7 +415,7 @@ func TestAPIClient_Do_Ugly_ContextCancelled(t *testing.T) {
|
||||||
|
|
||||||
// --- DoRaw method ---
|
// --- DoRaw method ---
|
||||||
|
|
||||||
func TestAPIClient_DoRaw_Good_Success(t *testing.T) {
|
func TestClient_APIClient_DoRaw_Success_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(`raw data here`))
|
_, _ = w.Write([]byte(`raw data here`))
|
||||||
}))
|
}))
|
||||||
|
|
@ -434,7 +434,7 @@ func TestAPIClient_DoRaw_Good_Success(t *testing.T) {
|
||||||
assert.Equal(t, "raw data here", string(data))
|
assert.Equal(t, "raw data here", string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_DoRaw_Good_AuthApplied(t *testing.T) {
|
func TestClient_APIClient_DoRaw_AuthApplied_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, pass, ok := r.BasicAuth()
|
user, pass, ok := r.BasicAuth()
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
|
|
@ -458,7 +458,7 @@ func TestAPIClient_DoRaw_Good_AuthApplied(t *testing.T) {
|
||||||
assert.Equal(t, "ok", string(data))
|
assert.Equal(t, "ok", string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_DoRaw_Bad_ClientError(t *testing.T) {
|
func TestClient_APIClient_DoRaw_ClientError_Bad(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
_, _ = w.Write([]byte(`forbidden`))
|
_, _ = w.Write([]byte(`forbidden`))
|
||||||
|
|
@ -479,7 +479,7 @@ func TestAPIClient_DoRaw_Bad_ClientError(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "raw-test: HTTP 403")
|
assert.Contains(t, err.Error(), "raw-test: HTTP 403")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_DoRaw_Good_RetriesServerError(t *testing.T) {
|
func TestClient_APIClient_DoRaw_RetriesServerError_Good(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -511,7 +511,7 @@ func TestAPIClient_DoRaw_Good_RetriesServerError(t *testing.T) {
|
||||||
assert.Equal(t, int32(2), attempts.Load())
|
assert.Equal(t, int32(2), attempts.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_DoRaw_Good_RateLimitRetry(t *testing.T) {
|
func TestClient_APIClient_DoRaw_RateLimitRetry_Good(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -544,7 +544,7 @@ func TestAPIClient_DoRaw_Good_RateLimitRetry(t *testing.T) {
|
||||||
assert.Equal(t, int32(2), attempts.Load())
|
assert.Equal(t, int32(2), attempts.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIClient_DoRaw_Bad_NoRetryOn4xx(t *testing.T) {
|
func TestClient_APIClient_DoRaw_NoRetryOn4xx_Bad(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -573,22 +573,22 @@ func TestAPIClient_DoRaw_Bad_NoRetryOn4xx(t *testing.T) {
|
||||||
|
|
||||||
// --- parseRetryAfter ---
|
// --- parseRetryAfter ---
|
||||||
|
|
||||||
func TestParseRetryAfter_Good_Seconds(t *testing.T) {
|
func TestClient_ParseRetryAfter_Seconds_Good(t *testing.T) {
|
||||||
d := parseRetryAfter("5")
|
d := parseRetryAfter("5")
|
||||||
assert.Equal(t, 5*time.Second, d)
|
assert.Equal(t, 5*time.Second, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRetryAfter_Good_EmptyDefault(t *testing.T) {
|
func TestClient_ParseRetryAfter_EmptyDefault_Good(t *testing.T) {
|
||||||
d := parseRetryAfter("")
|
d := parseRetryAfter("")
|
||||||
assert.Equal(t, 1*time.Second, d)
|
assert.Equal(t, 1*time.Second, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRetryAfter_Bad_InvalidFallback(t *testing.T) {
|
func TestClient_ParseRetryAfter_InvalidFallback_Bad(t *testing.T) {
|
||||||
d := parseRetryAfter("not-a-number")
|
d := parseRetryAfter("not-a-number")
|
||||||
assert.Equal(t, 1*time.Second, d)
|
assert.Equal(t, 1*time.Second, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRetryAfter_Good_Zero(t *testing.T) {
|
func TestClient_ParseRetryAfter_Zero_Good(t *testing.T) {
|
||||||
d := parseRetryAfter("0")
|
d := parseRetryAfter("0")
|
||||||
// 0 is not > 0, falls back to 1s
|
// 0 is not > 0, falls back to 1s
|
||||||
assert.Equal(t, 1*time.Second, d)
|
assert.Equal(t, 1*time.Second, d)
|
||||||
|
|
@ -596,7 +596,7 @@ func TestParseRetryAfter_Good_Zero(t *testing.T) {
|
||||||
|
|
||||||
// --- Integration: HCloudClient uses APIClient retry ---
|
// --- Integration: HCloudClient uses APIClient retry ---
|
||||||
|
|
||||||
func TestHCloudClient_Good_RetriesOnServerError(t *testing.T) {
|
func TestClient_HCloudClient_RetriesOnServerError_Good(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -632,7 +632,7 @@ func TestHCloudClient_Good_RetriesOnServerError(t *testing.T) {
|
||||||
assert.Equal(t, int32(2), attempts.Load())
|
assert.Equal(t, int32(2), attempts.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudClient_Good_HandlesRateLimit(t *testing.T) {
|
func TestClient_HCloudClient_HandlesRateLimit_Good(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -671,7 +671,7 @@ func TestHCloudClient_Good_HandlesRateLimit(t *testing.T) {
|
||||||
|
|
||||||
// --- Integration: CloudNS uses APIClient retry ---
|
// --- Integration: CloudNS uses APIClient retry ---
|
||||||
|
|
||||||
func TestCloudNSClient_Good_RetriesOnServerError(t *testing.T) {
|
func TestClient_CloudNSClient_RetriesOnServerError_Good(t *testing.T) {
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -707,7 +707,7 @@ func TestCloudNSClient_Good_RetriesOnServerError(t *testing.T) {
|
||||||
|
|
||||||
// --- Rate limit shared state ---
|
// --- Rate limit shared state ---
|
||||||
|
|
||||||
func TestAPIClient_Good_RateLimitSharedState(t *testing.T) {
|
func TestClient_APIClient_RateLimitSharedState_Good(t *testing.T) {
|
||||||
// Verify that the blockedUntil state is respected across requests
|
// Verify that the blockedUntil state is respected across requests
|
||||||
var attempts atomic.Int32
|
var attempts atomic.Int32
|
||||||
|
|
||||||
|
|
|
||||||
45
cloudns.go
45
cloudns.go
|
|
@ -2,17 +2,17 @@ package infra
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
const cloudnsBaseURL = "https://api.cloudns.net"
|
const cloudnsBaseURL = "https://api.cloudns.net"
|
||||||
|
|
||||||
// CloudNSClient is an HTTP client for the CloudNS DNS API.
|
// CloudNSClient is an HTTP client for the CloudNS DNS API.
|
||||||
|
// Usage: dns := infra.NewCloudNSClient(authID, password)
|
||||||
type CloudNSClient struct {
|
type CloudNSClient struct {
|
||||||
authID string
|
authID string
|
||||||
password string
|
password string
|
||||||
|
|
@ -22,6 +22,7 @@ type CloudNSClient struct {
|
||||||
|
|
||||||
// NewCloudNSClient creates a new CloudNS API client.
|
// NewCloudNSClient creates a new CloudNS API client.
|
||||||
// Uses sub-auth-user (auth-id) authentication.
|
// Uses sub-auth-user (auth-id) authentication.
|
||||||
|
// Usage: dns := infra.NewCloudNSClient(authID, password)
|
||||||
func NewCloudNSClient(authID, password string) *CloudNSClient {
|
func NewCloudNSClient(authID, password string) *CloudNSClient {
|
||||||
return &CloudNSClient{
|
return &CloudNSClient{
|
||||||
authID: authID,
|
authID: authID,
|
||||||
|
|
@ -32,6 +33,7 @@ func NewCloudNSClient(authID, password string) *CloudNSClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloudNSZone represents a DNS zone.
|
// CloudNSZone represents a DNS zone.
|
||||||
|
// Usage: zone := infra.CloudNSZone{}
|
||||||
type CloudNSZone struct {
|
type CloudNSZone struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|
@ -40,6 +42,7 @@ type CloudNSZone struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloudNSRecord represents a DNS record.
|
// CloudNSRecord represents a DNS record.
|
||||||
|
// Usage: record := infra.CloudNSRecord{}
|
||||||
type CloudNSRecord struct {
|
type CloudNSRecord struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|
@ -51,6 +54,7 @@ type CloudNSRecord struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListZones returns all DNS zones.
|
// ListZones returns all DNS zones.
|
||||||
|
// Usage: zones, err := dns.ListZones(ctx)
|
||||||
func (c *CloudNSClient) ListZones(ctx context.Context) ([]CloudNSZone, error) {
|
func (c *CloudNSClient) ListZones(ctx context.Context) ([]CloudNSZone, error) {
|
||||||
params := c.authParams()
|
params := c.authParams()
|
||||||
params.Set("page", "1")
|
params.Set("page", "1")
|
||||||
|
|
@ -63,7 +67,7 @@ func (c *CloudNSClient) ListZones(ctx context.Context) ([]CloudNSZone, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var zones []CloudNSZone
|
var zones []CloudNSZone
|
||||||
if err := json.Unmarshal(data, &zones); err != nil {
|
if r := core.JSONUnmarshal(data, &zones); !r.OK {
|
||||||
// CloudNS returns an empty object {} for no results instead of []
|
// CloudNS returns an empty object {} for no results instead of []
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +75,7 @@ func (c *CloudNSClient) ListZones(ctx context.Context) ([]CloudNSZone, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRecords returns all DNS records for a zone.
|
// ListRecords returns all DNS records for a zone.
|
||||||
|
// Usage: records, err := dns.ListRecords(ctx, "example.com")
|
||||||
func (c *CloudNSClient) ListRecords(ctx context.Context, domain string) (map[string]CloudNSRecord, error) {
|
func (c *CloudNSClient) ListRecords(ctx context.Context, domain string) (map[string]CloudNSRecord, error) {
|
||||||
params := c.authParams()
|
params := c.authParams()
|
||||||
params.Set("domain-name", domain)
|
params.Set("domain-name", domain)
|
||||||
|
|
@ -81,13 +86,14 @@ func (c *CloudNSClient) ListRecords(ctx context.Context, domain string) (map[str
|
||||||
}
|
}
|
||||||
|
|
||||||
var records map[string]CloudNSRecord
|
var records map[string]CloudNSRecord
|
||||||
if err := json.Unmarshal(data, &records); err != nil {
|
if r := core.JSONUnmarshal(data, &records); !r.OK {
|
||||||
return nil, coreerr.E("CloudNSClient.ListRecords", "parse records", err)
|
return nil, core.E("CloudNSClient.ListRecords", "parse records", coreResultErr(r, "CloudNSClient.ListRecords"))
|
||||||
}
|
}
|
||||||
return records, nil
|
return records, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateRecord creates a DNS record. Returns the record ID.
|
// CreateRecord creates a DNS record. Returns the record ID.
|
||||||
|
// Usage: id, err := dns.CreateRecord(ctx, "example.com", "www", "A", "1.2.3.4", 300)
|
||||||
func (c *CloudNSClient) CreateRecord(ctx context.Context, domain, host, recordType, value string, ttl int) (string, error) {
|
func (c *CloudNSClient) CreateRecord(ctx context.Context, domain, host, recordType, value string, ttl int) (string, error) {
|
||||||
params := c.authParams()
|
params := c.authParams()
|
||||||
params.Set("domain-name", domain)
|
params.Set("domain-name", domain)
|
||||||
|
|
@ -108,18 +114,19 @@ func (c *CloudNSClient) CreateRecord(ctx context.Context, domain, host, recordTy
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, &result); err != nil {
|
if r := core.JSONUnmarshal(data, &result); !r.OK {
|
||||||
return "", coreerr.E("CloudNSClient.CreateRecord", "parse response", err)
|
return "", core.E("CloudNSClient.CreateRecord", "parse response", coreResultErr(r, "CloudNSClient.CreateRecord"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Status != "Success" {
|
if result.Status != "Success" {
|
||||||
return "", coreerr.E("CloudNSClient.CreateRecord", result.StatusDescription, nil)
|
return "", core.E("CloudNSClient.CreateRecord", result.StatusDescription, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return strconv.Itoa(result.Data.ID), nil
|
return strconv.Itoa(result.Data.ID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateRecord updates an existing DNS record.
|
// UpdateRecord updates an existing DNS record.
|
||||||
|
// Usage: err := dns.UpdateRecord(ctx, "example.com", "123", "www", "A", "1.2.3.4", 300)
|
||||||
func (c *CloudNSClient) UpdateRecord(ctx context.Context, domain, recordID, host, recordType, value string, ttl int) error {
|
func (c *CloudNSClient) UpdateRecord(ctx context.Context, domain, recordID, host, recordType, value string, ttl int) error {
|
||||||
params := c.authParams()
|
params := c.authParams()
|
||||||
params.Set("domain-name", domain)
|
params.Set("domain-name", domain)
|
||||||
|
|
@ -138,18 +145,19 @@ func (c *CloudNSClient) UpdateRecord(ctx context.Context, domain, recordID, host
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StatusDescription string `json:"statusDescription"`
|
StatusDescription string `json:"statusDescription"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, &result); err != nil {
|
if r := core.JSONUnmarshal(data, &result); !r.OK {
|
||||||
return coreerr.E("CloudNSClient.UpdateRecord", "parse response", err)
|
return core.E("CloudNSClient.UpdateRecord", "parse response", coreResultErr(r, "CloudNSClient.UpdateRecord"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Status != "Success" {
|
if result.Status != "Success" {
|
||||||
return coreerr.E("CloudNSClient.UpdateRecord", result.StatusDescription, nil)
|
return core.E("CloudNSClient.UpdateRecord", result.StatusDescription, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteRecord deletes a DNS record by ID.
|
// DeleteRecord deletes a DNS record by ID.
|
||||||
|
// Usage: err := dns.DeleteRecord(ctx, "example.com", "123")
|
||||||
func (c *CloudNSClient) DeleteRecord(ctx context.Context, domain, recordID string) error {
|
func (c *CloudNSClient) DeleteRecord(ctx context.Context, domain, recordID string) error {
|
||||||
params := c.authParams()
|
params := c.authParams()
|
||||||
params.Set("domain-name", domain)
|
params.Set("domain-name", domain)
|
||||||
|
|
@ -164,12 +172,12 @@ func (c *CloudNSClient) DeleteRecord(ctx context.Context, domain, recordID strin
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StatusDescription string `json:"statusDescription"`
|
StatusDescription string `json:"statusDescription"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, &result); err != nil {
|
if r := core.JSONUnmarshal(data, &result); !r.OK {
|
||||||
return coreerr.E("CloudNSClient.DeleteRecord", "parse response", err)
|
return core.E("CloudNSClient.DeleteRecord", "parse response", coreResultErr(r, "CloudNSClient.DeleteRecord"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Status != "Success" {
|
if result.Status != "Success" {
|
||||||
return coreerr.E("CloudNSClient.DeleteRecord", result.StatusDescription, nil)
|
return core.E("CloudNSClient.DeleteRecord", result.StatusDescription, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -177,10 +185,11 @@ func (c *CloudNSClient) DeleteRecord(ctx context.Context, domain, recordID strin
|
||||||
|
|
||||||
// EnsureRecord creates or updates a DNS record to match the desired state.
|
// EnsureRecord creates or updates a DNS record to match the desired state.
|
||||||
// Returns true if a change was made.
|
// Returns true if a change was made.
|
||||||
|
// Usage: changed, err := dns.EnsureRecord(ctx, "example.com", "www", "A", "1.2.3.4", 300)
|
||||||
func (c *CloudNSClient) EnsureRecord(ctx context.Context, domain, host, recordType, value string, ttl int) (bool, error) {
|
func (c *CloudNSClient) EnsureRecord(ctx context.Context, domain, host, recordType, value string, ttl int) (bool, error) {
|
||||||
records, err := c.ListRecords(ctx, domain)
|
records, err := c.ListRecords(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, coreerr.E("CloudNSClient.EnsureRecord", "list records", err)
|
return false, core.E("CloudNSClient.EnsureRecord", "list records", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if record already exists
|
// Check if record already exists
|
||||||
|
|
@ -191,7 +200,7 @@ func (c *CloudNSClient) EnsureRecord(ctx context.Context, domain, host, recordTy
|
||||||
}
|
}
|
||||||
// Update existing record
|
// Update existing record
|
||||||
if err := c.UpdateRecord(ctx, domain, id, host, recordType, value, ttl); err != nil {
|
if err := c.UpdateRecord(ctx, domain, id, host, recordType, value, ttl); err != nil {
|
||||||
return false, coreerr.E("CloudNSClient.EnsureRecord", "update record", err)
|
return false, core.E("CloudNSClient.EnsureRecord", "update record", err)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
@ -199,17 +208,19 @@ func (c *CloudNSClient) EnsureRecord(ctx context.Context, domain, host, recordTy
|
||||||
|
|
||||||
// Create new record
|
// Create new record
|
||||||
if _, err := c.CreateRecord(ctx, domain, host, recordType, value, ttl); err != nil {
|
if _, err := c.CreateRecord(ctx, domain, host, recordType, value, ttl); err != nil {
|
||||||
return false, coreerr.E("CloudNSClient.EnsureRecord", "create record", err)
|
return false, core.E("CloudNSClient.EnsureRecord", "create record", err)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetACMEChallenge creates a DNS-01 ACME challenge TXT record.
|
// SetACMEChallenge creates a DNS-01 ACME challenge TXT record.
|
||||||
|
// Usage: id, err := dns.SetACMEChallenge(ctx, "example.com", token)
|
||||||
func (c *CloudNSClient) SetACMEChallenge(ctx context.Context, domain, value string) (string, error) {
|
func (c *CloudNSClient) SetACMEChallenge(ctx context.Context, domain, value string) (string, error) {
|
||||||
return c.CreateRecord(ctx, domain, "_acme-challenge", "TXT", value, 60)
|
return c.CreateRecord(ctx, domain, "_acme-challenge", "TXT", value, 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearACMEChallenge removes the DNS-01 ACME challenge TXT record.
|
// ClearACMEChallenge removes the DNS-01 ACME challenge TXT record.
|
||||||
|
// Usage: err := dns.ClearACMEChallenge(ctx, "example.com")
|
||||||
func (c *CloudNSClient) ClearACMEChallenge(ctx context.Context, domain string) error {
|
func (c *CloudNSClient) ClearACMEChallenge(ctx context.Context, domain string) error {
|
||||||
records, err := c.ListRecords(ctx, domain)
|
records, err := c.ListRecords(ctx, domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,18 @@ package infra
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Constructor ---
|
// --- Constructor ---
|
||||||
|
|
||||||
func TestNewCloudNSClient_Good(t *testing.T) {
|
func TestCloudNS_NewCloudNSClient_Good(t *testing.T) {
|
||||||
c := NewCloudNSClient("12345", "secret")
|
c := NewCloudNSClient("12345", "secret")
|
||||||
assert.NotNil(t, c)
|
assert.NotNil(t, c)
|
||||||
assert.Equal(t, "12345", c.authID)
|
assert.Equal(t, "12345", c.authID)
|
||||||
|
|
@ -23,7 +23,7 @@ func TestNewCloudNSClient_Good(t *testing.T) {
|
||||||
|
|
||||||
// --- authParams ---
|
// --- authParams ---
|
||||||
|
|
||||||
func TestCloudNSClient_AuthParams_Good(t *testing.T) {
|
func TestCloudNS_CloudNSClient_AuthParams_Good(t *testing.T) {
|
||||||
c := NewCloudNSClient("49500", "hunter2")
|
c := NewCloudNSClient("49500", "hunter2")
|
||||||
params := c.authParams()
|
params := c.authParams()
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ func TestCloudNSClient_AuthParams_Good(t *testing.T) {
|
||||||
|
|
||||||
// --- doRaw ---
|
// --- doRaw ---
|
||||||
|
|
||||||
func TestCloudNSClient_DoRaw_Good_ReturnsBody(t *testing.T) {
|
func TestCloudNS_CloudNSClient_DoRaw_ReturnsBody_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status":"Success"}`))
|
_, _ = w.Write([]byte(`{"status":"Success"}`))
|
||||||
|
|
@ -57,7 +57,7 @@ func TestCloudNSClient_DoRaw_Good_ReturnsBody(t *testing.T) {
|
||||||
assert.Contains(t, string(data), "Success")
|
assert.Contains(t, string(data), "Success")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSClient_DoRaw_Bad_HTTPError(t *testing.T) {
|
func TestCloudNS_CloudNSClient_DoRaw_HTTPError_Bad(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
_, _ = w.Write([]byte(`{"status":"Failed","statusDescription":"Invalid auth"}`))
|
_, _ = w.Write([]byte(`{"status":"Failed","statusDescription":"Invalid auth"}`))
|
||||||
|
|
@ -81,7 +81,7 @@ func TestCloudNSClient_DoRaw_Bad_HTTPError(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "cloudns API: HTTP 403")
|
assert.Contains(t, err.Error(), "cloudns API: HTTP 403")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSClient_DoRaw_Bad_ServerError(t *testing.T) {
|
func TestCloudNS_CloudNSClient_DoRaw_ServerError_Bad(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = w.Write([]byte(`Internal Server Error`))
|
_, _ = w.Write([]byte(`Internal Server Error`))
|
||||||
|
|
@ -107,36 +107,34 @@ func TestCloudNSClient_DoRaw_Bad_ServerError(t *testing.T) {
|
||||||
|
|
||||||
// --- Zone JSON parsing ---
|
// --- Zone JSON parsing ---
|
||||||
|
|
||||||
func TestCloudNSZone_JSON_Good(t *testing.T) {
|
func TestCloudNS_CloudNSZone_JSON_Good(t *testing.T) {
|
||||||
data := `[
|
data := `[
|
||||||
{"name": "example.com", "type": "master", "zone": "domain", "status": "1"},
|
{"name": "example.com", "type": "master", "zone": "domain", "status": "1"},
|
||||||
{"name": "test.io", "type": "master", "zone": "domain", "status": "1"}
|
{"name": "test.io", "type": "master", "zone": "domain", "status": "1"}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
var zones []CloudNSZone
|
var zones []CloudNSZone
|
||||||
err := json.Unmarshal([]byte(data), &zones)
|
requireCloudNSJSON(t, data, &zones)
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, zones, 2)
|
require.Len(t, zones, 2)
|
||||||
assert.Equal(t, "example.com", zones[0].Name)
|
assert.Equal(t, "example.com", zones[0].Name)
|
||||||
assert.Equal(t, "master", zones[0].Type)
|
assert.Equal(t, "master", zones[0].Type)
|
||||||
assert.Equal(t, "test.io", zones[1].Name)
|
assert.Equal(t, "test.io", zones[1].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSZone_JSON_Good_EmptyResponse(t *testing.T) {
|
func TestCloudNS_CloudNSZone_JSON_EmptyResponse_Good(t *testing.T) {
|
||||||
// CloudNS returns {} for no zones, not []
|
// CloudNS returns {} for no zones, not []
|
||||||
data := `{}`
|
data := `{}`
|
||||||
|
|
||||||
var zones []CloudNSZone
|
var zones []CloudNSZone
|
||||||
err := json.Unmarshal([]byte(data), &zones)
|
r := core.JSONUnmarshal([]byte(data), &zones)
|
||||||
|
|
||||||
// Should fail to parse as slice — this is the edge case ListZones handles
|
// Should fail to parse as slice — this is the edge case ListZones handles
|
||||||
assert.Error(t, err)
|
assert.False(t, r.OK)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Record JSON parsing ---
|
// --- Record JSON parsing ---
|
||||||
|
|
||||||
func TestCloudNSRecord_JSON_Good(t *testing.T) {
|
func TestCloudNS_CloudNSRecord_JSON_Good(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"12345": {
|
"12345": {
|
||||||
"id": "12345",
|
"id": "12345",
|
||||||
|
|
@ -158,9 +156,7 @@ func TestCloudNSRecord_JSON_Good(t *testing.T) {
|
||||||
}`
|
}`
|
||||||
|
|
||||||
var records map[string]CloudNSRecord
|
var records map[string]CloudNSRecord
|
||||||
err := json.Unmarshal([]byte(data), &records)
|
requireCloudNSJSON(t, data, &records)
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, records, 2)
|
require.Len(t, records, 2)
|
||||||
|
|
||||||
aRecord := records["12345"]
|
aRecord := records["12345"]
|
||||||
|
|
@ -177,7 +173,7 @@ func TestCloudNSRecord_JSON_Good(t *testing.T) {
|
||||||
assert.Equal(t, "10", mxRecord.Priority)
|
assert.Equal(t, "10", mxRecord.Priority)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSRecord_JSON_Good_TXTRecord(t *testing.T) {
|
func TestCloudNS_CloudNSRecord_JSON_TXTRecord_Good(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"99": {
|
"99": {
|
||||||
"id": "99",
|
"id": "99",
|
||||||
|
|
@ -190,9 +186,7 @@ func TestCloudNSRecord_JSON_Good_TXTRecord(t *testing.T) {
|
||||||
}`
|
}`
|
||||||
|
|
||||||
var records map[string]CloudNSRecord
|
var records map[string]CloudNSRecord
|
||||||
err := json.Unmarshal([]byte(data), &records)
|
requireCloudNSJSON(t, data, &records)
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, records, 1)
|
require.Len(t, records, 1)
|
||||||
|
|
||||||
txt := records["99"]
|
txt := records["99"]
|
||||||
|
|
@ -204,7 +198,7 @@ func TestCloudNSRecord_JSON_Good_TXTRecord(t *testing.T) {
|
||||||
|
|
||||||
// --- CreateRecord response parsing ---
|
// --- CreateRecord response parsing ---
|
||||||
|
|
||||||
func TestCloudNSClient_CreateRecord_Good_ResponseParsing(t *testing.T) {
|
func TestCloudNS_CloudNSClient_CreateRecord_ResponseParsing_Good(t *testing.T) {
|
||||||
data := `{"status":"Success","statusDescription":"The record was created successfully.","data":{"id":54321}}`
|
data := `{"status":"Success","statusDescription":"The record was created successfully.","data":{"id":54321}}`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
|
|
@ -215,13 +209,12 @@ func TestCloudNSClient_CreateRecord_Good_ResponseParsing(t *testing.T) {
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(data), &result)
|
requireCloudNSJSON(t, data, &result)
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Success", result.Status)
|
assert.Equal(t, "Success", result.Status)
|
||||||
assert.Equal(t, 54321, result.Data.ID)
|
assert.Equal(t, 54321, result.Data.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSClient_CreateRecord_Bad_FailedStatus(t *testing.T) {
|
func TestCloudNS_CloudNSClient_CreateRecord_FailedStatus_Bad(t *testing.T) {
|
||||||
data := `{"status":"Failed","statusDescription":"Record already exists."}`
|
data := `{"status":"Failed","statusDescription":"Record already exists."}`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
|
|
@ -229,15 +222,14 @@ func TestCloudNSClient_CreateRecord_Bad_FailedStatus(t *testing.T) {
|
||||||
StatusDescription string `json:"statusDescription"`
|
StatusDescription string `json:"statusDescription"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(data), &result)
|
requireCloudNSJSON(t, data, &result)
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Failed", result.Status)
|
assert.Equal(t, "Failed", result.Status)
|
||||||
assert.Equal(t, "Record already exists.", result.StatusDescription)
|
assert.Equal(t, "Record already exists.", result.StatusDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- UpdateRecord/DeleteRecord response parsing ---
|
// --- UpdateRecord/DeleteRecord response parsing ---
|
||||||
|
|
||||||
func TestCloudNSClient_UpdateDelete_Good_ResponseParsing(t *testing.T) {
|
func TestCloudNS_CloudNSClient_UpdateDelete_ResponseParsing_Good(t *testing.T) {
|
||||||
data := `{"status":"Success","statusDescription":"The record was updated successfully."}`
|
data := `{"status":"Success","statusDescription":"The record was updated successfully."}`
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
|
|
@ -245,14 +237,13 @@ func TestCloudNSClient_UpdateDelete_Good_ResponseParsing(t *testing.T) {
|
||||||
StatusDescription string `json:"statusDescription"`
|
StatusDescription string `json:"statusDescription"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(data), &result)
|
requireCloudNSJSON(t, data, &result)
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Success", result.Status)
|
assert.Equal(t, "Success", result.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Full round-trip tests via doRaw ---
|
// --- Full round-trip tests via doRaw ---
|
||||||
|
|
||||||
func TestCloudNSClient_ListZones_Good_ViaDoRaw(t *testing.T) {
|
func TestCloudNS_CloudNSClient_ListZones_ViaDoRaw_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.NotEmpty(t, r.URL.Query().Get("auth-id"))
|
assert.NotEmpty(t, r.URL.Query().Get("auth-id"))
|
||||||
assert.NotEmpty(t, r.URL.Query().Get("auth-password"))
|
assert.NotEmpty(t, r.URL.Query().Get("auth-password"))
|
||||||
|
|
@ -276,7 +267,7 @@ func TestCloudNSClient_ListZones_Good_ViaDoRaw(t *testing.T) {
|
||||||
assert.Equal(t, "example.com", zones[0].Name)
|
assert.Equal(t, "example.com", zones[0].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSClient_ListRecords_Good_ViaDoRaw(t *testing.T) {
|
func TestCloudNS_CloudNSClient_ListRecords_ViaDoRaw_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
||||||
|
|
||||||
|
|
@ -303,7 +294,7 @@ func TestCloudNSClient_ListRecords_Good_ViaDoRaw(t *testing.T) {
|
||||||
assert.Equal(t, "CNAME", records["2"].Type)
|
assert.Equal(t, "CNAME", records["2"].Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSClient_CreateRecord_Good_ViaDoRaw(t *testing.T) {
|
func TestCloudNS_CloudNSClient_CreateRecord_ViaDoRaw_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
||||||
|
|
@ -330,7 +321,7 @@ func TestCloudNSClient_CreateRecord_Good_ViaDoRaw(t *testing.T) {
|
||||||
assert.Equal(t, "99", id)
|
assert.Equal(t, "99", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSClient_DeleteRecord_Good_ViaDoRaw(t *testing.T) {
|
func TestCloudNS_CloudNSClient_DeleteRecord_ViaDoRaw_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
||||||
|
|
@ -355,7 +346,7 @@ func TestCloudNSClient_DeleteRecord_Good_ViaDoRaw(t *testing.T) {
|
||||||
|
|
||||||
// --- ACME challenge helpers ---
|
// --- ACME challenge helpers ---
|
||||||
|
|
||||||
func TestCloudNSClient_SetACMEChallenge_Good_ParamVerification(t *testing.T) {
|
func TestCloudNS_CloudNSClient_SetACMEChallenge_ParamVerification_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
||||||
assert.Equal(t, "_acme-challenge", r.URL.Query().Get("host"))
|
assert.Equal(t, "_acme-challenge", r.URL.Query().Get("host"))
|
||||||
|
|
@ -380,7 +371,7 @@ func TestCloudNSClient_SetACMEChallenge_Good_ParamVerification(t *testing.T) {
|
||||||
assert.Equal(t, "777", id)
|
assert.Equal(t, "777", id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSClient_ClearACMEChallenge_Good_Logic(t *testing.T) {
|
func TestCloudNS_CloudNSClient_ClearACMEChallenge_Logic_Good(t *testing.T) {
|
||||||
records := map[string]CloudNSRecord{
|
records := map[string]CloudNSRecord{
|
||||||
"1": {ID: "1", Type: "A", Host: "www", Record: "1.2.3.4"},
|
"1": {ID: "1", Type: "A", Host: "www", Record: "1.2.3.4"},
|
||||||
"2": {ID: "2", Type: "TXT", Host: "_acme-challenge", Record: "token1"},
|
"2": {ID: "2", Type: "TXT", Host: "_acme-challenge", Record: "token1"},
|
||||||
|
|
@ -402,7 +393,7 @@ func TestCloudNSClient_ClearACMEChallenge_Good_Logic(t *testing.T) {
|
||||||
|
|
||||||
// --- EnsureRecord logic ---
|
// --- EnsureRecord logic ---
|
||||||
|
|
||||||
func TestEnsureRecord_Good_Logic_AlreadyCorrect(t *testing.T) {
|
func TestCloudNS_EnsureRecord_Logic_AlreadyCorrect_Good(t *testing.T) {
|
||||||
records := map[string]CloudNSRecord{
|
records := map[string]CloudNSRecord{
|
||||||
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
|
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
|
||||||
}
|
}
|
||||||
|
|
@ -441,7 +432,7 @@ func TestEnsureRecord_Good_Logic_AlreadyCorrect(t *testing.T) {
|
||||||
assert.False(t, needsCreate, "should not need create when record exists")
|
assert.False(t, needsCreate, "should not need create when record exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureRecord_Good_Logic_NeedsUpdate(t *testing.T) {
|
func TestCloudNS_EnsureRecord_Logic_NeedsUpdate_Good(t *testing.T) {
|
||||||
records := map[string]CloudNSRecord{
|
records := map[string]CloudNSRecord{
|
||||||
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
|
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
|
||||||
}
|
}
|
||||||
|
|
@ -463,7 +454,7 @@ func TestEnsureRecord_Good_Logic_NeedsUpdate(t *testing.T) {
|
||||||
assert.True(t, needsUpdate, "should need update when value differs")
|
assert.True(t, needsUpdate, "should need update when value differs")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnsureRecord_Good_Logic_NeedsCreate(t *testing.T) {
|
func TestCloudNS_EnsureRecord_Logic_NeedsCreate_Good(t *testing.T) {
|
||||||
records := map[string]CloudNSRecord{
|
records := map[string]CloudNSRecord{
|
||||||
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
|
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
|
||||||
}
|
}
|
||||||
|
|
@ -484,7 +475,7 @@ func TestEnsureRecord_Good_Logic_NeedsCreate(t *testing.T) {
|
||||||
|
|
||||||
// --- Edge cases ---
|
// --- Edge cases ---
|
||||||
|
|
||||||
func TestCloudNSClient_DoRaw_Good_EmptyBody(t *testing.T) {
|
func TestCloudNS_CloudNSClient_DoRaw_EmptyBody_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
|
|
@ -507,19 +498,24 @@ func TestCloudNSClient_DoRaw_Good_EmptyBody(t *testing.T) {
|
||||||
assert.Empty(t, data)
|
assert.Empty(t, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSRecord_JSON_Good_EmptyMap(t *testing.T) {
|
func TestCloudNS_CloudNSRecord_JSON_EmptyMap_Good(t *testing.T) {
|
||||||
data := `{}`
|
data := `{}`
|
||||||
|
|
||||||
var records map[string]CloudNSRecord
|
var records map[string]CloudNSRecord
|
||||||
err := json.Unmarshal([]byte(data), &records)
|
requireCloudNSJSON(t, data, &records)
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Empty(t, records)
|
assert.Empty(t, records)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requireCloudNSJSON(t *testing.T, data string, target any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
r := core.JSONUnmarshal([]byte(data), target)
|
||||||
|
require.True(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
// --- UpdateRecord round-trip ---
|
// --- UpdateRecord round-trip ---
|
||||||
|
|
||||||
func TestCloudNSClient_UpdateRecord_Good_ViaDoRaw(t *testing.T) {
|
func TestCloudNS_CloudNSClient_UpdateRecord_ViaDoRaw_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
||||||
|
|
@ -546,7 +542,7 @@ func TestCloudNSClient_UpdateRecord_Good_ViaDoRaw(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSClient_UpdateRecord_Bad_FailedStatus(t *testing.T) {
|
func TestCloudNS_CloudNSClient_UpdateRecord_FailedStatus_Bad(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status":"Failed","statusDescription":"Record not found."}`))
|
_, _ = w.Write([]byte(`{"status":"Failed","statusDescription":"Record not found."}`))
|
||||||
|
|
@ -568,7 +564,7 @@ func TestCloudNSClient_UpdateRecord_Bad_FailedStatus(t *testing.T) {
|
||||||
|
|
||||||
// --- EnsureRecord round-trip ---
|
// --- EnsureRecord round-trip ---
|
||||||
|
|
||||||
func TestCloudNSClient_EnsureRecord_Good_AlreadyCorrect(t *testing.T) {
|
func TestCloudNS_CloudNSClient_EnsureRecord_AlreadyCorrect_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"1":{"id":"1","type":"A","host":"www","record":"1.2.3.4","ttl":"3600","status":1}}`))
|
_, _ = w.Write([]byte(`{"1":{"id":"1","type":"A","host":"www","record":"1.2.3.4","ttl":"3600","status":1}}`))
|
||||||
|
|
@ -588,7 +584,7 @@ func TestCloudNSClient_EnsureRecord_Good_AlreadyCorrect(t *testing.T) {
|
||||||
assert.False(t, changed, "should not change when record already correct")
|
assert.False(t, changed, "should not change when record already correct")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSClient_EnsureRecord_Good_NeedsUpdate(t *testing.T) {
|
func TestCloudNS_CloudNSClient_EnsureRecord_NeedsUpdate_Good(t *testing.T) {
|
||||||
callCount := 0
|
callCount := 0
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
callCount++
|
callCount++
|
||||||
|
|
@ -618,7 +614,7 @@ func TestCloudNSClient_EnsureRecord_Good_NeedsUpdate(t *testing.T) {
|
||||||
assert.True(t, changed, "should change when record needs update")
|
assert.True(t, changed, "should change when record needs update")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSClient_EnsureRecord_Good_NeedsCreate(t *testing.T) {
|
func TestCloudNS_CloudNSClient_EnsureRecord_NeedsCreate_Good(t *testing.T) {
|
||||||
callCount := 0
|
callCount := 0
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
callCount++
|
callCount++
|
||||||
|
|
@ -650,7 +646,7 @@ func TestCloudNSClient_EnsureRecord_Good_NeedsCreate(t *testing.T) {
|
||||||
|
|
||||||
// --- ClearACMEChallenge round-trip ---
|
// --- ClearACMEChallenge round-trip ---
|
||||||
|
|
||||||
func TestCloudNSClient_ClearACMEChallenge_Good_ViaDoRaw(t *testing.T) {
|
func TestCloudNS_CloudNSClient_ClearACMEChallenge_ViaDoRaw_Good(t *testing.T) {
|
||||||
callCount := 0
|
callCount := 0
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
callCount++
|
callCount++
|
||||||
|
|
@ -683,7 +679,7 @@ func TestCloudNSClient_ClearACMEChallenge_Good_ViaDoRaw(t *testing.T) {
|
||||||
assert.GreaterOrEqual(t, callCount, 2, "should have called list + delete")
|
assert.GreaterOrEqual(t, callCount, 2, "should have called list + delete")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCloudNSClient_DoRaw_Good_AuthQueryParams(t *testing.T) {
|
func TestCloudNS_CloudNSClient_DoRaw_AuthQueryParams_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, "49500", r.URL.Query().Get("auth-id"))
|
assert.Equal(t, "49500", r.URL.Query().Get("auth-id"))
|
||||||
assert.Equal(t, "supersecret", r.URL.Query().Get("auth-password"))
|
assert.Equal(t, "supersecret", r.URL.Query().Get("auth-password"))
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddMonitorCommands registers the 'monitor' command.
|
// AddMonitorCommands registers the 'monitor' command.
|
||||||
|
// Usage: monitor.AddMonitorCommands(root)
|
||||||
func AddMonitorCommands(root *cli.Command) {
|
func AddMonitorCommands(root *cli.Command) {
|
||||||
monitorCmd := &cli.Command{
|
monitorCmd := &cli.Command{
|
||||||
Use: "monitor",
|
Use: "monitor",
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,15 @@ package monitor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"encoding/json"
|
"context"
|
||||||
"fmt"
|
|
||||||
"maps"
|
"maps"
|
||||||
"os/exec"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-i18n"
|
"forge.lthn.ai/core/go-i18n"
|
||||||
|
"forge.lthn.ai/core/go-infra/internal/coreexec"
|
||||||
"forge.lthn.ai/core/go-io"
|
"forge.lthn.ai/core/go-io"
|
||||||
"forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/go-scm/repos"
|
"forge.lthn.ai/core/go-scm/repos"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -33,7 +31,8 @@ var (
|
||||||
monitorAll bool
|
monitorAll bool
|
||||||
)
|
)
|
||||||
|
|
||||||
// Finding represents a security finding from any source
|
// Finding represents a security finding from any source.
|
||||||
|
// Usage: finding := monitor.Finding{}
|
||||||
type Finding struct {
|
type Finding struct {
|
||||||
Source string `json:"source"` // semgrep, trivy, dependabot, secret-scanning, etc.
|
Source string `json:"source"` // semgrep, trivy, dependabot, secret-scanning, etc.
|
||||||
Severity string `json:"severity"` // critical, high, medium, low
|
Severity string `json:"severity"` // critical, high, medium, low
|
||||||
|
|
@ -48,7 +47,8 @@ type Finding struct {
|
||||||
Labels []string `json:"suggested_labels,omitempty"`
|
Labels []string `json:"suggested_labels,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CodeScanningAlert represents a GitHub code scanning alert
|
// CodeScanningAlert represents a GitHub code scanning alert.
|
||||||
|
// Usage: alert := monitor.CodeScanningAlert{}
|
||||||
type CodeScanningAlert struct {
|
type CodeScanningAlert struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
State string `json:"state"` // open, dismissed, fixed
|
State string `json:"state"` // open, dismissed, fixed
|
||||||
|
|
@ -73,7 +73,8 @@ type CodeScanningAlert struct {
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DependabotAlert represents a GitHub Dependabot alert
|
// DependabotAlert represents a GitHub Dependabot alert.
|
||||||
|
// Usage: alert := monitor.DependabotAlert{}
|
||||||
type DependabotAlert struct {
|
type DependabotAlert struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
State string `json:"state"` // open, dismissed, fixed
|
State string `json:"state"` // open, dismissed, fixed
|
||||||
|
|
@ -96,7 +97,8 @@ type DependabotAlert struct {
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecretScanningAlert represents a GitHub secret scanning alert
|
// SecretScanningAlert represents a GitHub secret scanning alert.
|
||||||
|
// Usage: alert := monitor.SecretScanningAlert{}
|
||||||
type SecretScanningAlert struct {
|
type SecretScanningAlert struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
State string `json:"state"` // open, resolved
|
State string `json:"state"` // open, resolved
|
||||||
|
|
@ -109,8 +111,8 @@ type SecretScanningAlert struct {
|
||||||
|
|
||||||
func runMonitor() error {
|
func runMonitor() error {
|
||||||
// Check gh is available
|
// Check gh is available
|
||||||
if _, err := exec.LookPath("gh"); err != nil {
|
if _, err := coreexec.LookPath("gh"); err != nil {
|
||||||
return log.E("monitor", i18n.T("error.gh_not_found"), err)
|
return core.E("monitor", i18n.T("error.gh_not_found"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine repos to scan
|
// Determine repos to scan
|
||||||
|
|
@ -120,7 +122,7 @@ func runMonitor() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(repoList) == 0 {
|
if len(repoList) == 0 {
|
||||||
return log.E("monitor", i18n.T("cmd.monitor.error.no_repos"), nil)
|
return core.E("monitor", i18n.T("cmd.monitor.error.no_repos"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all findings and errors
|
// Collect all findings and errors
|
||||||
|
|
@ -166,7 +168,7 @@ func runMonitor() error {
|
||||||
func resolveRepos() ([]string, error) {
|
func resolveRepos() ([]string, error) {
|
||||||
if monitorRepo != "" {
|
if monitorRepo != "" {
|
||||||
// Specific repo - if fully qualified (org/repo), use as-is
|
// Specific repo - if fully qualified (org/repo), use as-is
|
||||||
if strings.Contains(monitorRepo, "/") {
|
if core.Contains(monitorRepo, "/") {
|
||||||
return []string{monitorRepo}, nil
|
return []string{monitorRepo}, nil
|
||||||
}
|
}
|
||||||
// Otherwise, try to detect org from git remote, fallback to host-uk
|
// Otherwise, try to detect org from git remote, fallback to host-uk
|
||||||
|
|
@ -182,12 +184,12 @@ func resolveRepos() ([]string, error) {
|
||||||
// All repos from registry
|
// All repos from registry
|
||||||
registry, err := repos.FindRegistry(io.Local)
|
registry, err := repos.FindRegistry(io.Local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, log.E("monitor", "failed to find registry", err)
|
return nil, core.E("monitor", "failed to find registry", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
loaded, err := repos.LoadRegistry(io.Local, registry)
|
loaded, err := repos.LoadRegistry(io.Local, registry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, log.E("monitor", "failed to load registry", err)
|
return nil, core.E("monitor", "failed to load registry", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var repoList []string
|
var repoList []string
|
||||||
|
|
@ -210,26 +212,26 @@ func resolveRepos() ([]string, error) {
|
||||||
func fetchRepoFindings(repoFullName string) ([]Finding, []string) {
|
func fetchRepoFindings(repoFullName string) ([]Finding, []string) {
|
||||||
var findings []Finding
|
var findings []Finding
|
||||||
var errs []string
|
var errs []string
|
||||||
repoName := strings.Split(repoFullName, "/")[1]
|
repoName := repoShortName(repoFullName)
|
||||||
|
|
||||||
// Fetch code scanning alerts
|
// Fetch code scanning alerts
|
||||||
codeFindings, err := fetchCodeScanningAlerts(repoFullName)
|
codeFindings, err := fetchCodeScanningAlerts(repoFullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, fmt.Sprintf("%s: code-scanning: %s", repoName, err))
|
errs = append(errs, core.Sprintf("%s: code-scanning: %s", repoName, err))
|
||||||
}
|
}
|
||||||
findings = append(findings, codeFindings...)
|
findings = append(findings, codeFindings...)
|
||||||
|
|
||||||
// Fetch Dependabot alerts
|
// Fetch Dependabot alerts
|
||||||
depFindings, err := fetchDependabotAlerts(repoFullName)
|
depFindings, err := fetchDependabotAlerts(repoFullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, fmt.Sprintf("%s: dependabot: %s", repoName, err))
|
errs = append(errs, core.Sprintf("%s: dependabot: %s", repoName, err))
|
||||||
}
|
}
|
||||||
findings = append(findings, depFindings...)
|
findings = append(findings, depFindings...)
|
||||||
|
|
||||||
// Fetch secret scanning alerts
|
// Fetch secret scanning alerts
|
||||||
secretFindings, err := fetchSecretScanningAlerts(repoFullName)
|
secretFindings, err := fetchSecretScanningAlerts(repoFullName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, fmt.Sprintf("%s: secret-scanning: %s", repoName, err))
|
errs = append(errs, core.Sprintf("%s: secret-scanning: %s", repoName, err))
|
||||||
}
|
}
|
||||||
findings = append(findings, secretFindings...)
|
findings = append(findings, secretFindings...)
|
||||||
|
|
||||||
|
|
@ -238,33 +240,32 @@ func fetchRepoFindings(repoFullName string) ([]Finding, []string) {
|
||||||
|
|
||||||
// fetchCodeScanningAlerts fetches code scanning alerts
|
// fetchCodeScanningAlerts fetches code scanning alerts
|
||||||
func fetchCodeScanningAlerts(repoFullName string) ([]Finding, error) {
|
func fetchCodeScanningAlerts(repoFullName string) ([]Finding, error) {
|
||||||
args := []string{
|
output, err := coreexec.Run(
|
||||||
|
context.Background(),
|
||||||
|
"gh",
|
||||||
"api",
|
"api",
|
||||||
fmt.Sprintf("repos/%s/code-scanning/alerts", repoFullName),
|
core.Sprintf("repos/%s/code-scanning/alerts", repoFullName),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, core.E("monitor.fetchCodeScanning", "API request failed", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("gh", args...)
|
if output.ExitCode != 0 {
|
||||||
output, err := cmd.Output()
|
// These are expected conditions, not errors.
|
||||||
if err != nil {
|
if core.Contains(output.Stderr, "Advanced Security must be enabled") ||
|
||||||
// Check for expected "not enabled" responses vs actual errors
|
core.Contains(output.Stderr, "no analysis found") ||
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
core.Contains(output.Stderr, "Not Found") {
|
||||||
stderr := string(exitErr.Stderr)
|
return nil, nil
|
||||||
// These are expected conditions, not errors
|
|
||||||
if strings.Contains(stderr, "Advanced Security must be enabled") ||
|
|
||||||
strings.Contains(stderr, "no analysis found") ||
|
|
||||||
strings.Contains(stderr, "Not Found") {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil, log.E("monitor.fetchCodeScanning", "API request failed", err)
|
return nil, commandExitErr("monitor.fetchCodeScanning", output)
|
||||||
}
|
}
|
||||||
|
|
||||||
var alerts []CodeScanningAlert
|
var alerts []CodeScanningAlert
|
||||||
if err := json.Unmarshal(output, &alerts); err != nil {
|
if r := core.JSONUnmarshal([]byte(output.Stdout), &alerts); !r.OK {
|
||||||
return nil, log.E("monitor.fetchCodeScanning", "failed to parse response", err)
|
return nil, core.E("monitor.fetchCodeScanning", "failed to parse response", monitorResultErr(r, "monitor.fetchCodeScanning"))
|
||||||
}
|
}
|
||||||
|
|
||||||
repoName := strings.Split(repoFullName, "/")[1]
|
repoName := repoShortName(repoFullName)
|
||||||
var findings []Finding
|
var findings []Finding
|
||||||
for _, alert := range alerts {
|
for _, alert := range alerts {
|
||||||
if alert.State != "open" {
|
if alert.State != "open" {
|
||||||
|
|
@ -294,31 +295,31 @@ func fetchCodeScanningAlerts(repoFullName string) ([]Finding, error) {
|
||||||
|
|
||||||
// fetchDependabotAlerts fetches Dependabot alerts
|
// fetchDependabotAlerts fetches Dependabot alerts
|
||||||
func fetchDependabotAlerts(repoFullName string) ([]Finding, error) {
|
func fetchDependabotAlerts(repoFullName string) ([]Finding, error) {
|
||||||
args := []string{
|
output, err := coreexec.Run(
|
||||||
|
context.Background(),
|
||||||
|
"gh",
|
||||||
"api",
|
"api",
|
||||||
fmt.Sprintf("repos/%s/dependabot/alerts", repoFullName),
|
core.Sprintf("repos/%s/dependabot/alerts", repoFullName),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, core.E("monitor.fetchDependabot", "API request failed", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("gh", args...)
|
if output.ExitCode != 0 {
|
||||||
output, err := cmd.Output()
|
// Dependabot not enabled is expected.
|
||||||
if err != nil {
|
if core.Contains(output.Stderr, "Dependabot alerts are not enabled") ||
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
core.Contains(output.Stderr, "Not Found") {
|
||||||
stderr := string(exitErr.Stderr)
|
return nil, nil
|
||||||
// Dependabot not enabled is expected
|
|
||||||
if strings.Contains(stderr, "Dependabot alerts are not enabled") ||
|
|
||||||
strings.Contains(stderr, "Not Found") {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil, log.E("monitor.fetchDependabot", "API request failed", err)
|
return nil, commandExitErr("monitor.fetchDependabot", output)
|
||||||
}
|
}
|
||||||
|
|
||||||
var alerts []DependabotAlert
|
var alerts []DependabotAlert
|
||||||
if err := json.Unmarshal(output, &alerts); err != nil {
|
if r := core.JSONUnmarshal([]byte(output.Stdout), &alerts); !r.OK {
|
||||||
return nil, log.E("monitor.fetchDependabot", "failed to parse response", err)
|
return nil, core.E("monitor.fetchDependabot", "failed to parse response", monitorResultErr(r, "monitor.fetchDependabot"))
|
||||||
}
|
}
|
||||||
|
|
||||||
repoName := strings.Split(repoFullName, "/")[1]
|
repoName := repoShortName(repoFullName)
|
||||||
var findings []Finding
|
var findings []Finding
|
||||||
for _, alert := range alerts {
|
for _, alert := range alerts {
|
||||||
if alert.State != "open" {
|
if alert.State != "open" {
|
||||||
|
|
@ -330,7 +331,7 @@ func fetchDependabotAlerts(repoFullName string) ([]Finding, error) {
|
||||||
Rule: alert.SecurityAdvisory.CVEID,
|
Rule: alert.SecurityAdvisory.CVEID,
|
||||||
File: alert.Dependency.ManifestPath,
|
File: alert.Dependency.ManifestPath,
|
||||||
Line: 0,
|
Line: 0,
|
||||||
Message: fmt.Sprintf("%s: %s", alert.SecurityVulnerability.Package.Name, alert.SecurityAdvisory.Summary),
|
Message: core.Sprintf("%s: %s", alert.SecurityVulnerability.Package.Name, alert.SecurityAdvisory.Summary),
|
||||||
URL: alert.HTMLURL,
|
URL: alert.HTMLURL,
|
||||||
State: alert.State,
|
State: alert.State,
|
||||||
RepoName: repoName,
|
RepoName: repoName,
|
||||||
|
|
@ -345,31 +346,31 @@ func fetchDependabotAlerts(repoFullName string) ([]Finding, error) {
|
||||||
|
|
||||||
// fetchSecretScanningAlerts fetches secret scanning alerts
|
// fetchSecretScanningAlerts fetches secret scanning alerts
|
||||||
func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) {
|
func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) {
|
||||||
args := []string{
|
output, err := coreexec.Run(
|
||||||
|
context.Background(),
|
||||||
|
"gh",
|
||||||
"api",
|
"api",
|
||||||
fmt.Sprintf("repos/%s/secret-scanning/alerts", repoFullName),
|
core.Sprintf("repos/%s/secret-scanning/alerts", repoFullName),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, core.E("monitor.fetchSecretScanning", "API request failed", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("gh", args...)
|
if output.ExitCode != 0 {
|
||||||
output, err := cmd.Output()
|
// Secret scanning not enabled is expected.
|
||||||
if err != nil {
|
if core.Contains(output.Stderr, "Secret scanning is disabled") ||
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
core.Contains(output.Stderr, "Not Found") {
|
||||||
stderr := string(exitErr.Stderr)
|
return nil, nil
|
||||||
// Secret scanning not enabled is expected
|
|
||||||
if strings.Contains(stderr, "Secret scanning is disabled") ||
|
|
||||||
strings.Contains(stderr, "Not Found") {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil, log.E("monitor.fetchSecretScanning", "API request failed", err)
|
return nil, commandExitErr("monitor.fetchSecretScanning", output)
|
||||||
}
|
}
|
||||||
|
|
||||||
var alerts []SecretScanningAlert
|
var alerts []SecretScanningAlert
|
||||||
if err := json.Unmarshal(output, &alerts); err != nil {
|
if r := core.JSONUnmarshal([]byte(output.Stdout), &alerts); !r.OK {
|
||||||
return nil, log.E("monitor.fetchSecretScanning", "failed to parse response", err)
|
return nil, core.E("monitor.fetchSecretScanning", "failed to parse response", monitorResultErr(r, "monitor.fetchSecretScanning"))
|
||||||
}
|
}
|
||||||
|
|
||||||
repoName := strings.Split(repoFullName, "/")[1]
|
repoName := repoShortName(repoFullName)
|
||||||
var findings []Finding
|
var findings []Finding
|
||||||
for _, alert := range alerts {
|
for _, alert := range alerts {
|
||||||
if alert.State != "open" {
|
if alert.State != "open" {
|
||||||
|
|
@ -381,7 +382,7 @@ func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) {
|
||||||
Rule: alert.SecretType,
|
Rule: alert.SecretType,
|
||||||
File: alert.LocationType,
|
File: alert.LocationType,
|
||||||
Line: 0,
|
Line: 0,
|
||||||
Message: fmt.Sprintf("Exposed %s detected", alert.SecretType),
|
Message: core.Sprintf("Exposed %s detected", alert.SecretType),
|
||||||
URL: alert.HTMLURL,
|
URL: alert.HTMLURL,
|
||||||
State: alert.State,
|
State: alert.State,
|
||||||
RepoName: repoName,
|
RepoName: repoName,
|
||||||
|
|
@ -396,7 +397,7 @@ func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) {
|
||||||
|
|
||||||
// normalizeSeverity normalizes severity strings to standard values
|
// normalizeSeverity normalizes severity strings to standard values
|
||||||
func normalizeSeverity(s string) string {
|
func normalizeSeverity(s string) string {
|
||||||
s = strings.ToLower(s)
|
s = core.Lower(s)
|
||||||
switch s {
|
switch s {
|
||||||
case "critical", "crit":
|
case "critical", "crit":
|
||||||
return "critical"
|
return "critical"
|
||||||
|
|
@ -415,7 +416,7 @@ func normalizeSeverity(s string) string {
|
||||||
func filterBySeverity(findings []Finding, severities []string) []Finding {
|
func filterBySeverity(findings []Finding, severities []string) []Finding {
|
||||||
sevSet := make(map[string]bool)
|
sevSet := make(map[string]bool)
|
||||||
for _, s := range severities {
|
for _, s := range severities {
|
||||||
sevSet[strings.ToLower(s)] = true
|
sevSet[core.Lower(s)] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
var filtered []Finding
|
var filtered []Finding
|
||||||
|
|
@ -446,11 +447,11 @@ func sortBySeverity(findings []Finding) {
|
||||||
|
|
||||||
// outputJSON outputs findings as JSON
|
// outputJSON outputs findings as JSON
|
||||||
func outputJSON(findings []Finding) error {
|
func outputJSON(findings []Finding) error {
|
||||||
data, err := json.MarshalIndent(findings, "", " ")
|
r := core.JSONMarshal(findings)
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return log.E("monitor", "failed to marshal findings", err)
|
return core.E("monitor", "failed to marshal findings", monitorResultErr(r, "monitor"))
|
||||||
}
|
}
|
||||||
cli.Print("%s\n", string(data))
|
cli.Print("%s\n", string(r.Value.([]byte)))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -470,18 +471,18 @@ func outputTable(findings []Finding) error {
|
||||||
// Header summary
|
// Header summary
|
||||||
var parts []string
|
var parts []string
|
||||||
if counts["critical"] > 0 {
|
if counts["critical"] > 0 {
|
||||||
parts = append(parts, errorStyle.Render(fmt.Sprintf("%d critical", counts["critical"])))
|
parts = append(parts, errorStyle.Render(core.Sprintf("%d critical", counts["critical"])))
|
||||||
}
|
}
|
||||||
if counts["high"] > 0 {
|
if counts["high"] > 0 {
|
||||||
parts = append(parts, errorStyle.Render(fmt.Sprintf("%d high", counts["high"])))
|
parts = append(parts, errorStyle.Render(core.Sprintf("%d high", counts["high"])))
|
||||||
}
|
}
|
||||||
if counts["medium"] > 0 {
|
if counts["medium"] > 0 {
|
||||||
parts = append(parts, warningStyle.Render(fmt.Sprintf("%d medium", counts["medium"])))
|
parts = append(parts, warningStyle.Render(core.Sprintf("%d medium", counts["medium"])))
|
||||||
}
|
}
|
||||||
if counts["low"] > 0 {
|
if counts["low"] > 0 {
|
||||||
parts = append(parts, dimStyle.Render(fmt.Sprintf("%d low", counts["low"])))
|
parts = append(parts, dimStyle.Render(core.Sprintf("%d low", counts["low"])))
|
||||||
}
|
}
|
||||||
cli.Print("%s: %s\n", i18n.T("cmd.monitor.found"), strings.Join(parts, ", "))
|
cli.Print("%s: %s\n", i18n.T("cmd.monitor.found"), core.Join(", ", parts...))
|
||||||
cli.Blank()
|
cli.Blank()
|
||||||
|
|
||||||
// Group by repo
|
// Group by repo
|
||||||
|
|
@ -511,12 +512,12 @@ func outputTable(findings []Finding) error {
|
||||||
if f.File != "" {
|
if f.File != "" {
|
||||||
location = f.File
|
location = f.File
|
||||||
if f.Line > 0 {
|
if f.Line > 0 {
|
||||||
location = fmt.Sprintf("%s:%d", f.File, f.Line)
|
location = core.Sprintf("%s:%d", f.File, f.Line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.Print(" %s %s: %s",
|
cli.Print(" %s %s: %s",
|
||||||
sevStyle.Render(fmt.Sprintf("[%s]", f.Severity)),
|
sevStyle.Render(core.Sprintf("[%s]", f.Severity)),
|
||||||
dimStyle.Render(f.Source),
|
dimStyle.Render(f.Source),
|
||||||
truncate(f.Message, 60))
|
truncate(f.Message, 60))
|
||||||
if location != "" {
|
if location != "" {
|
||||||
|
|
@ -530,6 +531,16 @@ func outputTable(findings []Finding) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// repoShortName extracts the repo name from "org/repo" format.
|
||||||
|
// Returns the full string if no "/" is present.
|
||||||
|
func repoShortName(fullName string) string {
|
||||||
|
parts := core.Split(fullName, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
return fullName
|
||||||
|
}
|
||||||
|
|
||||||
// truncate truncates a string to max runes (Unicode-safe)
|
// truncate truncates a string to max runes (Unicode-safe)
|
||||||
func truncate(s string, max int) string {
|
func truncate(s string, max int) string {
|
||||||
runes := []rune(s)
|
runes := []rune(s)
|
||||||
|
|
@ -541,13 +552,15 @@ func truncate(s string, max int) string {
|
||||||
|
|
||||||
// detectRepoFromGit detects the repo from git remote
|
// detectRepoFromGit detects the repo from git remote
|
||||||
func detectRepoFromGit() (string, error) {
|
func detectRepoFromGit() (string, error) {
|
||||||
cmd := exec.Command("git", "remote", "get-url", "origin")
|
output, err := coreexec.Run(context.Background(), "git", "remote", "get-url", "origin")
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", log.E("monitor", i18n.T("cmd.monitor.error.not_git_repo"), err)
|
return "", core.E("monitor", i18n.T("cmd.monitor.error.not_git_repo"), err)
|
||||||
|
}
|
||||||
|
if output.ExitCode != 0 {
|
||||||
|
return "", core.E("monitor", i18n.T("cmd.monitor.error.not_git_repo"), commandExitErr("monitor.detectRepoFromGit", output))
|
||||||
}
|
}
|
||||||
|
|
||||||
url := strings.TrimSpace(string(output))
|
url := core.Trim(output.Stdout)
|
||||||
return parseGitHubRepo(url)
|
return parseGitHubRepo(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -557,7 +570,7 @@ func detectOrgFromGit() string {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
parts := strings.Split(repo, "/")
|
parts := core.Split(repo, "/")
|
||||||
if len(parts) >= 1 {
|
if len(parts) >= 1 {
|
||||||
return parts[0]
|
return parts[0]
|
||||||
}
|
}
|
||||||
|
|
@ -567,20 +580,41 @@ func detectOrgFromGit() string {
|
||||||
// parseGitHubRepo extracts org/repo from a git URL
|
// parseGitHubRepo extracts org/repo from a git URL
|
||||||
func parseGitHubRepo(url string) (string, error) {
|
func parseGitHubRepo(url string) (string, error) {
|
||||||
// Handle SSH URLs: git@github.com:org/repo.git
|
// Handle SSH URLs: git@github.com:org/repo.git
|
||||||
if strings.HasPrefix(url, "git@github.com:") {
|
if core.HasPrefix(url, "git@github.com:") {
|
||||||
path := strings.TrimPrefix(url, "git@github.com:")
|
path := core.TrimPrefix(url, "git@github.com:")
|
||||||
path = strings.TrimSuffix(path, ".git")
|
path = core.TrimSuffix(path, ".git")
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle HTTPS URLs: https://github.com/org/repo.git
|
// Handle HTTPS URLs: https://github.com/org/repo.git
|
||||||
if strings.Contains(url, "github.com/") {
|
if core.Contains(url, "github.com/") {
|
||||||
parts := strings.Split(url, "github.com/")
|
parts := core.Split(url, "github.com/")
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
path := strings.TrimSuffix(parts[1], ".git")
|
path := core.TrimSuffix(parts[1], ".git")
|
||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", log.E("monitor.parseGitHubRepo", "could not parse GitHub repo from URL: "+url, nil)
|
return "", core.E("monitor.parseGitHubRepo", core.Concat("could not parse GitHub repo from URL: ", url), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitorResultErr(r core.Result, op string) error {
|
||||||
|
if r.OK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err, ok := r.Value.(error); ok && err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.Value == nil {
|
||||||
|
return core.E(op, "unexpected empty core result", nil)
|
||||||
|
}
|
||||||
|
return core.E(op, core.Sprint(r.Value), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandExitErr(op string, result coreexec.Result) error {
|
||||||
|
msg := core.Trim(result.Stderr)
|
||||||
|
if msg == "" {
|
||||||
|
msg = core.Sprintf("command exited with status %d", result.ExitCode)
|
||||||
|
}
|
||||||
|
return core.E(op, msg, nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
cmd/monitor/specs/RFC.md
Normal file
54
cmd/monitor/specs/RFC.md
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# monitor
|
||||||
|
**Import:** `forge.lthn.ai/core/go-infra/cmd/monitor`
|
||||||
|
**Files:** 2
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
### `Finding`
|
||||||
|
Normalized security finding emitted by the `monitor` command regardless of source system.
|
||||||
|
- `Source string`: Source system or scanner name such as `semgrep`, `trivy`, or `dependabot`.
|
||||||
|
- `Severity string`: Normalized severity level.
|
||||||
|
- `Rule string`: Rule identifier, advisory identifier, or CVE.
|
||||||
|
- `File string`: Affected file path when the source provides one.
|
||||||
|
- `Line int`: Affected line number, or `0` when no location exists.
|
||||||
|
- `Message string`: Human-readable summary of the finding.
|
||||||
|
- `URL string`: Link to the upstream alert.
|
||||||
|
- `State string`: Alert state such as `open`, `dismissed`, `fixed`, or `resolved`.
|
||||||
|
- `RepoName string`: Short repository name used in output.
|
||||||
|
- `CreatedAt string`: Creation timestamp returned by GitHub.
|
||||||
|
- `Labels []string`: Suggested labels to attach downstream.
|
||||||
|
|
||||||
|
### `CodeScanningAlert`
|
||||||
|
Subset of the GitHub code scanning alert schema used by the command.
|
||||||
|
- `Number int`: Numeric GitHub alert ID.
|
||||||
|
- `State string`: Alert state.
|
||||||
|
- `Rule struct{ ID string; Severity string; Description string }`: Rule metadata returned by GitHub.
|
||||||
|
- `Tool struct{ Name string }`: Scanner or tool that emitted the alert.
|
||||||
|
- `MostRecentInstance struct{ Location struct{ Path string; StartLine int }; Message struct{ Text string } }`: Most recent code location and message payload attached to the alert.
|
||||||
|
- `HTMLURL string`: Browser URL for the alert.
|
||||||
|
- `CreatedAt string`: Creation timestamp.
|
||||||
|
|
||||||
|
### `DependabotAlert`
|
||||||
|
Subset of the GitHub Dependabot alert schema used by the command.
|
||||||
|
- `Number int`: Numeric GitHub alert ID.
|
||||||
|
- `State string`: Alert state.
|
||||||
|
- `SecurityVulnerability struct{ Severity string; Package struct{ Name string; Ecosystem string } }`: Vulnerability severity and affected package metadata.
|
||||||
|
- `SecurityAdvisory struct{ CVEID string; Summary string; Description string }`: Advisory identifiers and descriptive text.
|
||||||
|
- `Dependency struct{ ManifestPath string }`: Manifest file that introduced the vulnerable dependency.
|
||||||
|
- `HTMLURL string`: Browser URL for the alert.
|
||||||
|
- `CreatedAt string`: Creation timestamp.
|
||||||
|
|
||||||
|
### `SecretScanningAlert`
|
||||||
|
Subset of the GitHub secret scanning alert schema used by the command.
|
||||||
|
- `Number int`: Numeric GitHub alert ID.
|
||||||
|
- `State string`: Alert state.
|
||||||
|
- `SecretType string`: Secret or token classification.
|
||||||
|
- `Secret string`: Redacted secret preview from the API.
|
||||||
|
- `HTMLURL string`: Browser URL for the alert.
|
||||||
|
- `LocationType string`: Where GitHub found the secret.
|
||||||
|
- `CreatedAt string`: Creation timestamp.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `func AddMonitorCommands(root *cli.Command)`
|
||||||
|
Registers the top-level `monitor` command on the shared CLI root, along with its `repo`, `severity`, `json`, and `all` flags.
|
||||||
|
|
@ -9,6 +9,7 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddProdCommands registers the 'prod' command and all subcommands.
|
// AddProdCommands registers the 'prod' command and all subcommands.
|
||||||
|
// Usage: prod.AddProdCommands(root)
|
||||||
func AddProdCommands(root *cli.Command) {
|
func AddProdCommands(root *cli.Command) {
|
||||||
root.AddCommand(Cmd)
|
root.AddCommand(Cmd)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ package prod
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/go-infra"
|
"forge.lthn.ai/core/go-infra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -52,10 +51,10 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDNSClient() (*infra.CloudNSClient, error) {
|
func getDNSClient() (*infra.CloudNSClient, error) {
|
||||||
authID := os.Getenv("CLOUDNS_AUTH_ID")
|
authID := core.Env("CLOUDNS_AUTH_ID")
|
||||||
authPass := os.Getenv("CLOUDNS_AUTH_PASSWORD")
|
authPass := core.Env("CLOUDNS_AUTH_PASSWORD")
|
||||||
if authID == "" || authPass == "" {
|
if authID == "" || authPass == "" {
|
||||||
return nil, coreerr.E("prod.getDNSClient", "CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD required", nil)
|
return nil, core.E("prod.getDNSClient", "CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD required", nil)
|
||||||
}
|
}
|
||||||
return infra.NewCloudNSClient(authID, authPass), nil
|
return infra.NewCloudNSClient(authID, authPass), nil
|
||||||
}
|
}
|
||||||
|
|
@ -76,7 +75,7 @@ func runDNSList(cmd *cli.Command, args []string) error {
|
||||||
|
|
||||||
records, err := dns.ListRecords(ctx, zone)
|
records, err := dns.ListRecords(ctx, zone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("prod.runDNSList", "list records", err)
|
return core.E("prod.runDNSList", "list records", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.Print("%s DNS records for %s\n\n", cli.BoldStyle.Render("▶"), cli.TitleStyle.Render(zone))
|
cli.Print("%s DNS records for %s\n\n", cli.BoldStyle.Render("▶"), cli.TitleStyle.Render(zone))
|
||||||
|
|
@ -113,7 +112,7 @@ func runDNSSet(cmd *cli.Command, args []string) error {
|
||||||
|
|
||||||
changed, err := dns.EnsureRecord(ctx, dnsZone, host, recordType, value, dnsTTL)
|
changed, err := dns.EnsureRecord(ctx, dnsZone, host, recordType, value, dnsTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("prod.runDNSSet", "set record", err)
|
return core.E("prod.runDNSSet", "set record", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@ package prod
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-infra"
|
"forge.lthn.ai/core/go-infra"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var lbCmd = &cli.Command{
|
var lbCmd = &cli.Command{
|
||||||
|
|
@ -37,9 +35,9 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHCloudClient() (*infra.HCloudClient, error) {
|
func getHCloudClient() (*infra.HCloudClient, error) {
|
||||||
token := os.Getenv("HCLOUD_TOKEN")
|
token := core.Env("HCLOUD_TOKEN")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, coreerr.E("prod.getHCloudClient", "HCLOUD_TOKEN environment variable required", nil)
|
return nil, core.E("prod.getHCloudClient", "HCLOUD_TOKEN environment variable required", nil)
|
||||||
}
|
}
|
||||||
return infra.NewHCloudClient(token), nil
|
return infra.NewHCloudClient(token), nil
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +53,7 @@ func runLBStatus(cmd *cli.Command, args []string) error {
|
||||||
|
|
||||||
lbs, err := hc.ListLoadBalancers(ctx)
|
lbs, err := hc.ListLoadBalancers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("prod.runLBStatus", "list load balancers", err)
|
return core.E("prod.runLBStatus", "list load balancers", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(lbs) == 0 {
|
if len(lbs) == 0 {
|
||||||
|
|
@ -74,7 +72,7 @@ func runLBStatus(cmd *cli.Command, args []string) error {
|
||||||
cli.Print("\n Services:\n")
|
cli.Print("\n Services:\n")
|
||||||
for _, s := range lb.Services {
|
for _, s := range lb.Services {
|
||||||
cli.Print(" %s :%d -> :%d proxy_protocol=%v\n",
|
cli.Print(" %s :%d -> :%d proxy_protocol=%v\n",
|
||||||
s.Protocol, s.ListenPort, s.DestinationPort, s.ProxyProtocol)
|
s.Protocol, s.ListenPort, s.DestinationPort, s.Proxyprotocol)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -94,7 +92,7 @@ func runLBStatus(cmd *cli.Command, args []string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println()
|
core.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cmd is the root prod command.
|
// Cmd is the root prod command.
|
||||||
|
// Usage: root.AddCommand(prod.Cmd)
|
||||||
var Cmd = &cli.Command{
|
var Cmd = &cli.Command{
|
||||||
Use: "prod",
|
Use: "prod",
|
||||||
Short: "Production infrastructure management",
|
Short: "Production infrastructure management",
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,11 @@ package prod
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
"forge.lthn.ai/core/go-infra"
|
"forge.lthn.ai/core/go-infra"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var setupCmd = &cli.Command{
|
var setupCmd = &cli.Command{
|
||||||
|
|
@ -70,7 +69,7 @@ func runSetup(cmd *cli.Command, args []string) error {
|
||||||
|
|
||||||
if err := step.fn(ctx, cfg); err != nil {
|
if err := step.fn(ctx, cfg); err != nil {
|
||||||
cli.Print(" %s %s: %s\n", cli.ErrorStyle.Render("✗"), step.name, err)
|
cli.Print(" %s %s: %s\n", cli.ErrorStyle.Render("✗"), step.name, err)
|
||||||
return coreerr.E("prod.setup", "step "+step.name+" failed", err)
|
return core.E("prod.setup", core.Concat("step ", step.name, " failed"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.Print(" %s %s complete\n", cli.SuccessStyle.Render("✓"), step.name)
|
cli.Print(" %s %s complete\n", cli.SuccessStyle.Render("✓"), step.name)
|
||||||
|
|
@ -82,14 +81,14 @@ func runSetup(cmd *cli.Command, args []string) error {
|
||||||
|
|
||||||
func stepDiscover(ctx context.Context, cfg *infra.Config) error {
|
func stepDiscover(ctx context.Context, cfg *infra.Config) error {
|
||||||
// Discover HCloud servers
|
// Discover HCloud servers
|
||||||
hcloudToken := os.Getenv("HCLOUD_TOKEN")
|
hcloudToken := core.Env("HCLOUD_TOKEN")
|
||||||
if hcloudToken != "" {
|
if hcloudToken != "" {
|
||||||
cli.Print(" Discovering Hetzner Cloud servers...\n")
|
cli.Print(" Discovering Hetzner Cloud servers...\n")
|
||||||
|
|
||||||
hc := infra.NewHCloudClient(hcloudToken)
|
hc := infra.NewHCloudClient(hcloudToken)
|
||||||
servers, err := hc.ListServers(ctx)
|
servers, err := hc.ListServers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("prod.stepDiscover", "list HCloud servers", err)
|
return core.E("prod.stepDiscover", "list HCloud servers", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range servers {
|
for _, s := range servers {
|
||||||
|
|
@ -106,15 +105,15 @@ func stepDiscover(ctx context.Context, cfg *infra.Config) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discover Robot servers
|
// Discover Robot servers
|
||||||
robotUser := os.Getenv("HETZNER_ROBOT_USER")
|
robotUser := core.Env("HETZNER_ROBOT_USER")
|
||||||
robotPass := os.Getenv("HETZNER_ROBOT_PASS")
|
robotPass := core.Env("HETZNER_ROBOT_PASS")
|
||||||
if robotUser != "" && robotPass != "" {
|
if robotUser != "" && robotPass != "" {
|
||||||
cli.Print(" Discovering Hetzner Robot servers...\n")
|
cli.Print(" Discovering Hetzner Robot servers...\n")
|
||||||
|
|
||||||
hr := infra.NewHRobotClient(robotUser, robotPass)
|
hr := infra.NewHRobotClient(robotUser, robotPass)
|
||||||
servers, err := hr.ListServers(ctx)
|
servers, err := hr.ListServers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("prod.stepDiscover", "list Robot servers", err)
|
return core.E("prod.stepDiscover", "list Robot servers", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range servers {
|
for _, s := range servers {
|
||||||
|
|
@ -138,9 +137,9 @@ func stepDiscover(ctx context.Context, cfg *infra.Config) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error {
|
func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error {
|
||||||
hcloudToken := os.Getenv("HCLOUD_TOKEN")
|
hcloudToken := core.Env("HCLOUD_TOKEN")
|
||||||
if hcloudToken == "" {
|
if hcloudToken == "" {
|
||||||
return coreerr.E("prod.stepLoadBalancer", "HCLOUD_TOKEN required for load balancer management", nil)
|
return core.E("prod.stepLoadBalancer", "HCLOUD_TOKEN required for load balancer management", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
hc := infra.NewHCloudClient(hcloudToken)
|
hc := infra.NewHCloudClient(hcloudToken)
|
||||||
|
|
@ -148,7 +147,7 @@ func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error {
|
||||||
// Check if LB already exists
|
// Check if LB already exists
|
||||||
lbs, err := hc.ListLoadBalancers(ctx)
|
lbs, err := hc.ListLoadBalancers(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("prod.stepLoadBalancer", "list load balancers", err)
|
return core.E("prod.stepLoadBalancer", "list load balancers", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, lb := range lbs {
|
for _, lb := range lbs {
|
||||||
|
|
@ -175,7 +174,7 @@ func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error {
|
||||||
for _, b := range cfg.LoadBalancer.Backends {
|
for _, b := range cfg.LoadBalancer.Backends {
|
||||||
host, ok := cfg.Hosts[b.Host]
|
host, ok := cfg.Hosts[b.Host]
|
||||||
if !ok {
|
if !ok {
|
||||||
return coreerr.E("prod.stepLoadBalancer", "backend host '"+b.Host+"' not found in config", nil)
|
return core.E("prod.stepLoadBalancer", core.Concat("backend host '", b.Host, "' not found in config"), nil)
|
||||||
}
|
}
|
||||||
targets = append(targets, infra.HCloudLBCreateTarget{
|
targets = append(targets, infra.HCloudLBCreateTarget{
|
||||||
Type: "ip",
|
Type: "ip",
|
||||||
|
|
@ -190,7 +189,7 @@ func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error {
|
||||||
Protocol: l.Protocol,
|
Protocol: l.Protocol,
|
||||||
ListenPort: l.Frontend,
|
ListenPort: l.Frontend,
|
||||||
DestinationPort: l.Backend,
|
DestinationPort: l.Backend,
|
||||||
ProxyProtocol: l.ProxyProtocol,
|
Proxyprotocol: l.ProxyProtocol,
|
||||||
HealthCheck: &infra.HCloudLBHealthCheck{
|
HealthCheck: &infra.HCloudLBHealthCheck{
|
||||||
Protocol: cfg.LoadBalancer.Health.Protocol,
|
Protocol: cfg.LoadBalancer.Health.Protocol,
|
||||||
Port: l.Backend,
|
Port: l.Backend,
|
||||||
|
|
@ -223,7 +222,7 @@ func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error {
|
||||||
|
|
||||||
lb, err := hc.CreateLoadBalancer(ctx, req)
|
lb, err := hc.CreateLoadBalancer(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("prod.stepLoadBalancer", "create load balancer", err)
|
return core.E("prod.stepLoadBalancer", "create load balancer", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.Print(" Created: %s (ID: %d, IP: %s)\n",
|
cli.Print(" Created: %s (ID: %d, IP: %s)\n",
|
||||||
|
|
@ -233,10 +232,10 @@ func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func stepDNS(ctx context.Context, cfg *infra.Config) error {
|
func stepDNS(ctx context.Context, cfg *infra.Config) error {
|
||||||
authID := os.Getenv("CLOUDNS_AUTH_ID")
|
authID := core.Env("CLOUDNS_AUTH_ID")
|
||||||
authPass := os.Getenv("CLOUDNS_AUTH_PASSWORD")
|
authPass := core.Env("CLOUDNS_AUTH_PASSWORD")
|
||||||
if authID == "" || authPass == "" {
|
if authID == "" || authPass == "" {
|
||||||
return coreerr.E("prod.stepDNS", "CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD required", nil)
|
return core.E("prod.stepDNS", "CLOUDNS_AUTH_ID and CLOUDNS_AUTH_PASSWORD required", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
dns := infra.NewCloudNSClient(authID, authPass)
|
dns := infra.NewCloudNSClient(authID, authPass)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
package prod
|
package prod
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
core "dappco.re/go/core"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
"forge.lthn.ai/core/go-infra/internal/coreexec"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sshCmd = &cli.Command{
|
var sshCmd = &cli.Command{
|
||||||
|
|
@ -38,15 +34,14 @@ func runSSH(cmd *cli.Command, args []string) error {
|
||||||
for n, h := range cfg.Hosts {
|
for n, h := range cfg.Hosts {
|
||||||
cli.Print(" %s %s (%s)\n", cli.BoldStyle.Render(n), h.IP, h.Role)
|
cli.Print(" %s %s (%s)\n", cli.BoldStyle.Render(n), h.IP, h.Role)
|
||||||
}
|
}
|
||||||
return coreerr.E("prod.ssh", "host '"+name+"' not found in infra.yaml", nil)
|
return core.E("prod.ssh", core.Concat("host '", name, "' not found in infra.yaml"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
sshArgs := []string{
|
sshArgs := []string{
|
||||||
"ssh",
|
|
||||||
"-i", host.SSH.Key,
|
"-i", host.SSH.Key,
|
||||||
"-p", fmt.Sprintf("%d", host.SSH.Port),
|
"-p", core.Sprintf("%d", host.SSH.Port),
|
||||||
"-o", "StrictHostKeyChecking=accept-new",
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
fmt.Sprintf("%s@%s", host.SSH.User, host.IP),
|
core.Sprintf("%s@%s", host.SSH.User, host.IP),
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.Print("%s %s@%s (%s)\n",
|
cli.Print("%s %s@%s (%s)\n",
|
||||||
|
|
@ -54,11 +49,9 @@ func runSSH(cmd *cli.Command, args []string) error {
|
||||||
host.SSH.User, host.FQDN,
|
host.SSH.User, host.FQDN,
|
||||||
cli.DimStyle.Render(host.IP))
|
cli.DimStyle.Render(host.IP))
|
||||||
|
|
||||||
sshPath, err := exec.LookPath("ssh")
|
|
||||||
if err != nil {
|
|
||||||
return coreerr.E("prod.ssh", "ssh not found", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace current process with SSH
|
// Replace current process with SSH
|
||||||
return syscall.Exec(sshPath, sshArgs, os.Environ())
|
if err := coreexec.Exec("ssh", sshArgs...); err != nil {
|
||||||
|
return core.E("prod.ssh", "exec ssh", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,12 @@ package prod
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-ansible"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"forge.lthn.ai/core/cli/pkg/cli"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
"forge.lthn.ai/core/go-ansible"
|
||||||
"forge.lthn.ai/core/go-infra"
|
"forge.lthn.ai/core/go-infra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -85,11 +82,11 @@ func runStatus(cmd *cli.Command, args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check LB if token available
|
// Check LB if token available
|
||||||
if token := os.Getenv("HCLOUD_TOKEN"); token != "" {
|
if token := core.Env("HCLOUD_TOKEN"); token != "" {
|
||||||
fmt.Println()
|
core.Println()
|
||||||
checkLoadBalancer(ctx, token)
|
checkLoadBalancer(ctx, token)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println()
|
core.Println()
|
||||||
cli.Print("%s Load balancer: %s\n",
|
cli.Print("%s Load balancer: %s\n",
|
||||||
cli.DimStyle.Render(" ○"),
|
cli.DimStyle.Render(" ○"),
|
||||||
cli.DimStyle.Render("HCLOUD_TOKEN not set (skipped)"))
|
cli.DimStyle.Render("HCLOUD_TOKEN not set (skipped)"))
|
||||||
|
|
@ -115,14 +112,14 @@ func checkHost(ctx context.Context, name string, host *infra.Host) hostStatus {
|
||||||
|
|
||||||
client, err := ansible.NewSSHClient(sshCfg)
|
client, err := ansible.NewSSHClient(sshCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.Error = coreerr.E("prod.checkHost", "create SSH client", err)
|
s.Error = core.E("prod.checkHost", "create SSH client", err)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
defer func() { _ = client.Close() }()
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
if err := client.Connect(ctx); err != nil {
|
if err := client.Connect(ctx); err != nil {
|
||||||
s.Error = coreerr.E("prod.checkHost", "SSH connect", err)
|
s.Error = core.E("prod.checkHost", "SSH connect", err)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
s.Connected = true
|
s.Connected = true
|
||||||
|
|
@ -130,12 +127,12 @@ func checkHost(ctx context.Context, name string, host *infra.Host) hostStatus {
|
||||||
|
|
||||||
// OS info
|
// OS info
|
||||||
stdout, _, _, _ := client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2")
|
stdout, _, _, _ := client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2")
|
||||||
s.OS = strings.TrimSpace(stdout)
|
s.OS = core.Trim(stdout)
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null | head -1")
|
stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null | head -1")
|
||||||
if err == nil && stdout != "" {
|
if err == nil && stdout != "" {
|
||||||
s.Docker = strings.TrimSpace(stdout)
|
s.Docker = core.Trim(stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check each expected service
|
// Check each expected service
|
||||||
|
|
@ -151,14 +148,14 @@ func checkService(ctx context.Context, client *ansible.SSHClient, service string
|
||||||
switch service {
|
switch service {
|
||||||
case "coolify":
|
case "coolify":
|
||||||
stdout, _, _, _ := client.Run(ctx, "docker ps --format '{{.Names}}' 2>/dev/null | grep -c coolify")
|
stdout, _, _, _ := client.Run(ctx, "docker ps --format '{{.Names}}' 2>/dev/null | grep -c coolify")
|
||||||
if strings.TrimSpace(stdout) != "0" && strings.TrimSpace(stdout) != "" {
|
if core.Trim(stdout) != "0" && core.Trim(stdout) != "" {
|
||||||
return "running"
|
return "running"
|
||||||
}
|
}
|
||||||
return "not running"
|
return "not running"
|
||||||
|
|
||||||
case "traefik":
|
case "traefik":
|
||||||
stdout, _, _, _ := client.Run(ctx, "docker ps --format '{{.Names}}' 2>/dev/null | grep -c traefik")
|
stdout, _, _, _ := client.Run(ctx, "docker ps --format '{{.Names}}' 2>/dev/null | grep -c traefik")
|
||||||
if strings.TrimSpace(stdout) != "0" && strings.TrimSpace(stdout) != "" {
|
if core.Trim(stdout) != "0" && core.Trim(stdout) != "" {
|
||||||
return "running"
|
return "running"
|
||||||
}
|
}
|
||||||
return "not running"
|
return "not running"
|
||||||
|
|
@ -168,16 +165,16 @@ func checkService(ctx context.Context, client *ansible.SSHClient, service string
|
||||||
stdout, _, _, _ := client.Run(ctx,
|
stdout, _, _, _ := client.Run(ctx,
|
||||||
"docker exec $(docker ps -q --filter name=mariadb 2>/dev/null || echo none) "+
|
"docker exec $(docker ps -q --filter name=mariadb 2>/dev/null || echo none) "+
|
||||||
"mariadb -u root -e \"SHOW STATUS LIKE 'wsrep_cluster_size'\" --skip-column-names 2>/dev/null | awk '{print $2}'")
|
"mariadb -u root -e \"SHOW STATUS LIKE 'wsrep_cluster_size'\" --skip-column-names 2>/dev/null | awk '{print $2}'")
|
||||||
size := strings.TrimSpace(stdout)
|
size := core.Trim(stdout)
|
||||||
if size != "" && size != "0" {
|
if size != "" && size != "0" {
|
||||||
return fmt.Sprintf("cluster_size=%s", size)
|
return core.Sprintf("cluster_size=%s", size)
|
||||||
}
|
}
|
||||||
// Try non-Docker
|
// Try non-Docker
|
||||||
stdout, _, _, _ = client.Run(ctx,
|
stdout, _, _, _ = client.Run(ctx,
|
||||||
"mariadb -u root -e \"SHOW STATUS LIKE 'wsrep_cluster_size'\" --skip-column-names 2>/dev/null | awk '{print $2}'")
|
"mariadb -u root -e \"SHOW STATUS LIKE 'wsrep_cluster_size'\" --skip-column-names 2>/dev/null | awk '{print $2}'")
|
||||||
size = strings.TrimSpace(stdout)
|
size = core.Trim(stdout)
|
||||||
if size != "" && size != "0" {
|
if size != "" && size != "0" {
|
||||||
return fmt.Sprintf("cluster_size=%s", size)
|
return core.Sprintf("cluster_size=%s", size)
|
||||||
}
|
}
|
||||||
return "not running"
|
return "not running"
|
||||||
|
|
||||||
|
|
@ -185,18 +182,18 @@ func checkService(ctx context.Context, client *ansible.SSHClient, service string
|
||||||
stdout, _, _, _ := client.Run(ctx,
|
stdout, _, _, _ := client.Run(ctx,
|
||||||
"docker exec $(docker ps -q --filter name=redis 2>/dev/null || echo none) "+
|
"docker exec $(docker ps -q --filter name=redis 2>/dev/null || echo none) "+
|
||||||
"redis-cli ping 2>/dev/null")
|
"redis-cli ping 2>/dev/null")
|
||||||
if strings.TrimSpace(stdout) == "PONG" {
|
if core.Trim(stdout) == "PONG" {
|
||||||
return "running"
|
return "running"
|
||||||
}
|
}
|
||||||
stdout, _, _, _ = client.Run(ctx, "redis-cli ping 2>/dev/null")
|
stdout, _, _, _ = client.Run(ctx, "redis-cli ping 2>/dev/null")
|
||||||
if strings.TrimSpace(stdout) == "PONG" {
|
if core.Trim(stdout) == "PONG" {
|
||||||
return "running"
|
return "running"
|
||||||
}
|
}
|
||||||
return "not running"
|
return "not running"
|
||||||
|
|
||||||
case "forgejo-runner":
|
case "forgejo-runner":
|
||||||
stdout, _, _, _ := client.Run(ctx, "systemctl is-active forgejo-runner 2>/dev/null || docker ps --format '{{.Names}}' 2>/dev/null | grep -c runner")
|
stdout, _, _, _ := client.Run(ctx, "systemctl is-active forgejo-runner 2>/dev/null || docker ps --format '{{.Names}}' 2>/dev/null | grep -c runner")
|
||||||
val := strings.TrimSpace(stdout)
|
val := core.Trim(stdout)
|
||||||
if val == "active" || (val != "0" && val != "") {
|
if val == "active" || (val != "0" && val != "") {
|
||||||
return "running"
|
return "running"
|
||||||
}
|
}
|
||||||
|
|
@ -205,8 +202,8 @@ func checkService(ctx context.Context, client *ansible.SSHClient, service string
|
||||||
default:
|
default:
|
||||||
// Generic docker container check
|
// Generic docker container check
|
||||||
stdout, _, _, _ := client.Run(ctx,
|
stdout, _, _, _ := client.Run(ctx,
|
||||||
fmt.Sprintf("docker ps --format '{{.Names}}' 2>/dev/null | grep -c %s", service))
|
core.Sprintf("docker ps --format '{{.Names}}' 2>/dev/null | grep -c %s", service))
|
||||||
if strings.TrimSpace(stdout) != "0" && strings.TrimSpace(stdout) != "" {
|
if core.Trim(stdout) != "0" && core.Trim(stdout) != "" {
|
||||||
return "running"
|
return "running"
|
||||||
}
|
}
|
||||||
return "not running"
|
return "not running"
|
||||||
|
|
@ -248,7 +245,7 @@ func printHostStatus(s hostStatus) {
|
||||||
if s.OS != "" {
|
if s.OS != "" {
|
||||||
cli.Print(" %s", cli.DimStyle.Render(s.OS))
|
cli.Print(" %s", cli.DimStyle.Render(s.OS))
|
||||||
}
|
}
|
||||||
fmt.Println()
|
core.Println()
|
||||||
|
|
||||||
if s.Docker != "" {
|
if s.Docker != "" {
|
||||||
cli.Print(" %s %s\n", cli.SuccessStyle.Render("✓"), cli.DimStyle.Render(s.Docker))
|
cli.Print(" %s %s\n", cli.SuccessStyle.Render("✓"), cli.DimStyle.Render(s.Docker))
|
||||||
|
|
@ -271,7 +268,7 @@ func printHostStatus(s hostStatus) {
|
||||||
cli.Print(" %s %s %s\n", icon, svc, style.Render(status))
|
cli.Print(" %s %s %s\n", icon, svc, style.Render(status))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println()
|
core.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkLoadBalancer(ctx context.Context, token string) {
|
func checkLoadBalancer(ctx context.Context, token string) {
|
||||||
|
|
@ -316,9 +313,9 @@ func loadConfig() (*infra.Config, string, error) {
|
||||||
return cfg, infraFile, err
|
return cfg, infraFile, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cwd, err := os.Getwd()
|
cwd := core.Env("DIR_CWD")
|
||||||
if err != nil {
|
if cwd == "" {
|
||||||
return nil, "", err
|
return nil, "", core.E("prod.loadConfig", "DIR_CWD unavailable", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return infra.Discover(cwd)
|
return infra.Discover(cwd)
|
||||||
|
|
|
||||||
12
cmd/prod/specs/RFC.md
Normal file
12
cmd/prod/specs/RFC.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# prod
|
||||||
|
**Import:** `forge.lthn.ai/core/go-infra/cmd/prod`
|
||||||
|
**Files:** 7
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
This package exports no structs, interfaces, or type aliases.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `func AddProdCommands(root *cli.Command)`
|
||||||
|
Registers the exported `Cmd` tree on the shared CLI root so the `prod` command and its `status`, `setup`, `dns`, `lb`, and `ssh` subcommands become available.
|
||||||
71
config.go
71
config.go
|
|
@ -3,15 +3,12 @@
|
||||||
package infra
|
package infra
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
core "dappco.re/go/core"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the top-level infrastructure configuration parsed from infra.yaml.
|
// Config is the top-level infrastructure configuration parsed from infra.yaml.
|
||||||
|
// Usage: cfg := infra.Config{}
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Hosts map[string]*Host `yaml:"hosts"`
|
Hosts map[string]*Host `yaml:"hosts"`
|
||||||
LoadBalancer LoadBalancer `yaml:"load_balancer"`
|
LoadBalancer LoadBalancer `yaml:"load_balancer"`
|
||||||
|
|
@ -29,6 +26,7 @@ type Config struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Host represents a server in the infrastructure.
|
// Host represents a server in the infrastructure.
|
||||||
|
// Usage: host := infra.Host{}
|
||||||
type Host struct {
|
type Host struct {
|
||||||
FQDN string `yaml:"fqdn"`
|
FQDN string `yaml:"fqdn"`
|
||||||
IP string `yaml:"ip"`
|
IP string `yaml:"ip"`
|
||||||
|
|
@ -40,6 +38,7 @@ type Host struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHConf holds SSH connection details for a host.
|
// SSHConf holds SSH connection details for a host.
|
||||||
|
// Usage: ssh := infra.SSHConf{}
|
||||||
type SSHConf struct {
|
type SSHConf struct {
|
||||||
User string `yaml:"user"`
|
User string `yaml:"user"`
|
||||||
Key string `yaml:"key"`
|
Key string `yaml:"key"`
|
||||||
|
|
@ -47,6 +46,7 @@ type SSHConf struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadBalancer represents a Hetzner managed load balancer.
|
// LoadBalancer represents a Hetzner managed load balancer.
|
||||||
|
// Usage: lb := infra.LoadBalancer{}
|
||||||
type LoadBalancer struct {
|
type LoadBalancer struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
FQDN string `yaml:"fqdn"`
|
FQDN string `yaml:"fqdn"`
|
||||||
|
|
@ -61,12 +61,14 @@ type LoadBalancer struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend is a load balancer backend target.
|
// Backend is a load balancer backend target.
|
||||||
|
// Usage: backend := infra.Backend{}
|
||||||
type Backend struct {
|
type Backend struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthCheck configures load balancer health checking.
|
// HealthCheck configures load balancer health checking.
|
||||||
|
// Usage: check := infra.HealthCheck{}
|
||||||
type HealthCheck struct {
|
type HealthCheck struct {
|
||||||
Protocol string `yaml:"protocol"`
|
Protocol string `yaml:"protocol"`
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
|
|
@ -74,6 +76,7 @@ type HealthCheck struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listener maps a frontend port to a backend port.
|
// Listener maps a frontend port to a backend port.
|
||||||
|
// Usage: listener := infra.Listener{}
|
||||||
type Listener struct {
|
type Listener struct {
|
||||||
Frontend int `yaml:"frontend"`
|
Frontend int `yaml:"frontend"`
|
||||||
Backend int `yaml:"backend"`
|
Backend int `yaml:"backend"`
|
||||||
|
|
@ -82,18 +85,21 @@ type Listener struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LBCert holds the SSL certificate configuration for the load balancer.
|
// LBCert holds the SSL certificate configuration for the load balancer.
|
||||||
|
// Usage: cert := infra.LBCert{}
|
||||||
type LBCert struct {
|
type LBCert struct {
|
||||||
Certificate string `yaml:"certificate"`
|
Certificate string `yaml:"certificate"`
|
||||||
SAN []string `yaml:"san"`
|
SAN []string `yaml:"san"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network describes the private network.
|
// Network describes the private network.
|
||||||
|
// Usage: network := infra.Network{}
|
||||||
type Network struct {
|
type Network struct {
|
||||||
CIDR string `yaml:"cidr"`
|
CIDR string `yaml:"cidr"`
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNS holds DNS provider configuration and zone records.
|
// DNS holds DNS provider configuration and zone records.
|
||||||
|
// Usage: dns := infra.DNS{}
|
||||||
type DNS struct {
|
type DNS struct {
|
||||||
Provider string `yaml:"provider"`
|
Provider string `yaml:"provider"`
|
||||||
Nameservers []string `yaml:"nameservers"`
|
Nameservers []string `yaml:"nameservers"`
|
||||||
|
|
@ -101,11 +107,13 @@ type DNS struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zone is a DNS zone with its records.
|
// Zone is a DNS zone with its records.
|
||||||
|
// Usage: zone := infra.Zone{}
|
||||||
type Zone struct {
|
type Zone struct {
|
||||||
Records []DNSRecord `yaml:"records"`
|
Records []DNSRecord `yaml:"records"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNSRecord is a single DNS record.
|
// DNSRecord is a single DNS record.
|
||||||
|
// Usage: record := infra.DNSRecord{}
|
||||||
type DNSRecord struct {
|
type DNSRecord struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type"`
|
||||||
|
|
@ -114,11 +122,13 @@ type DNSRecord struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSL holds SSL certificate configuration.
|
// SSL holds SSL certificate configuration.
|
||||||
|
// Usage: ssl := infra.SSL{}
|
||||||
type SSL struct {
|
type SSL struct {
|
||||||
Wildcard WildcardCert `yaml:"wildcard"`
|
Wildcard WildcardCert `yaml:"wildcard"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WildcardCert describes a wildcard SSL certificate.
|
// WildcardCert describes a wildcard SSL certificate.
|
||||||
|
// Usage: cert := infra.WildcardCert{}
|
||||||
type WildcardCert struct {
|
type WildcardCert struct {
|
||||||
Domains []string `yaml:"domains"`
|
Domains []string `yaml:"domains"`
|
||||||
Method string `yaml:"method"`
|
Method string `yaml:"method"`
|
||||||
|
|
@ -127,6 +137,7 @@ type WildcardCert struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database describes the database cluster.
|
// Database describes the database cluster.
|
||||||
|
// Usage: db := infra.Database{}
|
||||||
type Database struct {
|
type Database struct {
|
||||||
Engine string `yaml:"engine"`
|
Engine string `yaml:"engine"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
|
|
@ -137,12 +148,14 @@ type Database struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DBNode is a database cluster node.
|
// DBNode is a database cluster node.
|
||||||
|
// Usage: node := infra.DBNode{}
|
||||||
type DBNode struct {
|
type DBNode struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupConfig describes automated backup settings.
|
// BackupConfig describes automated backup settings.
|
||||||
|
// Usage: backup := infra.BackupConfig{}
|
||||||
type BackupConfig struct {
|
type BackupConfig struct {
|
||||||
Schedule string `yaml:"schedule"`
|
Schedule string `yaml:"schedule"`
|
||||||
Destination string `yaml:"destination"`
|
Destination string `yaml:"destination"`
|
||||||
|
|
@ -151,6 +164,7 @@ type BackupConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache describes the cache/session cluster.
|
// Cache describes the cache/session cluster.
|
||||||
|
// Usage: cache := infra.Cache{}
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
Engine string `yaml:"engine"`
|
Engine string `yaml:"engine"`
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
|
|
@ -159,12 +173,14 @@ type Cache struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheNode is a cache cluster node.
|
// CacheNode is a cache cluster node.
|
||||||
|
// Usage: node := infra.CacheNode{}
|
||||||
type CacheNode struct {
|
type CacheNode struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container describes a container deployment.
|
// Container describes a container deployment.
|
||||||
|
// Usage: container := infra.Container{}
|
||||||
type Container struct {
|
type Container struct {
|
||||||
Image string `yaml:"image"`
|
Image string `yaml:"image"`
|
||||||
Port int `yaml:"port,omitempty"`
|
Port int `yaml:"port,omitempty"`
|
||||||
|
|
@ -175,18 +191,21 @@ type Container struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// S3Config describes object storage.
|
// S3Config describes object storage.
|
||||||
|
// Usage: s3 := infra.S3Config{}
|
||||||
type S3Config struct {
|
type S3Config struct {
|
||||||
Endpoint string `yaml:"endpoint"`
|
Endpoint string `yaml:"endpoint"`
|
||||||
Buckets map[string]*S3Bucket `yaml:"buckets"`
|
Buckets map[string]*S3Bucket `yaml:"buckets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// S3Bucket is an S3 bucket configuration.
|
// S3Bucket is an S3 bucket configuration.
|
||||||
|
// Usage: bucket := infra.S3Bucket{}
|
||||||
type S3Bucket struct {
|
type S3Bucket struct {
|
||||||
Purpose string `yaml:"purpose"`
|
Purpose string `yaml:"purpose"`
|
||||||
Paths []string `yaml:"paths"`
|
Paths []string `yaml:"paths"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CDN describes CDN configuration.
|
// CDN describes CDN configuration.
|
||||||
|
// Usage: cdn := infra.CDN{}
|
||||||
type CDN struct {
|
type CDN struct {
|
||||||
Provider string `yaml:"provider"`
|
Provider string `yaml:"provider"`
|
||||||
Origin string `yaml:"origin"`
|
Origin string `yaml:"origin"`
|
||||||
|
|
@ -194,6 +213,7 @@ type CDN struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CICD describes CI/CD configuration.
|
// CICD describes CI/CD configuration.
|
||||||
|
// Usage: cicd := infra.CICD{}
|
||||||
type CICD struct {
|
type CICD struct {
|
||||||
Provider string `yaml:"provider"`
|
Provider string `yaml:"provider"`
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
|
|
@ -203,24 +223,28 @@ type CICD struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monitoring describes monitoring configuration.
|
// Monitoring describes monitoring configuration.
|
||||||
|
// Usage: monitoring := infra.Monitoring{}
|
||||||
type Monitoring struct {
|
type Monitoring struct {
|
||||||
HealthEndpoints []HealthEndpoint `yaml:"health_endpoints"`
|
HealthEndpoints []HealthEndpoint `yaml:"health_endpoints"`
|
||||||
Alerts map[string]int `yaml:"alerts"`
|
Alerts map[string]int `yaml:"alerts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthEndpoint is a URL to monitor.
|
// HealthEndpoint is a URL to monitor.
|
||||||
|
// Usage: endpoint := infra.HealthEndpoint{}
|
||||||
type HealthEndpoint struct {
|
type HealthEndpoint struct {
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url"`
|
||||||
Interval int `yaml:"interval"`
|
Interval int `yaml:"interval"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backups describes backup schedules.
|
// Backups describes backup schedules.
|
||||||
|
// Usage: backups := infra.Backups{}
|
||||||
type Backups struct {
|
type Backups struct {
|
||||||
Daily []BackupJob `yaml:"daily"`
|
Daily []BackupJob `yaml:"daily"`
|
||||||
Weekly []BackupJob `yaml:"weekly"`
|
Weekly []BackupJob `yaml:"weekly"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupJob is a scheduled backup task.
|
// BackupJob is a scheduled backup task.
|
||||||
|
// Usage: job := infra.BackupJob{}
|
||||||
type BackupJob struct {
|
type BackupJob struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Type string `yaml:"type"`
|
Type string `yaml:"type"`
|
||||||
|
|
@ -229,15 +253,16 @@ type BackupJob struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads and parses an infra.yaml file.
|
// Load reads and parses an infra.yaml file.
|
||||||
|
// Usage: cfg, err := infra.Load("/srv/project/infra.yaml")
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
data, err := coreio.Local.Read(path)
|
read := localFS.Read(path)
|
||||||
if err != nil {
|
if !read.OK {
|
||||||
return nil, coreerr.E("infra.Load", "read infra config", err)
|
return nil, core.E("infra.Load", "read infra config", coreResultErr(read, "infra.Load"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
|
if err := yaml.Unmarshal([]byte(read.Value.(string)), &cfg); err != nil {
|
||||||
return nil, coreerr.E("infra.Load", "parse infra config", err)
|
return nil, core.E("infra.Load", "parse infra config", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expand SSH key paths
|
// Expand SSH key paths
|
||||||
|
|
@ -254,25 +279,27 @@ func Load(path string) (*Config, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discover searches for infra.yaml in the given directory and parent directories.
|
// Discover searches for infra.yaml in the given directory and parent directories.
|
||||||
|
// Usage: cfg, path, err := infra.Discover(".")
|
||||||
func Discover(startDir string) (*Config, string, error) {
|
func Discover(startDir string) (*Config, string, error) {
|
||||||
dir := startDir
|
dir := startDir
|
||||||
for {
|
for {
|
||||||
path := filepath.Join(dir, "infra.yaml")
|
path := core.JoinPath(dir, "infra.yaml")
|
||||||
if _, err := os.Stat(path); err == nil {
|
if localFS.Exists(path) {
|
||||||
cfg, err := Load(path)
|
cfg, err := Load(path)
|
||||||
return cfg, path, err
|
return cfg, path, err
|
||||||
}
|
}
|
||||||
|
|
||||||
parent := filepath.Dir(dir)
|
parent := core.PathDir(dir)
|
||||||
if parent == dir {
|
if parent == dir {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
dir = parent
|
dir = parent
|
||||||
}
|
}
|
||||||
return nil, "", coreerr.E("infra.Discover", "infra.yaml not found (searched from "+startDir+")", nil)
|
return nil, "", core.E("infra.Discover", core.Concat("infra.yaml not found (searched from ", startDir, ")"), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HostsByRole returns all hosts matching the given role.
|
// HostsByRole returns all hosts matching the given role.
|
||||||
|
// Usage: apps := cfg.HostsByRole("app")
|
||||||
func (c *Config) HostsByRole(role string) map[string]*Host {
|
func (c *Config) HostsByRole(role string) map[string]*Host {
|
||||||
result := make(map[string]*Host)
|
result := make(map[string]*Host)
|
||||||
for name, h := range c.Hosts {
|
for name, h := range c.Hosts {
|
||||||
|
|
@ -284,18 +311,26 @@ func (c *Config) HostsByRole(role string) map[string]*Host {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppServers returns hosts with role "app".
|
// AppServers returns hosts with role "app".
|
||||||
|
// Usage: apps := cfg.AppServers()
|
||||||
func (c *Config) AppServers() map[string]*Host {
|
func (c *Config) AppServers() map[string]*Host {
|
||||||
return c.HostsByRole("app")
|
return c.HostsByRole("app")
|
||||||
}
|
}
|
||||||
|
|
||||||
// expandPath expands ~ to home directory.
|
// expandPath expands ~ to home directory.
|
||||||
func expandPath(path string) string {
|
func expandPath(path string) string {
|
||||||
if len(path) > 0 && path[0] == '~' {
|
if core.HasPrefix(path, "~") {
|
||||||
home, err := os.UserHomeDir()
|
home := core.Env("DIR_HOME")
|
||||||
if err != nil {
|
if home == "" {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
return filepath.Join(home, path[1:])
|
suffix := core.TrimPrefix(path, "~")
|
||||||
|
if suffix == "" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
if core.HasPrefix(suffix, "/") {
|
||||||
|
return core.Concat(home, suffix)
|
||||||
|
}
|
||||||
|
return core.JoinPath(home, suffix)
|
||||||
}
|
}
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
package infra
|
package infra
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoad_Good(t *testing.T) {
|
func TestConfig_Load_Good(t *testing.T) {
|
||||||
// Find infra.yaml relative to test
|
// Find infra.yaml relative to test
|
||||||
// Walk up from test dir to find it
|
// Walk up from test dir to find it
|
||||||
dir, err := os.Getwd()
|
dir := core.Env("DIR_CWD")
|
||||||
if err != nil {
|
if dir == "" {
|
||||||
t.Fatal(err)
|
t.Fatal(core.E("TestLoad_Good", "DIR_CWD unavailable", nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, path, err := Discover(dir)
|
cfg, path, err := Discover(dir)
|
||||||
|
|
@ -59,18 +59,18 @@ func TestLoad_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_Bad(t *testing.T) {
|
func TestConfig_Load_Bad(t *testing.T) {
|
||||||
_, err := Load("/nonexistent/infra.yaml")
|
_, err := Load("/nonexistent/infra.yaml")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for nonexistent file")
|
t.Error("expected error for nonexistent file")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_Ugly(t *testing.T) {
|
func TestConfig_Load_Ugly(t *testing.T) {
|
||||||
// Invalid YAML
|
// Invalid YAML
|
||||||
tmp := filepath.Join(t.TempDir(), "infra.yaml")
|
tmp := core.JoinPath(t.TempDir(), "infra.yaml")
|
||||||
if err := os.WriteFile(tmp, []byte("{{invalid yaml"), 0644); err != nil {
|
if r := localFS.WriteMode(tmp, "{{invalid yaml", 0644); !r.OK {
|
||||||
t.Fatal(err)
|
t.Fatal(coreResultErr(r, "TestConfig_Load_Ugly"))
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := Load(tmp)
|
_, err := Load(tmp)
|
||||||
|
|
@ -128,14 +128,14 @@ func TestConfig_AppServers_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpandPath(t *testing.T) {
|
func TestConfig_ExpandPath_Good(t *testing.T) {
|
||||||
home, _ := os.UserHomeDir()
|
home := core.Env("DIR_HOME")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"~/.ssh/id_rsa", filepath.Join(home, ".ssh/id_rsa")},
|
{"~/.ssh/id_rsa", core.JoinPath(home, ".ssh", "id_rsa")},
|
||||||
{"/absolute/path", "/absolute/path"},
|
{"/absolute/path", "/absolute/path"},
|
||||||
{"relative/path", "relative/path"},
|
{"relative/path", "relative/path"},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
core_helpers.go
Normal file
18
core_helpers.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package infra
|
||||||
|
|
||||||
|
import core "dappco.re/go/core"
|
||||||
|
|
||||||
|
var localFS = (&core.Fs{}).NewUnrestricted()
|
||||||
|
|
||||||
|
func coreResultErr(r core.Result, op string) error {
|
||||||
|
if r.OK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err, ok := r.Value.(error); ok && err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.Value == nil {
|
||||||
|
return core.E(op, "unexpected empty core result", nil)
|
||||||
|
}
|
||||||
|
return core.E(op, core.Sprint(r.Value), nil)
|
||||||
|
}
|
||||||
3
go.mod
3
go.mod
|
|
@ -3,11 +3,11 @@ module forge.lthn.ai/core/go-infra
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
dappco.re/go/core v0.8.0-alpha.1
|
||||||
forge.lthn.ai/core/cli v0.3.5
|
forge.lthn.ai/core/cli v0.3.5
|
||||||
forge.lthn.ai/core/go-ansible v0.1.4
|
forge.lthn.ai/core/go-ansible v0.1.4
|
||||||
forge.lthn.ai/core/go-i18n v0.1.5
|
forge.lthn.ai/core/go-i18n v0.1.5
|
||||||
forge.lthn.ai/core/go-io v0.1.5
|
forge.lthn.ai/core/go-io v0.1.5
|
||||||
forge.lthn.ai/core/go-log v0.0.4
|
|
||||||
forge.lthn.ai/core/go-scm v0.3.4
|
forge.lthn.ai/core/go-scm v0.3.4
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
@ -16,6 +16,7 @@ require (
|
||||||
require (
|
require (
|
||||||
forge.lthn.ai/core/go v0.3.1 // indirect
|
forge.lthn.ai/core/go v0.3.1 // indirect
|
||||||
forge.lthn.ai/core/go-inference v0.1.4 // indirect
|
forge.lthn.ai/core/go-inference v0.1.4 // indirect
|
||||||
|
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -1,3 +1,5 @@
|
||||||
|
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||||
|
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
forge.lthn.ai/core/cli v0.3.5 h1:P7yK0DmSA1QnUMFuCjJZf/fk/akKPIxopQ6OwD8Sar8=
|
forge.lthn.ai/core/cli v0.3.5 h1:P7yK0DmSA1QnUMFuCjJZf/fk/akKPIxopQ6OwD8Sar8=
|
||||||
forge.lthn.ai/core/cli v0.3.5/go.mod h1:SeArHx+hbpX5iZqgASCD7Q1EDoc6uaaGiGBotmNzIx4=
|
forge.lthn.ai/core/cli v0.3.5/go.mod h1:SeArHx+hbpX5iZqgASCD7Q1EDoc6uaaGiGBotmNzIx4=
|
||||||
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
|
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
|
||||||
|
|
|
||||||
88
hetzner.go
88
hetzner.go
|
|
@ -1,13 +1,10 @@
|
||||||
package infra
|
package infra
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -16,6 +13,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// HCloudClient is an HTTP client for the Hetzner Cloud API.
|
// HCloudClient is an HTTP client for the Hetzner Cloud API.
|
||||||
|
// Usage: hc := infra.NewHCloudClient(token)
|
||||||
type HCloudClient struct {
|
type HCloudClient struct {
|
||||||
token string
|
token string
|
||||||
baseURL string
|
baseURL string
|
||||||
|
|
@ -23,6 +21,7 @@ type HCloudClient struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHCloudClient creates a new Hetzner Cloud API client.
|
// NewHCloudClient creates a new Hetzner Cloud API client.
|
||||||
|
// Usage: hc := infra.NewHCloudClient(token)
|
||||||
func NewHCloudClient(token string) *HCloudClient {
|
func NewHCloudClient(token string) *HCloudClient {
|
||||||
c := &HCloudClient{
|
c := &HCloudClient{
|
||||||
token: token,
|
token: token,
|
||||||
|
|
@ -38,6 +37,7 @@ func NewHCloudClient(token string) *HCloudClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudServer represents a Hetzner Cloud server.
|
// HCloudServer represents a Hetzner Cloud server.
|
||||||
|
// Usage: server := infra.HCloudServer{}
|
||||||
type HCloudServer struct {
|
type HCloudServer struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
@ -50,22 +50,26 @@ type HCloudServer struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudPublicNet holds public network info.
|
// HCloudPublicNet holds public network info.
|
||||||
|
// Usage: net := infra.HCloudPublicNet{}
|
||||||
type HCloudPublicNet struct {
|
type HCloudPublicNet struct {
|
||||||
IPv4 HCloudIPv4 `json:"ipv4"`
|
IPv4 HCloudIPv4 `json:"ipv4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudIPv4 holds an IPv4 address.
|
// HCloudIPv4 holds an IPv4 address.
|
||||||
|
// Usage: ip := infra.HCloudIPv4{}
|
||||||
type HCloudIPv4 struct {
|
type HCloudIPv4 struct {
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudPrivateNet holds private network info.
|
// HCloudPrivateNet holds private network info.
|
||||||
|
// Usage: net := infra.HCloudPrivateNet{}
|
||||||
type HCloudPrivateNet struct {
|
type HCloudPrivateNet struct {
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Network int `json:"network"`
|
Network int `json:"network"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudServerType holds server type info.
|
// HCloudServerType holds server type info.
|
||||||
|
// Usage: serverType := infra.HCloudServerType{}
|
||||||
type HCloudServerType struct {
|
type HCloudServerType struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
|
@ -75,12 +79,14 @@ type HCloudServerType struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudDatacenter holds datacenter info.
|
// HCloudDatacenter holds datacenter info.
|
||||||
|
// Usage: dc := infra.HCloudDatacenter{}
|
||||||
type HCloudDatacenter struct {
|
type HCloudDatacenter struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLoadBalancer represents a Hetzner Cloud load balancer.
|
// HCloudLoadBalancer represents a Hetzner Cloud load balancer.
|
||||||
|
// Usage: lb := infra.HCloudLoadBalancer{}
|
||||||
type HCloudLoadBalancer struct {
|
type HCloudLoadBalancer struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
@ -93,32 +99,37 @@ type HCloudLoadBalancer struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBPublicNet holds LB public network info.
|
// HCloudLBPublicNet holds LB public network info.
|
||||||
|
// Usage: net := infra.HCloudLBPublicNet{}
|
||||||
type HCloudLBPublicNet struct {
|
type HCloudLBPublicNet struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
IPv4 HCloudIPv4 `json:"ipv4"`
|
IPv4 HCloudIPv4 `json:"ipv4"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBAlgorithm holds the LB algorithm.
|
// HCloudLBAlgorithm holds the LB algorithm.
|
||||||
|
// Usage: algo := infra.HCloudLBAlgorithm{}
|
||||||
type HCloudLBAlgorithm struct {
|
type HCloudLBAlgorithm struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBService describes an LB listener.
|
// HCloudLBService describes an LB listener.
|
||||||
|
// Usage: service := infra.HCloudLBService{}
|
||||||
type HCloudLBService struct {
|
type HCloudLBService struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
ListenPort int `json:"listen_port"`
|
ListenPort int `json:"listen_port"`
|
||||||
DestinationPort int `json:"destination_port"`
|
DestinationPort int `json:"destination_port"`
|
||||||
ProxyProtocol bool `json:"proxyprotocol"`
|
Proxyprotocol bool `json:"proxyprotocol"`
|
||||||
HTTP *HCloudLBHTTP `json:"http,omitempty"`
|
HTTP *HCloudLBHTTP `json:"http,omitempty"`
|
||||||
HealthCheck *HCloudLBHealthCheck `json:"health_check,omitempty"`
|
HealthCheck *HCloudLBHealthCheck `json:"health_check,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBHTTP holds HTTP-specific LB options.
|
// HCloudLBHTTP holds HTTP-specific LB options.
|
||||||
|
// Usage: httpCfg := infra.HCloudLBHTTP{}
|
||||||
type HCloudLBHTTP struct {
|
type HCloudLBHTTP struct {
|
||||||
RedirectHTTP bool `json:"redirect_http"`
|
RedirectHTTP bool `json:"redirect_http"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBHealthCheck holds LB health check config.
|
// HCloudLBHealthCheck holds LB health check config.
|
||||||
|
// Usage: check := infra.HCloudLBHealthCheck{}
|
||||||
type HCloudLBHealthCheck struct {
|
type HCloudLBHealthCheck struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
|
|
@ -129,12 +140,14 @@ type HCloudLBHealthCheck struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBHCHTTP holds HTTP health check options.
|
// HCloudLBHCHTTP holds HTTP health check options.
|
||||||
|
// Usage: httpCheck := infra.HCloudLBHCHTTP{}
|
||||||
type HCloudLBHCHTTP struct {
|
type HCloudLBHCHTTP struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
StatusCode string `json:"status_codes"`
|
StatusCode string `json:"status_codes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBTarget is a load balancer backend target.
|
// HCloudLBTarget is a load balancer backend target.
|
||||||
|
// Usage: target := infra.HCloudLBTarget{}
|
||||||
type HCloudLBTarget struct {
|
type HCloudLBTarget struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
IP *HCloudLBTargetIP `json:"ip,omitempty"`
|
IP *HCloudLBTargetIP `json:"ip,omitempty"`
|
||||||
|
|
@ -143,22 +156,26 @@ type HCloudLBTarget struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBTargetIP is an IP-based LB target.
|
// HCloudLBTargetIP is an IP-based LB target.
|
||||||
|
// Usage: target := infra.HCloudLBTargetIP{}
|
||||||
type HCloudLBTargetIP struct {
|
type HCloudLBTargetIP struct {
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBTargetServer is a server-based LB target.
|
// HCloudLBTargetServer is a server-based LB target.
|
||||||
|
// Usage: target := infra.HCloudLBTargetServer{}
|
||||||
type HCloudLBTargetServer struct {
|
type HCloudLBTargetServer struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBHealthStatus holds target health info.
|
// HCloudLBHealthStatus holds target health info.
|
||||||
|
// Usage: status := infra.HCloudLBHealthStatus{}
|
||||||
type HCloudLBHealthStatus struct {
|
type HCloudLBHealthStatus struct {
|
||||||
ListenPort int `json:"listen_port"`
|
ListenPort int `json:"listen_port"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBCreateRequest holds load balancer creation params.
|
// HCloudLBCreateRequest holds load balancer creation params.
|
||||||
|
// Usage: req := infra.HCloudLBCreateRequest{}
|
||||||
type HCloudLBCreateRequest struct {
|
type HCloudLBCreateRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
LoadBalancerType string `json:"load_balancer_type"`
|
LoadBalancerType string `json:"load_balancer_type"`
|
||||||
|
|
@ -170,12 +187,14 @@ type HCloudLBCreateRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HCloudLBCreateTarget is a target for LB creation.
|
// HCloudLBCreateTarget is a target for LB creation.
|
||||||
|
// Usage: target := infra.HCloudLBCreateTarget{}
|
||||||
type HCloudLBCreateTarget struct {
|
type HCloudLBCreateTarget struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
IP *HCloudLBTargetIP `json:"ip,omitempty"`
|
IP *HCloudLBTargetIP `json:"ip,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListServers returns all Hetzner Cloud servers.
|
// ListServers returns all Hetzner Cloud servers.
|
||||||
|
// Usage: servers, err := hc.ListServers(ctx)
|
||||||
func (c *HCloudClient) ListServers(ctx context.Context) ([]HCloudServer, error) {
|
func (c *HCloudClient) ListServers(ctx context.Context) ([]HCloudServer, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
Servers []HCloudServer `json:"servers"`
|
Servers []HCloudServer `json:"servers"`
|
||||||
|
|
@ -187,6 +206,7 @@ func (c *HCloudClient) ListServers(ctx context.Context) ([]HCloudServer, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListLoadBalancers returns all load balancers.
|
// ListLoadBalancers returns all load balancers.
|
||||||
|
// Usage: lbs, err := hc.ListLoadBalancers(ctx)
|
||||||
func (c *HCloudClient) ListLoadBalancers(ctx context.Context) ([]HCloudLoadBalancer, error) {
|
func (c *HCloudClient) ListLoadBalancers(ctx context.Context) ([]HCloudLoadBalancer, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
LoadBalancers []HCloudLoadBalancer `json:"load_balancers"`
|
LoadBalancers []HCloudLoadBalancer `json:"load_balancers"`
|
||||||
|
|
@ -198,22 +218,25 @@ func (c *HCloudClient) ListLoadBalancers(ctx context.Context) ([]HCloudLoadBalan
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLoadBalancer returns a load balancer by ID.
|
// GetLoadBalancer returns a load balancer by ID.
|
||||||
|
// Usage: lb, err := hc.GetLoadBalancer(ctx, 1)
|
||||||
func (c *HCloudClient) GetLoadBalancer(ctx context.Context, id int) (*HCloudLoadBalancer, error) {
|
func (c *HCloudClient) GetLoadBalancer(ctx context.Context, id int) (*HCloudLoadBalancer, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
LoadBalancer HCloudLoadBalancer `json:"load_balancer"`
|
LoadBalancer HCloudLoadBalancer `json:"load_balancer"`
|
||||||
}
|
}
|
||||||
if err := c.get(ctx, fmt.Sprintf("/load_balancers/%d", id), &result); err != nil {
|
if err := c.get(ctx, core.Sprintf("/load_balancers/%d", id), &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &result.LoadBalancer, nil
|
return &result.LoadBalancer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateLoadBalancer creates a new load balancer.
|
// CreateLoadBalancer creates a new load balancer.
|
||||||
|
// Usage: lb, err := hc.CreateLoadBalancer(ctx, req)
|
||||||
func (c *HCloudClient) CreateLoadBalancer(ctx context.Context, req HCloudLBCreateRequest) (*HCloudLoadBalancer, error) {
|
func (c *HCloudClient) CreateLoadBalancer(ctx context.Context, req HCloudLBCreateRequest) (*HCloudLoadBalancer, error) {
|
||||||
body, err := json.Marshal(req)
|
marshaled := core.JSONMarshal(req)
|
||||||
if err != nil {
|
if !marshaled.OK {
|
||||||
return nil, coreerr.E("HCloudClient.CreateLoadBalancer", "marshal request", err)
|
return nil, core.E("HCloudClient.CreateLoadBalancer", "marshal request", coreResultErr(marshaled, "HCloudClient.CreateLoadBalancer"))
|
||||||
}
|
}
|
||||||
|
body := marshaled.Value.([]byte)
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
LoadBalancer HCloudLoadBalancer `json:"load_balancer"`
|
LoadBalancer HCloudLoadBalancer `json:"load_balancer"`
|
||||||
|
|
@ -225,54 +248,47 @@ func (c *HCloudClient) CreateLoadBalancer(ctx context.Context, req HCloudLBCreat
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteLoadBalancer deletes a load balancer by ID.
|
// DeleteLoadBalancer deletes a load balancer by ID.
|
||||||
|
// Usage: err := hc.DeleteLoadBalancer(ctx, 1)
|
||||||
func (c *HCloudClient) DeleteLoadBalancer(ctx context.Context, id int) error {
|
func (c *HCloudClient) DeleteLoadBalancer(ctx context.Context, id int) error {
|
||||||
return c.delete(ctx, fmt.Sprintf("/load_balancers/%d", id))
|
return c.delete(ctx, core.Sprintf("/load_balancers/%d", id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSnapshot creates a server snapshot.
|
// CreateSnapshot creates a server snapshot.
|
||||||
|
// Usage: err := hc.CreateSnapshot(ctx, 1, "daily snapshot")
|
||||||
func (c *HCloudClient) CreateSnapshot(ctx context.Context, serverID int, description string) error {
|
func (c *HCloudClient) CreateSnapshot(ctx context.Context, serverID int, description string) error {
|
||||||
body, err := json.Marshal(map[string]string{
|
marshaled := core.JSONMarshal(map[string]string{
|
||||||
"description": description,
|
"description": description,
|
||||||
"type": "snapshot",
|
"type": "snapshot",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if !marshaled.OK {
|
||||||
return coreerr.E("HCloudClient.CreateSnapshot", "marshal request", err)
|
return core.E("HCloudClient.CreateSnapshot", "marshal request", coreResultErr(marshaled, "HCloudClient.CreateSnapshot"))
|
||||||
}
|
}
|
||||||
return c.post(ctx, fmt.Sprintf("/servers/%d/actions/create_image", serverID), body, nil)
|
return c.post(ctx, core.Sprintf("/servers/%d/actions/create_image", serverID), marshaled.Value.([]byte), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HCloudClient) get(ctx context.Context, path string, result any) error {
|
func (c *HCloudClient) get(ctx context.Context, path string, result any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("HCloudClient.get", "build request", err)
|
return err
|
||||||
}
|
}
|
||||||
if err := c.do(req, result); err != nil {
|
return c.do(req, result)
|
||||||
return coreerr.E("HCloudClient.get", "execute request", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HCloudClient) post(ctx context.Context, path string, body []byte, result any) error {
|
func (c *HCloudClient) post(ctx context.Context, path string, body []byte, result any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, core.NewReader(string(body)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("HCloudClient.post", "build request", err)
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
if err := c.do(req, result); err != nil {
|
return c.do(req, result)
|
||||||
return coreerr.E("HCloudClient.post", "execute request", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HCloudClient) delete(ctx context.Context, path string) error {
|
func (c *HCloudClient) delete(ctx context.Context, path string) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+path, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.baseURL+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("HCloudClient.delete", "build request", err)
|
return err
|
||||||
}
|
}
|
||||||
if err := c.do(req, nil); err != nil {
|
return c.do(req, nil)
|
||||||
return coreerr.E("HCloudClient.delete", "execute request", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *HCloudClient) do(req *http.Request, result any) error {
|
func (c *HCloudClient) do(req *http.Request, result any) error {
|
||||||
|
|
@ -282,6 +298,7 @@ func (c *HCloudClient) do(req *http.Request, result any) error {
|
||||||
// --- Hetzner Robot API ---
|
// --- Hetzner Robot API ---
|
||||||
|
|
||||||
// HRobotClient is an HTTP client for the Hetzner Robot API.
|
// HRobotClient is an HTTP client for the Hetzner Robot API.
|
||||||
|
// Usage: hr := infra.NewHRobotClient(user, password)
|
||||||
type HRobotClient struct {
|
type HRobotClient struct {
|
||||||
user string
|
user string
|
||||||
password string
|
password string
|
||||||
|
|
@ -290,6 +307,7 @@ type HRobotClient struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHRobotClient creates a new Hetzner Robot API client.
|
// NewHRobotClient creates a new Hetzner Robot API client.
|
||||||
|
// Usage: hr := infra.NewHRobotClient(user, password)
|
||||||
func NewHRobotClient(user, password string) *HRobotClient {
|
func NewHRobotClient(user, password string) *HRobotClient {
|
||||||
c := &HRobotClient{
|
c := &HRobotClient{
|
||||||
user: user,
|
user: user,
|
||||||
|
|
@ -306,6 +324,7 @@ func NewHRobotClient(user, password string) *HRobotClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HRobotServer represents a Hetzner Robot dedicated server.
|
// HRobotServer represents a Hetzner Robot dedicated server.
|
||||||
|
// Usage: server := infra.HRobotServer{}
|
||||||
type HRobotServer struct {
|
type HRobotServer struct {
|
||||||
ServerIP string `json:"server_ip"`
|
ServerIP string `json:"server_ip"`
|
||||||
ServerName string `json:"server_name"`
|
ServerName string `json:"server_name"`
|
||||||
|
|
@ -317,6 +336,7 @@ type HRobotServer struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListServers returns all Robot dedicated servers.
|
// ListServers returns all Robot dedicated servers.
|
||||||
|
// Usage: servers, err := hr.ListServers(ctx)
|
||||||
func (c *HRobotClient) ListServers(ctx context.Context) ([]HRobotServer, error) {
|
func (c *HRobotClient) ListServers(ctx context.Context) ([]HRobotServer, error) {
|
||||||
var raw []struct {
|
var raw []struct {
|
||||||
Server HRobotServer `json:"server"`
|
Server HRobotServer `json:"server"`
|
||||||
|
|
@ -333,6 +353,7 @@ func (c *HRobotClient) ListServers(ctx context.Context) ([]HRobotServer, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetServer returns a Robot server by IP.
|
// GetServer returns a Robot server by IP.
|
||||||
|
// Usage: server, err := hr.GetServer(ctx, "203.0.113.10")
|
||||||
func (c *HRobotClient) GetServer(ctx context.Context, ip string) (*HRobotServer, error) {
|
func (c *HRobotClient) GetServer(ctx context.Context, ip string) (*HRobotServer, error) {
|
||||||
var raw struct {
|
var raw struct {
|
||||||
Server HRobotServer `json:"server"`
|
Server HRobotServer `json:"server"`
|
||||||
|
|
@ -346,10 +367,7 @@ func (c *HRobotClient) GetServer(ctx context.Context, ip string) (*HRobotServer,
|
||||||
func (c *HRobotClient) get(ctx context.Context, path string, result any) error {
|
func (c *HRobotClient) get(ctx context.Context, path string, result any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("HRobotClient.get", "build request", err)
|
return err
|
||||||
}
|
}
|
||||||
if err := c.api.Do(req, result); err != nil {
|
return c.api.Do(req, result)
|
||||||
return coreerr.E("HRobotClient.get", "execute request", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,24 @@ package infra
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewHCloudClient_Good(t *testing.T) {
|
func TestHetzner_NewHCloudClient_Good(t *testing.T) {
|
||||||
c := NewHCloudClient("my-token")
|
c := NewHCloudClient("my-token")
|
||||||
assert.NotNil(t, c)
|
assert.NotNil(t, c)
|
||||||
assert.Equal(t, "my-token", c.token)
|
assert.Equal(t, "my-token", c.token)
|
||||||
assert.NotNil(t, c.api)
|
assert.NotNil(t, c.api)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudClient_ListServers_Good(t *testing.T) {
|
func TestHetzner_HCloudClient_ListServers_Good(t *testing.T) {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||||
|
|
@ -41,7 +42,7 @@ func TestHCloudClient_ListServers_Good(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
writeCoreJSON(t, w, resp)
|
||||||
})
|
})
|
||||||
|
|
||||||
ts := httptest.NewServer(mux)
|
ts := httptest.NewServer(mux)
|
||||||
|
|
@ -65,7 +66,7 @@ func TestHCloudClient_ListServers_Good(t *testing.T) {
|
||||||
assert.Equal(t, "de2", servers[1].Name)
|
assert.Equal(t, "de2", servers[1].Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudClient_Do_Good_ParsesJSON(t *testing.T) {
|
func TestHetzner_HCloudClient_Do_ParsesJSON_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
@ -95,7 +96,7 @@ func TestHCloudClient_Do_Good_ParsesJSON(t *testing.T) {
|
||||||
assert.Equal(t, "running", result.Servers[0].Status)
|
assert.Equal(t, "running", result.Servers[0].Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudClient_Do_Bad_APIError(t *testing.T) {
|
func TestHetzner_HCloudClient_Do_APIError_Bad(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
_, _ = w.Write([]byte(`{"error":{"code":"forbidden","message":"insufficient permissions"}}`))
|
_, _ = w.Write([]byte(`{"error":{"code":"forbidden","message":"insufficient permissions"}}`))
|
||||||
|
|
@ -119,7 +120,7 @@ func TestHCloudClient_Do_Bad_APIError(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "hcloud API: HTTP 403")
|
assert.Contains(t, err.Error(), "hcloud API: HTTP 403")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudClient_Do_Bad_APIErrorNoJSON(t *testing.T) {
|
func TestHetzner_HCloudClient_Do_APIErrorNoJSON_Bad(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = w.Write([]byte(`Internal Server Error`))
|
_, _ = w.Write([]byte(`Internal Server Error`))
|
||||||
|
|
@ -139,7 +140,7 @@ func TestHCloudClient_Do_Bad_APIErrorNoJSON(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "hcloud API: HTTP 500")
|
assert.Contains(t, err.Error(), "hcloud API: HTTP 500")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudClient_Do_Good_NilResult(t *testing.T) {
|
func TestHetzner_HCloudClient_Do_NilResult_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}))
|
}))
|
||||||
|
|
@ -159,7 +160,7 @@ func TestHCloudClient_Do_Good_NilResult(t *testing.T) {
|
||||||
|
|
||||||
// --- Hetzner Robot API ---
|
// --- Hetzner Robot API ---
|
||||||
|
|
||||||
func TestNewHRobotClient_Good(t *testing.T) {
|
func TestHetzner_NewHRobotClient_Good(t *testing.T) {
|
||||||
c := NewHRobotClient("user", "pass")
|
c := NewHRobotClient("user", "pass")
|
||||||
assert.NotNil(t, c)
|
assert.NotNil(t, c)
|
||||||
assert.Equal(t, "user", c.user)
|
assert.Equal(t, "user", c.user)
|
||||||
|
|
@ -167,7 +168,7 @@ func TestNewHRobotClient_Good(t *testing.T) {
|
||||||
assert.NotNil(t, c.api)
|
assert.NotNil(t, c.api)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHRobotClient_ListServers_Good(t *testing.T) {
|
func TestHetzner_HRobotClient_ListServers_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
user, pass, ok := r.BasicAuth()
|
user, pass, ok := r.BasicAuth()
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
|
|
@ -198,7 +199,7 @@ func TestHRobotClient_ListServers_Good(t *testing.T) {
|
||||||
assert.Equal(t, "EX44", servers[0].Product)
|
assert.Equal(t, "EX44", servers[0].Product)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHRobotClient_Get_Bad_HTTPError(t *testing.T) {
|
func TestHetzner_HRobotClient_Get_HTTPError_Bad(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
_, _ = w.Write([]byte(`{"error":{"status":401,"code":"UNAUTHORIZED","message":"Invalid credentials"}}`))
|
_, _ = w.Write([]byte(`{"error":{"status":401,"code":"UNAUTHORIZED","message":"Invalid credentials"}}`))
|
||||||
|
|
@ -221,7 +222,7 @@ func TestHRobotClient_Get_Bad_HTTPError(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "hrobot API: HTTP 401")
|
assert.Contains(t, err.Error(), "hrobot API: HTTP 401")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudClient_ListLoadBalancers_Good(t *testing.T) {
|
func TestHetzner_HCloudClient_ListLoadBalancers_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, http.MethodGet, r.Method)
|
assert.Equal(t, http.MethodGet, r.Method)
|
||||||
assert.Equal(t, "/load_balancers", r.URL.Path)
|
assert.Equal(t, "/load_balancers", r.URL.Path)
|
||||||
|
|
@ -246,7 +247,7 @@ func TestHCloudClient_ListLoadBalancers_Good(t *testing.T) {
|
||||||
assert.Equal(t, 789, lbs[0].ID)
|
assert.Equal(t, 789, lbs[0].ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudClient_GetLoadBalancer_Good(t *testing.T) {
|
func TestHetzner_HCloudClient_GetLoadBalancer_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, "/load_balancers/789", r.URL.Path)
|
assert.Equal(t, "/load_balancers/789", r.URL.Path)
|
||||||
|
|
||||||
|
|
@ -268,14 +269,13 @@ func TestHCloudClient_GetLoadBalancer_Good(t *testing.T) {
|
||||||
assert.Equal(t, "hermes", lb.Name)
|
assert.Equal(t, "hermes", lb.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudClient_CreateLoadBalancer_Good(t *testing.T) {
|
func TestHetzner_HCloudClient_CreateLoadBalancer_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
assert.Equal(t, "/load_balancers", r.URL.Path)
|
assert.Equal(t, "/load_balancers", r.URL.Path)
|
||||||
|
|
||||||
var body HCloudLBCreateRequest
|
var body HCloudLBCreateRequest
|
||||||
err := json.NewDecoder(r.Body).Decode(&body)
|
decodeCoreJSONBody(t, r, &body)
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "hermes", body.Name)
|
assert.Equal(t, "hermes", body.Name)
|
||||||
assert.Equal(t, "lb11", body.LoadBalancerType)
|
assert.Equal(t, "lb11", body.LoadBalancerType)
|
||||||
assert.Equal(t, "round_robin", body.Algorithm.Type)
|
assert.Equal(t, "round_robin", body.Algorithm.Type)
|
||||||
|
|
@ -307,7 +307,7 @@ func TestHCloudClient_CreateLoadBalancer_Good(t *testing.T) {
|
||||||
assert.Equal(t, 789, lb.ID)
|
assert.Equal(t, 789, lb.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudClient_DeleteLoadBalancer_Good(t *testing.T) {
|
func TestHetzner_HCloudClient_DeleteLoadBalancer_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, http.MethodDelete, r.Method)
|
assert.Equal(t, http.MethodDelete, r.Method)
|
||||||
assert.Equal(t, "/load_balancers/789", r.URL.Path)
|
assert.Equal(t, "/load_balancers/789", r.URL.Path)
|
||||||
|
|
@ -327,14 +327,13 @@ func TestHCloudClient_DeleteLoadBalancer_Good(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudClient_CreateSnapshot_Good(t *testing.T) {
|
func TestHetzner_HCloudClient_CreateSnapshot_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, http.MethodPost, r.Method)
|
assert.Equal(t, http.MethodPost, r.Method)
|
||||||
assert.Equal(t, "/servers/123/actions/create_image", r.URL.Path)
|
assert.Equal(t, "/servers/123/actions/create_image", r.URL.Path)
|
||||||
|
|
||||||
var body map[string]string
|
var body map[string]string
|
||||||
err := json.NewDecoder(r.Body).Decode(&body)
|
decodeCoreJSONBody(t, r, &body)
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "daily backup", body["description"])
|
assert.Equal(t, "daily backup", body["description"])
|
||||||
assert.Equal(t, "snapshot", body["type"])
|
assert.Equal(t, "snapshot", body["type"])
|
||||||
|
|
||||||
|
|
@ -358,7 +357,7 @@ func TestHCloudClient_CreateSnapshot_Good(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHRobotClient_GetServer_Good(t *testing.T) {
|
func TestHetzner_HRobotClient_GetServer_Good(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, "/server/1.2.3.4", r.URL.Path)
|
assert.Equal(t, "/server/1.2.3.4", r.URL.Path)
|
||||||
|
|
||||||
|
|
@ -386,7 +385,7 @@ func TestHRobotClient_GetServer_Good(t *testing.T) {
|
||||||
|
|
||||||
// --- Type serialisation ---
|
// --- Type serialisation ---
|
||||||
|
|
||||||
func TestHCloudServer_JSON_Good(t *testing.T) {
|
func TestHetzner_HCloudServer_JSON_Good(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"id": 123,
|
"id": 123,
|
||||||
"name": "web-1",
|
"name": "web-1",
|
||||||
|
|
@ -399,9 +398,7 @@ func TestHCloudServer_JSON_Good(t *testing.T) {
|
||||||
}`
|
}`
|
||||||
|
|
||||||
var server HCloudServer
|
var server HCloudServer
|
||||||
err := json.Unmarshal([]byte(data), &server)
|
requireHetznerJSON(t, data, &server)
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 123, server.ID)
|
assert.Equal(t, 123, server.ID)
|
||||||
assert.Equal(t, "web-1", server.Name)
|
assert.Equal(t, "web-1", server.Name)
|
||||||
assert.Equal(t, "running", server.Status)
|
assert.Equal(t, "running", server.Status)
|
||||||
|
|
@ -415,7 +412,7 @@ func TestHCloudServer_JSON_Good(t *testing.T) {
|
||||||
assert.Equal(t, "prod", server.Labels["env"])
|
assert.Equal(t, "prod", server.Labels["env"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHCloudLoadBalancer_JSON_Good(t *testing.T) {
|
func TestHetzner_HCloudLoadBalancer_JSON_Good(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"id": 789,
|
"id": 789,
|
||||||
"name": "hermes",
|
"name": "hermes",
|
||||||
|
|
@ -431,9 +428,7 @@ func TestHCloudLoadBalancer_JSON_Good(t *testing.T) {
|
||||||
}`
|
}`
|
||||||
|
|
||||||
var lb HCloudLoadBalancer
|
var lb HCloudLoadBalancer
|
||||||
err := json.Unmarshal([]byte(data), &lb)
|
requireHetznerJSON(t, data, &lb)
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 789, lb.ID)
|
assert.Equal(t, 789, lb.ID)
|
||||||
assert.Equal(t, "hermes", lb.Name)
|
assert.Equal(t, "hermes", lb.Name)
|
||||||
assert.True(t, lb.PublicNet.Enabled)
|
assert.True(t, lb.PublicNet.Enabled)
|
||||||
|
|
@ -441,14 +436,14 @@ func TestHCloudLoadBalancer_JSON_Good(t *testing.T) {
|
||||||
assert.Equal(t, "round_robin", lb.Algorithm.Type)
|
assert.Equal(t, "round_robin", lb.Algorithm.Type)
|
||||||
require.Len(t, lb.Services, 1)
|
require.Len(t, lb.Services, 1)
|
||||||
assert.Equal(t, 443, lb.Services[0].ListenPort)
|
assert.Equal(t, 443, lb.Services[0].ListenPort)
|
||||||
assert.True(t, lb.Services[0].ProxyProtocol)
|
assert.True(t, lb.Services[0].Proxyprotocol)
|
||||||
require.Len(t, lb.Targets, 1)
|
require.Len(t, lb.Targets, 1)
|
||||||
assert.Equal(t, "ip", lb.Targets[0].Type)
|
assert.Equal(t, "ip", lb.Targets[0].Type)
|
||||||
assert.Equal(t, "10.0.0.1", lb.Targets[0].IP.IP)
|
assert.Equal(t, "10.0.0.1", lb.Targets[0].IP.IP)
|
||||||
assert.Equal(t, "healthy", lb.Targets[0].HealthStatus[0].Status)
|
assert.Equal(t, "healthy", lb.Targets[0].HealthStatus[0].Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHRobotServer_JSON_Good(t *testing.T) {
|
func TestHetzner_HRobotServer_JSON_Good(t *testing.T) {
|
||||||
data := `{
|
data := `{
|
||||||
"server_ip": "1.2.3.4",
|
"server_ip": "1.2.3.4",
|
||||||
"server_name": "noc",
|
"server_name": "noc",
|
||||||
|
|
@ -460,9 +455,7 @@ func TestHRobotServer_JSON_Good(t *testing.T) {
|
||||||
}`
|
}`
|
||||||
|
|
||||||
var server HRobotServer
|
var server HRobotServer
|
||||||
err := json.Unmarshal([]byte(data), &server)
|
requireHetznerJSON(t, data, &server)
|
||||||
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "1.2.3.4", server.ServerIP)
|
assert.Equal(t, "1.2.3.4", server.ServerIP)
|
||||||
assert.Equal(t, "noc", server.ServerName)
|
assert.Equal(t, "noc", server.ServerName)
|
||||||
assert.Equal(t, "EX44", server.Product)
|
assert.Equal(t, "EX44", server.Product)
|
||||||
|
|
@ -471,3 +464,28 @@ func TestHRobotServer_JSON_Good(t *testing.T) {
|
||||||
assert.False(t, server.Cancelled)
|
assert.False(t, server.Cancelled)
|
||||||
assert.Equal(t, "2026-03-01", server.PaidUntil)
|
assert.Equal(t, "2026-03-01", server.PaidUntil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requireHetznerJSON(t *testing.T, data string, target any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
r := core.JSONUnmarshal([]byte(data), target)
|
||||||
|
require.True(t, r.OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCoreJSON(t *testing.T, w http.ResponseWriter, value any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
r := core.JSONMarshal(value)
|
||||||
|
require.True(t, r.OK)
|
||||||
|
_, err := w.Write(r.Value.([]byte))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCoreJSONBody(t *testing.T, r *http.Request, target any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := core.JSONUnmarshal(body, target)
|
||||||
|
require.True(t, result.OK)
|
||||||
|
}
|
||||||
|
|
|
||||||
218
internal/coreexec/coreexec.go
Normal file
218
internal/coreexec/coreexec.go
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
package coreexec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
var localFS = (&core.Fs{}).NewUnrestricted()
|
||||||
|
|
||||||
|
const executeAccess = 1
|
||||||
|
|
||||||
|
// Result captures process output and exit status.
|
||||||
|
// Usage: result, err := coreexec.Run(ctx, "git", "status", "--short")
|
||||||
|
type Result struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
ExitCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookPath resolves an executable name against PATH.
|
||||||
|
// Usage: path, err := coreexec.LookPath("gh")
|
||||||
|
func LookPath(name string) (string, error) {
|
||||||
|
if name == "" {
|
||||||
|
return "", core.E("coreexec.LookPath", "empty executable name", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
ds := core.Env("DS")
|
||||||
|
if core.PathIsAbs(name) || core.Contains(name, ds) {
|
||||||
|
if isExecutable(name) {
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
return "", core.E("coreexec.LookPath", core.Concat("executable not found: ", name), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range core.Split(core.Env("PATH"), core.Env("PS")) {
|
||||||
|
if dir == "" {
|
||||||
|
dir = core.Env("DIR_CWD")
|
||||||
|
} else if !core.PathIsAbs(dir) {
|
||||||
|
dir = core.Path(core.Env("DIR_CWD"), dir)
|
||||||
|
}
|
||||||
|
candidate := core.Path(dir, name)
|
||||||
|
if isExecutable(candidate) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", core.E("coreexec.LookPath", core.Concat("executable not found in PATH: ", name), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes a command and captures stdout, stderr, and exit status.
|
||||||
|
// Usage: result, err := coreexec.Run(ctx, "gh", "api", "repos/org/repo")
|
||||||
|
func Run(ctx context.Context, name string, args ...string) (Result, error) {
|
||||||
|
path, err := LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tempDir := localFS.TempDir("coreexec-")
|
||||||
|
if tempDir == "" {
|
||||||
|
return Result{}, core.E("coreexec.Run", "create capture directory", nil)
|
||||||
|
}
|
||||||
|
defer func() { _ = coreResultErr(localFS.DeleteAll(tempDir), "coreexec.Run") }()
|
||||||
|
|
||||||
|
stdoutPath := core.Path(tempDir, "stdout")
|
||||||
|
stderrPath := core.Path(tempDir, "stderr")
|
||||||
|
|
||||||
|
stdoutFile, err := createFile(stdoutPath)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = stdoutFile.Close() }()
|
||||||
|
|
||||||
|
stderrFile, err := createFile(stderrPath)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
defer func() { _ = stderrFile.Close() }()
|
||||||
|
|
||||||
|
pid, err := syscall.ForkExec(path, append([]string{name}, args...), &syscall.ProcAttr{
|
||||||
|
Dir: core.Env("DIR_CWD"),
|
||||||
|
Env: syscall.Environ(),
|
||||||
|
Files: []uintptr{
|
||||||
|
0,
|
||||||
|
stdoutFile.Fd(),
|
||||||
|
stderrFile.Fd(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, core.E("coreexec.Run", core.Concat("start ", name), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := waitForPID(ctx, pid, name)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := readFile(stdoutPath)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := readFile(stderrPath)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result{
|
||||||
|
Stdout: stdout,
|
||||||
|
Stderr: stderr,
|
||||||
|
ExitCode: exitCode(status),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exec replaces the current process with the named executable.
|
||||||
|
// Usage: return coreexec.Exec("ssh", "-i", keyPath, host)
|
||||||
|
func Exec(name string, args ...string) error {
|
||||||
|
path, err := LookPath(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syscall.Exec(path, append([]string{name}, args...), syscall.Environ()); err != nil {
|
||||||
|
return core.E("coreexec.Exec", core.Concat("exec ", name), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type captureFile interface {
|
||||||
|
Close() error
|
||||||
|
Fd() uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
type waitResult struct {
|
||||||
|
status syscall.WaitStatus
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExecutable(path string) bool {
|
||||||
|
if !localFS.IsFile(path) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return syscall.Access(path, executeAccess) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFile(path string) (captureFile, error) {
|
||||||
|
created := localFS.Create(path)
|
||||||
|
if !created.OK {
|
||||||
|
return nil, core.E("coreexec.Run", core.Concat("create ", path), coreResultErr(created, "coreexec.Run"))
|
||||||
|
}
|
||||||
|
|
||||||
|
file, ok := created.Value.(captureFile)
|
||||||
|
if !ok {
|
||||||
|
return nil, core.E("coreexec.Run", core.Concat("capture handle type for ", path), nil)
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(path string) (string, error) {
|
||||||
|
read := localFS.Read(path)
|
||||||
|
if !read.OK {
|
||||||
|
return "", core.E("coreexec.Run", core.Concat("read ", path), coreResultErr(read, "coreexec.Run"))
|
||||||
|
}
|
||||||
|
|
||||||
|
content, ok := read.Value.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", core.E("coreexec.Run", core.Concat("unexpected content type for ", path), nil)
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForPID(ctx context.Context, pid int, name string) (syscall.WaitStatus, error) {
|
||||||
|
done := make(chan waitResult, 1)
|
||||||
|
go func() {
|
||||||
|
var status syscall.WaitStatus
|
||||||
|
_, err := syscall.Wait4(pid, &status, 0, nil)
|
||||||
|
done <- waitResult{status: status, err: err}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case result := <-done:
|
||||||
|
if result.err != nil {
|
||||||
|
return 0, core.E("coreexec.Run", core.Concat("wait ", name), result.err)
|
||||||
|
}
|
||||||
|
return result.status, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = syscall.Kill(pid, syscall.SIGKILL)
|
||||||
|
result := <-done
|
||||||
|
if result.err != nil {
|
||||||
|
return 0, core.E("coreexec.Run", core.Concat("wait ", name), result.err)
|
||||||
|
}
|
||||||
|
return 0, core.E("coreexec.Run", core.Concat("command cancelled: ", name), ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func exitCode(status syscall.WaitStatus) int {
|
||||||
|
if status.Exited() {
|
||||||
|
return status.ExitStatus()
|
||||||
|
}
|
||||||
|
if status.Signaled() {
|
||||||
|
return 128 + int(status.Signal())
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func coreResultErr(r core.Result, op string) error {
|
||||||
|
if r.OK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err, ok := r.Value.(error); ok && err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if r.Value == nil {
|
||||||
|
return core.E(op, "unexpected empty core result", nil)
|
||||||
|
}
|
||||||
|
return core.E(op, core.Sprint(r.Value), nil)
|
||||||
|
}
|
||||||
22
internal/coreexec/specs/RFC.md
Normal file
22
internal/coreexec/specs/RFC.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# coreexec
|
||||||
|
**Import:** `forge.lthn.ai/core/go-infra/internal/coreexec`
|
||||||
|
**Files:** 1
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
### `Result`
|
||||||
|
Captured output and exit status returned by `Run`.
|
||||||
|
- `Stdout string`: Standard output collected from the child process.
|
||||||
|
- `Stderr string`: Standard error collected from the child process.
|
||||||
|
- `ExitCode int`: Exit code derived from the child process wait status. Signalled processes are reported as `128 + signal`.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `func LookPath(name string) (string, error)`
|
||||||
|
Resolves an executable name against `PATH`, accepting both absolute paths and relative path-like inputs, and verifies execute permission before returning the resolved path.
|
||||||
|
|
||||||
|
### `func Run(ctx context.Context, name string, args ...string) (Result, error)`
|
||||||
|
Forks and executes a command, captures `stdout` and `stderr` to temporary files, waits for completion or context cancellation, and returns the resulting `Result`.
|
||||||
|
|
||||||
|
### `func Exec(name string, args ...string) error`
|
||||||
|
Replaces the current process image with the named executable using `syscall.Exec`.
|
||||||
418
specs/RFC.md
Normal file
418
specs/RFC.md
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
# infra
|
||||||
|
**Import:** `forge.lthn.ai/core/go-infra`
|
||||||
|
**Files:** 5
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
### `RetryConfig`
|
||||||
|
Controls exponential backoff retry behaviour for `APIClient`.
|
||||||
|
- `MaxRetries int`: Maximum number of retry attempts after the initial request. `0` disables retries.
|
||||||
|
- `InitialBackoff time.Duration`: Delay before the first retry.
|
||||||
|
- `MaxBackoff time.Duration`: Upper bound for the computed backoff delay.
|
||||||
|
|
||||||
|
### `APIClient`
|
||||||
|
Shared HTTP client with retry handling, shared rate-limit blocking, pluggable authentication, and configurable error prefixes. All struct fields are unexported.
|
||||||
|
- `func (a *APIClient) Do(req *http.Request, result any) error`: Applies authentication, honours the shared rate-limit window, retries transport failures plus `429` and `5xx` responses, and JSON-decodes into `result` when `result` is non-nil.
|
||||||
|
- `func (a *APIClient) DoRaw(req *http.Request) ([]byte, error)`: Runs the same request pipeline as `Do` but returns the raw response body instead of decoding JSON.
|
||||||
|
|
||||||
|
### `APIClientOption`
|
||||||
|
`type APIClientOption func(*APIClient)`
|
||||||
|
|
||||||
|
Functional option consumed by `NewAPIClient` to mutate a newly created `APIClient`.
|
||||||
|
|
||||||
|
### `CloudNSClient`
|
||||||
|
HTTP client for the CloudNS DNS API. Authentication details, base URL, and delegated `APIClient` state are stored in unexported fields.
|
||||||
|
- `func (c *CloudNSClient) ListZones(ctx context.Context) ([]CloudNSZone, error)`: Fetches all zones visible to the configured CloudNS sub-user.
|
||||||
|
- `func (c *CloudNSClient) ListRecords(ctx context.Context, domain string) (map[string]CloudNSRecord, error)`: Fetches all records for a zone and returns them keyed by CloudNS record ID.
|
||||||
|
- `func (c *CloudNSClient) CreateRecord(ctx context.Context, domain, host, recordType, value string, ttl int) (string, error)`: Creates a record and returns the created record ID.
|
||||||
|
- `func (c *CloudNSClient) UpdateRecord(ctx context.Context, domain, recordID, host, recordType, value string, ttl int) error`: Replaces an existing CloudNS record with the supplied values.
|
||||||
|
- `func (c *CloudNSClient) DeleteRecord(ctx context.Context, domain, recordID string) error`: Deletes a record by CloudNS record ID.
|
||||||
|
- `func (c *CloudNSClient) EnsureRecord(ctx context.Context, domain, host, recordType, value string, ttl int) (bool, error)`: Idempotently creates or updates a record and reports whether a change was applied.
|
||||||
|
- `func (c *CloudNSClient) SetACMEChallenge(ctx context.Context, domain, value string) (string, error)`: Creates the `_acme-challenge` TXT record used for DNS-01 validation.
|
||||||
|
- `func (c *CloudNSClient) ClearACMEChallenge(ctx context.Context, domain string) error`: Deletes all `_acme-challenge` TXT records in the zone.
|
||||||
|
|
||||||
|
### `CloudNSZone`
|
||||||
|
CloudNS zone metadata returned by `ListZones`.
|
||||||
|
- `Name string`: Zone name as returned by CloudNS.
|
||||||
|
- `Type string`: Zone type reported by the API.
|
||||||
|
- `Zone string`: Zone domain name or zone identifier.
|
||||||
|
- `Status string`: Current CloudNS status string for the zone.
|
||||||
|
|
||||||
|
### `CloudNSRecord`
|
||||||
|
CloudNS DNS record model returned by `ListRecords`.
|
||||||
|
- `ID string`: CloudNS record ID.
|
||||||
|
- `Type string`: DNS record type such as `A`, `CNAME`, or `TXT`.
|
||||||
|
- `Host string`: Relative host label stored in the zone.
|
||||||
|
- `Record string`: Record payload or answer value.
|
||||||
|
- `TTL string`: TTL reported by CloudNS. The API returns it as a string.
|
||||||
|
- `Priority string`: Optional priority value for record types that support it.
|
||||||
|
- `Status int`: CloudNS numeric status flag for the record.
|
||||||
|
|
||||||
|
### `Config`
|
||||||
|
Top-level `infra.yaml` model loaded by `Load` and `Discover`.
|
||||||
|
- `Hosts map[string]*Host`: Named infrastructure hosts keyed by logical host name.
|
||||||
|
- `LoadBalancer LoadBalancer`: Managed load balancer definition.
|
||||||
|
- `Network Network`: Shared private network definition.
|
||||||
|
- `DNS DNS`: DNS provider configuration and desired zone state.
|
||||||
|
- `SSL SSL`: Certificate and TLS settings.
|
||||||
|
- `Database Database`: Database cluster settings.
|
||||||
|
- `Cache Cache`: Cache or session cluster settings.
|
||||||
|
- `Containers map[string]*Container`: Named container deployment definitions.
|
||||||
|
- `S3 S3Config`: Object storage configuration.
|
||||||
|
- `CDN CDN`: CDN provider configuration.
|
||||||
|
- `CICD CICD`: CI/CD service configuration.
|
||||||
|
- `Monitoring Monitoring`: Health-check and alert thresholds.
|
||||||
|
- `Backups Backups`: Backup job schedules.
|
||||||
|
- `func (c *Config) HostsByRole(role string) map[string]*Host`: Returns the subset of `Hosts` whose `Role` matches `role`.
|
||||||
|
- `func (c *Config) AppServers() map[string]*Host`: Convenience wrapper around `HostsByRole("app")`.
|
||||||
|
|
||||||
|
### `Host`
|
||||||
|
Infrastructure host definition loaded from `infra.yaml`.
|
||||||
|
- `FQDN string`: Public hostname for the machine.
|
||||||
|
- `IP string`: Primary public IP address.
|
||||||
|
- `PrivateIP string`: Optional private network IP.
|
||||||
|
- `Type string`: Provider class, such as `hcloud` or `hrobot`.
|
||||||
|
- `Role string`: Functional role such as `bastion`, `app`, or `builder`.
|
||||||
|
- `SSH SSHConf`: SSH connection settings for the host.
|
||||||
|
- `Services []string`: Services expected to run on the host.
|
||||||
|
|
||||||
|
### `SSHConf`
|
||||||
|
SSH connection settings associated with a `Host`.
|
||||||
|
- `User string`: SSH username.
|
||||||
|
- `Key string`: Path to the private key file. `Load` expands `~` and defaults are applied before use.
|
||||||
|
- `Port int`: SSH port. `Load` defaults this to `22` when omitted.
|
||||||
|
|
||||||
|
### `LoadBalancer`
|
||||||
|
Desired Hetzner managed load balancer configuration loaded from `infra.yaml`.
|
||||||
|
- `Name string`: Hetzner load balancer name.
|
||||||
|
- `FQDN string`: DNS name expected to point at the load balancer.
|
||||||
|
- `Provider string`: Provider identifier for the managed load balancer service.
|
||||||
|
- `Type string`: Hetzner load balancer type name.
|
||||||
|
- `Location string`: Hetzner location or datacenter slug.
|
||||||
|
- `Algorithm string`: Load-balancing algorithm name.
|
||||||
|
- `Backends []Backend`: Backend targets referenced by host name and port.
|
||||||
|
- `Health HealthCheck`: Health-check policy applied to listeners.
|
||||||
|
- `Listeners []Listener`: Frontend-to-backend listener mappings.
|
||||||
|
- `SSL LBCert`: TLS certificate settings for the load balancer.
|
||||||
|
|
||||||
|
### `Backend`
|
||||||
|
Load balancer backend target declared in `infra.yaml`.
|
||||||
|
- `Host string`: Host key in `Config.Hosts` to use as the backend target.
|
||||||
|
- `Port int`: Backend port to route traffic to.
|
||||||
|
|
||||||
|
### `HealthCheck`
|
||||||
|
Load balancer health-check settings.
|
||||||
|
- `Protocol string`: Protocol used for checks.
|
||||||
|
- `Path string`: HTTP path for health checks when the protocol is HTTP-based.
|
||||||
|
- `Interval int`: Probe interval in seconds.
|
||||||
|
|
||||||
|
### `Listener`
|
||||||
|
Frontend listener mapping for the managed load balancer.
|
||||||
|
- `Frontend int`: Exposed listener port.
|
||||||
|
- `Backend int`: Destination backend port.
|
||||||
|
- `Protocol string`: Listener protocol.
|
||||||
|
- `ProxyProtocol bool`: Whether the Hetzner listener should enable PROXY protocol forwarding.
|
||||||
|
|
||||||
|
### `LBCert`
|
||||||
|
TLS certificate settings for the load balancer.
|
||||||
|
- `Certificate string`: Certificate identifier or path.
|
||||||
|
- `SAN []string`: Subject alternative names covered by the certificate.
|
||||||
|
|
||||||
|
### `Network`
|
||||||
|
Private network definition from `infra.yaml`.
|
||||||
|
- `CIDR string`: Network CIDR block.
|
||||||
|
- `Name string`: Logical network name.
|
||||||
|
|
||||||
|
### `DNS`
|
||||||
|
DNS provider settings and desired zone contents.
|
||||||
|
- `Provider string`: DNS provider identifier.
|
||||||
|
- `Nameservers []string`: Authoritative nameservers for the managed domains.
|
||||||
|
- `Zones map[string]*Zone`: Desired DNS zones keyed by zone name.
|
||||||
|
|
||||||
|
### `Zone`
|
||||||
|
Desired DNS zone contents.
|
||||||
|
- `Records []DNSRecord`: Desired records for the zone.
|
||||||
|
|
||||||
|
### `DNSRecord`
|
||||||
|
Desired DNS record entry in `infra.yaml`.
|
||||||
|
- `Name string`: Record name or host label.
|
||||||
|
- `Type string`: DNS record type.
|
||||||
|
- `Value string`: Record value.
|
||||||
|
- `TTL int`: Record TTL in seconds.
|
||||||
|
|
||||||
|
### `SSL`
|
||||||
|
Top-level TLS configuration.
|
||||||
|
- `Wildcard WildcardCert`: Wildcard certificate settings.
|
||||||
|
|
||||||
|
### `WildcardCert`
|
||||||
|
Wildcard certificate request and deployment settings.
|
||||||
|
- `Domains []string`: Domain names covered by the wildcard certificate.
|
||||||
|
- `Method string`: Certificate issuance method.
|
||||||
|
- `DNSProvider string`: DNS provider used for validation.
|
||||||
|
- `Termination string`: Termination point for the certificate.
|
||||||
|
|
||||||
|
### `Database`
|
||||||
|
Database cluster configuration.
|
||||||
|
- `Engine string`: Database engine name.
|
||||||
|
- `Version string`: Engine version.
|
||||||
|
- `Cluster string`: Cluster mode or cluster identifier.
|
||||||
|
- `Nodes []DBNode`: Database nodes in the cluster.
|
||||||
|
- `SSTMethod string`: State snapshot transfer method.
|
||||||
|
- `Backup BackupConfig`: Automated backup settings for the database cluster.
|
||||||
|
|
||||||
|
### `DBNode`
|
||||||
|
Single database node definition.
|
||||||
|
- `Host string`: Host identifier for the database node.
|
||||||
|
- `Port int`: Database port.
|
||||||
|
|
||||||
|
### `BackupConfig`
|
||||||
|
Backup settings attached to `Database`.
|
||||||
|
- `Schedule string`: Backup schedule expression.
|
||||||
|
- `Destination string`: Backup destination type or endpoint.
|
||||||
|
- `Bucket string`: Bucket name when backups target object storage.
|
||||||
|
- `Prefix string`: Object key prefix for stored backups.
|
||||||
|
|
||||||
|
### `Cache`
|
||||||
|
Cache or session cluster configuration.
|
||||||
|
- `Engine string`: Cache engine name.
|
||||||
|
- `Version string`: Engine version.
|
||||||
|
- `Sentinel bool`: Whether Redis Sentinel style high availability is enabled.
|
||||||
|
- `Nodes []CacheNode`: Cache nodes in the cluster.
|
||||||
|
|
||||||
|
### `CacheNode`
|
||||||
|
Single cache node definition.
|
||||||
|
- `Host string`: Host identifier for the cache node.
|
||||||
|
- `Port int`: Cache service port.
|
||||||
|
|
||||||
|
### `Container`
|
||||||
|
Named container deployment definition.
|
||||||
|
- `Image string`: Container image reference.
|
||||||
|
- `Port int`: Optional exposed service port.
|
||||||
|
- `Runtime string`: Optional container runtime identifier.
|
||||||
|
- `Command string`: Optional command override.
|
||||||
|
- `Replicas int`: Optional replica count.
|
||||||
|
- `DependsOn []string`: Optional dependency list naming other services or components.
|
||||||
|
|
||||||
|
### `S3Config`
|
||||||
|
Object storage configuration.
|
||||||
|
- `Endpoint string`: S3-compatible endpoint URL or host.
|
||||||
|
- `Buckets map[string]*S3Bucket`: Named bucket definitions keyed by logical bucket name.
|
||||||
|
|
||||||
|
### `S3Bucket`
|
||||||
|
Single S3 bucket definition.
|
||||||
|
- `Purpose string`: Intended bucket role or usage.
|
||||||
|
- `Paths []string`: Managed paths or prefixes within the bucket.
|
||||||
|
|
||||||
|
### `CDN`
|
||||||
|
CDN configuration.
|
||||||
|
- `Provider string`: CDN provider identifier.
|
||||||
|
- `Origin string`: Origin hostname or endpoint.
|
||||||
|
- `Zones []string`: Zones served through the CDN.
|
||||||
|
|
||||||
|
### `CICD`
|
||||||
|
CI/CD service configuration.
|
||||||
|
- `Provider string`: CI/CD provider identifier.
|
||||||
|
- `URL string`: Service URL.
|
||||||
|
- `Runner string`: Runner type or runner host reference.
|
||||||
|
- `Registry string`: Container registry endpoint.
|
||||||
|
- `DeployHook string`: Deploy hook URL or tokenized endpoint.
|
||||||
|
|
||||||
|
### `Monitoring`
|
||||||
|
Monitoring and alert configuration.
|
||||||
|
- `HealthEndpoints []HealthEndpoint`: Endpoints that should be polled for health.
|
||||||
|
- `Alerts map[string]int`: Numeric thresholds keyed by alert name.
|
||||||
|
|
||||||
|
### `HealthEndpoint`
|
||||||
|
Health endpoint monitored by the platform.
|
||||||
|
- `URL string`: Endpoint URL.
|
||||||
|
- `Interval int`: Polling interval in seconds.
|
||||||
|
|
||||||
|
### `Backups`
|
||||||
|
Backup schedules grouped by cadence.
|
||||||
|
- `Daily []BackupJob`: Jobs that run daily.
|
||||||
|
- `Weekly []BackupJob`: Jobs that run weekly.
|
||||||
|
|
||||||
|
### `BackupJob`
|
||||||
|
Scheduled backup job definition.
|
||||||
|
- `Name string`: Job name.
|
||||||
|
- `Type string`: Backup type or mechanism.
|
||||||
|
- `Destination string`: Optional destination override.
|
||||||
|
- `Hosts []string`: Optional host list associated with the job.
|
||||||
|
|
||||||
|
### `HCloudClient`
|
||||||
|
HTTP client for the Hetzner Cloud API. Token, base URL, and delegated `APIClient` state are stored in unexported fields.
|
||||||
|
- `func (c *HCloudClient) ListServers(ctx context.Context) ([]HCloudServer, error)`: Lists cloud servers.
|
||||||
|
- `func (c *HCloudClient) ListLoadBalancers(ctx context.Context) ([]HCloudLoadBalancer, error)`: Lists managed load balancers.
|
||||||
|
- `func (c *HCloudClient) GetLoadBalancer(ctx context.Context, id int) (*HCloudLoadBalancer, error)`: Fetches a load balancer by numeric ID.
|
||||||
|
- `func (c *HCloudClient) CreateLoadBalancer(ctx context.Context, req HCloudLBCreateRequest) (*HCloudLoadBalancer, error)`: Creates a load balancer from the supplied request payload.
|
||||||
|
- `func (c *HCloudClient) DeleteLoadBalancer(ctx context.Context, id int) error`: Deletes a load balancer by numeric ID.
|
||||||
|
- `func (c *HCloudClient) CreateSnapshot(ctx context.Context, serverID int, description string) error`: Creates a snapshot image for a server.
|
||||||
|
|
||||||
|
### `HCloudServer`
|
||||||
|
Hetzner Cloud server model returned by `ListServers`.
|
||||||
|
- `ID int`: Hetzner server ID.
|
||||||
|
- `Name string`: Server name.
|
||||||
|
- `Status string`: Provisioning or runtime status string.
|
||||||
|
- `PublicNet HCloudPublicNet`: Public networking information.
|
||||||
|
- `PrivateNet []HCloudPrivateNet`: Attached private network interfaces.
|
||||||
|
- `ServerType HCloudServerType`: Server flavour metadata.
|
||||||
|
- `Datacenter HCloudDatacenter`: Datacenter metadata.
|
||||||
|
- `Labels map[string]string`: Hetzner labels attached to the server.
|
||||||
|
|
||||||
|
### `HCloudPublicNet`
|
||||||
|
Hetzner Cloud public network metadata.
|
||||||
|
- `IPv4 HCloudIPv4`: Primary public IPv4 address.
|
||||||
|
|
||||||
|
### `HCloudIPv4`
|
||||||
|
Hetzner IPv4 model.
|
||||||
|
- `IP string`: IPv4 address string.
|
||||||
|
|
||||||
|
### `HCloudPrivateNet`
|
||||||
|
Hetzner private network attachment.
|
||||||
|
- `IP string`: Private IP assigned to the server on the network.
|
||||||
|
- `Network int`: Numeric network ID.
|
||||||
|
|
||||||
|
### `HCloudServerType`
|
||||||
|
Hetzner server flavour metadata.
|
||||||
|
- `Name string`: Server type name.
|
||||||
|
- `Description string`: Provider description for the server type.
|
||||||
|
- `Cores int`: Number of vCPUs.
|
||||||
|
- `Memory float64`: RAM size reported by the API.
|
||||||
|
- `Disk int`: Disk size reported by the API.
|
||||||
|
|
||||||
|
### `HCloudDatacenter`
|
||||||
|
Hetzner datacenter or location metadata.
|
||||||
|
- `Name string`: Datacenter or location name.
|
||||||
|
- `Description string`: Provider description.
|
||||||
|
|
||||||
|
### `HCloudLoadBalancer`
|
||||||
|
Hetzner managed load balancer model.
|
||||||
|
- `ID int`: Load balancer ID.
|
||||||
|
- `Name string`: Load balancer name.
|
||||||
|
- `PublicNet HCloudLBPublicNet`: Public network state, including the IPv4 address.
|
||||||
|
- `Algorithm HCloudLBAlgorithm`: Load-balancing algorithm.
|
||||||
|
- `Services []HCloudLBService`: Configured listeners and service definitions.
|
||||||
|
- `Targets []HCloudLBTarget`: Attached backend targets.
|
||||||
|
- `Location HCloudDatacenter`: Location metadata.
|
||||||
|
- `Labels map[string]string`: Hetzner labels attached to the load balancer.
|
||||||
|
|
||||||
|
### `HCloudLBPublicNet`
|
||||||
|
Public network state for a Hetzner load balancer.
|
||||||
|
- `Enabled bool`: Whether public networking is enabled.
|
||||||
|
- `IPv4 HCloudIPv4`: Assigned public IPv4 address.
|
||||||
|
|
||||||
|
### `HCloudLBAlgorithm`
|
||||||
|
Hetzner load-balancing algorithm descriptor.
|
||||||
|
- `Type string`: Algorithm name.
|
||||||
|
|
||||||
|
### `HCloudLBService`
|
||||||
|
Hetzner load balancer listener or service definition.
|
||||||
|
- `Protocol string`: Listener protocol.
|
||||||
|
- `ListenPort int`: Frontend port exposed by the load balancer.
|
||||||
|
- `DestinationPort int`: Backend port targeted by the service.
|
||||||
|
- `Proxyprotocol bool`: Whether PROXY protocol forwarding is enabled. The API field name is `proxyprotocol`.
|
||||||
|
- `HTTP *HCloudLBHTTP`: Optional HTTP-specific settings.
|
||||||
|
- `HealthCheck *HCloudLBHealthCheck`: Optional health-check configuration.
|
||||||
|
|
||||||
|
### `HCloudLBHTTP`
|
||||||
|
HTTP-specific load balancer service settings.
|
||||||
|
- `RedirectHTTP bool`: Whether plain HTTP requests should be redirected.
|
||||||
|
|
||||||
|
### `HCloudLBHealthCheck`
|
||||||
|
Hetzner load balancer health-check definition.
|
||||||
|
- `Protocol string`: Health-check protocol.
|
||||||
|
- `Port int`: Backend port to probe.
|
||||||
|
- `Interval int`: Probe interval.
|
||||||
|
- `Timeout int`: Probe timeout.
|
||||||
|
- `Retries int`: Failure threshold before a target is considered unhealthy.
|
||||||
|
- `HTTP *HCloudLBHCHTTP`: Optional HTTP-specific health-check options.
|
||||||
|
|
||||||
|
### `HCloudLBHCHTTP`
|
||||||
|
HTTP-specific Hetzner health-check options.
|
||||||
|
- `Path string`: HTTP path used for the probe.
|
||||||
|
- `StatusCode string`: Expected status code matcher serialized as `status_codes`.
|
||||||
|
|
||||||
|
### `HCloudLBTarget`
|
||||||
|
Backend target attached to a Hetzner load balancer.
|
||||||
|
- `Type string`: Target type, such as `ip` or `server`.
|
||||||
|
- `IP *HCloudLBTargetIP`: IP target metadata when the target type is IP-based.
|
||||||
|
- `Server *HCloudLBTargetServer`: Server reference when the target type is server-based.
|
||||||
|
- `HealthStatus []HCloudLBHealthStatus`: Health status entries for the target.
|
||||||
|
|
||||||
|
### `HCloudLBTargetIP`
|
||||||
|
IP-based load balancer target.
|
||||||
|
- `IP string`: Backend IP address.
|
||||||
|
|
||||||
|
### `HCloudLBTargetServer`
|
||||||
|
Server-based load balancer target reference.
|
||||||
|
- `ID int`: Hetzner server ID.
|
||||||
|
|
||||||
|
### `HCloudLBHealthStatus`
|
||||||
|
Health state for one listening port on a load balancer target.
|
||||||
|
- `ListenPort int`: Listener port associated with the status.
|
||||||
|
- `Status string`: Health status string such as `healthy`.
|
||||||
|
|
||||||
|
### `HCloudLBCreateRequest`
|
||||||
|
Request payload for `HCloudClient.CreateLoadBalancer`.
|
||||||
|
- `Name string`: New load balancer name.
|
||||||
|
- `LoadBalancerType string`: Hetzner load balancer type slug.
|
||||||
|
- `Location string`: Hetzner location or datacenter slug.
|
||||||
|
- `Algorithm HCloudLBAlgorithm`: Algorithm selection.
|
||||||
|
- `Services []HCloudLBService`: Listener definitions to create.
|
||||||
|
- `Targets []HCloudLBCreateTarget`: Backend targets to attach at creation time.
|
||||||
|
- `Labels map[string]string`: Labels to apply to the new load balancer.
|
||||||
|
|
||||||
|
### `HCloudLBCreateTarget`
|
||||||
|
Target entry used during load balancer creation.
|
||||||
|
- `Type string`: Target type.
|
||||||
|
- `IP *HCloudLBTargetIP`: IP target metadata when the target is IP-based.
|
||||||
|
|
||||||
|
### `HRobotClient`
|
||||||
|
HTTP client for the Hetzner Robot API. Credentials, base URL, and delegated `APIClient` state are stored in unexported fields.
|
||||||
|
- `func (c *HRobotClient) ListServers(ctx context.Context) ([]HRobotServer, error)`: Lists dedicated servers available from Robot.
|
||||||
|
- `func (c *HRobotClient) GetServer(ctx context.Context, ip string) (*HRobotServer, error)`: Fetches one Robot server by server IP.
|
||||||
|
|
||||||
|
### `HRobotServer`
|
||||||
|
Hetzner Robot dedicated server model.
|
||||||
|
- `ServerIP string`: Public server IP address.
|
||||||
|
- `ServerName string`: Server hostname.
|
||||||
|
- `Product string`: Product or hardware plan name.
|
||||||
|
- `Datacenter string`: Datacenter code returned by the API field `dc`.
|
||||||
|
- `Status string`: Robot status string.
|
||||||
|
- `Cancelled bool`: Whether the server is cancelled.
|
||||||
|
- `PaidUntil string`: Billing paid-through date string.
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### `func DefaultRetryConfig() RetryConfig`
|
||||||
|
Returns the library defaults used by `NewAPIClient`: three retries, `100ms` initial backoff, and `5s` maximum backoff.
|
||||||
|
|
||||||
|
### `func WithHTTPClient(c *http.Client) APIClientOption`
|
||||||
|
Injects a custom `http.Client` into an `APIClient`.
|
||||||
|
|
||||||
|
### `func WithRetry(cfg RetryConfig) APIClientOption`
|
||||||
|
Injects a retry policy into an `APIClient`.
|
||||||
|
|
||||||
|
### `func WithAuth(fn func(req *http.Request)) APIClientOption`
|
||||||
|
Registers a callback that mutates each outgoing request before it is sent.
|
||||||
|
|
||||||
|
### `func WithPrefix(p string) APIClientOption`
|
||||||
|
Sets the error prefix used when wrapping client errors.
|
||||||
|
|
||||||
|
### `func NewAPIClient(opts ...APIClientOption) *APIClient`
|
||||||
|
Builds an `APIClient` with a `30s` HTTP timeout, `DefaultRetryConfig`, default prefix `api`, and any supplied options.
|
||||||
|
|
||||||
|
### `func NewCloudNSClient(authID, password string) *CloudNSClient`
|
||||||
|
Builds a CloudNS client configured for `auth-id` and `auth-password` query-parameter authentication.
|
||||||
|
|
||||||
|
### `func Load(path string) (*Config, error)`
|
||||||
|
Reads and unmarshals `infra.yaml`, expands host SSH key paths, and defaults missing SSH ports to `22`.
|
||||||
|
|
||||||
|
### `func Discover(startDir string) (*Config, string, error)`
|
||||||
|
Searches `startDir` and its parents for `infra.yaml`, returning the parsed config together with the discovered file path.
|
||||||
|
|
||||||
|
### `func NewHCloudClient(token string) *HCloudClient`
|
||||||
|
Builds a Hetzner Cloud client that authenticates requests with a bearer token.
|
||||||
|
|
||||||
|
### `func NewHRobotClient(user, password string) *HRobotClient`
|
||||||
|
Builds a Hetzner Robot client that authenticates requests with HTTP basic auth.
|
||||||
Loading…
Add table
Reference in a new issue