feat(recipe): normalize git URLs to canonical host/path names
Add NormalizeRecipeName to unify the various ways a recipe can be referenced - https/http/ssh URLs, SCP-style git@host:path, already- canonical host/path, and short catalogue names - to a single stable "host/path" form, preserving any ":version" suffix. Wire it into recipe.Get and ValidateRecipe so every entry point accepts git URLs, and add Recipe.ShortName to recover the bare recipe name from a prefixed one.
This commit is contained in:
@@ -59,9 +59,15 @@ func ValidateRecipe(args []string, cmdName string) recipe.Recipe {
|
||||
log.Fatal(i18n.G("no recipe name provided"))
|
||||
}
|
||||
|
||||
if _, ok := knownRecipes[recipeName]; !ok {
|
||||
if !strings.Contains(recipeName, "/") {
|
||||
log.Fatal(i18n.G("no recipe '%s' exists?", recipeName))
|
||||
recipeName = recipe.NormalizeRecipeName(recipeName)
|
||||
|
||||
lookupName := recipeName
|
||||
if i := strings.LastIndex(lookupName, ":"); i >= 0 {
|
||||
lookupName = lookupName[:i]
|
||||
}
|
||||
if _, ok := knownRecipes[lookupName]; !ok {
|
||||
if !strings.Contains(lookupName, "/") {
|
||||
log.Fatal(i18n.G("no recipe '%s' exists? pass a git URL (e.g. https://git.example.com/user/recipe) to use a recipe outside the catalogue", lookupName))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -121,7 +122,84 @@ type Features struct {
|
||||
SSO string `json:"sso"`
|
||||
}
|
||||
|
||||
// scpURLPattern matches SCP-style git URLs like git@host:path or git@host:port/path.
|
||||
// Captures: 1=host(:port)?, 2=path.
|
||||
var scpURLPattern = regexp.MustCompile(`^[\w.-]+@([\w.-]+(?::\d+)?):(.+)$`)
|
||||
|
||||
// NormalizeRecipeName canonicalizes a recipe identifier to a stable
|
||||
// "host/path" form (or returns short catalog names unchanged). Accepts:
|
||||
//
|
||||
// - https://host/path[.git][:version]
|
||||
// - http://host/path[.git][:version]
|
||||
// - ssh://git@host[:port]/path[.git][:version]
|
||||
// - git@host:path[.git][:version] (SCP-style)
|
||||
// - host/path[.git][:version] (already canonical)
|
||||
// - short-name[:version] (catalog recipe, pass through)
|
||||
//
|
||||
// The optional trailing :version suffix is preserved verbatim. The .git
|
||||
// suffix and trailing slashes on the path are stripped so the four URL
|
||||
// forms of the same repository collapse to one canonical value.
|
||||
func NormalizeRecipeName(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
// Split off the version suffix first, but only if it's not part of a
|
||||
// scheme (https://) or SCP-style git@host: prefix. The simplest way is
|
||||
// to detect those prefixes and treat the rest as the path-with-version.
|
||||
var (
|
||||
body = input
|
||||
version string
|
||||
)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(input, "https://") || strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "ssh://"):
|
||||
u, err := url.Parse(input)
|
||||
if err != nil {
|
||||
return input
|
||||
}
|
||||
host := u.Hostname() // strip port to keep canonical form colon-free
|
||||
p := strings.TrimPrefix(u.Path, "/")
|
||||
p, version = splitVersion(p)
|
||||
body = host + "/" + p
|
||||
case scpURLPattern.MatchString(input):
|
||||
m := scpURLPattern.FindStringSubmatch(input)
|
||||
host := m[1]
|
||||
if i := strings.Index(host, ":"); i >= 0 {
|
||||
host = host[:i] // strip port
|
||||
}
|
||||
p, v := splitVersion(m[2])
|
||||
body = host + "/" + p
|
||||
version = v
|
||||
default:
|
||||
body, version = splitVersion(input)
|
||||
}
|
||||
|
||||
body = strings.TrimSuffix(body, "/")
|
||||
body = strings.TrimSuffix(body, ".git")
|
||||
|
||||
if version != "" {
|
||||
return body + ":" + version
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// splitVersion separates a trailing ":version" suffix from a recipe path.
|
||||
// It only treats the final colon-separated segment as a version when it is
|
||||
// non-empty and contains no slash (a slash means the colon belonged to the
|
||||
// path, e.g. a host:port or scheme separator).
|
||||
func splitVersion(s string) (string, string) {
|
||||
idx := strings.LastIndex(s, ":")
|
||||
if idx < 0 {
|
||||
return s, ""
|
||||
}
|
||||
candidate := s[idx+1:]
|
||||
if candidate == "" || strings.Contains(candidate, "/") {
|
||||
return s, ""
|
||||
}
|
||||
return s[:idx], candidate
|
||||
}
|
||||
|
||||
func Get(name string) Recipe {
|
||||
name = NormalizeRecipeName(name)
|
||||
version := ""
|
||||
versionRaw := ""
|
||||
if strings.Contains(name, ":") {
|
||||
@@ -209,6 +287,16 @@ func (r Recipe) String() string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ShortName returns the final path segment of the recipe name, i.e. the
|
||||
// bare recipe name without any "host/org/" prefix carried by externally
|
||||
// sourced (git URL) recipes. For catalogue recipes it returns Name unchanged.
|
||||
func (r Recipe) ShortName() string {
|
||||
if i := strings.LastIndex(r.Name, "/"); i >= 0 {
|
||||
return r.Name[i+1:]
|
||||
}
|
||||
return r.Name
|
||||
}
|
||||
|
||||
func escapeRecipeName(recipeName string) string {
|
||||
recipeName = strings.ReplaceAll(recipeName, "/", "_")
|
||||
recipeName = strings.ReplaceAll(recipeName, ".", "_")
|
||||
|
||||
@@ -88,6 +88,88 @@ func TestGet(t *testing.T) {
|
||||
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "https://mygit.org/myorg/cool-recipe",
|
||||
recipe: Recipe{
|
||||
Name: "mygit.org/myorg/cool-recipe",
|
||||
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
|
||||
GitURL: "https://mygit.org/myorg/cool-recipe.git",
|
||||
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
|
||||
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
|
||||
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
|
||||
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
|
||||
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "https://mygit.org/myorg/cool-recipe.git",
|
||||
recipe: Recipe{
|
||||
Name: "mygit.org/myorg/cool-recipe",
|
||||
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
|
||||
GitURL: "https://mygit.org/myorg/cool-recipe.git",
|
||||
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
|
||||
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
|
||||
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
|
||||
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
|
||||
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "https://mygit.org/myorg/cool-recipe.git:1.2.4",
|
||||
recipe: Recipe{
|
||||
Name: "mygit.org/myorg/cool-recipe",
|
||||
EnvVersion: "1.2.4",
|
||||
EnvVersionRaw: "1.2.4",
|
||||
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
|
||||
GitURL: "https://mygit.org/myorg/cool-recipe.git",
|
||||
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
|
||||
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
|
||||
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
|
||||
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
|
||||
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh://git@mygit.org/myorg/cool-recipe.git",
|
||||
recipe: Recipe{
|
||||
Name: "mygit.org/myorg/cool-recipe",
|
||||
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
|
||||
GitURL: "https://mygit.org/myorg/cool-recipe.git",
|
||||
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
|
||||
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
|
||||
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
|
||||
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
|
||||
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "git@mygit.org:myorg/cool-recipe.git",
|
||||
recipe: Recipe{
|
||||
Name: "mygit.org/myorg/cool-recipe",
|
||||
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
|
||||
GitURL: "https://mygit.org/myorg/cool-recipe.git",
|
||||
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
|
||||
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
|
||||
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
|
||||
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
|
||||
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "git@mygit.org:myorg/cool-recipe:1.2.4",
|
||||
recipe: Recipe{
|
||||
Name: "mygit.org/myorg/cool-recipe",
|
||||
EnvVersion: "1.2.4",
|
||||
EnvVersionRaw: "1.2.4",
|
||||
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
|
||||
GitURL: "https://mygit.org/myorg/cool-recipe.git",
|
||||
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
|
||||
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
|
||||
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
|
||||
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
|
||||
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testcases {
|
||||
@@ -101,6 +183,49 @@ func TestGet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRecipeName(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
// catalog short names pass through
|
||||
{"foo", "foo"},
|
||||
{"foo:1.2.3", "foo:1.2.3"},
|
||||
|
||||
// bare host/path form
|
||||
{"mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
|
||||
{"mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
|
||||
{"mygit.org/myorg/cool-recipe.git:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"},
|
||||
{"mygit.org/myorg/cool-recipe/", "mygit.org/myorg/cool-recipe"},
|
||||
|
||||
// https://
|
||||
{"https://mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
|
||||
{"https://mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
|
||||
{"https://mygit.org/myorg/cool-recipe.git:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"},
|
||||
{"http://mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
|
||||
|
||||
// ssh://, with and without port
|
||||
{"ssh://git@mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
|
||||
{"ssh://git@mygit.org:2222/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
|
||||
|
||||
// SCP-style git@host:path
|
||||
{"git@mygit.org:myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
|
||||
{"git@mygit.org:myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
|
||||
{"git@mygit.org:myorg/cool-recipe:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"},
|
||||
|
||||
// whitespace
|
||||
{" https://mygit.org/myorg/cool-recipe ", "mygit.org/myorg/cool-recipe"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := NormalizeRecipeName(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("NormalizeRecipeName(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVersionLabelLocalDoesNotUseTimeoutLabel(t *testing.T) {
|
||||
test.Setup()
|
||||
t.Cleanup(func() { test.Teardown() })
|
||||
|
||||
Reference in New Issue
Block a user