3 Commits

Author SHA1 Message Date
82fce4129d flake: install shell completions for mcproxyctl
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:41:30 -07:00
5580bf74b0 Add VERSION variable and push target to Makefile
Extract VERSION variable (was inline). Add version tag to docker image.
Add push target that builds then pushes to MCR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:32:17 -07:00
28321e22f4 Make AddRoute idempotent (upsert instead of reject duplicates)
AddRoute now updates an existing route if one already exists for the
same (listener, hostname) pair, instead of returning AlreadyExists.
This makes repeated deploys idempotent — the MCP agent can register
routes on every deploy without needing to remove them first.

- DB: INSERT ... ON CONFLICT DO UPDATE (SQLite upsert)
- In-memory: overwrite existing route unconditionally
- gRPC: error code changed from AlreadyExists to Internal (for real DB errors)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:01:45 -07:00
8 changed files with 72 additions and 36 deletions

View File

@@ -1,6 +1,8 @@
.PHONY: build test vet lint proto proto-lint clean docker all devserver
.PHONY: build test vet lint proto proto-lint clean docker push all devserver
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty)"
MCR := mcr.svc.mcp.metacircular.net:8443
VERSION := $(shell git describe --tags --always --dirty)
LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(VERSION)"
mc-proxy:
go build $(LDFLAGS) -o mc-proxy ./cmd/mc-proxy
@@ -33,7 +35,10 @@ clean:
rm -f mc-proxy mcproxyctl
docker:
docker build --build-arg VERSION=$(shell git describe --tags --always --dirty) -t mc-proxy -f Dockerfile .
docker build --build-arg VERSION=$(VERSION) -t $(MCR)/mc-proxy:$(VERSION) -f Dockerfile .
push: docker
docker push $(MCR)/mc-proxy:$(VERSION)
devserver: mc-proxy
@mkdir -p srv

View File

@@ -27,6 +27,14 @@
"-w"
"-X main.version=${version}"
];
postInstall = ''
mkdir -p $out/share/zsh/site-functions
mkdir -p $out/share/bash-completion/completions
mkdir -p $out/share/fish/vendor_completions.d
$out/bin/mcproxyctl completion zsh > $out/share/zsh/site-functions/_mcproxyctl
$out/bin/mcproxyctl completion bash > $out/share/bash-completion/completions/mcproxyctl
$out/bin/mcproxyctl completion fish > $out/share/fish/vendor_completions.d/mcproxyctl.fish
'';
};
};
};

View File

@@ -250,15 +250,30 @@ func TestRouteL7Fields(t *testing.T) {
}
}
func TestRouteDuplicateHostname(t *testing.T) {
func TestRouteUpsert(t *testing.T) {
store := openTestDB(t)
listenerID, _ := store.CreateListener(":443", false, 0)
if _, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:8443", "l4", "", "", false, false); err != nil {
t.Fatalf("first create: %v", err)
}
if _, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:9443", "l4", "", "", false, false); err == nil {
t.Fatal("expected error for duplicate hostname on same listener")
// Same (listener, hostname) with different backend — should upsert, not error.
if _, err := store.CreateRoute(listenerID, "example.com", "127.0.0.1:9443", "l7", "/cert.pem", "/key.pem", false, false); err != nil {
t.Fatalf("upsert: %v", err)
}
routes, err := store.ListRoutes(listenerID)
if err != nil {
t.Fatalf("list routes: %v", err)
}
if len(routes) != 1 {
t.Fatalf("expected 1 route after upsert, got %d", len(routes))
}
if routes[0].Backend != "127.0.0.1:9443" {
t.Fatalf("expected updated backend, got %q", routes[0].Backend)
}
if routes[0].Mode != "l7" {
t.Fatalf("expected updated mode, got %q", routes[0].Mode)
}
}

View File

@@ -39,11 +39,20 @@ func (s *Store) ListRoutes(listenerID int64) ([]Route, error) {
return routes, rows.Err()
}
// CreateRoute inserts a route and returns its ID.
// CreateRoute inserts or updates a route and returns its ID. If a route
// for the same (listener_id, hostname) already exists, it is updated
// with the new values (upsert), making the operation idempotent.
func (s *Store) CreateRoute(listenerID int64, hostname, backend, mode, tlsCert, tlsKey string, backendTLS, sendProxyProtocol bool) (int64, error) {
result, err := s.db.Exec(
`INSERT INTO routes (listener_id, hostname, backend, mode, tls_cert, tls_key, backend_tls, send_proxy_protocol)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(listener_id, hostname) DO UPDATE SET
backend = excluded.backend,
mode = excluded.mode,
tls_cert = excluded.tls_cert,
tls_key = excluded.tls_key,
backend_tls = excluded.backend_tls,
send_proxy_protocol = excluded.send_proxy_protocol`,
listenerID, hostname, backend, mode, tlsCert, tlsKey, backendTLS, sendProxyProtocol,
)
if err != nil {

View File

@@ -144,10 +144,10 @@ func (a *AdminServer) AddRoute(_ context.Context, req *pb.AddRouteRequest) (*pb.
}
}
// Write-through: DB first, then memory.
// Write-through: DB first (upsert), then memory.
if _, err := a.store.CreateRoute(ls.ID, hostname, req.Route.Backend, mode,
req.Route.TlsCert, req.Route.TlsKey, req.Route.BackendTls, req.Route.SendProxyProtocol); err != nil {
return nil, status.Errorf(codes.AlreadyExists, "%v", err)
return nil, status.Errorf(codes.Internal, "%v", err)
}
info := server.RouteInfo{
@@ -158,10 +158,7 @@ func (a *AdminServer) AddRoute(_ context.Context, req *pb.AddRouteRequest) (*pb.
BackendTLS: req.Route.BackendTls,
SendProxyProtocol: req.Route.SendProxyProtocol,
}
if err := ls.AddRoute(hostname, info); err != nil {
// DB succeeded but memory failed (should not happen since DB enforces uniqueness).
a.logger.Error("inconsistency: DB write succeeded but memory update failed", "error", err)
}
ls.AddRoute(hostname, info)
a.logger.Info("route added", "listener", ls.Addr, "hostname", hostname, "backend", req.Route.Backend, "mode", mode)
return &pb.AddRouteResponse{}, nil

View File

@@ -229,19 +229,29 @@ func TestAddRoute(t *testing.T) {
}
}
func TestAddRouteDuplicate(t *testing.T) {
func TestAddRouteUpsert(t *testing.T) {
env := setup(t)
ctx := context.Background()
// a.test already exists from setup(). Adding again with a different
// backend should succeed (upsert) and update the route.
_, err := env.client.AddRoute(ctx, &pb.AddRouteRequest{
ListenerAddr: ":443",
Route: &pb.Route{Hostname: "a.test", Backend: "127.0.0.1:1111"},
})
if err == nil {
t.Fatal("expected error for duplicate route")
if err != nil {
t.Fatalf("upsert should succeed: %v", err)
}
// Verify the route was updated, not duplicated.
routes, err := env.client.ListRoutes(ctx, &pb.ListRoutesRequest{ListenerAddr: ":443"})
if err != nil {
t.Fatalf("list routes: %v", err)
}
for _, r := range routes.Routes {
if r.Hostname == "a.test" && r.Backend != "127.0.0.1:1111" {
t.Fatalf("expected updated backend 127.0.0.1:1111, got %q", r.Backend)
}
if s, ok := status.FromError(err); !ok || s.Code() != codes.AlreadyExists {
t.Fatalf("expected AlreadyExists, got %v", err)
}
}

View File

@@ -69,19 +69,15 @@ func (ls *ListenerState) Routes() map[string]RouteInfo {
return m
}
// AddRoute adds a route to the listener. Returns an error if the hostname
// already exists.
func (ls *ListenerState) AddRoute(hostname string, info RouteInfo) error {
// AddRoute adds or updates a route on the listener. If a route for the
// hostname already exists, it is replaced (upsert).
func (ls *ListenerState) AddRoute(hostname string, info RouteInfo) {
key := strings.ToLower(hostname)
ls.mu.Lock()
defer ls.mu.Unlock()
if _, ok := ls.routes[key]; ok {
return fmt.Errorf("route %q already exists", hostname)
}
ls.routes[key] = info
return nil
}
// RemoveRoute removes a route from the listener. Returns an error if the

View File

@@ -679,16 +679,12 @@ func TestListenerStateRoutes(t *testing.T) {
}
// AddRoute
if err := ls.AddRoute("b.test", l4Route("127.0.0.1:2")); err != nil {
t.Fatalf("AddRoute: %v", err)
}
ls.AddRoute("b.test", l4Route("127.0.0.1:2"))
// AddRoute duplicate
if err := ls.AddRoute("b.test", l4Route("127.0.0.1:3")); err == nil {
t.Fatal("expected error for duplicate route")
}
// AddRoute duplicate (upsert — updates in place)
ls.AddRoute("b.test", l4Route("127.0.0.1:3"))
// Routes snapshot
// Routes snapshot — still 2 routes, the duplicate replaced the first.
routes := ls.Routes()
if len(routes) != 2 {
t.Fatalf("expected 2 routes, got %d", len(routes))
@@ -708,8 +704,8 @@ func TestListenerStateRoutes(t *testing.T) {
if len(routes) != 1 {
t.Fatalf("expected 1 route, got %d", len(routes))
}
if routes["b.test"].Backend != "127.0.0.1:2" {
t.Fatalf("expected b.test → 127.0.0.1:2, got %q", routes["b.test"].Backend)
if routes["b.test"].Backend != "127.0.0.1:3" {
t.Fatalf("expected b.test → 127.0.0.1:3 (upserted), got %q", routes["b.test"].Backend)
}
}