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

import (
"fmt"
"text/tabwriter"

"github.com/spf13/cobra"

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

// newSharedExposeCmd wires `shared expose [services...]` — publish the shared
// engines on stable 127.0.0.1 host ports so GUI clients (DataGrip, a Redis/S3
// browser, the RabbitMQ UI) can connect. Opt-in and loopback-only; it never
// touches the deterministic generated compose (an up-time overlay). `--off`
// removes the publish and returns the stack to DNS-only.
func newSharedExposeCmd(g *GlobalOpts) *cobra.Command {
var off bool
cmd := &cobra.Command{
Use: "expose [services...]",
Short: "Publish shared services on stable 127.0.0.1 ports for local GUI clients",
Long: "Publish the shared engines on stable 127.0.0.1 host ports so host tools and GUI\n" +
"clients (DataGrip, TablePlus, a Redis/S3 browser, the RabbitMQ management UI)\n" +
"can reach them. Ports are ledger-allocated (stable across runs) and loopback-only.\n" +
"With no arguments, every exposable shared service is published; name services to\n" +
"scope it. `--off` removes the publish. The persist survives up/down.",
RunE: func(cmd *cobra.Command, args []string) error {
d, closeFn, err := buildUpDeps(cmd)
if err != nil {
return err
}
defer closeFn()
if off {
if err := orchestrate.UnexposeShared(cmd.Context(), d); err != nil {
return err
}
if g.JSON {
return writeJSON(cmd, map[string]any{"exposed": []any{}})
}
if !g.Quiet {
fmt.Fprintln(cmd.OutOrStdout(), "shared services are DNS-only again (host ports removed)")
}
return nil
}
ports, err := orchestrate.ExposeShared(cmd.Context(), d, args)
if err != nil {
return err
}
return renderExposed(cmd, g, ports)
},
}
cmd.Flags().BoolVar(&off, "off", false, "remove the host-port publish (back to DNS-only)")
return cmd
}

// newSharedPortsCmd wires `shared ports` — the read-only projection of the
// currently-published host ports + connection strings (lock-free snapshot).
func newSharedPortsCmd(g *GlobalOpts) *cobra.Command {
return &cobra.Command{
Use: "ports",
Short: "Show the published 127.0.0.1 host ports for shared services (and connection strings)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
d, closeFn, err := buildUpDeps(cmd)
if err != nil {
return err
}
defer closeFn()
ports, err := orchestrate.ExposedStatus(cmd.Context(), d)
if err != nil {
return err
}
if len(ports) == 0 && !g.JSON {
fmt.Fprintln(cmd.OutOrStdout(), "no shared services exposed — run `devstack shared expose`")
return nil
}
return renderExposed(cmd, g, ports)
},
}
}

// renderExposed prints the exposed-port projection as JSON or an aligned table.
func renderExposed(cmd *cobra.Command, g *GlobalOpts, ports []orchestrate.ExposedPort) error {
if g.JSON {
return writeJSON(cmd, map[string]any{"exposed": ports})
}
if g.Quiet {
for _, p := range ports {
if p.URL != "" {
fmt.Fprintln(cmd.OutOrStdout(), p.URL)
}
}
return nil
}
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "SERVICE\tPORT\tADDRESS\tCONNECT")
for _, p := range ports {
label := p.Alias
if !p.Primary {
label = p.Alias + " (" + p.Label + ")"
}
fmt.Fprintf(tw, "%s\t%s\t127.0.0.1:%d\t%s\n", label, p.Label, p.Port, p.URL)
}
if err := tw.Flush(); err != nil {
return err
}
// A one-line reminder that per-project Postgres DBs use their own dev creds.
for _, p := range ports {
if p.Engine == "postgres" && p.Primary {
fmt.Fprintf(cmd.OutOrStdout(),
"\nper-project database: postgres://<project>:<project>@127.0.0.1:%d/<project>?sslmode=disable\n", p.Port)
break
}
}
return nil
}
76 changes: 76 additions & 0 deletions internal/cli/expose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cli

import (
"bytes"
"strings"
"testing"

"github.com/spf13/cobra"

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

func exposeFixture() []orchestrate.ExposedPort {
return []orchestrate.ExposedPort{
{Instance: "postgres", Engine: "postgres", Alias: "shared-postgres", Label: "postgres", Host: "127.0.0.1", Port: 55432, Container: 5432, Primary: true, URL: "postgres://devstack:devstack@127.0.0.1:55432/postgres?sslmode=disable"},
{Instance: "minio", Engine: "minio", Alias: "shared-minio", Label: "console", Host: "127.0.0.1", Port: 59001, Container: 9001, Primary: false, URL: "http://127.0.0.1:59001"},
}
}

func TestRenderExposed_Table(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&buf)
if err := renderExposed(cmd, &GlobalOpts{}, exposeFixture()); err != nil {
t.Fatal(err)
}
out := buf.String()
for _, want := range []string{"shared-postgres", "55432", "shared-minio (console)", "59001", "per-project database:"} {
if !strings.Contains(out, want) {
t.Errorf("table missing %q:\n%s", want, out)
}
}
}

func TestRenderExposed_JSON(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&buf)
if err := renderExposed(cmd, &GlobalOpts{JSON: true}, exposeFixture()); err != nil {
t.Fatal(err)
}
out := buf.String()
if !strings.Contains(out, "\"exposed\"") || !strings.Contains(out, "\"port\": 55432") {
t.Errorf("json missing fields:\n%s", out)
}
}

func TestRenderExposed_Quiet(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&buf)
if err := renderExposed(cmd, &GlobalOpts{Quiet: true}, exposeFixture()); err != nil {
t.Fatal(err)
}
out := strings.TrimSpace(buf.String())
// Quiet emits only the connection URLs, one per line.
lines := strings.Split(out, "\n")
if len(lines) != 2 || !strings.HasPrefix(lines[0], "postgres://") {
t.Errorf("quiet should print only URLs, got:\n%s", out)
}
}

// TestSharedExposeCommandsRegistered guards that `shared expose` and
// `shared ports` are wired into the shared command tree.
func TestSharedExposeCommandsRegistered(t *testing.T) {
sh := newSharedCmd(&GlobalOpts{})
have := map[string]bool{}
for _, c := range sh.Commands() {
have[c.Name()] = true
}
for _, want := range []string{"expose", "ports", "status", "gc", "doctor"} {
if !have[want] {
t.Errorf("shared subcommand %q not registered", want)
}
}
}
2 changes: 2 additions & 0 deletions internal/cli/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ func newSharedCmd(g *GlobalOpts) *cobra.Command {
newSharedStatusCmd(g),
newSharedGcCmd(g),
newSharedDoctorCmd(g),
newSharedExposeCmd(g),
newSharedPortsCmd(g),
)
return cmd
}
Expand Down
Loading
Loading