diff --git a/internal/cli/client.go b/internal/cli/client.go index 8511d187..239052df 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -17,6 +17,7 @@ import ( "github.com/tracebloc/cli/internal/api" "github.com/tracebloc/cli/internal/cluster" "github.com/tracebloc/cli/internal/config" + "github.com/tracebloc/cli/internal/geo" "github.com/tracebloc/cli/internal/slug" "github.com/tracebloc/cli/internal/ui" ) @@ -135,6 +136,10 @@ func authedClient() (*api.Client, *config.Config, error) { return client, cfg, nil } +// detectZone suggests a location zone (cloud metadata → GeoIP). A seam so tests +// stay hermetic (no network). +var detectZone = geo.Detect + func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clientCreateOpts) (err error) { // Always leave a full provision trace on disk, even on a quiet/headless run // (RFC-0001 §8.5). On any failure, point at the (idempotent) resume command @@ -184,9 +189,17 @@ func runClientCreate(ctx context.Context, p *ui.Printer, pr prompter, opts clien if pr == nil { return errMissingFlag("--location") } - // Never silent-empty: the prompt requires a non-empty zone. (Cloud / - // GeoIP auto-detect of a suggested default is a fast-follow.) - if location, err = pr.Input("Location zone (e.g. DE)", "physical zone, for the carbon footprint", "", validateNonEmpty); err != nil { + // Auto-detect a suggested zone (cloud metadata → IP geolocation) and + // pre-fill it as the prompt default; the user confirms with Enter or + // overrides. Never silent (it's a prompt), never empty (validateNonEmpty). + suggested := "" + help := "electricityMaps zone for the carbon footprint (e.g. DE)" + if z := detectZone(ctx); z != nil { + suggested = z.Code + help = fmt.Sprintf("detected %s via %s (%s confidence) — Enter to accept, or type your zone", + z.Code, z.Source, z.Confidence) + } + if location, err = pr.Input("Location zone (e.g. DE)", help, suggested, validateNonEmpty); err != nil { return mapClientErr(err) } } diff --git a/internal/cli/client_test.go b/internal/cli/client_test.go index 5222510e..fb3fa8af 100644 --- a/internal/cli/client_test.go +++ b/internal/cli/client_test.go @@ -16,6 +16,7 @@ import ( "github.com/tracebloc/cli/internal/api" "github.com/tracebloc/cli/internal/cluster" "github.com/tracebloc/cli/internal/config" + "github.com/tracebloc/cli/internal/geo" "github.com/tracebloc/cli/internal/ui" ) @@ -75,6 +76,15 @@ func stubInClusterClient(t *testing.T, lc *cluster.InClusterClient, err error) { t.Cleanup(func() { readInClusterClient = orig }) } +// stubDetect replaces the location auto-detector so command tests stay hermetic +// (no real cloud-metadata / GeoIP probes). +func stubDetect(t *testing.T, z *geo.Zone) { + t.Helper() + orig := detectZone + detectZone = func(context.Context) *geo.Zone { return z } + t.Cleanup(func() { detectZone = orig }) +} + func TestClientCreate_Success(t *testing.T) { var body api.CreateClientRequest withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { @@ -279,6 +289,7 @@ func TestClientCreate_Interactive(t *testing.T) { _, _ = w.Write([]byte(`{"id":9,"first_name":"Lab One","username":"u-9","namespace":"lab-one","location":"DE"}`)) } }) + stubDetect(t, nil) // hermetic: no real cloud/GeoIP probes confirmYes := true pr := &fakePrompter{ answers: map[string]string{ @@ -310,6 +321,7 @@ func TestClientCreate_InteractiveCancel(t *testing.T) { } _, _ = w.Write([]byte(`[]`)) }) + stubDetect(t, nil) confirmNo := false pr := &fakePrompter{ answers: map[string]string{ @@ -678,3 +690,31 @@ func TestClientCreate_ReRunReviewShowsAdoptedNamespace(t *testing.T) { t.Errorf("review showed a bumped namespace — the cluster's own client wasn't excluded from collision detection:\n%s", out.String()) } } + +func TestClientCreate_AcceptsDetectedZone(t *testing.T) { + var body api.CreateClientRequest + withClientBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/edge-device/": + _, _ = w.Write([]byte(`[]`)) + case r.Method == http.MethodPost && r.URL.Path == "/edge-device/": + _ = json.NewDecoder(r.Body).Decode(&body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":3,"first_name":"Edge","username":"u-3","namespace":"edge","location":"FR"}`)) + } + }) + // Detector suggests FR; the user accepts it — no scripted answer for the + // location prompt, so the fake returns the pre-filled default. + stubDetect(t, &geo.Zone{Code: "FR", Source: "aws", Confidence: geo.High}) + confirmYes := true + pr := &fakePrompter{ + answers: map[string]string{"Client name": "Edge"}, + confirm: &confirmYes, + } + if err := runClientCreate(context.Background(), ui.New(&bytes.Buffer{}), pr, clientCreateOpts{}); err != nil { + t.Fatalf("create: %v", err) + } + if body.Location != "FR" { + t.Errorf("location = %q, want FR (detected zone accepted as the default)", body.Location) + } +} diff --git a/internal/geo/geo.go b/internal/geo/geo.go new file mode 100644 index 00000000..6e341b95 --- /dev/null +++ b/internal/geo/geo.go @@ -0,0 +1,198 @@ +// Package geo best-effort detects the host's electricityMaps zone (backend +// ZONE_CHOICES) to pre-fill `client create`'s location prompt: cloud instance +// metadata first (high confidence — the VM reports its own region), then IP +// geolocation (low confidence — flagged). The result is only ever a SUGGESTED +// default the user confirms or overrides; detection failing just means an empty +// default (RFC-0001 location auto-detect, cli#84). +package geo + +import ( + "context" + "io" + "net/http" + "strings" + "time" +) + +// Confidence levels for a detected zone. +const ( + High = "high" // cloud instance metadata — the host runs in this region + Low = "low" // IP geolocation — can be wrong behind VPN / proxy / egress NAT +) + +// Zone is a best-effort location guess. Code is an ISO 3166-1 alpha-2 country +// (always a valid top-level electricityMaps zone); Source names how it was found. +type Zone struct { + Code string + Source string + Confidence string +} + +// Metadata / GeoIP endpoints — package vars so tests can point them at httptest. +var ( + awsIMDSBase = "http://169.254.169.254" + gcpMetaBase = "http://metadata.google.internal" + azureIMDSBase = "http://169.254.169.254" + geoIPURL = "https://www.cloudflare.com/cdn-cgi/trace" +) + +const ( + cloudProbeTimeout = 1500 * time.Millisecond + geoIPTimeout = 3 * time.Second +) + +var ( + // Metadata endpoints are link-local — never via a proxy, and fail fast. + metadataClient = &http.Client{Transport: &http.Transport{Proxy: nil}} + // GeoIP is a public host — honor the corporate proxy like the API client. + geoIPClient = &http.Client{Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}} +) + +// Detect returns a best-effort zone, or nil if nothing could be determined +// (offline, egress-restricted, or bare metal with no usable IP geolocation). It +// never blocks long: the cloud probes share one short deadline and run +// concurrently; GeoIP is a single call only reached when the host isn't a +// recognized cloud region. +func Detect(ctx context.Context) *Zone { + if region, provider := probeCloud(ctx); region != "" { + if cc, ok := regionCountry(region); ok { + return &Zone{Code: cc, Source: provider, Confidence: High} + } + // A cloud host whose region isn't in the map — fall through to GeoIP for + // a VALID zone rather than suggest an unknown string the backend rejects. + } + if cc := probeGeoIP(ctx); cc != "" { + return &Zone{Code: cc, Source: "geoip", Confidence: Low} + } + return nil +} + +// probeCloud runs the three cloud probes concurrently under one deadline and +// returns the first that reports a region (so a real cloud host answers in one +// round-trip instead of waiting through the others' timeouts). +func probeCloud(ctx context.Context) (region, provider string) { + ctx, cancel := context.WithTimeout(ctx, cloudProbeTimeout) + defer cancel() + type res struct{ region, provider string } + // Snapshot the endpoint bases synchronously, before spawning the goroutines, + // so each probe reads a captured local — never the package var. We return on + // the first winner and leave the losers running to their deadline; if they + // read the globals directly, a test's t.Cleanup (which restores those vars) + // races the still-running goroutines (go test -race). + awsBase, gcpBase, azBase := awsIMDSBase, gcpMetaBase, azureIMDSBase + probes := []struct { + name string + fn func(context.Context) string + }{ + {"aws", func(c context.Context) string { return detectAWS(c, awsBase) }}, + {"gcp", func(c context.Context) string { return detectGCP(c, gcpBase) }}, + {"azure", func(c context.Context) string { return detectAzure(c, azBase) }}, + } + ch := make(chan res, len(probes)) + for _, p := range probes { + p := p + go func() { ch <- res{p.fn(ctx), p.name} }() + } + for range probes { + if r := <-ch; r.region != "" { + return r.region, r.provider + } + } + return "", "" +} + +// detectAWS reads the region from EC2 IMDS, preferring IMDSv2 (token) and +// falling back to IMDSv1 (no token) if the token PUT is refused. +func detectAWS(ctx context.Context, base string) string { + var token string + if req, err := http.NewRequestWithContext(ctx, http.MethodPut, base+"/latest/api/token", nil); err == nil { + req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "60") + if t, ok := doText(metadataClient, req); ok { + token = t + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/latest/meta-data/placement/region", nil) + if err != nil { + return "" + } + if token != "" { + req.Header.Set("X-aws-ec2-metadata-token", token) + } + region, _ := doText(metadataClient, req) + return region +} + +// detectGCP reads the instance zone and trims the trailing zone letter to a +// region ("projects/N/zones/europe-west3-c" → "europe-west3"). +func detectGCP(ctx context.Context, base string) string { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/computeMetadata/v1/instance/zone", nil) + if err != nil { + return "" + } + req.Header.Set("Metadata-Flavor", "Google") + zone, ok := doText(metadataClient, req) + if !ok || zone == "" { + return "" + } + if i := strings.LastIndex(zone, "/"); i >= 0 { + zone = zone[i+1:] + } + if i := strings.LastIndex(zone, "-"); i >= 0 { + zone = zone[:i] + } + return zone +} + +// detectAzure reads the compute location from Azure IMDS (already a region-like +// string, e.g. "germanywestcentral"). +func detectAzure(ctx context.Context, base string) string { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + base+"/metadata/instance/compute/location?api-version=2021-02-01&format=text", nil) + if err != nil { + return "" + } + req.Header.Set("Metadata", "true") + loc, _ := doText(metadataClient, req) + return loc +} + +// probeGeoIP reads the ISO country from Cloudflare's trace endpoint (the `loc=` +// line) — HTTPS, no API key, returns a 2-letter country code. +func probeGeoIP(ctx context.Context) string { + ctx, cancel := context.WithTimeout(ctx, geoIPTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, geoIPURL, nil) + if err != nil { + return "" + } + body, ok := doText(geoIPClient, req) + if !ok { + return "" + } + for _, line := range strings.Split(body, "\n") { + if cc, found := strings.CutPrefix(line, "loc="); found { + cc = strings.TrimSpace(cc) + if len(cc) == 2 { + return strings.ToUpper(cc) + } + } + } + return "" +} + +// doText runs req and returns the trimmed body on a 2xx, else ("", false). +func doText(client *http.Client, req *http.Request) (string, bool) { + resp, err := client.Do(req) + if err != nil { + return "", false + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", false + } + b, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return "", false + } + return strings.TrimSpace(string(b)), true +} diff --git a/internal/geo/geo_test.go b/internal/geo/geo_test.go new file mode 100644 index 00000000..a91c4eff --- /dev/null +++ b/internal/geo/geo_test.go @@ -0,0 +1,150 @@ +package geo + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// setEndpoints points the metadata / GeoIP endpoints at test servers. +func setEndpoints(t *testing.T, aws, gcp, azure, geoip string) { + t.Helper() + oa, og, oz, ogi := awsIMDSBase, gcpMetaBase, azureIMDSBase, geoIPURL + awsIMDSBase, gcpMetaBase, azureIMDSBase, geoIPURL = aws, gcp, azure, geoip + t.Cleanup(func() { awsIMDSBase, gcpMetaBase, azureIMDSBase, geoIPURL = oa, og, oz, ogi }) +} + +// notFoundServer is a stand-in for an absent provider (every probe 404s fast). +func notFoundServer(t *testing.T) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +func TestDetect_AWS(t *testing.T) { + aws := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPut && r.URL.Path == "/latest/api/token": + _, _ = w.Write([]byte("tok-123")) + case r.Method == http.MethodGet && r.URL.Path == "/latest/meta-data/placement/region": + if r.Header.Get("X-aws-ec2-metadata-token") != "tok-123" { + w.WriteHeader(http.StatusUnauthorized) // enforce the IMDSv2 token + return + } + _, _ = w.Write([]byte("eu-central-1")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(aws.Close) + nf := notFoundServer(t) + setEndpoints(t, aws.URL, nf, nf, nf) + + if z := Detect(context.Background()); z == nil || z.Code != "DE" || z.Source != "aws" || z.Confidence != High { + t.Fatalf("got %+v, want DE/aws/high", z) + } +} + +func TestDetect_GCP(t *testing.T) { + gcp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/computeMetadata/v1/instance/zone" && r.Header.Get("Metadata-Flavor") == "Google" { + _, _ = w.Write([]byte("projects/123/zones/europe-west3-c")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(gcp.Close) + nf := notFoundServer(t) + setEndpoints(t, nf, gcp.URL, nf, nf) + + if z := Detect(context.Background()); z == nil || z.Code != "DE" || z.Source != "gcp" || z.Confidence != High { + t.Fatalf("got %+v, want DE/gcp/high", z) + } +} + +func TestDetect_Azure(t *testing.T) { + az := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/metadata/instance/compute/location" && r.Header.Get("Metadata") == "true" { + _, _ = w.Write([]byte("germanywestcentral")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(az.Close) + nf := notFoundServer(t) + setEndpoints(t, nf, nf, az.URL, nf) + + if z := Detect(context.Background()); z == nil || z.Code != "DE" || z.Source != "azure" || z.Confidence != High { + t.Fatalf("got %+v, want DE/azure/high", z) + } +} + +func TestDetect_GeoIPFallback(t *testing.T) { + geoip := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("fl=1f\nip=1.2.3.4\nloc=FR\ncolo=CDG\n")) + })) + t.Cleanup(geoip.Close) + nf := notFoundServer(t) + setEndpoints(t, nf, nf, nf, geoip.URL) + + if z := Detect(context.Background()); z == nil || z.Code != "FR" || z.Source != "geoip" || z.Confidence != Low { + t.Fatalf("got %+v, want FR/geoip/low", z) + } +} + +func TestDetect_UnmappedRegionFallsBackToGeoIP(t *testing.T) { + // A cloud region we don't map must NOT be suggested verbatim (the backend + // would reject it) — Detect falls through to GeoIP for a valid zone. + aws := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/latest/api/token": + _, _ = w.Write([]byte("t")) + case "/latest/meta-data/placement/region": + _, _ = w.Write([]byte("antarctica-south-1")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(aws.Close) + geoip := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("loc=US\n")) + })) + t.Cleanup(geoip.Close) + nf := notFoundServer(t) + setEndpoints(t, aws.URL, nf, nf, geoip.URL) + + if z := Detect(context.Background()); z == nil || z.Code != "US" || z.Source != "geoip" { + t.Fatalf("got %+v, want US/geoip (unmapped region → GeoIP)", z) + } +} + +func TestDetect_Nothing(t *testing.T) { + nf := notFoundServer(t) + setEndpoints(t, nf, nf, nf, nf) + if z := Detect(context.Background()); z != nil { + t.Fatalf("got %+v, want nil", z) + } +} + +func TestRegionCountry(t *testing.T) { + cases := map[string]string{ + "eu-central-1": "DE", // AWS + "europe-west3": "DE", // GCP + "germanywestcentral": "DE", // Azure + "us-east-1": "US", + "ap-southeast-1": "SG", + "EU-WEST-2": "GB", // case-insensitive + } + for region, want := range cases { + if got, ok := regionCountry(region); !ok || got != want { + t.Errorf("regionCountry(%q) = %q,%v; want %q,true", region, got, ok, want) + } + } + if _, ok := regionCountry("mars-north-1"); ok { + t.Error("unmapped region should return ok=false") + } +} diff --git a/internal/geo/regions.go b/internal/geo/regions.go new file mode 100644 index 00000000..e22588d3 --- /dev/null +++ b/internal/geo/regions.go @@ -0,0 +1,67 @@ +package geo + +import "strings" + +// regionCountry maps a cloud region / location string to its ISO 3166-1 alpha-2 +// country — always a valid top-level electricityMaps zone (backend ZONE_CHOICES). +// It covers the common AWS / GCP / Azure regions; an unmapped region falls +// through to IP geolocation in Detect, so this need not be exhaustive — extend +// as new regions appear. Country = where the region's datacenters physically sit. +func regionCountry(region string) (string, bool) { + cc, ok := regionToCountry[strings.ToLower(strings.TrimSpace(region))] + return cc, ok +} + +var regionToCountry = map[string]string{ + // ── AWS ── + "us-east-1": "US", "us-east-2": "US", "us-west-1": "US", "us-west-2": "US", + "ca-central-1": "CA", "ca-west-1": "CA", + "eu-west-1": "IE", "eu-west-2": "GB", "eu-west-3": "FR", + "eu-central-1": "DE", "eu-central-2": "CH", + "eu-north-1": "SE", "eu-south-1": "IT", "eu-south-2": "ES", + "ap-south-1": "IN", "ap-south-2": "IN", + "ap-southeast-1": "SG", "ap-southeast-2": "AU", "ap-southeast-3": "ID", "ap-southeast-4": "AU", + "ap-northeast-1": "JP", "ap-northeast-2": "KR", "ap-northeast-3": "JP", + "ap-east-1": "HK", + "sa-east-1": "BR", + "me-south-1": "BH", "me-central-1": "AE", + "af-south-1": "ZA", + "il-central-1": "IL", + + // ── GCP ── + "us-central1": "US", "us-east1": "US", "us-east4": "US", "us-east5": "US", + "us-west1": "US", "us-west2": "US", "us-west3": "US", "us-west4": "US", "us-south1": "US", + "northamerica-northeast1": "CA", "northamerica-northeast2": "CA", + "southamerica-east1": "BR", "southamerica-west1": "CL", + "europe-west1": "BE", "europe-west2": "GB", "europe-west3": "DE", "europe-west4": "NL", + "europe-west6": "CH", "europe-west8": "IT", "europe-west9": "FR", "europe-west10": "DE", "europe-west12": "IT", + "europe-central2": "PL", "europe-north1": "FI", "europe-southwest1": "ES", + "asia-east1": "TW", "asia-east2": "HK", + "asia-northeast1": "JP", "asia-northeast2": "JP", "asia-northeast3": "KR", + "asia-south1": "IN", "asia-south2": "IN", + "asia-southeast1": "SG", "asia-southeast2": "ID", + "australia-southeast1": "AU", "australia-southeast2": "AU", + "me-west1": "IL", "me-central1": "QA", "me-central2": "SA", + + // ── Azure ── + "eastus": "US", "eastus2": "US", "centralus": "US", "northcentralus": "US", + "southcentralus": "US", "westus": "US", "westus2": "US", "westus3": "US", "westcentralus": "US", + "canadacentral": "CA", "canadaeast": "CA", + "brazilsouth": "BR", "brazilsoutheast": "BR", + "northeurope": "IE", "westeurope": "NL", + "uksouth": "GB", "ukwest": "GB", + "francecentral": "FR", "francesouth": "FR", + "germanywestcentral": "DE", "germanynorth": "DE", + "switzerlandnorth": "CH", "switzerlandwest": "CH", + "norwayeast": "NO", "norwaywest": "NO", + "swedencentral": "SE", "polandcentral": "PL", "italynorth": "IT", "spaincentral": "ES", + "eastasia": "HK", "southeastasia": "SG", + "japaneast": "JP", "japanwest": "JP", + "koreacentral": "KR", "koreasouth": "KR", + "centralindia": "IN", "southindia": "IN", "westindia": "IN", + "australiaeast": "AU", "australiasoutheast": "AU", "australiacentral": "AU", + "uaenorth": "AE", "uaecentral": "AE", + "qatarcentral": "QA", + "southafricanorth": "ZA", + "israelcentral": "IL", +}