From 67dae9cc945329d97c77b6c6234fafdf6a025a3c Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 15:32:27 +0000 Subject: [PATCH 1/4] Upgrade to dappco.re/go/core v0.8.0-alpha.1 Co-Authored-By: Virgil --- client.go | 38 ++++++----- cloudns.go | 33 +++++----- cloudns_test.go | 38 +++++------ cmd/monitor/cmd_monitor.go | 127 ++++++++++++++++++++----------------- cmd/prod/cmd_dns.go | 13 ++-- cmd/prod/cmd_lb.go | 12 ++-- cmd/prod/cmd_setup.go | 31 +++++---- cmd/prod/cmd_ssh.go | 11 ++-- cmd/prod/cmd_status.go | 46 +++++++------- config.go | 39 ++++++------ config_test.go | 13 ++-- core_helpers.go | 18 ++++++ go.mod | 3 +- go.sum | 2 + hetzner.go | 25 ++++---- hetzner_test.go | 48 +++++++++----- 16 files changed, 269 insertions(+), 228 deletions(-) create mode 100644 core_helpers.go diff --git a/client.go b/client.go index 1a7af1c..e5d3601 100644 --- a/client.go +++ b/client.go @@ -1,8 +1,6 @@ package infra import ( - "encoding/json" - "fmt" "io" "math" "math/rand/v2" @@ -11,7 +9,7 @@ import ( "sync" "time" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" ) // RetryConfig controls exponential backoff retry behaviour. @@ -37,11 +35,11 @@ func DefaultRetryConfig() RetryConfig { // and configurable authentication. Provider-specific clients embed or // delegate to this struct. type APIClient struct { - client *http.Client - retry RetryConfig - authFn func(req *http.Request) - prefix string // error prefix, e.g. "hcloud API" - mu sync.Mutex + client *http.Client + retry RetryConfig + authFn func(req *http.Request) + prefix string // error prefix, e.g. "hcloud API" + mu sync.Mutex blockedUntil time.Time // rate-limit window } @@ -107,7 +105,7 @@ func (a *APIClient) Do(req *http.Request, result any) error { resp, err := a.client.Do(req) if err != nil { - lastErr = coreerr.E(a.prefix, "request failed", err) + lastErr = core.E(a.prefix, "request failed", err) if attempt < attempts-1 { a.backoff(attempt, req) } @@ -117,7 +115,7 @@ func (a *APIClient) Do(req *http.Request, result any) error { data, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err != nil { - lastErr = coreerr.E("client.Do", "read response", err) + lastErr = core.E("client.Do", "read response", err) if attempt < attempts-1 { a.backoff(attempt, req) } @@ -131,7 +129,7 @@ func (a *APIClient) Do(req *http.Request, result any) error { a.blockedUntil = time.Now().Add(retryAfter) 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 { select { case <-req.Context().Done(): @@ -144,7 +142,7 @@ func (a *APIClient) Do(req *http.Request, result any) error { // Server errors are retryable. if resp.StatusCode >= 500 { - lastErr = coreerr.E(a.prefix, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil) + lastErr = core.E(a.prefix, core.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil) if attempt < attempts-1 { a.backoff(attempt, req) } @@ -153,13 +151,13 @@ func (a *APIClient) Do(req *http.Request, result any) error { // Client errors (4xx, except 429 handled above) are not retried. if resp.StatusCode >= 400 { - return coreerr.E(a.prefix, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil) + return core.E(a.prefix, core.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil) } // Success — decode if requested. if result != nil { - if err := json.Unmarshal(data, result); err != nil { - return coreerr.E("client.Do", "decode response", err) + if r := core.JSONUnmarshal(data, result); !r.OK { + return core.E("client.Do", "decode response", coreResultErr(r, "client.Do")) } } return nil @@ -193,7 +191,7 @@ func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) { resp, err := a.client.Do(req) if err != nil { - lastErr = coreerr.E(a.prefix, "request failed", err) + lastErr = core.E(a.prefix, "request failed", err) if attempt < attempts-1 { a.backoff(attempt, req) } @@ -203,7 +201,7 @@ func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) { data, err := io.ReadAll(resp.Body) _ = resp.Body.Close() if err != nil { - lastErr = coreerr.E("client.DoRaw", "read response", err) + lastErr = core.E("client.DoRaw", "read response", err) if attempt < attempts-1 { a.backoff(attempt, req) } @@ -216,7 +214,7 @@ func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) { a.blockedUntil = time.Now().Add(retryAfter) 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 { select { case <-req.Context().Done(): @@ -228,7 +226,7 @@ func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) { } if resp.StatusCode >= 500 { - lastErr = coreerr.E(a.prefix, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil) + lastErr = core.E(a.prefix, core.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil) if attempt < attempts-1 { a.backoff(attempt, req) } @@ -236,7 +234,7 @@ func (a *APIClient) DoRaw(req *http.Request) ([]byte, error) { } if resp.StatusCode >= 400 { - return nil, coreerr.E(a.prefix, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil) + return nil, core.E(a.prefix, core.Sprintf("HTTP %d: %s", resp.StatusCode, truncateBody(data, maxErrBodyLen)), nil) } return data, nil diff --git a/cloudns.go b/cloudns.go index 7130b4c..4c3533f 100644 --- a/cloudns.go +++ b/cloudns.go @@ -2,12 +2,11 @@ package infra import ( "context" - "encoding/json" "net/http" "net/url" "strconv" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" ) const cloudnsBaseURL = "https://api.cloudns.net" @@ -63,7 +62,7 @@ func (c *CloudNSClient) ListZones(ctx context.Context) ([]CloudNSZone, error) { } 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 [] return nil, nil } @@ -81,8 +80,8 @@ func (c *CloudNSClient) ListRecords(ctx context.Context, domain string) (map[str } var records map[string]CloudNSRecord - if err := json.Unmarshal(data, &records); err != nil { - return nil, coreerr.E("CloudNSClient.ListRecords", "parse records", err) + if r := core.JSONUnmarshal(data, &records); !r.OK { + return nil, core.E("CloudNSClient.ListRecords", "parse records", coreResultErr(r, "CloudNSClient.ListRecords")) } return records, nil } @@ -108,12 +107,12 @@ func (c *CloudNSClient) CreateRecord(ctx context.Context, domain, host, recordTy ID int `json:"id"` } `json:"data"` } - if err := json.Unmarshal(data, &result); err != nil { - return "", coreerr.E("CloudNSClient.CreateRecord", "parse response", err) + if r := core.JSONUnmarshal(data, &result); !r.OK { + return "", core.E("CloudNSClient.CreateRecord", "parse response", coreResultErr(r, "CloudNSClient.CreateRecord")) } 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 @@ -138,12 +137,12 @@ func (c *CloudNSClient) UpdateRecord(ctx context.Context, domain, recordID, host Status string `json:"status"` StatusDescription string `json:"statusDescription"` } - if err := json.Unmarshal(data, &result); err != nil { - return coreerr.E("CloudNSClient.UpdateRecord", "parse response", err) + if r := core.JSONUnmarshal(data, &result); !r.OK { + return core.E("CloudNSClient.UpdateRecord", "parse response", coreResultErr(r, "CloudNSClient.UpdateRecord")) } if result.Status != "Success" { - return coreerr.E("CloudNSClient.UpdateRecord", result.StatusDescription, nil) + return core.E("CloudNSClient.UpdateRecord", result.StatusDescription, nil) } return nil @@ -164,12 +163,12 @@ func (c *CloudNSClient) DeleteRecord(ctx context.Context, domain, recordID strin Status string `json:"status"` StatusDescription string `json:"statusDescription"` } - if err := json.Unmarshal(data, &result); err != nil { - return coreerr.E("CloudNSClient.DeleteRecord", "parse response", err) + if r := core.JSONUnmarshal(data, &result); !r.OK { + return core.E("CloudNSClient.DeleteRecord", "parse response", coreResultErr(r, "CloudNSClient.DeleteRecord")) } if result.Status != "Success" { - return coreerr.E("CloudNSClient.DeleteRecord", result.StatusDescription, nil) + return core.E("CloudNSClient.DeleteRecord", result.StatusDescription, nil) } return nil @@ -180,7 +179,7 @@ func (c *CloudNSClient) DeleteRecord(ctx context.Context, domain, recordID strin 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 { - return false, coreerr.E("CloudNSClient.EnsureRecord", "list records", err) + return false, core.E("CloudNSClient.EnsureRecord", "list records", err) } // Check if record already exists @@ -191,7 +190,7 @@ func (c *CloudNSClient) EnsureRecord(ctx context.Context, domain, host, recordTy } // Update existing record 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 } @@ -199,7 +198,7 @@ func (c *CloudNSClient) EnsureRecord(ctx context.Context, domain, host, recordTy // Create new record 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 } diff --git a/cloudns_test.go b/cloudns_test.go index 3df7593..0cf6ac8 100644 --- a/cloudns_test.go +++ b/cloudns_test.go @@ -2,11 +2,11 @@ package infra import ( "context" - "encoding/json" "net/http" "net/http/httptest" "testing" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -114,9 +114,7 @@ func TestCloudNSZone_JSON_Good(t *testing.T) { ]` var zones []CloudNSZone - err := json.Unmarshal([]byte(data), &zones) - - require.NoError(t, err) + requireCloudNSJSON(t, data, &zones) require.Len(t, zones, 2) assert.Equal(t, "example.com", zones[0].Name) assert.Equal(t, "master", zones[0].Type) @@ -128,10 +126,10 @@ func TestCloudNSZone_JSON_Good_EmptyResponse(t *testing.T) { data := `{}` 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 - assert.Error(t, err) + assert.False(t, r.OK) } // --- Record JSON parsing --- @@ -158,9 +156,7 @@ func TestCloudNSRecord_JSON_Good(t *testing.T) { }` var records map[string]CloudNSRecord - err := json.Unmarshal([]byte(data), &records) - - require.NoError(t, err) + requireCloudNSJSON(t, data, &records) require.Len(t, records, 2) aRecord := records["12345"] @@ -190,9 +186,7 @@ func TestCloudNSRecord_JSON_Good_TXTRecord(t *testing.T) { }` var records map[string]CloudNSRecord - err := json.Unmarshal([]byte(data), &records) - - require.NoError(t, err) + requireCloudNSJSON(t, data, &records) require.Len(t, records, 1) txt := records["99"] @@ -215,8 +209,7 @@ func TestCloudNSClient_CreateRecord_Good_ResponseParsing(t *testing.T) { } `json:"data"` } - err := json.Unmarshal([]byte(data), &result) - require.NoError(t, err) + requireCloudNSJSON(t, data, &result) assert.Equal(t, "Success", result.Status) assert.Equal(t, 54321, result.Data.ID) } @@ -229,8 +222,7 @@ func TestCloudNSClient_CreateRecord_Bad_FailedStatus(t *testing.T) { StatusDescription string `json:"statusDescription"` } - err := json.Unmarshal([]byte(data), &result) - require.NoError(t, err) + requireCloudNSJSON(t, data, &result) assert.Equal(t, "Failed", result.Status) assert.Equal(t, "Record already exists.", result.StatusDescription) } @@ -245,8 +237,7 @@ func TestCloudNSClient_UpdateDelete_Good_ResponseParsing(t *testing.T) { StatusDescription string `json:"statusDescription"` } - err := json.Unmarshal([]byte(data), &result) - require.NoError(t, err) + requireCloudNSJSON(t, data, &result) assert.Equal(t, "Success", result.Status) } @@ -511,12 +502,17 @@ func TestCloudNSRecord_JSON_Good_EmptyMap(t *testing.T) { data := `{}` var records map[string]CloudNSRecord - err := json.Unmarshal([]byte(data), &records) - - require.NoError(t, err) + requireCloudNSJSON(t, data, &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 --- func TestCloudNSClient_UpdateRecord_Good_ViaDoRaw(t *testing.T) { diff --git a/cmd/monitor/cmd_monitor.go b/cmd/monitor/cmd_monitor.go index 482fb84..2891cd8 100644 --- a/cmd/monitor/cmd_monitor.go +++ b/cmd/monitor/cmd_monitor.go @@ -11,17 +11,14 @@ package monitor import ( "cmp" - "encoding/json" - "fmt" "maps" "os/exec" "slices" - "strings" + core "dappco.re/go/core" "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-io" - "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-scm/repos" ) @@ -110,7 +107,7 @@ type SecretScanningAlert struct { func runMonitor() error { // Check gh is available if _, err := exec.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 @@ -120,7 +117,7 @@ func runMonitor() error { } 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 @@ -166,7 +163,7 @@ func runMonitor() error { func resolveRepos() ([]string, error) { if monitorRepo != "" { // Specific repo - if fully qualified (org/repo), use as-is - if strings.Contains(monitorRepo, "/") { + if core.Contains(monitorRepo, "/") { return []string{monitorRepo}, nil } // Otherwise, try to detect org from git remote, fallback to host-uk @@ -182,12 +179,12 @@ func resolveRepos() ([]string, error) { // All repos from registry registry, err := repos.FindRegistry(io.Local) 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) 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 @@ -215,21 +212,21 @@ func fetchRepoFindings(repoFullName string) ([]Finding, []string) { // Fetch code scanning alerts codeFindings, err := fetchCodeScanningAlerts(repoFullName) 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...) // Fetch Dependabot alerts depFindings, err := fetchDependabotAlerts(repoFullName) 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...) // Fetch secret scanning alerts secretFindings, err := fetchSecretScanningAlerts(repoFullName) 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...) @@ -240,7 +237,7 @@ func fetchRepoFindings(repoFullName string) ([]Finding, []string) { func fetchCodeScanningAlerts(repoFullName string) ([]Finding, error) { args := []string{ "api", - fmt.Sprintf("repos/%s/code-scanning/alerts", repoFullName), + core.Sprintf("repos/%s/code-scanning/alerts", repoFullName), } cmd := exec.Command("gh", args...) @@ -250,18 +247,18 @@ func fetchCodeScanningAlerts(repoFullName string) ([]Finding, error) { if exitErr, ok := err.(*exec.ExitError); ok { stderr := string(exitErr.Stderr) // 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") { + 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, log.E("monitor.fetchCodeScanning", "API request failed", err) + return nil, core.E("monitor.fetchCodeScanning", "API request failed", err) } var alerts []CodeScanningAlert - if err := json.Unmarshal(output, &alerts); err != nil { - return nil, log.E("monitor.fetchCodeScanning", "failed to parse response", err) + if r := core.JSONUnmarshal(output, &alerts); !r.OK { + return nil, core.E("monitor.fetchCodeScanning", "failed to parse response", monitorResultErr(r, "monitor.fetchCodeScanning")) } repoName := repoShortName(repoFullName) @@ -296,7 +293,7 @@ func fetchCodeScanningAlerts(repoFullName string) ([]Finding, error) { func fetchDependabotAlerts(repoFullName string) ([]Finding, error) { args := []string{ "api", - fmt.Sprintf("repos/%s/dependabot/alerts", repoFullName), + core.Sprintf("repos/%s/dependabot/alerts", repoFullName), } cmd := exec.Command("gh", args...) @@ -305,17 +302,17 @@ func fetchDependabotAlerts(repoFullName string) ([]Finding, error) { if exitErr, ok := err.(*exec.ExitError); ok { stderr := string(exitErr.Stderr) // Dependabot not enabled is expected - if strings.Contains(stderr, "Dependabot alerts are not enabled") || - strings.Contains(stderr, "Not Found") { + if core.Contains(stderr, "Dependabot alerts are not enabled") || + core.Contains(stderr, "Not Found") { return nil, nil } } - return nil, log.E("monitor.fetchDependabot", "API request failed", err) + return nil, core.E("monitor.fetchDependabot", "API request failed", err) } var alerts []DependabotAlert - if err := json.Unmarshal(output, &alerts); err != nil { - return nil, log.E("monitor.fetchDependabot", "failed to parse response", err) + if r := core.JSONUnmarshal(output, &alerts); !r.OK { + return nil, core.E("monitor.fetchDependabot", "failed to parse response", monitorResultErr(r, "monitor.fetchDependabot")) } repoName := repoShortName(repoFullName) @@ -330,7 +327,7 @@ func fetchDependabotAlerts(repoFullName string) ([]Finding, error) { Rule: alert.SecurityAdvisory.CVEID, File: alert.Dependency.ManifestPath, 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, State: alert.State, RepoName: repoName, @@ -347,7 +344,7 @@ func fetchDependabotAlerts(repoFullName string) ([]Finding, error) { func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) { args := []string{ "api", - fmt.Sprintf("repos/%s/secret-scanning/alerts", repoFullName), + core.Sprintf("repos/%s/secret-scanning/alerts", repoFullName), } cmd := exec.Command("gh", args...) @@ -356,17 +353,17 @@ func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) { if exitErr, ok := err.(*exec.ExitError); ok { stderr := string(exitErr.Stderr) // Secret scanning not enabled is expected - if strings.Contains(stderr, "Secret scanning is disabled") || - strings.Contains(stderr, "Not Found") { + if core.Contains(stderr, "Secret scanning is disabled") || + core.Contains(stderr, "Not Found") { return nil, nil } } - return nil, log.E("monitor.fetchSecretScanning", "API request failed", err) + return nil, core.E("monitor.fetchSecretScanning", "API request failed", err) } var alerts []SecretScanningAlert - if err := json.Unmarshal(output, &alerts); err != nil { - return nil, log.E("monitor.fetchSecretScanning", "failed to parse response", err) + if r := core.JSONUnmarshal(output, &alerts); !r.OK { + return nil, core.E("monitor.fetchSecretScanning", "failed to parse response", monitorResultErr(r, "monitor.fetchSecretScanning")) } repoName := repoShortName(repoFullName) @@ -381,7 +378,7 @@ func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) { Rule: alert.SecretType, File: alert.LocationType, Line: 0, - Message: fmt.Sprintf("Exposed %s detected", alert.SecretType), + Message: core.Sprintf("Exposed %s detected", alert.SecretType), URL: alert.HTMLURL, State: alert.State, RepoName: repoName, @@ -396,7 +393,7 @@ func fetchSecretScanningAlerts(repoFullName string) ([]Finding, error) { // normalizeSeverity normalizes severity strings to standard values func normalizeSeverity(s string) string { - s = strings.ToLower(s) + s = core.Lower(s) switch s { case "critical", "crit": return "critical" @@ -415,7 +412,7 @@ func normalizeSeverity(s string) string { func filterBySeverity(findings []Finding, severities []string) []Finding { sevSet := make(map[string]bool) for _, s := range severities { - sevSet[strings.ToLower(s)] = true + sevSet[core.Lower(s)] = true } var filtered []Finding @@ -446,11 +443,11 @@ func sortBySeverity(findings []Finding) { // outputJSON outputs findings as JSON func outputJSON(findings []Finding) error { - data, err := json.MarshalIndent(findings, "", " ") - if err != nil { - return log.E("monitor", "failed to marshal findings", err) + r := core.JSONMarshal(findings) + if !r.OK { + 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 } @@ -470,18 +467,18 @@ func outputTable(findings []Finding) error { // Header summary var parts []string 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 { - 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 { - 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 { - 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() // Group by repo @@ -511,12 +508,12 @@ func outputTable(findings []Finding) error { if f.File != "" { location = f.File 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", - sevStyle.Render(fmt.Sprintf("[%s]", f.Severity)), + sevStyle.Render(core.Sprintf("[%s]", f.Severity)), dimStyle.Render(f.Source), truncate(f.Message, 60)) if location != "" { @@ -533,8 +530,9 @@ func outputTable(findings []Finding) error { // repoShortName extracts the repo name from "org/repo" format. // Returns the full string if no "/" is present. func repoShortName(fullName string) string { - if i := strings.LastIndex(fullName, "/"); i >= 0 { - return fullName[i+1:] + parts := core.Split(fullName, "/") + if len(parts) > 0 { + return parts[len(parts)-1] } return fullName } @@ -553,10 +551,10 @@ func detectRepoFromGit() (string, error) { cmd := exec.Command("git", "remote", "get-url", "origin") output, err := cmd.Output() 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) } - url := strings.TrimSpace(string(output)) + url := core.Trim(string(output)) return parseGitHubRepo(url) } @@ -566,7 +564,7 @@ func detectOrgFromGit() string { if err != nil { return "" } - parts := strings.Split(repo, "/") + parts := core.Split(repo, "/") if len(parts) >= 1 { return parts[0] } @@ -576,20 +574,33 @@ func detectOrgFromGit() string { // parseGitHubRepo extracts org/repo from a git URL func parseGitHubRepo(url string) (string, error) { // Handle SSH URLs: git@github.com:org/repo.git - if strings.HasPrefix(url, "git@github.com:") { - path := strings.TrimPrefix(url, "git@github.com:") - path = strings.TrimSuffix(path, ".git") + if core.HasPrefix(url, "git@github.com:") { + path := core.TrimPrefix(url, "git@github.com:") + path = core.TrimSuffix(path, ".git") return path, nil } // Handle HTTPS URLs: https://github.com/org/repo.git - if strings.Contains(url, "github.com/") { - parts := strings.Split(url, "github.com/") + if core.Contains(url, "github.com/") { + parts := core.Split(url, "github.com/") if len(parts) >= 2 { - path := strings.TrimSuffix(parts[1], ".git") + path := core.TrimSuffix(parts[1], ".git") 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) } diff --git a/cmd/prod/cmd_dns.go b/cmd/prod/cmd_dns.go index d15325e..5fc5aef 100644 --- a/cmd/prod/cmd_dns.go +++ b/cmd/prod/cmd_dns.go @@ -2,11 +2,10 @@ package prod import ( "context" - "os" "time" + core "dappco.re/go/core" "forge.lthn.ai/core/cli/pkg/cli" - coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-infra" ) @@ -52,10 +51,10 @@ func init() { } func getDNSClient() (*infra.CloudNSClient, error) { - authID := os.Getenv("CLOUDNS_AUTH_ID") - authPass := os.Getenv("CLOUDNS_AUTH_PASSWORD") + authID := core.Env("CLOUDNS_AUTH_ID") + authPass := core.Env("CLOUDNS_AUTH_PASSWORD") 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 } @@ -76,7 +75,7 @@ func runDNSList(cmd *cli.Command, args []string) error { records, err := dns.ListRecords(ctx, zone) 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)) @@ -113,7 +112,7 @@ func runDNSSet(cmd *cli.Command, args []string) error { changed, err := dns.EnsureRecord(ctx, dnsZone, host, recordType, value, dnsTTL) if err != nil { - return coreerr.E("prod.runDNSSet", "set record", err) + return core.E("prod.runDNSSet", "set record", err) } if changed { diff --git a/cmd/prod/cmd_lb.go b/cmd/prod/cmd_lb.go index 29d8b9d..0a62693 100644 --- a/cmd/prod/cmd_lb.go +++ b/cmd/prod/cmd_lb.go @@ -2,12 +2,10 @@ package prod import ( "context" - "fmt" - "os" "time" + core "dappco.re/go/core" "forge.lthn.ai/core/cli/pkg/cli" - coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-infra" ) @@ -37,9 +35,9 @@ func init() { } func getHCloudClient() (*infra.HCloudClient, error) { - token := os.Getenv("HCLOUD_TOKEN") + token := core.Env("HCLOUD_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 } @@ -55,7 +53,7 @@ func runLBStatus(cmd *cli.Command, args []string) error { lbs, err := hc.ListLoadBalancers(ctx) 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 { @@ -94,7 +92,7 @@ func runLBStatus(cmd *cli.Command, args []string) error { } } } - fmt.Println() + core.Println() } return nil diff --git a/cmd/prod/cmd_setup.go b/cmd/prod/cmd_setup.go index de7c0cc..6aafbdd 100644 --- a/cmd/prod/cmd_setup.go +++ b/cmd/prod/cmd_setup.go @@ -2,11 +2,10 @@ package prod import ( "context" - "os" "time" + core "dappco.re/go/core" "forge.lthn.ai/core/cli/pkg/cli" - coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-infra" ) @@ -70,7 +69,7 @@ func runSetup(cmd *cli.Command, args []string) error { if err := step.fn(ctx, cfg); err != nil { 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) @@ -82,14 +81,14 @@ func runSetup(cmd *cli.Command, args []string) error { func stepDiscover(ctx context.Context, cfg *infra.Config) error { // Discover HCloud servers - hcloudToken := os.Getenv("HCLOUD_TOKEN") + hcloudToken := core.Env("HCLOUD_TOKEN") if hcloudToken != "" { cli.Print(" Discovering Hetzner Cloud servers...\n") hc := infra.NewHCloudClient(hcloudToken) servers, err := hc.ListServers(ctx) 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 { @@ -106,15 +105,15 @@ func stepDiscover(ctx context.Context, cfg *infra.Config) error { } // Discover Robot servers - robotUser := os.Getenv("HETZNER_ROBOT_USER") - robotPass := os.Getenv("HETZNER_ROBOT_PASS") + robotUser := core.Env("HETZNER_ROBOT_USER") + robotPass := core.Env("HETZNER_ROBOT_PASS") if robotUser != "" && robotPass != "" { cli.Print(" Discovering Hetzner Robot servers...\n") hr := infra.NewHRobotClient(robotUser, robotPass) servers, err := hr.ListServers(ctx) 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 { @@ -138,9 +137,9 @@ func stepDiscover(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 == "" { - 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) @@ -148,7 +147,7 @@ func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error { // Check if LB already exists lbs, err := hc.ListLoadBalancers(ctx) 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 { @@ -175,7 +174,7 @@ func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error { for _, b := range cfg.LoadBalancer.Backends { host, ok := cfg.Hosts[b.Host] 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{ Type: "ip", @@ -223,7 +222,7 @@ func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error { lb, err := hc.CreateLoadBalancer(ctx, req) 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", @@ -233,10 +232,10 @@ func stepLoadBalancer(ctx context.Context, cfg *infra.Config) error { } func stepDNS(ctx context.Context, cfg *infra.Config) error { - authID := os.Getenv("CLOUDNS_AUTH_ID") - authPass := os.Getenv("CLOUDNS_AUTH_PASSWORD") + authID := core.Env("CLOUDNS_AUTH_ID") + authPass := core.Env("CLOUDNS_AUTH_PASSWORD") 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) diff --git a/cmd/prod/cmd_ssh.go b/cmd/prod/cmd_ssh.go index 0f53189..04907bb 100644 --- a/cmd/prod/cmd_ssh.go +++ b/cmd/prod/cmd_ssh.go @@ -1,13 +1,12 @@ package prod import ( - "fmt" "os" "os/exec" "syscall" + core "dappco.re/go/core" "forge.lthn.ai/core/cli/pkg/cli" - coreerr "forge.lthn.ai/core/go-log" ) var sshCmd = &cli.Command{ @@ -38,15 +37,15 @@ func runSSH(cmd *cli.Command, args []string) error { for n, h := range cfg.Hosts { 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{ "ssh", "-i", host.SSH.Key, - "-p", fmt.Sprintf("%d", host.SSH.Port), + "-p", core.Sprintf("%d", host.SSH.Port), "-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", @@ -56,7 +55,7 @@ func runSSH(cmd *cli.Command, args []string) error { sshPath, err := exec.LookPath("ssh") if err != nil { - return coreerr.E("prod.ssh", "ssh not found", err) + return core.E("prod.ssh", "ssh not found", err) } // Replace current process with SSH diff --git a/cmd/prod/cmd_status.go b/cmd/prod/cmd_status.go index 15d9d29..66e4517 100644 --- a/cmd/prod/cmd_status.go +++ b/cmd/prod/cmd_status.go @@ -2,15 +2,13 @@ package prod import ( "context" - "fmt" "os" - "strings" "sync" "time" - "forge.lthn.ai/core/go-ansible" + core "dappco.re/go/core" "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" ) @@ -85,11 +83,11 @@ func runStatus(cmd *cli.Command, args []string) error { } // Check LB if token available - if token := os.Getenv("HCLOUD_TOKEN"); token != "" { - fmt.Println() + if token := core.Env("HCLOUD_TOKEN"); token != "" { + core.Println() checkLoadBalancer(ctx, token) } else { - fmt.Println() + core.Println() cli.Print("%s Load balancer: %s\n", cli.DimStyle.Render(" ○"), cli.DimStyle.Render("HCLOUD_TOKEN not set (skipped)")) @@ -115,14 +113,14 @@ func checkHost(ctx context.Context, name string, host *infra.Host) hostStatus { client, err := ansible.NewSSHClient(sshCfg) 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 } defer func() { _ = client.Close() }() start := time.Now() 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 } s.Connected = true @@ -130,12 +128,12 @@ func checkHost(ctx context.Context, name string, host *infra.Host) hostStatus { // OS info 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 stdout, _, _, err = client.Run(ctx, "docker --version 2>/dev/null | head -1") if err == nil && stdout != "" { - s.Docker = strings.TrimSpace(stdout) + s.Docker = core.Trim(stdout) } // Check each expected service @@ -151,14 +149,14 @@ func checkService(ctx context.Context, client *ansible.SSHClient, service string switch service { case "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 "not running" case "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 "not running" @@ -168,16 +166,16 @@ func checkService(ctx context.Context, client *ansible.SSHClient, service string stdout, _, _, _ := client.Run(ctx, "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}'") - size := strings.TrimSpace(stdout) + size := core.Trim(stdout) if size != "" && size != "0" { - return fmt.Sprintf("cluster_size=%s", size) + return core.Sprintf("cluster_size=%s", size) } // Try non-Docker stdout, _, _, _ = client.Run(ctx, "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" { - return fmt.Sprintf("cluster_size=%s", size) + return core.Sprintf("cluster_size=%s", size) } return "not running" @@ -185,18 +183,18 @@ func checkService(ctx context.Context, client *ansible.SSHClient, service string stdout, _, _, _ := client.Run(ctx, "docker exec $(docker ps -q --filter name=redis 2>/dev/null || echo none) "+ "redis-cli ping 2>/dev/null") - if strings.TrimSpace(stdout) == "PONG" { + if core.Trim(stdout) == "PONG" { return "running" } stdout, _, _, _ = client.Run(ctx, "redis-cli ping 2>/dev/null") - if strings.TrimSpace(stdout) == "PONG" { + if core.Trim(stdout) == "PONG" { return "running" } return "not running" 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") - val := strings.TrimSpace(stdout) + val := core.Trim(stdout) if val == "active" || (val != "0" && val != "") { return "running" } @@ -205,8 +203,8 @@ func checkService(ctx context.Context, client *ansible.SSHClient, service string default: // Generic docker container check stdout, _, _, _ := client.Run(ctx, - fmt.Sprintf("docker ps --format '{{.Names}}' 2>/dev/null | grep -c %s", service)) - if strings.TrimSpace(stdout) != "0" && strings.TrimSpace(stdout) != "" { + core.Sprintf("docker ps --format '{{.Names}}' 2>/dev/null | grep -c %s", service)) + if core.Trim(stdout) != "0" && core.Trim(stdout) != "" { return "running" } return "not running" @@ -248,7 +246,7 @@ func printHostStatus(s hostStatus) { if s.OS != "" { cli.Print(" %s", cli.DimStyle.Render(s.OS)) } - fmt.Println() + core.Println() if s.Docker != "" { cli.Print(" %s %s\n", cli.SuccessStyle.Render("✓"), cli.DimStyle.Render(s.Docker)) @@ -271,7 +269,7 @@ func printHostStatus(s hostStatus) { cli.Print(" %s %s %s\n", icon, svc, style.Render(status)) } - fmt.Println() + core.Println() } func checkLoadBalancer(ctx context.Context, token string) { diff --git a/config.go b/config.go index a75a2b3..eba6ec9 100644 --- a/config.go +++ b/config.go @@ -3,11 +3,7 @@ package infra import ( - "os" - "path/filepath" - - coreerr "forge.lthn.ai/core/go-log" - coreio "forge.lthn.ai/core/go-io" + core "dappco.re/go/core" "gopkg.in/yaml.v3" ) @@ -230,14 +226,14 @@ type BackupJob struct { // Load reads and parses an infra.yaml file. func Load(path string) (*Config, error) { - data, err := coreio.Local.Read(path) - if err != nil { - return nil, coreerr.E("infra.Load", "read infra config", err) + read := localFS.Read(path) + if !read.OK { + return nil, core.E("infra.Load", "read infra config", coreResultErr(read, "infra.Load")) } var cfg Config - if err := yaml.Unmarshal([]byte(data), &cfg); err != nil { - return nil, coreerr.E("infra.Load", "parse infra config", err) + if err := yaml.Unmarshal([]byte(read.Value.(string)), &cfg); err != nil { + return nil, core.E("infra.Load", "parse infra config", err) } // Expand SSH key paths @@ -257,19 +253,19 @@ func Load(path string) (*Config, error) { func Discover(startDir string) (*Config, string, error) { dir := startDir for { - path := filepath.Join(dir, "infra.yaml") - if _, err := os.Stat(path); err == nil { + path := core.JoinPath(dir, "infra.yaml") + if localFS.Exists(path) { cfg, err := Load(path) return cfg, path, err } - parent := filepath.Dir(dir) + parent := core.PathDir(dir) if parent == dir { break } 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. @@ -290,12 +286,19 @@ func (c *Config) AppServers() map[string]*Host { // expandPath expands ~ to home directory. func expandPath(path string) string { - if len(path) > 0 && path[0] == '~' { - home, err := os.UserHomeDir() - if err != nil { + if core.HasPrefix(path, "~") { + home := core.Env("DIR_HOME") + if home == "" { 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 } diff --git a/config_test.go b/config_test.go index b10955f..9557a1d 100644 --- a/config_test.go +++ b/config_test.go @@ -2,8 +2,9 @@ package infra import ( "os" - "path/filepath" "testing" + + core "dappco.re/go/core" ) func TestLoad_Good(t *testing.T) { @@ -68,9 +69,9 @@ func TestLoad_Bad(t *testing.T) { func TestLoad_Ugly(t *testing.T) { // Invalid YAML - tmp := filepath.Join(t.TempDir(), "infra.yaml") - if err := os.WriteFile(tmp, []byte("{{invalid yaml"), 0644); err != nil { - t.Fatal(err) + tmp := core.JoinPath(t.TempDir(), "infra.yaml") + if r := localFS.WriteMode(tmp, "{{invalid yaml", 0644); !r.OK { + t.Fatal(coreResultErr(r, "TestLoad_Ugly")) } _, err := Load(tmp) @@ -129,13 +130,13 @@ func TestConfig_AppServers_Good(t *testing.T) { } func TestExpandPath(t *testing.T) { - home, _ := os.UserHomeDir() + home := core.Env("DIR_HOME") tests := []struct { input 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"}, {"relative/path", "relative/path"}, } diff --git a/core_helpers.go b/core_helpers.go new file mode 100644 index 0000000..c4db534 --- /dev/null +++ b/core_helpers.go @@ -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) +} diff --git a/go.mod b/go.mod index 7519ab3..338d4eb 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,11 @@ module forge.lthn.ai/core/go-infra go 1.26.0 require ( + dappco.re/go/core v0.8.0-alpha.1 forge.lthn.ai/core/cli v0.3.5 forge.lthn.ai/core/go-ansible v0.1.4 forge.lthn.ai/core/go-i18n 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 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 @@ -16,6 +16,7 @@ require ( require ( forge.lthn.ai/core/go v0.3.1 // 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/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect diff --git a/go.sum b/go.sum index fe135df..90d605b 100644 --- a/go.sum +++ b/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/go.mod h1:SeArHx+hbpX5iZqgASCD7Q1EDoc6uaaGiGBotmNzIx4= forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM= diff --git a/hetzner.go b/hetzner.go index c9ff370..8e1adfb 100644 --- a/hetzner.go +++ b/hetzner.go @@ -2,12 +2,9 @@ package infra import ( "context" - "encoding/json" - "fmt" "net/http" - "strings" - coreerr "forge.lthn.ai/core/go-log" + core "dappco.re/go/core" ) const ( @@ -202,7 +199,7 @@ func (c *HCloudClient) GetLoadBalancer(ctx context.Context, id int) (*HCloudLoad var result struct { 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 &result.LoadBalancer, nil @@ -210,10 +207,11 @@ func (c *HCloudClient) GetLoadBalancer(ctx context.Context, id int) (*HCloudLoad // CreateLoadBalancer creates a new load balancer. func (c *HCloudClient) CreateLoadBalancer(ctx context.Context, req HCloudLBCreateRequest) (*HCloudLoadBalancer, error) { - body, err := json.Marshal(req) - if err != nil { - return nil, coreerr.E("HCloudClient.CreateLoadBalancer", "marshal request", err) + marshaled := core.JSONMarshal(req) + if !marshaled.OK { + return nil, core.E("HCloudClient.CreateLoadBalancer", "marshal request", coreResultErr(marshaled, "HCloudClient.CreateLoadBalancer")) } + body := marshaled.Value.([]byte) var result struct { LoadBalancer HCloudLoadBalancer `json:"load_balancer"` @@ -226,16 +224,19 @@ func (c *HCloudClient) CreateLoadBalancer(ctx context.Context, req HCloudLBCreat // DeleteLoadBalancer deletes a load balancer by ID. 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. func (c *HCloudClient) CreateSnapshot(ctx context.Context, serverID int, description string) error { - body, _ := json.Marshal(map[string]string{ + marshaled := core.JSONMarshal(map[string]string{ "description": description, "type": "snapshot", }) - return c.post(ctx, fmt.Sprintf("/servers/%d/actions/create_image", serverID), body, nil) + if !marshaled.OK { + return core.E("HCloudClient.CreateSnapshot", "marshal request", coreResultErr(marshaled, "HCloudClient.CreateSnapshot")) + } + 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 { @@ -247,7 +248,7 @@ func (c *HCloudClient) get(ctx context.Context, path string, 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, strings.NewReader(string(body))) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, core.NewReader(string(body))) if err != nil { return err } diff --git a/hetzner_test.go b/hetzner_test.go index cb2ad46..7b658d4 100644 --- a/hetzner_test.go +++ b/hetzner_test.go @@ -2,11 +2,12 @@ package infra import ( "context" - "encoding/json" + "io" "net/http" "net/http/httptest" "testing" + core "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -41,7 +42,7 @@ func TestHCloudClient_ListServers_Good(t *testing.T) { }, } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + writeCoreJSON(t, w, resp) }) ts := httptest.NewServer(mux) @@ -274,8 +275,7 @@ func TestHCloudClient_CreateLoadBalancer_Good(t *testing.T) { assert.Equal(t, "/load_balancers", r.URL.Path) var body HCloudLBCreateRequest - err := json.NewDecoder(r.Body).Decode(&body) - require.NoError(t, err) + decodeCoreJSONBody(t, r, &body) assert.Equal(t, "hermes", body.Name) assert.Equal(t, "lb11", body.LoadBalancerType) assert.Equal(t, "round_robin", body.Algorithm.Type) @@ -333,8 +333,7 @@ func TestHCloudClient_CreateSnapshot_Good(t *testing.T) { assert.Equal(t, "/servers/123/actions/create_image", r.URL.Path) var body map[string]string - err := json.NewDecoder(r.Body).Decode(&body) - require.NoError(t, err) + decodeCoreJSONBody(t, r, &body) assert.Equal(t, "daily backup", body["description"]) assert.Equal(t, "snapshot", body["type"]) @@ -399,9 +398,7 @@ func TestHCloudServer_JSON_Good(t *testing.T) { }` var server HCloudServer - err := json.Unmarshal([]byte(data), &server) - - require.NoError(t, err) + requireHetznerJSON(t, data, &server) assert.Equal(t, 123, server.ID) assert.Equal(t, "web-1", server.Name) assert.Equal(t, "running", server.Status) @@ -431,9 +428,7 @@ func TestHCloudLoadBalancer_JSON_Good(t *testing.T) { }` var lb HCloudLoadBalancer - err := json.Unmarshal([]byte(data), &lb) - - require.NoError(t, err) + requireHetznerJSON(t, data, &lb) assert.Equal(t, 789, lb.ID) assert.Equal(t, "hermes", lb.Name) assert.True(t, lb.PublicNet.Enabled) @@ -460,9 +455,7 @@ func TestHRobotServer_JSON_Good(t *testing.T) { }` var server HRobotServer - err := json.Unmarshal([]byte(data), &server) - - require.NoError(t, err) + requireHetznerJSON(t, data, &server) assert.Equal(t, "1.2.3.4", server.ServerIP) assert.Equal(t, "noc", server.ServerName) assert.Equal(t, "EX44", server.Product) @@ -471,3 +464,28 @@ func TestHRobotServer_JSON_Good(t *testing.T) { assert.False(t, server.Cancelled) 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) +} -- 2.45.3 From 47910d06671973bb51775c9793f1eaa9f80f34ec Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 18:14:55 +0000 Subject: [PATCH 2/4] AX v0.8.0 polish pass Co-authored-by: Virgil --- client.go | 11 ++ client_test.go | 60 +++++----- cloudns.go | 12 ++ cloudns_test.go | 60 +++++----- cmd/monitor/cmd_commands.go | 1 + cmd/monitor/cmd_monitor.go | 120 ++++++++++--------- cmd/prod/cmd_commands.go | 1 + cmd/prod/cmd_prod.go | 1 + cmd/prod/cmd_ssh.go | 16 +-- cmd/prod/cmd_status.go | 7 +- config.go | 32 +++++ config_test.go | 17 ++- hetzner.go | 32 +++++ hetzner_test.go | 36 +++--- internal/coreexec/coreexec.go | 218 ++++++++++++++++++++++++++++++++++ 15 files changed, 469 insertions(+), 155 deletions(-) create mode 100644 internal/coreexec/coreexec.go diff --git a/client.go b/client.go index e5d3601..268bc8a 100644 --- a/client.go +++ b/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) diff --git a/client_test.go b/client_test.go index 619af96..b48ae7d 100644 --- a/client_test.go +++ b/client_test.go @@ -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 diff --git a/cloudns.go b/cloudns.go index 4c3533f..d7cacaa 100644 --- a/cloudns.go +++ b/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 { diff --git a/cloudns_test.go b/cloudns_test.go index 0cf6ac8..715d1d9 100644 --- a/cloudns_test.go +++ b/cloudns_test.go @@ -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")) diff --git a/cmd/monitor/cmd_commands.go b/cmd/monitor/cmd_commands.go index b653bef..749f53e 100644 --- a/cmd/monitor/cmd_commands.go +++ b/cmd/monitor/cmd_commands.go @@ -27,6 +27,7 @@ var ( ) // AddMonitorCommands registers the 'monitor' command. +// Usage: monitor.AddMonitorCommands(root) func AddMonitorCommands(root *cli.Command) { monitorCmd := &cli.Command{ Use: "monitor", diff --git a/cmd/monitor/cmd_monitor.go b/cmd/monitor/cmd_monitor.go index 2891cd8..ee6943a 100644 --- a/cmd/monitor/cmd_monitor.go +++ b/cmd/monitor/cmd_monitor.go @@ -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) +} diff --git a/cmd/prod/cmd_commands.go b/cmd/prod/cmd_commands.go index 1ecb3c8..9891742 100644 --- a/cmd/prod/cmd_commands.go +++ b/cmd/prod/cmd_commands.go @@ -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) } diff --git a/cmd/prod/cmd_prod.go b/cmd/prod/cmd_prod.go index f00b364..bd38f26 100644 --- a/cmd/prod/cmd_prod.go +++ b/cmd/prod/cmd_prod.go @@ -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", diff --git a/cmd/prod/cmd_ssh.go b/cmd/prod/cmd_ssh.go index 04907bb..79c6cfb 100644 --- a/cmd/prod/cmd_ssh.go +++ b/cmd/prod/cmd_ssh.go @@ -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 } diff --git a/cmd/prod/cmd_status.go b/cmd/prod/cmd_status.go index 66e4517..0a77fb2 100644 --- a/cmd/prod/cmd_status.go +++ b/cmd/prod/cmd_status.go @@ -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) diff --git a/config.go b/config.go index eba6ec9..60ad5ac 100644 --- a/config.go +++ b/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") } diff --git a/config_test.go b/config_test.go index 9557a1d..b13fb14 100644 --- a/config_test.go +++ b/config_test.go @@ -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 { diff --git a/hetzner.go b/hetzner.go index 8e1adfb..e8d17c1 100644 --- a/hetzner.go +++ b/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"` diff --git a/hetzner_test.go b/hetzner_test.go index 7b658d4..f5a87eb 100644 --- a/hetzner_test.go +++ b/hetzner_test.go @@ -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", diff --git a/internal/coreexec/coreexec.go b/internal/coreexec/coreexec.go new file mode 100644 index 0000000..5272902 --- /dev/null +++ b/internal/coreexec/coreexec.go @@ -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) +} -- 2.45.3 From 08651929a1a0efcd9b4590ab2e4f4cd83f4456d0 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 03:20:38 +0000 Subject: [PATCH 3/4] chore: verification pass -- 2.45.3 From 90a74d8f096edcdfc29d5e45dd5381d7f39d8796 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 27 Mar 2026 20:07:11 +0000 Subject: [PATCH 4/4] docs: add API RFCs --- cmd/monitor/specs/RFC.md | 54 +++++ cmd/prod/specs/RFC.md | 12 + internal/coreexec/specs/RFC.md | 22 ++ specs/RFC.md | 418 +++++++++++++++++++++++++++++++++ 4 files changed, 506 insertions(+) create mode 100644 cmd/monitor/specs/RFC.md create mode 100644 cmd/prod/specs/RFC.md create mode 100644 internal/coreexec/specs/RFC.md create mode 100644 specs/RFC.md diff --git a/cmd/monitor/specs/RFC.md b/cmd/monitor/specs/RFC.md new file mode 100644 index 0000000..cedd0df --- /dev/null +++ b/cmd/monitor/specs/RFC.md @@ -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. diff --git a/cmd/prod/specs/RFC.md b/cmd/prod/specs/RFC.md new file mode 100644 index 0000000..798c8ce --- /dev/null +++ b/cmd/prod/specs/RFC.md @@ -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. diff --git a/internal/coreexec/specs/RFC.md b/internal/coreexec/specs/RFC.md new file mode 100644 index 0000000..034a54a --- /dev/null +++ b/internal/coreexec/specs/RFC.md @@ -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`. diff --git a/specs/RFC.md b/specs/RFC.md new file mode 100644 index 0000000..5e62f20 --- /dev/null +++ b/specs/RFC.md @@ -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. -- 2.45.3