-
Notifications
You must be signed in to change notification settings - Fork 4
local connect: add opt-in --local-dns for resolving cluster names via a local DNS resolver #342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
07d82f7
f74f615
d8ae0df
b3b7cd0
bd89789
77f5963
79ec064
44b9102
0e6c121
7d75d85
ce126a7
9429803
04f6c8b
49aac83
9a4d687
48a594e
587761e
f2ebe46
ce4f10d
c4e43a4
99f6e9d
3677834
ac86f05
c3a8932
5a5e17c
df4cc3d
be91305
b1dc989
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| package local | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "io" | ||
|
|
||
| "github.com/signadot/cli/internal/config" | ||
| sbmgr "github.com/signadot/cli/internal/locald/sandboxmanager" | ||
| "github.com/signadot/cli/internal/print" | ||
| "github.com/signadot/cli/internal/sdtab" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| func newHosts(localConfig *config.Local) *cobra.Command { | ||
| cfg := &config.LocalHosts{Local: localConfig} | ||
|
|
||
| cmd := &cobra.Command{ | ||
| Use: "hosts", | ||
| Short: "List the cluster hosts resolvable from the local machine and their IP addresses", | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| return runHosts(cfg, cmd.OutOrStdout(), args) | ||
| }, | ||
| } | ||
| cfg.AddFlags(cmd) | ||
|
|
||
| return cmd | ||
| } | ||
|
|
||
| // printableHost is the JSON/YAML shape of a single resolvable host. Each host | ||
| // carries exactly one address: the root controller assigns a single virtual | ||
| // address per name and reports it as HostEntry.Ip (IPv4-preferred). | ||
| type printableHost struct { | ||
| Name string `json:"name"` | ||
| IP string `json:"ip"` | ||
| } | ||
|
|
||
| func runHosts(cfg *config.LocalHosts, out io.Writer, args []string) error { | ||
| if err := cfg.InitLocalConfig(); err != nil { | ||
| return err | ||
| } | ||
| resp, err := sbmgr.GetHosts() | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| hosts := make([]printableHost, 0, len(resp.Entries)) | ||
| for _, e := range resp.Entries { | ||
| hosts = append(hosts, printableHost{Name: e.Name, IP: e.Ip}) | ||
| } | ||
|
|
||
| switch cfg.OutputFormat { | ||
| case config.OutputFormatDefault: | ||
| return printHosts(out, hosts) | ||
| case config.OutputFormatJSON: | ||
| return print.RawJSON(out, hosts) | ||
| case config.OutputFormatYAML: | ||
| return print.RawK8SYAML(out, hosts) | ||
| default: | ||
| return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) | ||
| } | ||
| } | ||
|
|
||
| type hostRow struct { | ||
| Name string `sdtab:"NAME"` | ||
| IP string `sdtab:"IP"` | ||
| } | ||
|
|
||
| // printHosts renders the default aligned NAME/IP table, consistent with the | ||
| // other list commands (cluster, routegroup, ...). The entries arrive already | ||
| // sorted by name from the root controller (see rootServer.GetHosts). | ||
| func printHosts(out io.Writer, hosts []printableHost) error { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Default output should use type hostRow struct {
Name string `sdtab:"NAME"`
IP string `sdtab:"IP"`
}
func printHosts(out io.Writer, hosts []printableHost) error {
t := sdtab.New[hostRow](out)
t.AddHeader()
for _, h := range hosts {
t.AddRow(hostRow{Name: h.Name, IP: h.IP})
}
return t.Flush()
}
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My reasoning for the standard output was that this command is different because it is likely to get many many more outputs than another list using sdtab. So, the headers basically are likely to be invisible, off screen in any case -- why force the acrobatics and extra machinery of json or header filtering on consumers? More generally, removing the headers is a basic UX concern regarding standard unix pipelining. But, since it seems this reasoning only subtly justifies deviating from the unusable-in-pipelines UX elsewhere, I'll add the sdtab. |
||
| t := sdtab.New[hostRow](out) | ||
| t.AddHeader() | ||
| for _, h := range hosts { | ||
| t.AddRow(hostRow{Name: h.Name, IP: h.IP}) | ||
| } | ||
| return t.Flush() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| package local | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| // TestPrintHosts checks the default table has a header row and one aligned | ||
| // NAME/IP row per host, in the order given (already name-sorted by the daemon). | ||
| func TestPrintHosts(t *testing.T) { | ||
| var b bytes.Buffer | ||
| err := printHosts(&b, []printableHost{ | ||
| {Name: "a.myns.svc.cluster.local", IP: "242.242.0.3"}, | ||
| {Name: "b.other.svc.cluster.local", IP: "242.242.0.4"}, | ||
| }) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n") | ||
| if len(lines) != 3 { | ||
| t.Fatalf("got %d lines, want 3 (header + 2 rows):\n%s", len(lines), b.String()) | ||
| } | ||
| if !strings.Contains(lines[0], "NAME") || !strings.Contains(lines[0], "IP") { | ||
| t.Errorf("header line missing NAME/IP: %q", lines[0]) | ||
| } | ||
| if !strings.HasPrefix(lines[1], "a.myns.svc.cluster.local") || !strings.Contains(lines[1], "242.242.0.3") { | ||
| t.Errorf("row 1 unexpected: %q", lines[1]) | ||
| } | ||
| if !strings.HasPrefix(lines[2], "b.other.svc.cluster.local") || !strings.Contains(lines[2], "242.242.0.4") { | ||
| t.Errorf("row 2 unexpected: %q", lines[2]) | ||
| } | ||
| } | ||
|
|
||
| // TestPrintHostsEmpty: no hosts still emits just the header (and no panic). | ||
| func TestPrintHostsEmpty(t *testing.T) { | ||
| var b bytes.Buffer | ||
| if err := printHosts(&b, nil); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| if !strings.Contains(b.String(), "NAME") { | ||
| t.Errorf("empty output missing header: %q", b.String()) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ func printRawStatus(cfg *config.LocalStatus, out io.Writer, printer func(out io. | |
| OperatorInfo any `json:"operatorInfo,omitempty"` | ||
| Localnet any `json:"localnet,omitempty"` | ||
| Hosts any `json:"hosts,omitempty"` | ||
| LocalDNS any `json:"localDNS,omitempty"` | ||
| Portforward any `json:"portforward,omitempty"` | ||
| ControlPlaneProxy any `json:"controlPlaneProxy,omitempty"` | ||
| SandboxesWatcher any `json:"sandboxesWatcher,omitempty"` | ||
|
|
@@ -45,6 +46,7 @@ func printRawStatus(cfg *config.LocalStatus, out io.Writer, printer func(out io. | |
| OperatorInfo: getRawOperatorInfo(cfg, status.OperatorInfo), | ||
| Localnet: getRawLocalnet(cfg, ciConfig, status.Localnet, statusMap), | ||
| Hosts: getRawHosts(cfg, ciConfig, status.Hosts, statusMap), | ||
| LocalDNS: getRawLocalDNS(cfg, ciConfig, status.LocalDns, statusMap), | ||
| Portforward: getRawPortforward(cfg, ciConfig, status.Portforward, statusMap), | ||
| ControlPlaneProxy: getRawControlPlaneProxy(cfg, ciConfig, status.ControlPlaneProxy, statusMap), | ||
| SandboxesWatcher: getRawWatcher(cfg, status.Watcher, statusMap), | ||
|
|
@@ -186,6 +188,13 @@ func getRawHosts(cfg *config.LocalStatus, ciConfig *config.ConnectInvocationConf | |
| if !ciConfig.WithRootManager { | ||
| return hosts | ||
| } | ||
| if ciConfig.EnableLocalDNS { | ||
| // In --local-dns mode /etc/hosts management is intentionally not | ||
| // running, so the root manager reports no hosts status. Omit the section | ||
| // (mirroring getRawLocalDNS when local DNS is disabled) rather than | ||
| // emitting a bogus {"healthy": false, "numHosts": 0}. | ||
| return nil | ||
| } | ||
|
|
||
| if cfg.Details { | ||
| // Details view | ||
|
|
@@ -219,6 +228,48 @@ func getRawHosts(cfg *config.LocalStatus, ciConfig *config.ConnectInvocationConf | |
| return result | ||
| } | ||
|
|
||
| func getRawLocalDNS(cfg *config.LocalStatus, ciConfig *config.ConnectInvocationConfig, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The inverse gating is missing on |
||
| ldns *commonapi.LocalDNSStatus, statusMap map[string]any) any { | ||
| if !ciConfig.WithRootManager || !ciConfig.EnableLocalDNS { | ||
| return nil | ||
| } | ||
|
|
||
| if cfg.Details { | ||
| // Details view | ||
| return statusMap["localDns"] | ||
| } | ||
|
|
||
| // Standard view | ||
| type PrintableLocalDNS struct { | ||
| Healthy bool `json:"healthy"` | ||
| RecordCount uint32 `json:"recordCount,omitempty"` | ||
| HostCount uint32 `json:"hostCount,omitempty"` | ||
| BindAddr string `json:"bindAddr,omitempty"` | ||
| Warning string `json:"warning,omitempty"` | ||
| LastErrorReason string `json:"lastErrorReason,omitempty"` | ||
| } | ||
|
|
||
| result := &PrintableLocalDNS{Healthy: false} | ||
| if ldns == nil || ldns.Health == nil { | ||
| return result | ||
| } | ||
| if ldns.Health.Healthy { | ||
| result = &PrintableLocalDNS{ | ||
| Healthy: true, | ||
| RecordCount: ldns.RecordCount, | ||
| HostCount: ldns.HostCount, | ||
| BindAddr: ldns.BindAddr, | ||
| Warning: ldns.Warning, | ||
| } | ||
| } else { | ||
| result = &PrintableLocalDNS{ | ||
| Healthy: false, | ||
| LastErrorReason: ldns.Health.LastErrorReason, | ||
| } | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| func getRawPortforward(cfg *config.LocalStatus, ciConfig *config.ConnectInvocationConfig, | ||
| portforward *commonapi.PortForwardStatus, statusMap map[string]any) any { | ||
| if ciConfig.ConnectionConfig.Type != connectcfg.PortForwardLinkType { | ||
|
|
@@ -437,7 +488,11 @@ func (p *statusPrinter) printSuccess() { | |
| } | ||
| if p.ciConfig.WithRootManager { | ||
| p.printLocalnetStatus() | ||
| p.printHostsStatus() | ||
| if p.ciConfig.EnableLocalDNS { | ||
| p.printLocalDNSStatus() | ||
| } else { | ||
| p.printHostsStatus() | ||
| } | ||
| } | ||
| p.printSandboxesWatcherStatus() | ||
| p.printSandboxStatus() | ||
|
|
@@ -505,6 +560,51 @@ func (p *statusPrinter) printHostsStatus() { | |
| p.printLine(p.out, 1, fmt.Sprintf("%d hosts accessible via /etc/hosts", p.status.Hosts.NumHosts), "*") | ||
| } | ||
|
|
||
| func (p *statusPrinter) printLocalDNSStatus() { | ||
| ldns := p.status.LocalDns | ||
| if ldns == nil || ldns.Health == nil { | ||
| p.printLine(p.out, 1, "local DNS resolver is not running", "*") | ||
| return | ||
| } | ||
| if ldns.Health.Healthy { | ||
| // RecordCount is the total resolvable DNS names, which includes the | ||
| // synthesized short forms (e.g. <svc>.<ns>, <svc>.<ns>.svc); HostCount is | ||
| // the distinct hosts `local hosts` lists. Show both so the larger name | ||
| // count doesn't read as a discrepancy against `local hosts` — but omit the | ||
| // host count when it's absent (0), which is what an older daemon that | ||
| // predates the host_count field reports (a new CLI vs old locald). | ||
| msg := fmt.Sprintf("%d names resolvable via local DNS (%s)", ldns.RecordCount, ldns.BindAddr) | ||
| if ldns.HostCount > 0 { | ||
| msg = fmt.Sprintf("%d names (%d hosts) resolvable via local DNS (%s)", | ||
| ldns.RecordCount, ldns.HostCount, ldns.BindAddr) | ||
| } | ||
| p.printLine(p.out, 1, msg, "*") | ||
| } else { | ||
| p.printLine(p.out, 1, fmt.Sprintf("local DNS resolver not healthy (%q)", | ||
| ldns.Health.LastErrorReason), "*") | ||
| } | ||
| if ldns.Warning != "" { | ||
| p.printLine(p.out, 2, "warning: "+ldns.Warning, p.red("!")) | ||
| } | ||
| if p.cfg.Details { | ||
| if ldns.Mode != "" { | ||
| p.printLine(p.out, 2, "mode: "+ldns.Mode, "*") | ||
| } | ||
| if len(ldns.Suffixes) > 0 { | ||
| p.printLine(p.out, 2, "Suffixes:", "*") | ||
| for _, s := range ldns.Suffixes { | ||
| p.printLine(p.out, 3, s, "-") | ||
| } | ||
| } | ||
| if len(ldns.Upstreams) > 0 { | ||
| p.printLine(p.out, 2, "Upstreams:", "*") | ||
| for _, u := range ldns.Upstreams { | ||
| p.printLine(p.out, 3, u, "-") | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func (p *statusPrinter) printSandboxesWatcherStatus() { | ||
| msg := "sandboxes watcher is not running" | ||
| if p.status.Watcher != nil && p.status.Watcher.Health != nil { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Schema forward-compat: this bakes a single
ipinto the-o json/-o yamloutput, while the proto already carriesrepeated ipsand this PR lays IPv6 dual-stack groundwork. When AAAA support lands, eitheripgets a breaking rename toipsfor script consumers, or it stays and silently under-reports dual-stack hosts. Emitting"ips": [...]from day one avoids the breaking change; the human-readable view can keep showing one address.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will change the proto to be singleton ip. This doesn't affect ipv6/dual-stack forward compatibility and is simpler, following the table below:
the ipMap representation retains
[]ipas it is the backing for the resolver which needs to answer AAAA and A record requests.