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) +}