Skip to content
Open
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM golang:1.25 AS build
FROM golang:1.26.9 AS build

ARG BUILD_VERSION=0.0.0-auto

Expand Down
28 changes: 28 additions & 0 deletions commands/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,33 @@ func AddKoolProxy(root *cobra.Command) {
return proxy.NewManager(sh, environment.NewEnvStorage()).Trust()
},
})
proxyCommand.AddCommand(newProxyAliasesCommand(environment.NewEnvStorage()))
root.AddCommand(proxyCommand)
}

func newProxyAliasesCommand(env environment.EnvStorage) *cobra.Command {
return &cobra.Command{
Use: "aliases",
Short: "List configured internal proxy aliases for the current project",
Long: "List configured internal Docker DNS aliases for the current project. This does not inspect running containers or contact Caddy.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
aliases, err := proxy.NewManager(shell.NewShell(), env).Aliases()
if err != nil {
return err
}
if len(aliases) == 0 {
cmd.Println("No proxy aliases configured.")
return nil
}
table := shell.NewTableWriter()
table.SetWriter(cmd.OutOrStdout())
table.AppendHeader("Project", "Service", "Network", "Alias")
for _, alias := range aliases {
table.AppendRow(alias.Project, alias.Service, alias.Network, alias.Name)
}
table.Render()
return nil
},
}
}
116 changes: 116 additions & 0 deletions commands/proxy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package commands

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/spf13/cobra"
)

func TestProxyAliasesCommand(t *testing.T) {
config := `proxy:
domain: app.localhost
network: shared
routes:
node:
ports: ["3001:3001"]
app:
ports: ["80:8080", "8081:8081"]
`
for _, workspace := range []bool{false, true} {
name := "source"
if workspace {
name = "workspace"
}
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "kool.yml"), []byte(config), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("PWD", dir)
t.Setenv("KOOL_NAME", "fallback")
t.Setenv("COMPOSE_PROJECT_NAME", "compose-fallback")
t.Setenv("KOOL_WORKSPACE_SOURCE_PROJECT", "example")
t.Setenv("KOOL_WORKSPACE_PROJECT", "example-workspace-task-a")
t.Setenv("KOOL_PROXY_HOST", "app.localhost")
t.Setenv("KOOL_WORKSPACE", "false")
project, alias := "example", "kool-2c16ea1198025827"
if workspace {
t.Setenv("KOOL_WORKSPACE", "true")
project, alias = "example-workspace-task-a", "kool-1c11a4b25a7f4f2e"
}
// Listing configured names must not require Docker or Caddy.
t.Setenv("PATH", t.TempDir())
root := &cobra.Command{Use: "kool"}
AddKoolProxy(root)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"proxy", "aliases"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
output := out.String()
for _, expected := range []string{"PROJECT", "SERVICE", "NETWORK", "ALIAS", project, "shared", "app", "node", alias} {
if !strings.Contains(output, expected) {
t.Errorf("expected %q in output:\n%s", expected, output)
}
}
if strings.Count(output, alias) != 1 {
t.Fatalf("multiple ports must not duplicate a service alias:\n%s", output)
}
if strings.Index(output, "app") > strings.Index(output, "node") {
t.Fatalf("expected services sorted by name:\n%s", output)
}
})
}
}

func TestProxyAliasesCommandEmptyAndInvalidConfig(t *testing.T) {
for _, test := range []struct {
name, config, want string
wantError bool
}{
{"missing config", "", "No proxy aliases configured.", false},
{"no proxy", "scripts: {}\n", "No proxy aliases configured.", false},
{"empty routes", "proxy:\n domain: app.localhost\n", "No proxy aliases configured.", false},
{"invalid config", "proxy:\n domain: app.localhost\n routes:\n app:\n ports: [invalid]\n", "listen:target", true},
} {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
if test.config != "" {
if err := os.WriteFile(filepath.Join(dir, "kool.yml"), []byte(test.config), 0644); err != nil {
t.Fatal(err)
}
}
t.Setenv("PWD", dir)
t.Setenv("PATH", t.TempDir())
root := &cobra.Command{Use: "kool"}
AddKoolProxy(root)
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"proxy", "aliases"})
err := root.Execute()
if test.wantError {
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("expected %q error, got %v", test.want, err)
}
} else if err != nil || !strings.Contains(out.String(), test.want) {
t.Fatalf("expected %q, got %q, error: %v", test.want, out.String(), err)
}
})
}
}

func TestProxyAliasesCommandRejectsArguments(t *testing.T) {
root := &cobra.Command{Use: "kool"}
AddKoolProxy(root)
root.SetArgs([]string{"proxy", "aliases", "app"})
if err := root.Execute(); err == nil {
t.Fatal("expected aliases to reject positional arguments")
}
}
2 changes: 1 addition & 1 deletion docs/01-Getting-Started/5-CI-Integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ If your CI runner already has Docker available on the host (e.g. GitHub Actions'

## What's in the image

- `kool` CLI — built with Go 1.25, statically linked
- `kool` CLI — built with Go 1.26.9, statically linked
- `docker` CLI
- `docker compose` plugin (Compose V2)
- `git` and `bash`
Expand Down
15 changes: 15 additions & 0 deletions docs/15-Snippets/Workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,21 @@ environment:

No Kool or proxy labels are required in `docker-compose.yml`.

### Debugging internal proxy aliases

Kool gives every proxied service a stable, 21-character Docker network alias:
`kool-` followed by a hash of the full Compose project and service names. This
keeps upstream DNS names short even for long workspace or service names. Browser
hostnames and Compose service names are unchanged.

Run `kool proxy aliases` in the source project or workspace to see its configured
project, service, network, and alias mappings. The command does not require Docker
or Caddy and shows intended configuration, not live container state.

When upgrading from project/service-based aliases, recreate all proxied services
with `kool start` in each project/workspace so Docker and Caddy use the new names
together.

### Vite assets and HMR

Configure Vite to use the public origin passed by Compose:
Expand Down
33 changes: 32 additions & 1 deletion services/proxy/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ type Manager interface {
Remove([]string) error
RemoveProject(string, []string) error
Trust() error
Aliases() ([]Alias, error)
}

// Alias describes a configured proxy service's internal Docker DNS name.
type Alias struct {
Project string
Service string
Network string
Name string
}

type route struct {
Expand Down Expand Up @@ -300,6 +309,23 @@ func (m *DefaultManager) Trust() error {
}
}

// Aliases lists the current project's configured aliases without contacting Docker or Caddy.
func (m *DefaultManager) Aliases() ([]Alias, error) {
cfg, err := m.loadConfig()
if err != nil || cfg == nil {
return nil, err
}
var aliases []Alias
seen := make(map[string]bool)
for _, route := range cfg.Routes {
if !seen[route.Service] {
aliases = append(aliases, Alias{Project: m.project(), Service: route.Service, Network: cfg.Network, Name: m.alias(route.Service)})
seen[route.Service] = true
}
}
return aliases, nil
}

func (m *DefaultManager) loadConfig() (*config, error) {
parsed, err := parser.LoadKoolYaml(m.env.Get("PWD"))
if errors.Is(err, parser.ErrKoolYmlNotFound) {
Expand Down Expand Up @@ -1020,6 +1046,8 @@ func (m *DefaultManager) removeProjectRoutesUnlocked(services []string) error {
aliases := make(map[string]bool, len(services))
for _, service := range services {
aliases[m.alias(service)] = true
// Also allow stopping routes created before aliases were hashed.
aliases[m.project()+"-"+service] = true
}
return m.filterProjectRoutesUnlocked(func(route caddyRouteMetadata) bool {
if len(aliases) == 0 {
Expand Down Expand Up @@ -1648,7 +1676,10 @@ func (m *DefaultManager) request(method, url string, body []byte) (*http.Respons
}

func (m *DefaultManager) alias(service string) string {
return m.project() + "-" + service
// Separate the complete project/service pair so ambiguous concatenations
// cannot share an alias. A 64-bit digest keeps every label short and DNS-safe.
digest := sha256.Sum256([]byte(m.project() + "\x00" + service))
return fmt.Sprintf("kool-%x", digest[:8])
}

func (m *DefaultManager) routeID(route route) string {
Expand Down
Loading
Loading