Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions internal/cli/client.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)
}
}
Expand Down
40 changes: 40 additions & 0 deletions internal/cli/client_test.go
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
)

Expand DownExpand Up@@ -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) {
Expand DownExpand Up@@ -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{
Expand DownExpand Up@@ -310,6 +321,7 @@ func TestClientCreate_InteractiveCancel(t *testing.T) {
}
_, _ = w.Write([]byte(`[]`))
})
stubDetect(t, nil)
confirmNo := false
pr := &fakePrompter{
answers: map[string]string{
Expand DownExpand Up@@ -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)
}
}
198 changes: 198 additions & 0 deletions internal/geo/geo.go
Original file line numberDiff line numberDiff line change
@@ -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
}
Loading
Loading