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
129 changes: 129 additions & 0 deletions internal/cli/dns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package cli

import (
"errors"
"fmt"
"io/fs"

"github.com/spf13/cobra"

"github.com/open-source-cloud/devstack/internal/dns"
"github.com/open-source-cloud/devstack/internal/proxy"
)

// newDnsCmd wires `dns setup|status|remove` — the marker-fenced /etc/hosts block
// for <service>.<project>.localhost (spec 05). Hostnames come from the proxy
// route table (network.proxy.engine: caddy). Writing /etc/hosts needs root; the
// command surfaces a clear sudo remediation.
func newDnsCmd(g *GlobalOpts) *cobra.Command {
cmd := &cobra.Command{
Use: "dns",
Short: "Manage the /etc/hosts entries for *.localhost service URLs",
}
cmd.AddCommand(newDnsSetupCmd(g), newDnsStatusCmd(g), newDnsRemoveCmd(g))
return cmd
}

// dnsHosts returns the local hostnames the workspace's proxy routes resolve to.
func dnsHosts() ([]string, error) {
m, err := loadWorkspace()
if err != nil {
return nil, err
}
routes := proxy.BuildRoutes(m)
hosts := make([]string, 0, len(routes))
for _, r := range routes {
hosts = append(hosts, r.Host)
}
return hosts, nil
}

func newDnsSetupCmd(g *GlobalOpts) *cobra.Command {
return &cobra.Command{
Use: "setup",
Short: "Write the devstack-managed /etc/hosts block (needs sudo)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
hosts, err := dnsHosts()
if err != nil {
return err
}
if len(hosts) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "no proxy routes (set network.proxy.engine: caddy and add service ports)")
return nil
}
changed, err := dns.Apply(dns.DefaultHostsPath, hosts)
if err != nil {
return hostsPermError(err)
}
if g.JSON {
return writeJSON(cmd, map[string]any{"hosts": hosts, "changed": changed})
}
w := cmd.OutOrStdout()
if changed {
fmt.Fprintf(w, "updated %s with %d host(s)\n", dns.DefaultHostsPath, len(hosts))
} else {
fmt.Fprintln(w, "/etc/hosts already up to date")
}
return nil
},
}
}

func newDnsStatusCmd(g *GlobalOpts) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show which *.localhost entries are present/missing in /etc/hosts",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
hosts, err := dnsHosts()
if err != nil {
return err
}
present, err := dns.Present(dns.DefaultHostsPath)
if err != nil {
return err
}
missing, err := dns.Missing(dns.DefaultHostsPath, hosts)
if err != nil {
return err
}
if g.JSON {
return writeJSON(cmd, map[string]any{"present": present, "missing": missing})
}
w := cmd.OutOrStdout()
fmt.Fprintf(w, "present: %v\n", present)
if len(missing) > 0 {
fmt.Fprintf(w, "missing: %v (run `devstack dns setup`)\n", missing)
}
return nil
},
}
}

func newDnsRemoveCmd(g *GlobalOpts) *cobra.Command {
return &cobra.Command{
Use: "remove",
Short: "Remove the devstack-managed /etc/hosts block (needs sudo)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
changed, err := dns.Remove(dns.DefaultHostsPath)
if err != nil {
return hostsPermError(err)
}
if g.JSON {
return writeJSON(cmd, map[string]any{"changed": changed})
}
fmt.Fprintf(cmd.OutOrStdout(), "removed devstack /etc/hosts block: changed=%v\n", changed)
return nil
},
}
}

// hostsPermError maps a permission failure to the sudo remediation.
func hostsPermError(err error) error {
if errors.Is(err, fs.ErrPermission) {
return fmt.Errorf("%w\nhint: editing %s needs root — re-run with `sudo`", err, dns.DefaultHostsPath)
}
return err
}
40 changes: 40 additions & 0 deletions internal/cli/dns_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package cli

import (
"path/filepath"
"strings"
"testing"
)

func TestDnsRegistered(t *testing.T) {
root := NewRootCmd(Options{})
for _, sub := range []string{"setup", "status", "remove"} {
c, _, err := root.Find([]string{"dns", sub})
if err != nil || c.Name() != sub || c.RunE == nil {
t.Errorf("dns %s not registered as a real command: %v", sub, err)
}
}
}

func TestDnsStatusReportsRoutes(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "workspace.yaml"),
"apiVersion: devstack/v1\nkind: Workspace\nname: shop\nnetwork: { proxy: { engine: caddy } }\nprojects:\n - { name: api, path: api }\n")
mustWrite(t, filepath.Join(dir, "api", "devstack.yaml"),
"apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n web: { template: node.vite, ports: { http: 8080 } }\n")
t.Chdir(dir)

var out strings.Builder
root := NewRootCmd(Options{})
root.SetArgs([]string{"dns", "status"})
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("dns status: %v\n%s", err, out.String())
}
// The proxy route host should appear (present or missing — /etc/hosts is read
// only here and won't contain it, so it lands in missing).
if !strings.Contains(out.String(), "web.api.localhost") {
t.Errorf("dns status should mention the route host:\n%s", out.String())
}
}
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func NewRootCmd(opts Options) *cobra.Command {
newUpCmd(g),
newDownCmd(g),
newStatusCmd(g),
newDnsCmd(g),
newDoctorCmd(g),
newConfigCmd(g),
newGenerateCmd(g),
Expand Down
3 changes: 0 additions & 3 deletions internal/cli/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) {
stub("uninstall", "Remove the local root CA from trust stores", "M5"),
stub("status", "Show local CA trust status", "M5"),
),
stub("dns", "Local DNS helpers (*.localhost)", "M5",
stub("setup", "Configure local DNS resolution", "M5"),
),
stub("tunnel", "Optional public tunnel via cloudflared", "M5",
stub("login", "Authenticate cloudflared", "M5"),
stub("create", "Create a named tunnel", "M5"),
Expand Down
Loading
Loading