Upgrade to dappco.re/go/core v0.8.0-alpha.1

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-03-26 15:32:27 +00:00
parent 1cecf00148
commit 67dae9cc94
16 changed files with 269 additions and 228 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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) {

View file

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

View file

@ -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 {

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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) {

View file

@ -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
}

View file

@ -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"},
}

18
core_helpers.go Normal file
View file

@ -0,0 +1,18 @@
package infra
import core "dappco.re/go/core"
var localFS = (&core.Fs{}).NewUnrestricted()
func coreResultErr(r core.Result, op string) error {
if r.OK {
return nil
}
if err, ok := r.Value.(error); ok && err != nil {
return err
}
if r.Value == nil {
return core.E(op, "unexpected empty core result", nil)
}
return core.E(op, core.Sprint(r.Value), nil)
}

3
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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
}

View file

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