AX v0.8.0 polish pass
Co-authored-by: Virgil <virgil@lethean.io>
This commit is contained in:
parent
67dae9cc94
commit
47910d0667
15 changed files with 469 additions and 155 deletions
11
client.go
11
client.go
|
|
@ -13,6 +13,7 @@ import (
|
|||
)
|
||||
|
||||
// RetryConfig controls exponential backoff retry behaviour.
|
||||
// Usage: cfg := infra.RetryConfig{}
|
||||
type RetryConfig struct {
|
||||
// MaxRetries is the maximum number of retry attempts (0 = no retries).
|
||||
MaxRetries int
|
||||
|
|
@ -23,6 +24,7 @@ type RetryConfig struct {
|
|||
}
|
||||
|
||||
// DefaultRetryConfig returns sensible defaults: 3 retries, 100ms initial, 5s max.
|
||||
// Usage: cfg := infra.DefaultRetryConfig()
|
||||
func DefaultRetryConfig() RetryConfig {
|
||||
return RetryConfig{
|
||||
MaxRetries: 3,
|
||||
|
|
@ -34,6 +36,7 @@ func DefaultRetryConfig() RetryConfig {
|
|||
// APIClient is a shared HTTP client with retry, rate-limit handling,
|
||||
// and configurable authentication. Provider-specific clients embed or
|
||||
// delegate to this struct.
|
||||
// Usage: client := infra.NewAPIClient()
|
||||
type APIClient struct {
|
||||
client *http.Client
|
||||
retry RetryConfig
|
||||
|
|
@ -44,29 +47,35 @@ type APIClient struct {
|
|||
}
|
||||
|
||||
// APIClientOption configures an APIClient.
|
||||
// Usage: client := infra.NewAPIClient(infra.WithPrefix("api"))
|
||||
type APIClientOption func(*APIClient)
|
||||
|
||||
// WithHTTPClient sets a custom http.Client.
|
||||
// Usage: client := infra.NewAPIClient(infra.WithHTTPClient(http.DefaultClient))
|
||||
func WithHTTPClient(c *http.Client) APIClientOption {
|
||||
return func(a *APIClient) { a.client = c }
|
||||
}
|
||||
|
||||
// WithRetry sets the retry configuration.
|
||||
// Usage: client := infra.NewAPIClient(infra.WithRetry(infra.DefaultRetryConfig()))
|
||||
func WithRetry(cfg RetryConfig) APIClientOption {
|
||||
return func(a *APIClient) { a.retry = cfg }
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return func(a *APIClient) { a.authFn = fn }
|
||||
}
|
||||
|
||||
// WithPrefix sets the error message prefix (e.g. "hcloud API").
|
||||
// Usage: client := infra.NewAPIClient(infra.WithPrefix("hcloud API"))
|
||||
func WithPrefix(p string) APIClientOption {
|
||||
return func(a *APIClient) { a.prefix = p }
|
||||
}
|
||||
|
||||
// NewAPIClient creates a new APIClient with the given options.
|
||||
// Usage: client := infra.NewAPIClient(infra.WithPrefix("cloudns API"))
|
||||
func NewAPIClient(opts ...APIClientOption) *APIClient {
|
||||
a := &APIClient{
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
|
|
@ -82,6 +91,7 @@ func NewAPIClient(opts ...APIClientOption) *APIClient {
|
|||
// Do executes an HTTP request with authentication, retry logic, and
|
||||
// rate-limit handling. If result is non-nil, the response body is
|
||||
// JSON-decoded into it.
|
||||
// Usage: err := client.Do(req, &result)
|
||||
func (a *APIClient) Do(req *http.Request, result any) error {
|
||||
if a.authFn != nil {
|
||||
a.authFn(req)
|
||||
|
|
@ -168,6 +178,7 @@ func (a *APIClient) Do(req *http.Request, result any) error {
|
|||
|
||||
// DoRaw executes a request and returns the raw response body.
|
||||
// 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) {
|
||||
if a.authFn != nil {
|
||||
a.authFn(req)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
|
||||
// --- Constructor ---
|
||||
|
||||
func TestNewAPIClient_Good_Defaults(t *testing.T) {
|
||||
func TestClient_NewAPIClient_Defaults_Good(t *testing.T) {
|
||||
c := NewAPIClient()
|
||||
assert.NotNil(t, c.client)
|
||||
assert.Equal(t, "api", c.prefix)
|
||||
|
|
@ -24,7 +24,7 @@ func TestNewAPIClient_Good_Defaults(t *testing.T) {
|
|||
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}
|
||||
authCalled := false
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ func TestNewAPIClient_Good_WithOptions(t *testing.T) {
|
|||
assert.True(t, authCalled)
|
||||
}
|
||||
|
||||
func TestDefaultRetryConfig_Good(t *testing.T) {
|
||||
func TestClient_DefaultRetryConfig_Good(t *testing.T) {
|
||||
cfg := DefaultRetryConfig()
|
||||
assert.Equal(t, 3, cfg.MaxRetries)
|
||||
assert.Equal(t, 100*time.Millisecond, cfg.InitialBackoff)
|
||||
|
|
@ -55,7 +55,7 @@ func TestDefaultRetryConfig_Good(t *testing.T) {
|
|||
|
||||
// --- 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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"name":"test"}`))
|
||||
|
|
@ -78,7 +78,7 @@ func TestAPIClient_Do_Good_Success(t *testing.T) {
|
|||
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) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
|
@ -96,7 +96,7 @@ func TestAPIClient_Do_Good_NilResult(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, "Bearer my-token", r.Header.Get("Authorization"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
|
@ -119,7 +119,7 @@ func TestAPIClient_Do_Good_AuthApplied(t *testing.T) {
|
|||
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) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`not found`))
|
||||
|
|
@ -141,7 +141,7 @@ func TestAPIClient_Do_Bad_ClientError(t *testing.T) {
|
|||
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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`not json`))
|
||||
|
|
@ -164,7 +164,7 @@ func TestAPIClient_Do_Bad_DecodeError(t *testing.T) {
|
|||
|
||||
// --- Retry logic ---
|
||||
|
||||
func TestAPIClient_Do_Good_RetriesServerError(t *testing.T) {
|
||||
func TestClient_APIClient_Do_RetriesServerError_Good(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Bad_ExhaustsRetries(t *testing.T) {
|
||||
func TestClient_APIClient_Do_ExhaustsRetries_Bad(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Good_NoRetryOn4xx(t *testing.T) {
|
||||
func TestClient_APIClient_Do_NoRetryOn4xx_Good(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Good_ZeroRetries(t *testing.T) {
|
||||
func TestClient_APIClient_Do_ZeroRetries_Good(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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 ---
|
||||
|
||||
func TestAPIClient_Do_Good_RateLimitRetry(t *testing.T) {
|
||||
func TestClient_APIClient_Do_RateLimitRetry_Good(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Bad_RateLimitExhausted(t *testing.T) {
|
||||
func TestClient_APIClient_Do_RateLimitExhausted_Bad(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func TestAPIClient_Do_Good_RateLimitNoRetryAfterHeader(t *testing.T) {
|
||||
func TestClient_APIClient_Do_RateLimitNoRetryAfterHeader_Good(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`fail`))
|
||||
|
|
@ -415,7 +415,7 @@ func TestAPIClient_Do_Ugly_ContextCancelled(t *testing.T) {
|
|||
|
||||
// --- 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) {
|
||||
_, _ = 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))
|
||||
}
|
||||
|
||||
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) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
assert.True(t, ok)
|
||||
|
|
@ -458,7 +458,7 @@ func TestAPIClient_DoRaw_Good_AuthApplied(t *testing.T) {
|
|||
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) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = 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")
|
||||
}
|
||||
|
||||
func TestAPIClient_DoRaw_Good_RetriesServerError(t *testing.T) {
|
||||
func TestClient_APIClient_DoRaw_RetriesServerError_Good(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
func TestAPIClient_DoRaw_Good_RateLimitRetry(t *testing.T) {
|
||||
func TestClient_APIClient_DoRaw_RateLimitRetry_Good(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
func TestAPIClient_DoRaw_Bad_NoRetryOn4xx(t *testing.T) {
|
||||
func TestClient_APIClient_DoRaw_NoRetryOn4xx_Bad(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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 ---
|
||||
|
||||
func TestParseRetryAfter_Good_Seconds(t *testing.T) {
|
||||
func TestClient_ParseRetryAfter_Seconds_Good(t *testing.T) {
|
||||
d := parseRetryAfter("5")
|
||||
assert.Equal(t, 5*time.Second, d)
|
||||
}
|
||||
|
||||
func TestParseRetryAfter_Good_EmptyDefault(t *testing.T) {
|
||||
func TestClient_ParseRetryAfter_EmptyDefault_Good(t *testing.T) {
|
||||
d := parseRetryAfter("")
|
||||
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")
|
||||
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")
|
||||
// 0 is not > 0, falls back to 1s
|
||||
assert.Equal(t, 1*time.Second, d)
|
||||
|
|
@ -596,7 +596,7 @@ func TestParseRetryAfter_Good_Zero(t *testing.T) {
|
|||
|
||||
// --- Integration: HCloudClient uses APIClient retry ---
|
||||
|
||||
func TestHCloudClient_Good_RetriesOnServerError(t *testing.T) {
|
||||
func TestClient_HCloudClient_RetriesOnServerError_Good(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
func TestHCloudClient_Good_HandlesRateLimit(t *testing.T) {
|
||||
func TestClient_HCloudClient_HandlesRateLimit_Good(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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 ---
|
||||
|
||||
func TestCloudNSClient_Good_RetriesOnServerError(t *testing.T) {
|
||||
func TestClient_CloudNSClient_RetriesOnServerError_Good(t *testing.T) {
|
||||
var attempts atomic.Int32
|
||||
|
||||
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 ---
|
||||
|
||||
func TestAPIClient_Good_RateLimitSharedState(t *testing.T) {
|
||||
func TestClient_APIClient_RateLimitSharedState_Good(t *testing.T) {
|
||||
// Verify that the blockedUntil state is respected across requests
|
||||
var attempts atomic.Int32
|
||||
|
||||
|
|
|
|||
12
cloudns.go
12
cloudns.go
|
|
@ -12,6 +12,7 @@ import (
|
|||
const cloudnsBaseURL = "https://api.cloudns.net"
|
||||
|
||||
// CloudNSClient is an HTTP client for the CloudNS DNS API.
|
||||
// Usage: dns := infra.NewCloudNSClient(authID, password)
|
||||
type CloudNSClient struct {
|
||||
authID string
|
||||
password string
|
||||
|
|
@ -21,6 +22,7 @@ type CloudNSClient struct {
|
|||
|
||||
// NewCloudNSClient creates a new CloudNS API client.
|
||||
// Uses sub-auth-user (auth-id) authentication.
|
||||
// Usage: dns := infra.NewCloudNSClient(authID, password)
|
||||
func NewCloudNSClient(authID, password string) *CloudNSClient {
|
||||
return &CloudNSClient{
|
||||
authID: authID,
|
||||
|
|
@ -31,6 +33,7 @@ func NewCloudNSClient(authID, password string) *CloudNSClient {
|
|||
}
|
||||
|
||||
// CloudNSZone represents a DNS zone.
|
||||
// Usage: zone := infra.CloudNSZone{}
|
||||
type CloudNSZone struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
|
|
@ -39,6 +42,7 @@ type CloudNSZone struct {
|
|||
}
|
||||
|
||||
// CloudNSRecord represents a DNS record.
|
||||
// Usage: record := infra.CloudNSRecord{}
|
||||
type CloudNSRecord struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
|
|
@ -50,6 +54,7 @@ type CloudNSRecord struct {
|
|||
}
|
||||
|
||||
// ListZones returns all DNS zones.
|
||||
// Usage: zones, err := dns.ListZones(ctx)
|
||||
func (c *CloudNSClient) ListZones(ctx context.Context) ([]CloudNSZone, error) {
|
||||
params := c.authParams()
|
||||
params.Set("page", "1")
|
||||
|
|
@ -70,6 +75,7 @@ func (c *CloudNSClient) ListZones(ctx context.Context) ([]CloudNSZone, error) {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
params := c.authParams()
|
||||
params.Set("domain-name", domain)
|
||||
|
|
@ -87,6 +93,7 @@ func (c *CloudNSClient) ListRecords(ctx context.Context, domain string) (map[str
|
|||
}
|
||||
|
||||
// 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) {
|
||||
params := c.authParams()
|
||||
params.Set("domain-name", domain)
|
||||
|
|
@ -119,6 +126,7 @@ func (c *CloudNSClient) CreateRecord(ctx context.Context, domain, host, recordTy
|
|||
}
|
||||
|
||||
// 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 {
|
||||
params := c.authParams()
|
||||
params.Set("domain-name", domain)
|
||||
|
|
@ -149,6 +157,7 @@ func (c *CloudNSClient) UpdateRecord(ctx context.Context, domain, recordID, host
|
|||
}
|
||||
|
||||
// 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 {
|
||||
params := c.authParams()
|
||||
params.Set("domain-name", domain)
|
||||
|
|
@ -176,6 +185,7 @@ func (c *CloudNSClient) DeleteRecord(ctx context.Context, domain, recordID strin
|
|||
|
||||
// EnsureRecord creates or updates a DNS record to match the desired state.
|
||||
// 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) {
|
||||
records, err := c.ListRecords(ctx, domain)
|
||||
if err != nil {
|
||||
|
|
@ -204,11 +214,13 @@ func (c *CloudNSClient) EnsureRecord(ctx context.Context, domain, host, recordTy
|
|||
}
|
||||
|
||||
// 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) {
|
||||
return c.CreateRecord(ctx, domain, "_acme-challenge", "TXT", value, 60)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
records, err := c.ListRecords(ctx, domain)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
|
||||
// --- Constructor ---
|
||||
|
||||
func TestNewCloudNSClient_Good(t *testing.T) {
|
||||
func TestCloudNS_NewCloudNSClient_Good(t *testing.T) {
|
||||
c := NewCloudNSClient("12345", "secret")
|
||||
assert.NotNil(t, c)
|
||||
assert.Equal(t, "12345", c.authID)
|
||||
|
|
@ -23,7 +23,7 @@ func TestNewCloudNSClient_Good(t *testing.T) {
|
|||
|
||||
// --- authParams ---
|
||||
|
||||
func TestCloudNSClient_AuthParams_Good(t *testing.T) {
|
||||
func TestCloudNS_CloudNSClient_AuthParams_Good(t *testing.T) {
|
||||
c := NewCloudNSClient("49500", "hunter2")
|
||||
params := c.authParams()
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ func TestCloudNSClient_AuthParams_Good(t *testing.T) {
|
|||
|
||||
// --- 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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"Success"}`))
|
||||
|
|
@ -57,7 +57,7 @@ func TestCloudNSClient_DoRaw_Good_ReturnsBody(t *testing.T) {
|
|||
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) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = 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")
|
||||
}
|
||||
|
||||
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) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`Internal Server Error`))
|
||||
|
|
@ -107,7 +107,7 @@ func TestCloudNSClient_DoRaw_Bad_ServerError(t *testing.T) {
|
|||
|
||||
// --- Zone JSON parsing ---
|
||||
|
||||
func TestCloudNSZone_JSON_Good(t *testing.T) {
|
||||
func TestCloudNS_CloudNSZone_JSON_Good(t *testing.T) {
|
||||
data := `[
|
||||
{"name": "example.com", "type": "master", "zone": "domain", "status": "1"},
|
||||
{"name": "test.io", "type": "master", "zone": "domain", "status": "1"}
|
||||
|
|
@ -121,7 +121,7 @@ func TestCloudNSZone_JSON_Good(t *testing.T) {
|
|||
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 []
|
||||
data := `{}`
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ func TestCloudNSZone_JSON_Good_EmptyResponse(t *testing.T) {
|
|||
|
||||
// --- Record JSON parsing ---
|
||||
|
||||
func TestCloudNSRecord_JSON_Good(t *testing.T) {
|
||||
func TestCloudNS_CloudNSRecord_JSON_Good(t *testing.T) {
|
||||
data := `{
|
||||
"12345": {
|
||||
"id": "12345",
|
||||
|
|
@ -173,7 +173,7 @@ func TestCloudNSRecord_JSON_Good(t *testing.T) {
|
|||
assert.Equal(t, "10", mxRecord.Priority)
|
||||
}
|
||||
|
||||
func TestCloudNSRecord_JSON_Good_TXTRecord(t *testing.T) {
|
||||
func TestCloudNS_CloudNSRecord_JSON_TXTRecord_Good(t *testing.T) {
|
||||
data := `{
|
||||
"99": {
|
||||
"id": "99",
|
||||
|
|
@ -198,7 +198,7 @@ func TestCloudNSRecord_JSON_Good_TXTRecord(t *testing.T) {
|
|||
|
||||
// --- 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}}`
|
||||
|
||||
var result struct {
|
||||
|
|
@ -214,7 +214,7 @@ func TestCloudNSClient_CreateRecord_Good_ResponseParsing(t *testing.T) {
|
|||
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."}`
|
||||
|
||||
var result struct {
|
||||
|
|
@ -229,7 +229,7 @@ func TestCloudNSClient_CreateRecord_Bad_FailedStatus(t *testing.T) {
|
|||
|
||||
// --- 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."}`
|
||||
|
||||
var result struct {
|
||||
|
|
@ -243,7 +243,7 @@ func TestCloudNSClient_UpdateDelete_Good_ResponseParsing(t *testing.T) {
|
|||
|
||||
// --- 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) {
|
||||
assert.NotEmpty(t, r.URL.Query().Get("auth-id"))
|
||||
assert.NotEmpty(t, r.URL.Query().Get("auth-password"))
|
||||
|
|
@ -267,7 +267,7 @@ func TestCloudNSClient_ListZones_Good_ViaDoRaw(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
||||
|
||||
|
|
@ -294,7 +294,7 @@ func TestCloudNSClient_ListRecords_Good_ViaDoRaw(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
||||
|
|
@ -321,7 +321,7 @@ func TestCloudNSClient_CreateRecord_Good_ViaDoRaw(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
||||
|
|
@ -346,7 +346,7 @@ func TestCloudNSClient_DeleteRecord_Good_ViaDoRaw(t *testing.T) {
|
|||
|
||||
// --- 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) {
|
||||
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
||||
assert.Equal(t, "_acme-challenge", r.URL.Query().Get("host"))
|
||||
|
|
@ -371,7 +371,7 @@ func TestCloudNSClient_SetACMEChallenge_Good_ParamVerification(t *testing.T) {
|
|||
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{
|
||||
"1": {ID: "1", Type: "A", Host: "www", Record: "1.2.3.4"},
|
||||
"2": {ID: "2", Type: "TXT", Host: "_acme-challenge", Record: "token1"},
|
||||
|
|
@ -393,7 +393,7 @@ func TestCloudNSClient_ClearACMEChallenge_Good_Logic(t *testing.T) {
|
|||
|
||||
// --- EnsureRecord logic ---
|
||||
|
||||
func TestEnsureRecord_Good_Logic_AlreadyCorrect(t *testing.T) {
|
||||
func TestCloudNS_EnsureRecord_Logic_AlreadyCorrect_Good(t *testing.T) {
|
||||
records := map[string]CloudNSRecord{
|
||||
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
|
||||
}
|
||||
|
|
@ -432,7 +432,7 @@ func TestEnsureRecord_Good_Logic_AlreadyCorrect(t *testing.T) {
|
|||
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{
|
||||
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
|
||||
}
|
||||
|
|
@ -454,7 +454,7 @@ func TestEnsureRecord_Good_Logic_NeedsUpdate(t *testing.T) {
|
|||
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{
|
||||
"10": {ID: "10", Type: "A", Host: "www", Record: "1.2.3.4"},
|
||||
}
|
||||
|
|
@ -475,7 +475,7 @@ func TestEnsureRecord_Good_Logic_NeedsCreate(t *testing.T) {
|
|||
|
||||
// --- 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) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
|
@ -498,7 +498,7 @@ func TestCloudNSClient_DoRaw_Good_EmptyBody(t *testing.T) {
|
|||
assert.Empty(t, data)
|
||||
}
|
||||
|
||||
func TestCloudNSRecord_JSON_Good_EmptyMap(t *testing.T) {
|
||||
func TestCloudNS_CloudNSRecord_JSON_EmptyMap_Good(t *testing.T) {
|
||||
data := `{}`
|
||||
|
||||
var records map[string]CloudNSRecord
|
||||
|
|
@ -515,7 +515,7 @@ func requireCloudNSJSON(t *testing.T, data string, target any) {
|
|||
|
||||
// --- 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) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "example.com", r.URL.Query().Get("domain-name"))
|
||||
|
|
@ -542,7 +542,7 @@ func TestCloudNSClient_UpdateRecord_Good_ViaDoRaw(t *testing.T) {
|
|||
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) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"Failed","statusDescription":"Record not found."}`))
|
||||
|
|
@ -564,7 +564,7 @@ func TestCloudNSClient_UpdateRecord_Bad_FailedStatus(t *testing.T) {
|
|||
|
||||
// --- 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) {
|
||||
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}}`))
|
||||
|
|
@ -584,7 +584,7 @@ func TestCloudNSClient_EnsureRecord_Good_AlreadyCorrect(t *testing.T) {
|
|||
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
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
|
|
@ -614,7 +614,7 @@ func TestCloudNSClient_EnsureRecord_Good_NeedsUpdate(t *testing.T) {
|
|||
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
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
|
|
@ -646,7 +646,7 @@ func TestCloudNSClient_EnsureRecord_Good_NeedsCreate(t *testing.T) {
|
|||
|
||||
// --- ClearACMEChallenge round-trip ---
|
||||
|
||||
func TestCloudNSClient_ClearACMEChallenge_Good_ViaDoRaw(t *testing.T) {
|
||||
func TestCloudNS_CloudNSClient_ClearACMEChallenge_ViaDoRaw_Good(t *testing.T) {
|
||||
callCount := 0
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
|
|
@ -679,7 +679,7 @@ func TestCloudNSClient_ClearACMEChallenge_Good_ViaDoRaw(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, "49500", r.URL.Query().Get("auth-id"))
|
||||
assert.Equal(t, "supersecret", r.URL.Query().Get("auth-password"))
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ var (
|
|||
)
|
||||
|
||||
// AddMonitorCommands registers the 'monitor' command.
|
||||
// Usage: monitor.AddMonitorCommands(root)
|
||||
func AddMonitorCommands(root *cli.Command) {
|
||||
monitorCmd := &cli.Command{
|
||||
Use: "monitor",
|
||||
|
|
|
|||
|
|
@ -11,13 +11,14 @@ package monitor
|
|||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"maps"
|
||||
"os/exec"
|
||||
"slices"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"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-scm/repos"
|
||||
)
|
||||
|
|
@ -30,7 +31,8 @@ var (
|
|||
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 {
|
||||
Source string `json:"source"` // semgrep, trivy, dependabot, secret-scanning, etc.
|
||||
Severity string `json:"severity"` // critical, high, medium, low
|
||||
|
|
@ -45,7 +47,8 @@ type Finding struct {
|
|||
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 {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"` // open, dismissed, fixed
|
||||
|
|
@ -70,7 +73,8 @@ type CodeScanningAlert struct {
|
|||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// DependabotAlert represents a GitHub Dependabot alert
|
||||
// DependabotAlert represents a GitHub Dependabot alert.
|
||||
// Usage: alert := monitor.DependabotAlert{}
|
||||
type DependabotAlert struct {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"` // open, dismissed, fixed
|
||||
|
|
@ -93,7 +97,8 @@ type DependabotAlert struct {
|
|||
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 {
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"` // open, resolved
|
||||
|
|
@ -106,7 +111,7 @@ type SecretScanningAlert struct {
|
|||
|
||||
func runMonitor() error {
|
||||
// Check gh is available
|
||||
if _, err := exec.LookPath("gh"); err != nil {
|
||||
if _, err := coreexec.LookPath("gh"); err != nil {
|
||||
return core.E("monitor", i18n.T("error.gh_not_found"), err)
|
||||
}
|
||||
|
||||
|
|
@ -235,29 +240,28 @@ func fetchRepoFindings(repoFullName string) ([]Finding, []string) {
|
|||
|
||||
// fetchCodeScanningAlerts fetches code scanning alerts
|
||||
func fetchCodeScanningAlerts(repoFullName string) ([]Finding, error) {
|
||||
args := []string{
|
||||
output, err := coreexec.Run(
|
||||
context.Background(),
|
||||
"gh",
|
||||
"api",
|
||||
core.Sprintf("repos/%s/code-scanning/alerts", repoFullName),
|
||||
}
|
||||
|
||||
cmd := exec.Command("gh", args...)
|
||||
output, err := cmd.Output()
|
||||
)
|
||||
if err != nil {
|
||||
// Check for expected "not enabled" responses vs actual errors
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
stderr := string(exitErr.Stderr)
|
||||
// These are expected conditions, not errors
|
||||
if core.Contains(stderr, "Advanced Security must be enabled") ||
|
||||
core.Contains(stderr, "no analysis found") ||
|
||||
core.Contains(stderr, "Not Found") {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return nil, core.E("monitor.fetchCodeScanning", "API request failed", err)
|
||||
}
|
||||
|
||||
if output.ExitCode != 0 {
|
||||
// These are expected conditions, not errors.
|
||||
if core.Contains(output.Stderr, "Advanced Security must be enabled") ||
|
||||
core.Contains(output.Stderr, "no analysis found") ||
|
||||
core.Contains(output.Stderr, "Not Found") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, commandExitErr("monitor.fetchCodeScanning", output)
|
||||
}
|
||||
|
||||
var alerts []CodeScanningAlert
|
||||
if r := core.JSONUnmarshal(output, &alerts); !r.OK {
|
||||
if r := core.JSONUnmarshal([]byte(output.Stdout), &alerts); !r.OK {
|
||||
return nil, core.E("monitor.fetchCodeScanning", "failed to parse response", monitorResultErr(r, "monitor.fetchCodeScanning"))
|
||||
}
|
||||
|
||||
|
|
@ -291,27 +295,27 @@ func fetchCodeScanningAlerts(repoFullName string) ([]Finding, error) {
|
|||
|
||||
// fetchDependabotAlerts fetches Dependabot alerts
|
||||
func fetchDependabotAlerts(repoFullName string) ([]Finding, error) {
|
||||
args := []string{
|
||||
output, err := coreexec.Run(
|
||||
context.Background(),
|
||||
"gh",
|
||||
"api",
|
||||
core.Sprintf("repos/%s/dependabot/alerts", repoFullName),
|
||||
}
|
||||
|
||||
cmd := exec.Command("gh", args...)
|
||||
output, err := cmd.Output()
|
||||
)
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
stderr := string(exitErr.Stderr)
|
||||
// Dependabot not enabled is expected
|
||||
if core.Contains(stderr, "Dependabot alerts are not enabled") ||
|
||||
core.Contains(stderr, "Not Found") {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return nil, core.E("monitor.fetchDependabot", "API request failed", err)
|
||||
}
|
||||
|
||||
if output.ExitCode != 0 {
|
||||
// Dependabot not enabled is expected.
|
||||
if core.Contains(output.Stderr, "Dependabot alerts are not enabled") ||
|
||||
core.Contains(output.Stderr, "Not Found") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, commandExitErr("monitor.fetchDependabot", output)
|
||||
}
|
||||
|
||||
var alerts []DependabotAlert
|
||||
if r := core.JSONUnmarshal(output, &alerts); !r.OK {
|
||||
if r := core.JSONUnmarshal([]byte(output.Stdout), &alerts); !r.OK {
|
||||
return nil, core.E("monitor.fetchDependabot", "failed to parse response", monitorResultErr(r, "monitor.fetchDependabot"))
|
||||
}
|
||||
|
||||
|
|
@ -342,27 +346,27 @@ func fetchDependabotAlerts(repoFullName string) ([]Finding, error) {
|
|||
|
||||
// fetchSecretScanningAlerts fetches secret scanning alerts
|
||||
func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) {
|
||||
args := []string{
|
||||
output, err := coreexec.Run(
|
||||
context.Background(),
|
||||
"gh",
|
||||
"api",
|
||||
core.Sprintf("repos/%s/secret-scanning/alerts", repoFullName),
|
||||
}
|
||||
|
||||
cmd := exec.Command("gh", args...)
|
||||
output, err := cmd.Output()
|
||||
)
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
stderr := string(exitErr.Stderr)
|
||||
// Secret scanning not enabled is expected
|
||||
if core.Contains(stderr, "Secret scanning is disabled") ||
|
||||
core.Contains(stderr, "Not Found") {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return nil, core.E("monitor.fetchSecretScanning", "API request failed", err)
|
||||
}
|
||||
|
||||
if output.ExitCode != 0 {
|
||||
// Secret scanning not enabled is expected.
|
||||
if core.Contains(output.Stderr, "Secret scanning is disabled") ||
|
||||
core.Contains(output.Stderr, "Not Found") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, commandExitErr("monitor.fetchSecretScanning", output)
|
||||
}
|
||||
|
||||
var alerts []SecretScanningAlert
|
||||
if r := core.JSONUnmarshal(output, &alerts); !r.OK {
|
||||
if r := core.JSONUnmarshal([]byte(output.Stdout), &alerts); !r.OK {
|
||||
return nil, core.E("monitor.fetchSecretScanning", "failed to parse response", monitorResultErr(r, "monitor.fetchSecretScanning"))
|
||||
}
|
||||
|
||||
|
|
@ -548,13 +552,15 @@ func truncate(s string, max int) string {
|
|||
|
||||
// detectRepoFromGit detects the repo from git remote
|
||||
func detectRepoFromGit() (string, error) {
|
||||
cmd := exec.Command("git", "remote", "get-url", "origin")
|
||||
output, err := cmd.Output()
|
||||
output, err := coreexec.Run(context.Background(), "git", "remote", "get-url", "origin")
|
||||
if err != nil {
|
||||
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 := core.Trim(string(output))
|
||||
url := core.Trim(output.Stdout)
|
||||
return parseGitHubRepo(url)
|
||||
}
|
||||
|
||||
|
|
@ -604,3 +610,11 @@ func monitorResultErr(r core.Result, op string) error {
|
|||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ func init() {
|
|||
}
|
||||
|
||||
// AddProdCommands registers the 'prod' command and all subcommands.
|
||||
// Usage: prod.AddProdCommands(root)
|
||||
func AddProdCommands(root *cli.Command) {
|
||||
root.AddCommand(Cmd)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ var (
|
|||
)
|
||||
|
||||
// Cmd is the root prod command.
|
||||
// Usage: root.AddCommand(prod.Cmd)
|
||||
var Cmd = &cli.Command{
|
||||
Use: "prod",
|
||||
Short: "Production infrastructure management",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
package prod
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-infra/internal/coreexec"
|
||||
)
|
||||
|
||||
var sshCmd = &cli.Command{
|
||||
|
|
@ -41,7 +38,6 @@ func runSSH(cmd *cli.Command, args []string) error {
|
|||
}
|
||||
|
||||
sshArgs := []string{
|
||||
"ssh",
|
||||
"-i", host.SSH.Key,
|
||||
"-p", core.Sprintf("%d", host.SSH.Port),
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
|
|
@ -53,11 +49,9 @@ func runSSH(cmd *cli.Command, args []string) error {
|
|||
host.SSH.User, host.FQDN,
|
||||
cli.DimStyle.Render(host.IP))
|
||||
|
||||
sshPath, err := exec.LookPath("ssh")
|
||||
if err != nil {
|
||||
return core.E("prod.ssh", "ssh not found", err)
|
||||
}
|
||||
|
||||
// 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,7 +2,6 @@ package prod
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -314,9 +313,9 @@ func loadConfig() (*infra.Config, string, error) {
|
|||
return cfg, infraFile, err
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
cwd := core.Env("DIR_CWD")
|
||||
if cwd == "" {
|
||||
return nil, "", core.E("prod.loadConfig", "DIR_CWD unavailable", nil)
|
||||
}
|
||||
|
||||
return infra.Discover(cwd)
|
||||
|
|
|
|||
32
config.go
32
config.go
|
|
@ -8,6 +8,7 @@ import (
|
|||
)
|
||||
|
||||
// Config is the top-level infrastructure configuration parsed from infra.yaml.
|
||||
// Usage: cfg := infra.Config{}
|
||||
type Config struct {
|
||||
Hosts map[string]*Host `yaml:"hosts"`
|
||||
LoadBalancer LoadBalancer `yaml:"load_balancer"`
|
||||
|
|
@ -25,6 +26,7 @@ type Config struct {
|
|||
}
|
||||
|
||||
// Host represents a server in the infrastructure.
|
||||
// Usage: host := infra.Host{}
|
||||
type Host struct {
|
||||
FQDN string `yaml:"fqdn"`
|
||||
IP string `yaml:"ip"`
|
||||
|
|
@ -36,6 +38,7 @@ type Host struct {
|
|||
}
|
||||
|
||||
// SSHConf holds SSH connection details for a host.
|
||||
// Usage: ssh := infra.SSHConf{}
|
||||
type SSHConf struct {
|
||||
User string `yaml:"user"`
|
||||
Key string `yaml:"key"`
|
||||
|
|
@ -43,6 +46,7 @@ type SSHConf struct {
|
|||
}
|
||||
|
||||
// LoadBalancer represents a Hetzner managed load balancer.
|
||||
// Usage: lb := infra.LoadBalancer{}
|
||||
type LoadBalancer struct {
|
||||
Name string `yaml:"name"`
|
||||
FQDN string `yaml:"fqdn"`
|
||||
|
|
@ -57,12 +61,14 @@ type LoadBalancer struct {
|
|||
}
|
||||
|
||||
// Backend is a load balancer backend target.
|
||||
// Usage: backend := infra.Backend{}
|
||||
type Backend struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
// HealthCheck configures load balancer health checking.
|
||||
// Usage: check := infra.HealthCheck{}
|
||||
type HealthCheck struct {
|
||||
Protocol string `yaml:"protocol"`
|
||||
Path string `yaml:"path"`
|
||||
|
|
@ -70,6 +76,7 @@ type HealthCheck struct {
|
|||
}
|
||||
|
||||
// Listener maps a frontend port to a backend port.
|
||||
// Usage: listener := infra.Listener{}
|
||||
type Listener struct {
|
||||
Frontend int `yaml:"frontend"`
|
||||
Backend int `yaml:"backend"`
|
||||
|
|
@ -78,18 +85,21 @@ type Listener struct {
|
|||
}
|
||||
|
||||
// LBCert holds the SSL certificate configuration for the load balancer.
|
||||
// Usage: cert := infra.LBCert{}
|
||||
type LBCert struct {
|
||||
Certificate string `yaml:"certificate"`
|
||||
SAN []string `yaml:"san"`
|
||||
}
|
||||
|
||||
// Network describes the private network.
|
||||
// Usage: network := infra.Network{}
|
||||
type Network struct {
|
||||
CIDR string `yaml:"cidr"`
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
// DNS holds DNS provider configuration and zone records.
|
||||
// Usage: dns := infra.DNS{}
|
||||
type DNS struct {
|
||||
Provider string `yaml:"provider"`
|
||||
Nameservers []string `yaml:"nameservers"`
|
||||
|
|
@ -97,11 +107,13 @@ type DNS struct {
|
|||
}
|
||||
|
||||
// Zone is a DNS zone with its records.
|
||||
// Usage: zone := infra.Zone{}
|
||||
type Zone struct {
|
||||
Records []DNSRecord `yaml:"records"`
|
||||
}
|
||||
|
||||
// DNSRecord is a single DNS record.
|
||||
// Usage: record := infra.DNSRecord{}
|
||||
type DNSRecord struct {
|
||||
Name string `yaml:"name"`
|
||||
Type string `yaml:"type"`
|
||||
|
|
@ -110,11 +122,13 @@ type DNSRecord struct {
|
|||
}
|
||||
|
||||
// SSL holds SSL certificate configuration.
|
||||
// Usage: ssl := infra.SSL{}
|
||||
type SSL struct {
|
||||
Wildcard WildcardCert `yaml:"wildcard"`
|
||||
}
|
||||
|
||||
// WildcardCert describes a wildcard SSL certificate.
|
||||
// Usage: cert := infra.WildcardCert{}
|
||||
type WildcardCert struct {
|
||||
Domains []string `yaml:"domains"`
|
||||
Method string `yaml:"method"`
|
||||
|
|
@ -123,6 +137,7 @@ type WildcardCert struct {
|
|||
}
|
||||
|
||||
// Database describes the database cluster.
|
||||
// Usage: db := infra.Database{}
|
||||
type Database struct {
|
||||
Engine string `yaml:"engine"`
|
||||
Version string `yaml:"version"`
|
||||
|
|
@ -133,12 +148,14 @@ type Database struct {
|
|||
}
|
||||
|
||||
// DBNode is a database cluster node.
|
||||
// Usage: node := infra.DBNode{}
|
||||
type DBNode struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
// BackupConfig describes automated backup settings.
|
||||
// Usage: backup := infra.BackupConfig{}
|
||||
type BackupConfig struct {
|
||||
Schedule string `yaml:"schedule"`
|
||||
Destination string `yaml:"destination"`
|
||||
|
|
@ -147,6 +164,7 @@ type BackupConfig struct {
|
|||
}
|
||||
|
||||
// Cache describes the cache/session cluster.
|
||||
// Usage: cache := infra.Cache{}
|
||||
type Cache struct {
|
||||
Engine string `yaml:"engine"`
|
||||
Version string `yaml:"version"`
|
||||
|
|
@ -155,12 +173,14 @@ type Cache struct {
|
|||
}
|
||||
|
||||
// CacheNode is a cache cluster node.
|
||||
// Usage: node := infra.CacheNode{}
|
||||
type CacheNode struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
// Container describes a container deployment.
|
||||
// Usage: container := infra.Container{}
|
||||
type Container struct {
|
||||
Image string `yaml:"image"`
|
||||
Port int `yaml:"port,omitempty"`
|
||||
|
|
@ -171,18 +191,21 @@ type Container struct {
|
|||
}
|
||||
|
||||
// S3Config describes object storage.
|
||||
// Usage: s3 := infra.S3Config{}
|
||||
type S3Config struct {
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Buckets map[string]*S3Bucket `yaml:"buckets"`
|
||||
}
|
||||
|
||||
// S3Bucket is an S3 bucket configuration.
|
||||
// Usage: bucket := infra.S3Bucket{}
|
||||
type S3Bucket struct {
|
||||
Purpose string `yaml:"purpose"`
|
||||
Paths []string `yaml:"paths"`
|
||||
}
|
||||
|
||||
// CDN describes CDN configuration.
|
||||
// Usage: cdn := infra.CDN{}
|
||||
type CDN struct {
|
||||
Provider string `yaml:"provider"`
|
||||
Origin string `yaml:"origin"`
|
||||
|
|
@ -190,6 +213,7 @@ type CDN struct {
|
|||
}
|
||||
|
||||
// CICD describes CI/CD configuration.
|
||||
// Usage: cicd := infra.CICD{}
|
||||
type CICD struct {
|
||||
Provider string `yaml:"provider"`
|
||||
URL string `yaml:"url"`
|
||||
|
|
@ -199,24 +223,28 @@ type CICD struct {
|
|||
}
|
||||
|
||||
// Monitoring describes monitoring configuration.
|
||||
// Usage: monitoring := infra.Monitoring{}
|
||||
type Monitoring struct {
|
||||
HealthEndpoints []HealthEndpoint `yaml:"health_endpoints"`
|
||||
Alerts map[string]int `yaml:"alerts"`
|
||||
}
|
||||
|
||||
// HealthEndpoint is a URL to monitor.
|
||||
// Usage: endpoint := infra.HealthEndpoint{}
|
||||
type HealthEndpoint struct {
|
||||
URL string `yaml:"url"`
|
||||
Interval int `yaml:"interval"`
|
||||
}
|
||||
|
||||
// Backups describes backup schedules.
|
||||
// Usage: backups := infra.Backups{}
|
||||
type Backups struct {
|
||||
Daily []BackupJob `yaml:"daily"`
|
||||
Weekly []BackupJob `yaml:"weekly"`
|
||||
}
|
||||
|
||||
// BackupJob is a scheduled backup task.
|
||||
// Usage: job := infra.BackupJob{}
|
||||
type BackupJob struct {
|
||||
Name string `yaml:"name"`
|
||||
Type string `yaml:"type"`
|
||||
|
|
@ -225,6 +253,7 @@ type BackupJob struct {
|
|||
}
|
||||
|
||||
// Load reads and parses an infra.yaml file.
|
||||
// Usage: cfg, err := infra.Load("/srv/project/infra.yaml")
|
||||
func Load(path string) (*Config, error) {
|
||||
read := localFS.Read(path)
|
||||
if !read.OK {
|
||||
|
|
@ -250,6 +279,7 @@ func Load(path string) (*Config, error) {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
dir := startDir
|
||||
for {
|
||||
|
|
@ -269,6 +299,7 @@ func Discover(startDir string) (*Config, string, error) {
|
|||
}
|
||||
|
||||
// HostsByRole returns all hosts matching the given role.
|
||||
// Usage: apps := cfg.HostsByRole("app")
|
||||
func (c *Config) HostsByRole(role string) map[string]*Host {
|
||||
result := make(map[string]*Host)
|
||||
for name, h := range c.Hosts {
|
||||
|
|
@ -280,6 +311,7 @@ func (c *Config) HostsByRole(role string) map[string]*Host {
|
|||
}
|
||||
|
||||
// AppServers returns hosts with role "app".
|
||||
// Usage: apps := cfg.AppServers()
|
||||
func (c *Config) AppServers() map[string]*Host {
|
||||
return c.HostsByRole("app")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"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
|
||||
// Walk up from test dir to find it
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
dir := core.Env("DIR_CWD")
|
||||
if dir == "" {
|
||||
t.Fatal(core.E("TestLoad_Good", "DIR_CWD unavailable", nil))
|
||||
}
|
||||
|
||||
cfg, path, err := Discover(dir)
|
||||
|
|
@ -60,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")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Ugly(t *testing.T) {
|
||||
func TestConfig_Load_Ugly(t *testing.T) {
|
||||
// Invalid YAML
|
||||
tmp := core.JoinPath(t.TempDir(), "infra.yaml")
|
||||
if r := localFS.WriteMode(tmp, "{{invalid yaml", 0644); !r.OK {
|
||||
t.Fatal(coreResultErr(r, "TestLoad_Ugly"))
|
||||
t.Fatal(coreResultErr(r, "TestConfig_Load_Ugly"))
|
||||
}
|
||||
|
||||
_, err := Load(tmp)
|
||||
|
|
@ -129,7 +128,7 @@ func TestConfig_AppServers_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestExpandPath(t *testing.T) {
|
||||
func TestConfig_ExpandPath_Good(t *testing.T) {
|
||||
home := core.Env("DIR_HOME")
|
||||
|
||||
tests := []struct {
|
||||
|
|
|
|||
32
hetzner.go
32
hetzner.go
|
|
@ -13,6 +13,7 @@ const (
|
|||
)
|
||||
|
||||
// HCloudClient is an HTTP client for the Hetzner Cloud API.
|
||||
// Usage: hc := infra.NewHCloudClient(token)
|
||||
type HCloudClient struct {
|
||||
token string
|
||||
baseURL string
|
||||
|
|
@ -20,6 +21,7 @@ type HCloudClient struct {
|
|||
}
|
||||
|
||||
// NewHCloudClient creates a new Hetzner Cloud API client.
|
||||
// Usage: hc := infra.NewHCloudClient(token)
|
||||
func NewHCloudClient(token string) *HCloudClient {
|
||||
c := &HCloudClient{
|
||||
token: token,
|
||||
|
|
@ -35,6 +37,7 @@ func NewHCloudClient(token string) *HCloudClient {
|
|||
}
|
||||
|
||||
// HCloudServer represents a Hetzner Cloud server.
|
||||
// Usage: server := infra.HCloudServer{}
|
||||
type HCloudServer struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
|
@ -47,22 +50,26 @@ type HCloudServer struct {
|
|||
}
|
||||
|
||||
// HCloudPublicNet holds public network info.
|
||||
// Usage: net := infra.HCloudPublicNet{}
|
||||
type HCloudPublicNet struct {
|
||||
IPv4 HCloudIPv4 `json:"ipv4"`
|
||||
}
|
||||
|
||||
// HCloudIPv4 holds an IPv4 address.
|
||||
// Usage: ip := infra.HCloudIPv4{}
|
||||
type HCloudIPv4 struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// HCloudPrivateNet holds private network info.
|
||||
// Usage: net := infra.HCloudPrivateNet{}
|
||||
type HCloudPrivateNet struct {
|
||||
IP string `json:"ip"`
|
||||
Network int `json:"network"`
|
||||
}
|
||||
|
||||
// HCloudServerType holds server type info.
|
||||
// Usage: serverType := infra.HCloudServerType{}
|
||||
type HCloudServerType struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
|
|
@ -72,12 +79,14 @@ type HCloudServerType struct {
|
|||
}
|
||||
|
||||
// HCloudDatacenter holds datacenter info.
|
||||
// Usage: dc := infra.HCloudDatacenter{}
|
||||
type HCloudDatacenter struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// HCloudLoadBalancer represents a Hetzner Cloud load balancer.
|
||||
// Usage: lb := infra.HCloudLoadBalancer{}
|
||||
type HCloudLoadBalancer struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
|
@ -90,17 +99,20 @@ type HCloudLoadBalancer struct {
|
|||
}
|
||||
|
||||
// HCloudLBPublicNet holds LB public network info.
|
||||
// Usage: net := infra.HCloudLBPublicNet{}
|
||||
type HCloudLBPublicNet struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IPv4 HCloudIPv4 `json:"ipv4"`
|
||||
}
|
||||
|
||||
// HCloudLBAlgorithm holds the LB algorithm.
|
||||
// Usage: algo := infra.HCloudLBAlgorithm{}
|
||||
type HCloudLBAlgorithm struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// HCloudLBService describes an LB listener.
|
||||
// Usage: service := infra.HCloudLBService{}
|
||||
type HCloudLBService struct {
|
||||
Protocol string `json:"protocol"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
|
|
@ -111,11 +123,13 @@ type HCloudLBService struct {
|
|||
}
|
||||
|
||||
// HCloudLBHTTP holds HTTP-specific LB options.
|
||||
// Usage: httpCfg := infra.HCloudLBHTTP{}
|
||||
type HCloudLBHTTP struct {
|
||||
RedirectHTTP bool `json:"redirect_http"`
|
||||
}
|
||||
|
||||
// HCloudLBHealthCheck holds LB health check config.
|
||||
// Usage: check := infra.HCloudLBHealthCheck{}
|
||||
type HCloudLBHealthCheck struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Port int `json:"port"`
|
||||
|
|
@ -126,12 +140,14 @@ type HCloudLBHealthCheck struct {
|
|||
}
|
||||
|
||||
// HCloudLBHCHTTP holds HTTP health check options.
|
||||
// Usage: httpCheck := infra.HCloudLBHCHTTP{}
|
||||
type HCloudLBHCHTTP struct {
|
||||
Path string `json:"path"`
|
||||
StatusCode string `json:"status_codes"`
|
||||
}
|
||||
|
||||
// HCloudLBTarget is a load balancer backend target.
|
||||
// Usage: target := infra.HCloudLBTarget{}
|
||||
type HCloudLBTarget struct {
|
||||
Type string `json:"type"`
|
||||
IP *HCloudLBTargetIP `json:"ip,omitempty"`
|
||||
|
|
@ -140,22 +156,26 @@ type HCloudLBTarget struct {
|
|||
}
|
||||
|
||||
// HCloudLBTargetIP is an IP-based LB target.
|
||||
// Usage: target := infra.HCloudLBTargetIP{}
|
||||
type HCloudLBTargetIP struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
// HCloudLBTargetServer is a server-based LB target.
|
||||
// Usage: target := infra.HCloudLBTargetServer{}
|
||||
type HCloudLBTargetServer struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
// HCloudLBHealthStatus holds target health info.
|
||||
// Usage: status := infra.HCloudLBHealthStatus{}
|
||||
type HCloudLBHealthStatus struct {
|
||||
ListenPort int `json:"listen_port"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// HCloudLBCreateRequest holds load balancer creation params.
|
||||
// Usage: req := infra.HCloudLBCreateRequest{}
|
||||
type HCloudLBCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
LoadBalancerType string `json:"load_balancer_type"`
|
||||
|
|
@ -167,12 +187,14 @@ type HCloudLBCreateRequest struct {
|
|||
}
|
||||
|
||||
// HCloudLBCreateTarget is a target for LB creation.
|
||||
// Usage: target := infra.HCloudLBCreateTarget{}
|
||||
type HCloudLBCreateTarget struct {
|
||||
Type string `json:"type"`
|
||||
IP *HCloudLBTargetIP `json:"ip,omitempty"`
|
||||
}
|
||||
|
||||
// ListServers returns all Hetzner Cloud servers.
|
||||
// Usage: servers, err := hc.ListServers(ctx)
|
||||
func (c *HCloudClient) ListServers(ctx context.Context) ([]HCloudServer, error) {
|
||||
var result struct {
|
||||
Servers []HCloudServer `json:"servers"`
|
||||
|
|
@ -184,6 +206,7 @@ func (c *HCloudClient) ListServers(ctx context.Context) ([]HCloudServer, error)
|
|||
}
|
||||
|
||||
// ListLoadBalancers returns all load balancers.
|
||||
// Usage: lbs, err := hc.ListLoadBalancers(ctx)
|
||||
func (c *HCloudClient) ListLoadBalancers(ctx context.Context) ([]HCloudLoadBalancer, error) {
|
||||
var result struct {
|
||||
LoadBalancers []HCloudLoadBalancer `json:"load_balancers"`
|
||||
|
|
@ -195,6 +218,7 @@ func (c *HCloudClient) ListLoadBalancers(ctx context.Context) ([]HCloudLoadBalan
|
|||
}
|
||||
|
||||
// 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) {
|
||||
var result struct {
|
||||
LoadBalancer HCloudLoadBalancer `json:"load_balancer"`
|
||||
|
|
@ -206,6 +230,7 @@ func (c *HCloudClient) GetLoadBalancer(ctx context.Context, id int) (*HCloudLoad
|
|||
}
|
||||
|
||||
// CreateLoadBalancer creates a new load balancer.
|
||||
// Usage: lb, err := hc.CreateLoadBalancer(ctx, req)
|
||||
func (c *HCloudClient) CreateLoadBalancer(ctx context.Context, req HCloudLBCreateRequest) (*HCloudLoadBalancer, error) {
|
||||
marshaled := core.JSONMarshal(req)
|
||||
if !marshaled.OK {
|
||||
|
|
@ -223,11 +248,13 @@ func (c *HCloudClient) CreateLoadBalancer(ctx context.Context, req HCloudLBCreat
|
|||
}
|
||||
|
||||
// DeleteLoadBalancer deletes a load balancer by ID.
|
||||
// Usage: err := hc.DeleteLoadBalancer(ctx, 1)
|
||||
func (c *HCloudClient) DeleteLoadBalancer(ctx context.Context, id int) error {
|
||||
return c.delete(ctx, core.Sprintf("/load_balancers/%d", id))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
marshaled := core.JSONMarshal(map[string]string{
|
||||
"description": description,
|
||||
|
|
@ -271,6 +298,7 @@ func (c *HCloudClient) do(req *http.Request, result any) error {
|
|||
// --- Hetzner Robot API ---
|
||||
|
||||
// HRobotClient is an HTTP client for the Hetzner Robot API.
|
||||
// Usage: hr := infra.NewHRobotClient(user, password)
|
||||
type HRobotClient struct {
|
||||
user string
|
||||
password string
|
||||
|
|
@ -279,6 +307,7 @@ type HRobotClient struct {
|
|||
}
|
||||
|
||||
// NewHRobotClient creates a new Hetzner Robot API client.
|
||||
// Usage: hr := infra.NewHRobotClient(user, password)
|
||||
func NewHRobotClient(user, password string) *HRobotClient {
|
||||
c := &HRobotClient{
|
||||
user: user,
|
||||
|
|
@ -295,6 +324,7 @@ func NewHRobotClient(user, password string) *HRobotClient {
|
|||
}
|
||||
|
||||
// HRobotServer represents a Hetzner Robot dedicated server.
|
||||
// Usage: server := infra.HRobotServer{}
|
||||
type HRobotServer struct {
|
||||
ServerIP string `json:"server_ip"`
|
||||
ServerName string `json:"server_name"`
|
||||
|
|
@ -306,6 +336,7 @@ type HRobotServer struct {
|
|||
}
|
||||
|
||||
// ListServers returns all Robot dedicated servers.
|
||||
// Usage: servers, err := hr.ListServers(ctx)
|
||||
func (c *HRobotClient) ListServers(ctx context.Context) ([]HRobotServer, error) {
|
||||
var raw []struct {
|
||||
Server HRobotServer `json:"server"`
|
||||
|
|
@ -322,6 +353,7 @@ func (c *HRobotClient) ListServers(ctx context.Context) ([]HRobotServer, error)
|
|||
}
|
||||
|
||||
// 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) {
|
||||
var raw struct {
|
||||
Server HRobotServer `json:"server"`
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewHCloudClient_Good(t *testing.T) {
|
||||
func TestHetzner_NewHCloudClient_Good(t *testing.T) {
|
||||
c := NewHCloudClient("my-token")
|
||||
assert.NotNil(t, c)
|
||||
assert.Equal(t, "my-token", c.token)
|
||||
assert.NotNil(t, c.api)
|
||||
}
|
||||
|
||||
func TestHCloudClient_ListServers_Good(t *testing.T) {
|
||||
func TestHetzner_HCloudClient_ListServers_Good(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/servers", func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
|
@ -66,7 +66,7 @@ func TestHCloudClient_ListServers_Good(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
|
@ -96,7 +96,7 @@ func TestHCloudClient_Do_Good_ParsesJSON(t *testing.T) {
|
|||
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) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"forbidden","message":"insufficient permissions"}}`))
|
||||
|
|
@ -120,7 +120,7 @@ func TestHCloudClient_Do_Bad_APIError(t *testing.T) {
|
|||
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) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`Internal Server Error`))
|
||||
|
|
@ -140,7 +140,7 @@ func TestHCloudClient_Do_Bad_APIErrorNoJSON(t *testing.T) {
|
|||
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) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
|
@ -160,7 +160,7 @@ func TestHCloudClient_Do_Good_NilResult(t *testing.T) {
|
|||
|
||||
// --- Hetzner Robot API ---
|
||||
|
||||
func TestNewHRobotClient_Good(t *testing.T) {
|
||||
func TestHetzner_NewHRobotClient_Good(t *testing.T) {
|
||||
c := NewHRobotClient("user", "pass")
|
||||
assert.NotNil(t, c)
|
||||
assert.Equal(t, "user", c.user)
|
||||
|
|
@ -168,7 +168,7 @@ func TestNewHRobotClient_Good(t *testing.T) {
|
|||
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) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
assert.True(t, ok)
|
||||
|
|
@ -199,7 +199,7 @@ func TestHRobotClient_ListServers_Good(t *testing.T) {
|
|||
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) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":{"status":401,"code":"UNAUTHORIZED","message":"Invalid credentials"}}`))
|
||||
|
|
@ -222,7 +222,7 @@ func TestHRobotClient_Get_Bad_HTTPError(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/load_balancers", r.URL.Path)
|
||||
|
|
@ -247,7 +247,7 @@ func TestHCloudClient_ListLoadBalancers_Good(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, "/load_balancers/789", r.URL.Path)
|
||||
|
||||
|
|
@ -269,7 +269,7 @@ func TestHCloudClient_GetLoadBalancer_Good(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/load_balancers", r.URL.Path)
|
||||
|
|
@ -307,7 +307,7 @@ func TestHCloudClient_CreateLoadBalancer_Good(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Equal(t, "/load_balancers/789", r.URL.Path)
|
||||
|
|
@ -327,7 +327,7 @@ func TestHCloudClient_DeleteLoadBalancer_Good(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/servers/123/actions/create_image", r.URL.Path)
|
||||
|
|
@ -357,7 +357,7 @@ func TestHCloudClient_CreateSnapshot_Good(t *testing.T) {
|
|||
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) {
|
||||
assert.Equal(t, "/server/1.2.3.4", r.URL.Path)
|
||||
|
||||
|
|
@ -385,7 +385,7 @@ func TestHRobotClient_GetServer_Good(t *testing.T) {
|
|||
|
||||
// --- Type serialisation ---
|
||||
|
||||
func TestHCloudServer_JSON_Good(t *testing.T) {
|
||||
func TestHetzner_HCloudServer_JSON_Good(t *testing.T) {
|
||||
data := `{
|
||||
"id": 123,
|
||||
"name": "web-1",
|
||||
|
|
@ -412,7 +412,7 @@ func TestHCloudServer_JSON_Good(t *testing.T) {
|
|||
assert.Equal(t, "prod", server.Labels["env"])
|
||||
}
|
||||
|
||||
func TestHCloudLoadBalancer_JSON_Good(t *testing.T) {
|
||||
func TestHetzner_HCloudLoadBalancer_JSON_Good(t *testing.T) {
|
||||
data := `{
|
||||
"id": 789,
|
||||
"name": "hermes",
|
||||
|
|
@ -443,7 +443,7 @@ func TestHCloudLoadBalancer_JSON_Good(t *testing.T) {
|
|||
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 := `{
|
||||
"server_ip": "1.2.3.4",
|
||||
"server_name": "noc",
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue