diff --git a/.gitignore b/.gitignore index 815e1fa..cdc6f76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ -.core/ .idea/ +.vscode/ +*.log +.core/ diff --git a/client.go b/client.go index d5f29c8..5f27c5a 100644 --- a/client.go +++ b/client.go @@ -11,7 +11,7 @@ import ( "sync" "time" - coreerr "forge.lthn.ai/core/go-log" + coreerr "dappco.re/go/core/log" ) // RetryConfig controls exponential backoff retry behaviour. diff --git a/client_test.go b/client_test.go index 13db078..0143cb2 100644 --- a/client_test.go +++ b/client_test.go @@ -137,7 +137,8 @@ func TestAPIClient_Do_Bad_ClientError(t *testing.T) { err = c.Do(req, nil) assert.Error(t, err) - assert.Contains(t, err.Error(), "test-api 404") + assert.Contains(t, err.Error(), "test-api") + assert.Contains(t, err.Error(), "HTTP 404") assert.Contains(t, err.Error(), "not found") } @@ -226,7 +227,8 @@ func TestAPIClient_Do_Bad_ExhaustsRetries(t *testing.T) { err = c.Do(req, nil) assert.Error(t, err) - assert.Contains(t, err.Error(), "exhaust-test 500") + assert.Contains(t, err.Error(), "exhaust-test") + assert.Contains(t, err.Error(), "HTTP 500") // 1 initial + 2 retries = 3 attempts assert.Equal(t, int32(3), attempts.Load()) } @@ -476,7 +478,8 @@ func TestAPIClient_DoRaw_Bad_ClientError(t *testing.T) { _, err = c.DoRaw(req) assert.Error(t, err) - assert.Contains(t, err.Error(), "raw-test 403") + assert.Contains(t, err.Error(), "raw-test") + assert.Contains(t, err.Error(), "HTTP 403") } func TestAPIClient_DoRaw_Good_RetriesServerError(t *testing.T) { diff --git a/cloudns.go b/cloudns.go index 7130b4c..e60d176 100644 --- a/cloudns.go +++ b/cloudns.go @@ -7,7 +7,7 @@ import ( "net/url" "strconv" - coreerr "forge.lthn.ai/core/go-log" + coreerr "dappco.re/go/core/log" ) const cloudnsBaseURL = "https://api.cloudns.net" diff --git a/cloudns_test.go b/cloudns_test.go index 3436f95..193fe88 100644 --- a/cloudns_test.go +++ b/cloudns_test.go @@ -78,7 +78,8 @@ func TestCloudNSClient_DoRaw_Bad_HTTPError(t *testing.T) { _, err = client.doRaw(req) assert.Error(t, err) - assert.Contains(t, err.Error(), "cloudns API 403") + assert.Contains(t, err.Error(), "cloudns API") + assert.Contains(t, err.Error(), "HTTP 403") } func TestCloudNSClient_DoRaw_Bad_ServerError(t *testing.T) { @@ -102,7 +103,8 @@ func TestCloudNSClient_DoRaw_Bad_ServerError(t *testing.T) { _, err = client.doRaw(req) assert.Error(t, err) - assert.Contains(t, err.Error(), "cloudns API 500") + assert.Contains(t, err.Error(), "cloudns API") + assert.Contains(t, err.Error(), "HTTP 500") } // --- Zone JSON parsing --- diff --git a/cmd/monitor/cmd_monitor.go b/cmd/monitor/cmd_monitor.go index 7b144bf..d3d893f 100644 --- a/cmd/monitor/cmd_monitor.go +++ b/cmd/monitor/cmd_monitor.go @@ -18,10 +18,10 @@ import ( "slices" "strings" + "dappco.re/go/core/io" + "dappco.re/go/core/log" "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" ) diff --git a/cmd/prod/cmd_dns.go b/cmd/prod/cmd_dns.go index d15325e..b386e16 100644 --- a/cmd/prod/cmd_dns.go +++ b/cmd/prod/cmd_dns.go @@ -5,9 +5,9 @@ import ( "os" "time" + "dappco.re/go/core/infra" + coreerr "dappco.re/go/core/log" "forge.lthn.ai/core/cli/pkg/cli" - coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/go-infra" ) var dnsCmd = &cli.Command{ diff --git a/cmd/prod/cmd_lb.go b/cmd/prod/cmd_lb.go index 29d8b9d..4226c6b 100644 --- a/cmd/prod/cmd_lb.go +++ b/cmd/prod/cmd_lb.go @@ -6,9 +6,9 @@ import ( "os" "time" + "dappco.re/go/core/infra" + coreerr "dappco.re/go/core/log" "forge.lthn.ai/core/cli/pkg/cli" - coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/go-infra" ) var lbCmd = &cli.Command{ diff --git a/cmd/prod/cmd_setup.go b/cmd/prod/cmd_setup.go index de7c0cc..ec2045e 100644 --- a/cmd/prod/cmd_setup.go +++ b/cmd/prod/cmd_setup.go @@ -5,9 +5,9 @@ import ( "os" "time" + "dappco.re/go/core/infra" + coreerr "dappco.re/go/core/log" "forge.lthn.ai/core/cli/pkg/cli" - coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/go-infra" ) var setupCmd = &cli.Command{ diff --git a/cmd/prod/cmd_ssh.go b/cmd/prod/cmd_ssh.go index 0f53189..a78d46d 100644 --- a/cmd/prod/cmd_ssh.go +++ b/cmd/prod/cmd_ssh.go @@ -6,8 +6,8 @@ import ( "os/exec" "syscall" + coreerr "dappco.re/go/core/log" "forge.lthn.ai/core/cli/pkg/cli" - coreerr "forge.lthn.ai/core/go-log" ) var sshCmd = &cli.Command{ diff --git a/cmd/prod/cmd_status.go b/cmd/prod/cmd_status.go index 15d9d29..1ecf766 100644 --- a/cmd/prod/cmd_status.go +++ b/cmd/prod/cmd_status.go @@ -8,10 +8,10 @@ import ( "sync" "time" - "forge.lthn.ai/core/go-ansible" + "dappco.re/go/core/infra" + coreerr "dappco.re/go/core/log" "forge.lthn.ai/core/cli/pkg/cli" - coreerr "forge.lthn.ai/core/go-log" - "forge.lthn.ai/core/go-infra" + "forge.lthn.ai/core/go-ansible" ) var statusCmd = &cli.Command{ diff --git a/config.go b/config.go index a75a2b3..17123e4 100644 --- a/config.go +++ b/config.go @@ -6,8 +6,8 @@ import ( "os" "path/filepath" - coreerr "forge.lthn.ai/core/go-log" - coreio "forge.lthn.ai/core/go-io" + coreio "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" "gopkg.in/yaml.v3" ) diff --git a/docs/index.md b/docs/index.md index 54545c3..bb65c52 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,14 +5,14 @@ description: Infrastructure provider API clients and YAML-based configuration fo # go-infra -`forge.lthn.ai/core/go-infra` provides typed Go clients for infrastructure provider APIs (Hetzner Cloud, Hetzner Robot, CloudNS) and a declarative YAML configuration layer for describing production topology. It also ships CLI commands for production management (`core prod`) and security monitoring (`core monitor`). +`dappco.re/go/core/infra` provides typed Go clients for infrastructure provider APIs (Hetzner Cloud, Hetzner Robot, CloudNS) and a declarative YAML configuration layer for describing production topology. It also ships CLI commands for production management (`core prod`) and security monitoring (`core monitor`). The library has no framework dependencies beyond the Go standard library, YAML parsing, and testify for tests. All HTTP communication goes through a shared `APIClient` that handles retries, exponential backoff, and rate-limit compliance automatically. ## Module Path ``` -forge.lthn.ai/core/go-infra +dappco.re/go/core/infra ``` Requires **Go 1.26+**. @@ -22,7 +22,7 @@ Requires **Go 1.26+**. ### Using the API Clients Directly ```go -import "forge.lthn.ai/core/go-infra" +import "dappco.re/go/core/infra" // Hetzner Cloud -- list all servers hc := infra.NewHCloudClient(os.Getenv("HCLOUD_TOKEN")) @@ -40,7 +40,7 @@ changed, err := dns.EnsureRecord(ctx, "example.com", "www", "A", "1.2.3.4", 300) ### Loading Infrastructure Configuration ```go -import "forge.lthn.ai/core/go-infra" +import "dappco.re/go/core/infra" // Auto-discover infra.yaml by walking up from the current directory cfg, path, err := infra.Discover(".") diff --git a/go.mod b/go.mod index 7519ab3..04fcf76 100644 --- a/go.mod +++ b/go.mod @@ -1,21 +1,23 @@ -module forge.lthn.ai/core/go-infra +module dappco.re/go/core/infra go 1.26.0 require ( - 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 + dappco.re/go/core/io v0.2.0 + dappco.re/go/core/log v0.1.0 + forge.lthn.ai/core/cli v0.3.7 + forge.lthn.ai/core/go-ansible v0.1.6 + forge.lthn.ai/core/go-i18n v0.1.7 + forge.lthn.ai/core/go-scm v0.3.6 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) 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 v0.3.3 // indirect + forge.lthn.ai/core/go-inference v0.1.6 // indirect + forge.lthn.ai/core/go-io v0.1.7 // 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..76a287c 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,23 @@ -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= -forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc= -forge.lthn.ai/core/go-ansible v0.1.4 h1:kja8xFADQ97c0X1RqCTHrYG8SZwfCtP33Ms48bJDmmI= -forge.lthn.ai/core/go-ansible v0.1.4/go.mod h1:TV7eGhm8z86iihVPpn5I21jzBZ3UoR/TRva6tNOCRoU= -forge.lthn.ai/core/go-i18n v0.1.5 h1:B4hV4eTl63akZiplM8lswuttctrcSOCWyFSGBZmu6Nc= -forge.lthn.ai/core/go-i18n v0.1.5/go.mod h1:hJsUxmqdPly73i3VkTDxvmbrpjxSd65hQVQqWA3+fnM= -forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0= -forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= -forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM= -forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI= +dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= +dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= +dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= +dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= +forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= +forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= +forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= +forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ= +forge.lthn.ai/core/go-ansible v0.1.6 h1:jTeW26Gqa4mhv+4newyrfwfg6TY/17nG9yWymZdBF1Y= +forge.lthn.ai/core/go-ansible v0.1.6/go.mod h1:RcthHm/ouMCZLYW3R4LgVWASXvg2Ndc408GX5TxqDZE= +forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA= +forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= +forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E= +forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= +forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk= +forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= -forge.lthn.ai/core/go-scm v0.3.4 h1:McZvp2gI3wEPCF/jim8O4F1+Vp477N81TUiiklTq5hw= -forge.lthn.ai/core/go-scm v0.3.4/go.mod h1:AOrx4CEmV8/Q73Cvd2LkbFniYGpk46mticpYmK5MnJA= +forge.lthn.ai/core/go-scm v0.3.6 h1:LFNx8Fs82mrpxro/MPUM6tMiD4DqPmdu83UknXztQjc= +forge.lthn.ai/core/go-scm v0.3.6/go.mod h1:IWFIYDfRH0mtRdqY5zV06l/RkmkPpBM6FcbKWhg1Qa8= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= diff --git a/hetzner.go b/hetzner.go index c9ff370..ee93c1e 100644 --- a/hetzner.go +++ b/hetzner.go @@ -7,7 +7,7 @@ import ( "net/http" "strings" - coreerr "forge.lthn.ai/core/go-log" + coreerr "dappco.re/go/core/log" ) const ( diff --git a/hetzner_test.go b/hetzner_test.go index 204c76e..18ba5b6 100644 --- a/hetzner_test.go +++ b/hetzner_test.go @@ -116,7 +116,8 @@ func TestHCloudClient_Do_Bad_APIError(t *testing.T) { var result struct{} err := client.get(context.Background(), "/servers", &result) assert.Error(t, err) - assert.Contains(t, err.Error(), "hcloud API 403") + assert.Contains(t, err.Error(), "hcloud API") + assert.Contains(t, err.Error(), "HTTP 403") } func TestHCloudClient_Do_Bad_APIErrorNoJSON(t *testing.T) { @@ -136,7 +137,8 @@ func TestHCloudClient_Do_Bad_APIErrorNoJSON(t *testing.T) { err := client.get(context.Background(), "/servers", nil) assert.Error(t, err) - assert.Contains(t, err.Error(), "hcloud API 500") + assert.Contains(t, err.Error(), "hcloud API") + assert.Contains(t, err.Error(), "HTTP 500") } func TestHCloudClient_Do_Good_NilResult(t *testing.T) { @@ -218,7 +220,8 @@ func TestHRobotClient_Get_Bad_HTTPError(t *testing.T) { err := client.get(context.Background(), "/server", nil) assert.Error(t, err) - assert.Contains(t, err.Error(), "hrobot API 401") + assert.Contains(t, err.Error(), "hrobot API") + assert.Contains(t, err.Error(), "HTTP 401") } // --- Type serialisation --- diff --git a/kb/API-Clients.md b/kb/API-Clients.md new file mode 100644 index 0000000..5299e64 --- /dev/null +++ b/kb/API-Clients.md @@ -0,0 +1,45 @@ +# API Clients + +Module: `dappco.re/go/core/infra` + +## CloudNS Client + +`NewCloudNSClient(authID, password)` creates a client for the CloudNS DNS API (`api.cloudns.net`). + +### DNS Record Operations + +| Method | Description | +|--------|-------------| +| `ListZones(ctx)` | Returns all DNS zones | +| `ListRecords(ctx, domain)` | Returns all records for a zone | +| `CreateRecord(ctx, domain, host, type, value, ttl)` | Creates a record, returns ID | +| `UpdateRecord(ctx, domain, id, host, type, value, ttl)` | Updates an existing record | +| `DeleteRecord(ctx, domain, id)` | Deletes a record by ID | +| `EnsureRecord(ctx, domain, host, type, value, ttl)` | Idempotent create-or-update, returns `(changed bool, err)` | + +### ACME Helpers + +- `SetACMEChallenge(ctx, domain, value)` — Creates `_acme-challenge` TXT record (TTL 60s) +- `ClearACMEChallenge(ctx, domain)` — Removes all `_acme-challenge` TXT records + +## Hetzner Cloud Client + +`NewHCloudClient(token)` creates a client for the Hetzner Cloud API (`api.hetzner.cloud/v1`). Uses Bearer token auth. + +### Types + +- `HCloudServer` — ID, Name, Status, PublicNet (IPv4), PrivateNet, ServerType, Datacenter, Labels +- `HCloudLoadBalancer` — ID, Name, PublicNet, Algorithm, Services, Targets, Location, Labels +- `HCloudLBCreateRequest` — Creation parameters for load balancers + +## Hetzner Robot Client + +`NewHRobotClient(user, password)` creates a client for dedicated server management (`robot-ws.your-server.de`). Uses Basic auth. + +- `HRobotServer` — ServerIP, ServerName, Product, Datacenter, Status, PaidUntil + +## Infrastructure Config + +`Load(path)` reads `infra.yaml`. `Discover(startDir)` walks up directories looking for `infra.yaml`. + +Helper: `cfg.HostsByRole(role)` filters hosts, `cfg.AppServers()` returns hosts with role "app". diff --git a/kb/Home.md b/kb/Home.md new file mode 100644 index 0000000..8eaf5ef --- /dev/null +++ b/kb/Home.md @@ -0,0 +1,58 @@ +# go-infra + +Module: `dappco.re/go/core/infra` + +Infrastructure management for the Host UK production environment. Provides API clients for CloudNS DNS, Hetzner Cloud, and Hetzner Robot, plus a declarative infrastructure configuration model loaded from `infra.yaml`. + +## Architecture + +| File | Purpose | +|------|---------| +| `config.go` | `Config` struct and `Load()`/`Discover()` for `infra.yaml` | +| `client.go` | `APIClient` — shared HTTP client with auth, retries, JSON handling | +| `cloudns.go` | `CloudNSClient` — DNS record management via CloudNS API | +| `hetzner.go` | `HCloudClient` (Cloud API) + `HRobotClient` (Robot API) | + +CLI commands in `cmd/prod/` (dns, lb, setup, ssh, status) and `cmd/monitor/`. + +## Key Types + +### Infrastructure Config + +- **`Config`** — Top-level config: `Hosts`, `LoadBalancer`, `Network`, `DNS`, `SSL`, `Database`, `Cache`, `Containers`, `S3`, `CDN`, `CICD`, `Monitoring`, `Backups` +- **`Host`** — Server: `FQDN`, `IP`, `PrivateIP`, `Type` (hcloud/hrobot), `Role` (bastion/app/builder), `SSH`, `Services` +- **`LoadBalancer`** — LB config with backends, health checks, listeners, SSL +- **`DNS`** — Provider config with zones and records +- **`Database`** — Cluster config (engine, version, nodes, backup) + +### API Clients + +- **`CloudNSClient`** — DNS operations: `ListZones()`, `ListRecords()`, `CreateRecord()`, `UpdateRecord()`, `DeleteRecord()`, `EnsureRecord()`, `SetACMEChallenge()`, `ClearACMEChallenge()` +- **`HCloudClient`** — Hetzner Cloud: `ListServers()`, `ListLoadBalancers()`, `GetLoadBalancer()`, `CreateLoadBalancer()`, `DeleteLoadBalancer()`, `CreateSnapshot()` +- **`HRobotClient`** — Hetzner Robot (dedicated): `ListServers()`, `GetServer()` +- **`APIClient`** — Shared HTTP client with configurable auth and error handling + +## Usage + +```go +import "dappco.re/go/core/infra" + +// Load infrastructure config +cfg, path, _ := infra.Discover(".") + +// Get app servers +appServers := cfg.AppServers() + +// DNS management +dns := infra.NewCloudNSClient(authID, password) +changed, _ := dns.EnsureRecord(ctx, "example.com", "www", "A", "1.2.3.4", 300) + +// Hetzner Cloud +hcloud := infra.NewHCloudClient(token) +servers, _ := hcloud.ListServers(ctx) +``` + +## Dependencies + +- `gopkg.in/yaml.v3` — YAML config parsing +- No core ecosystem dependencies (standalone)