diff --git a/CHANGELOG.md b/CHANGELOG.md index bad4584..6d7648d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to teploy-dash are recorded here. ## [Unreleased] +### Fixed +- **Git sources on self-hosted forges whose SSH and HTTP ports differ.** + Repository matching now compares host and path only, ignoring scheme + and port, so an app that pulls over `ssh://git@forge:2222/o/r` and a + forge credential or webhook source spelled `http://forge:3000/o/r` + name the same repository. Before, a push woke nothing ("no app is + connected to this repository yet") and the credential was refused as a + scope mismatch, leaving only the server's poll timer to deploy. The + forge API is still derived from the credential's HTTP(S) address, + never from an SSH port. Source IDs and webhook URLs are unchanged. + ## [0.1.18] - 2026-10-20 ### Added diff --git a/internal/server/gitsource.go b/internal/server/gitsource.go index 8c2d7ff..6fad39c 100644 --- a/internal/server/gitsource.go +++ b/internal/server/gitsource.go @@ -719,8 +719,7 @@ func (s *Server) gitAppMatch(ctx context.Context, serverName, appName, repositor if err != nil || !hasGit { return false, true } - canonical, err := source.CanonicalURL(configRepo) - if err != nil || canonical != repository { + if !source.SameRepository(configRepo, repository) { return false, true } return configBranch == branch, false diff --git a/internal/source/provider.go b/internal/source/provider.go index ae0754c..c76a42a 100644 --- a/internal/source/provider.go +++ b/internal/source/provider.go @@ -100,9 +100,7 @@ func (p *Providers) Policy(src *Source) (Provider, error) { log.Printf("[source] provider %q has a \"preview\" policy; pull request previews were removed and the key is ignored", src.CredentialRef) } v := entry.Provider - repo, e := CanonicalURL(v.Repository) - want, we := CanonicalURL(src.CloneURL) - if e != nil || we != nil || repo != want || v.Forge != src.Forge { + if !SameRepository(v.Repository, src.CloneURL) || v.Forge != src.Forge { return Provider{}, fmt.Errorf("source provider repository or forge scope mismatch") } u, e := url.Parse(v.Repository) @@ -426,8 +424,7 @@ func (p *Providers) CredentialRefFor(rawCloneURL string) (string, Provider, bool } sort.Strings(names) for _, name := range names { - repo, err := CanonicalURL(entries[name].Repository) - if err != nil || repo != want { + if !SameRepository(entries[name].Repository, want) { continue } return name, entries[name].Provider, true diff --git a/internal/source/provider_test.go b/internal/source/provider_test.go index b4ec315..451f673 100644 --- a/internal/source/provider_test.go +++ b/internal/source/provider_test.go @@ -530,6 +530,9 @@ func TestCredentialRefForFindsMatchingProvider(t *testing.T) { if ref, _, ok := providers.CredentialRefFor("https://forge.example/other/repo/"); !ok || ref != "beta" { t.Fatalf("lookup = %q %v", ref, ok) } + if ref, _, ok := providers.CredentialRefFor("ssh://git@forge.example:2222/other/repo.git"); !ok || ref != "beta" { + t.Fatalf("ssh spelling on another port missed the HTTPS provider: %q %v", ref, ok) + } if _, _, ok := providers.CredentialRefFor("https://github.com/nothing/repo"); ok { t.Fatal("unrelated repository matched a provider") } @@ -541,3 +544,22 @@ func TestCredentialRefForFindsMatchingProvider(t *testing.T) { t.Fatal("damaged provider file matched") } } + +func TestProviderPolicyAcceptsSSHSourceOnAnotherPort(t *testing.T) { + path := filepath.Join(t.TempDir(), "providers.json") + os.WriteFile(path, []byte(`{"forge":{"forge":"forgejo","repository":"http://forge.lan:49152/tyler/app","allow_private":true,"token_env":"TOK"}}`), 0600) + providers := &Providers{Path: path} + src := &Source{Forge: ForgeForgejo, CloneURL: "https://forge.lan:2222/tyler/app", CredentialRef: "forge"} + policy, err := providers.Policy(src) + if err != nil { + t.Fatal(err) + } + // The API is reached on the HTTP port, never the SSH one. + if policy.APIURL != "http://forge.lan:49152/api/v1" { + t.Fatalf("api url = %q", policy.APIURL) + } + src.CloneURL = "https://forge.lan:2222/tyler/other" + if _, err := providers.Policy(src); err == nil { + t.Fatal("credential applied to another repository") + } +} diff --git a/internal/source/routing.go b/internal/source/routing.go index 33fc645..eae4533 100644 --- a/internal/source/routing.go +++ b/internal/source/routing.go @@ -121,19 +121,18 @@ func (r *Routing) Unlink(server, app string) error { return r.persist() } -// RoutesFor returns the apps cached against one repository (canonical URL -// match), oldest link first so relays wake the longest-connected app +// RoutesFor returns the apps cached against one repository (host and path, +// any transport), oldest link first so relays wake the longest-connected app // first when several share a repository. func (r *Routing) RoutesFor(repository string) []Route { - canonical, err := CanonicalURL(repository) - if err != nil { + if _, err := RepositoryKey(repository); err != nil { return nil } r.mu.Lock() defer r.mu.Unlock() var out []Route for _, route := range r.routes { - if route.Repository == canonical { + if SameRepository(route.Repository, repository) { out = append(out, route) } } diff --git a/internal/source/routing_test.go b/internal/source/routing_test.go index 12bc803..c2eefa1 100644 --- a/internal/source/routing_test.go +++ b/internal/source/routing_test.go @@ -71,3 +71,21 @@ func TestRoutingRejectsInvalidInput(t *testing.T) { t.Fatal("missing app accepted") } } + +func TestRoutingMatchesAcrossTransports(t *testing.T) { + routing, err := OpenRouting(t.TempDir()) + if err != nil { + t.Fatal(err) + } + // The app pulls over SSH on the forge's SSH port; the source was + // registered with the forge's HTTP clone URL on another port. + if err := routing.Link("infra-home", "web", "ssh://git@forge.lan:2222/tyler/app.git"); err != nil { + t.Fatal(err) + } + if got := routing.RoutesFor("http://forge.lan:49152/tyler/app"); len(got) != 1 || got[0].App != "web" { + t.Fatalf("routes = %+v", got) + } + if got := routing.RoutesFor("http://forge.lan:49152/tyler/other"); len(got) != 0 { + t.Fatalf("other repository woke %+v", got) + } +} diff --git a/internal/source/url.go b/internal/source/url.go index 4caebfd..bded26b 100644 --- a/internal/source/url.go +++ b/internal/source/url.go @@ -165,6 +165,30 @@ func CanonicalURL(rawCloneURL string) (string, error) { return strings.ToLower(normalized), nil } +// RepositoryKey is the repository join key: case-folded host and path, +// without scheme or port. Transport is not identity, and a self-hosted +// forge usually serves SSH and HTTP on different ports, so +// ssh://git@h:2222/o/r and http://h:3000/o/r name one repository. Source +// IDs keep the full canonical URL (IdentityKey); only matching uses this. +func RepositoryKey(rawCloneURL string) (string, error) { + canonical, err := CanonicalURL(rawCloneURL) + if err != nil { + return "", err + } + u, err := url.Parse(canonical) + if err != nil { + return "", err + } + return u.Hostname() + u.Path, nil +} + +// SameRepository reports whether two clone URLs name one repository. +func SameRepository(a, b string) bool { + ka, ea := RepositoryKey(a) + kb, eb := RepositoryKey(b) + return ea == nil && eb == nil && ka == kb +} + // DeriveID returns the stable source ID — the fleet-envelope convention // (deterministic sha256 of the identity key, truncated to 16 hex chars). func DeriveID(forge Forge, rawCloneURL string) (string, error) { diff --git a/internal/source/url_test.go b/internal/source/url_test.go index d45d857..efd01df 100644 --- a/internal/source/url_test.go +++ b/internal/source/url_test.go @@ -155,3 +155,27 @@ func TestCanonicalURLJoinsAcrossCase(t *testing.T) { t.Fatalf("canonical join key must fold case and transport: %q vs %q", a, b) } } + +func TestSameRepositoryIgnoresTransport(t *testing.T) { + same := [][2]string{ + {"ssh://git@forge.lan:2222/tyler/app.git", "http://forge.lan:49152/tyler/app"}, + {"git@github.com:Owner/Repo.git", "https://github.com/owner/repo"}, + {"https://forge.lan:8443/tyler/app/", "ssh://forge.lan/tyler/app"}, + } + for _, pair := range same { + if !SameRepository(pair[0], pair[1]) { + t.Errorf("%q and %q should name one repository", pair[0], pair[1]) + } + } + different := [][2]string{ + {"ssh://git@forge.lan:2222/tyler/app", "http://forge.lan:49152/tyler/other"}, + {"ssh://git@forge.lan:2222/tyler/app", "http://other.lan:49152/tyler/app"}, + {"https://github.com/owner/repo", "https://github.com/owner/repo/sub"}, + {"not a url", "not a url"}, + } + for _, pair := range different { + if SameRepository(pair[0], pair[1]) { + t.Errorf("%q and %q must not match", pair[0], pair[1]) + } + } +}