diff --git a/cli/app/new.go b/cli/app/new.go index 67b69d09..9961b74a 100644 --- a/cli/app/new.go +++ b/cli/app/new.go @@ -32,6 +32,18 @@ deploy " to do so. You can see what recipes are available (i.e. values for the [recipe] argument) by running "abra recipe ls". +In addition to short catalogue names, [recipe] also accepts arbitrary git +URLs to use a recipe from outside the catalogue (e.g. a fork or work in +progress). Any of these forms is accepted: + + abra app new git.example.com/user/recipe + abra app new https://git.example.com/user/recipe + abra app new git@git.example.com:user/recipe + +In that case a RECIPE= line is written to the app's .env +file so a subsequent "abra app deploy" (on this or another machine) will +re-fetch the recipe from the same git source. + Recipe commit hashes are supported values for "[version]". Passing the "--secrets/-S" flag will automatically generate secrets for your @@ -295,7 +307,7 @@ func ensureDomainFlag(recipe recipePkg.Recipe, server string) error { if appDomain == "" && !internal.NoInput { prompt := &survey.Input{ Message: i18n.G("Specify app domain"), - Default: fmt.Sprintf("%s.%s", recipe.Name, server), + Default: fmt.Sprintf("%s.%s", recipe.ShortName(), server), } if err := survey.AskOne(prompt, &appDomain); err != nil { return err @@ -306,6 +318,10 @@ func ensureDomainFlag(recipe recipePkg.Recipe, server string) error { return errors.New(i18n.G("no domain provided")) } + if strings.ContainsAny(appDomain, "/\\") { + return errors.New(i18n.G("invalid domain '%s': must not contain '/' or '\\'", appDomain)) + } + return nil } diff --git a/cli/internal/validate.go b/cli/internal/validate.go index f39b9d34..e281b2ee 100644 --- a/cli/internal/validate.go +++ b/cli/internal/validate.go @@ -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)) } } diff --git a/cli/recipe/list.go b/cli/recipe/list.go index a63ec7d5..7dce1906 100644 --- a/cli/recipe/list.go +++ b/cli/recipe/list.go @@ -2,11 +2,13 @@ package recipe import ( "fmt" + "path" "sort" "strconv" "strings" "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/config" "coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/i18n" "coopcloud.tech/abra/pkg/log" @@ -41,6 +43,7 @@ var RecipeListCommand = &cobra.Command{ headers := []string{ i18n.G("name"), + i18n.G("source"), i18n.G("category"), i18n.G("status"), i18n.G("healthcheck"), @@ -56,6 +59,7 @@ var RecipeListCommand = &cobra.Command{ for _, recipe := range recipes { row := []string{ recipe.Name, + i18n.G("catalogue"), recipe.Category, strconv.Itoa(recipe.Features.Status), recipe.Features.Healthcheck, @@ -76,6 +80,23 @@ var RecipeListCommand = &cobra.Command{ } } + externals := externalRecipes(catl) + sort.Strings(externals) + for _, name := range externals { + row := []string{ + name, + i18n.G("external"), + "-", "-", "-", "-", "-", "-", "-", + } + if pattern != "" { + if !strings.Contains(name, pattern) { + continue + } + } + table.Row(row...) + rows = append(rows, row) + } + if len(rows) > 0 { if internal.MachineReadable { out, err := formatter.ToJSON(headers, rows) @@ -93,6 +114,30 @@ var RecipeListCommand = &cobra.Command{ }, } +// externalRecipes returns canonical names of locally-cloned recipes that +// were sourced from an arbitrary git URL (i.e. they carry a .abra-source +// sidecar) and are not already listed in the catalogue. +func externalRecipes(catl recipe.RecipeCatalogue) []string { + dirs, err := recipe.GetRecipesLocal() + if err != nil { + log.Debug(i18n.G("can't read local recipes: %s", err)) + return nil + } + + var names []string + for _, dir := range dirs { + canonical := recipe.ReadRecipeSource(path.Join(config.RECIPES_DIR, dir)) + if canonical == "" { + continue + } + if _, inCatalogue := catl[canonical]; inCatalogue { + continue + } + names = append(names, canonical) + } + return names +} + var ( pattern string ) diff --git a/cmd/abra/main.go b/cmd/abra/main.go index d31d1e82..514583ad 100644 --- a/cmd/abra/main.go +++ b/cmd/abra/main.go @@ -16,7 +16,7 @@ func main() { Version = "dev" } if Commit == "" { - Commit = " " + Commit = "unknown-commit" } cli.Run(Version, Commit) diff --git a/pkg/app/app.go b/pkg/app/app.go index 2b723f9f..aad77d1a 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -392,11 +392,15 @@ func TemplateAppEnvSample(r recipe.Recipe, appName, server, domain string) error newContents := strings.Replace( string(read), - fmt.Sprintf("%s.example.com", r.Name), + fmt.Sprintf("%s.example.com", r.ShortName()), domain, -1, ) + if strings.Contains(r.Name, "/") { + newContents = injectRecipeLine(newContents, r.Name) + } + err = os.WriteFile(appEnvPath, []byte(newContents), 0) if err != nil { return err @@ -407,6 +411,37 @@ func TemplateAppEnvSample(r recipe.Recipe, appName, server, domain string) error return nil } +// injectRecipeLine ensures the env file carries a RECIPE= +// line so a downstream `abra app deploy` (potentially on another machine) +// can re-fetch the recipe from its original git source. If a RECIPE= line +// already exists in the copied .env.sample it is replaced; otherwise a new +// line is inserted immediately after the TYPE= line, or appended at the +// end if no TYPE= line is present. +func injectRecipeLine(contents, canonicalName string) string { + lines := strings.Split(contents, "\n") + for i, line := range lines { + trimmed := strings.TrimLeft(line, " \t") + if strings.HasPrefix(trimmed, "RECIPE=") { + lines[i] = "RECIPE=" + canonicalName + return strings.Join(lines, "\n") + } + } + for i, line := range lines { + trimmed := strings.TrimLeft(line, " \t") + if strings.HasPrefix(trimmed, "TYPE=") { + out := make([]string, 0, len(lines)+1) + out = append(out, lines[:i+1]...) + out = append(out, "RECIPE="+canonicalName) + out = append(out, lines[i+1:]...) + return strings.Join(out, "\n") + } + } + if contents != "" && !strings.HasSuffix(contents, "\n") { + contents += "\n" + } + return contents + "RECIPE=" + canonicalName + "\n" +} + // SanitiseAppName makes a app name usable with Docker by replacing illegal // characters. func SanitiseAppName(name string) string { diff --git a/pkg/app/inject_recipe_test.go b/pkg/app/inject_recipe_test.go new file mode 100644 index 00000000..b538d999 --- /dev/null +++ b/pkg/app/inject_recipe_test.go @@ -0,0 +1,30 @@ +package app + +import "testing" + +func TestInjectRecipeLineAfterType(t *testing.T) { + in := "DOMAIN=example.com\nTYPE=foo\nVERSION=1.0\n" + want := "DOMAIN=example.com\nTYPE=foo\nRECIPE=org/foo\nVERSION=1.0\n" + got := injectRecipeLine(in, "org/foo") + if got != want { + t.Errorf("injectRecipeLine inserted RECIPE in wrong position\nwant:\n%q\ngot:\n%q", want, got) + } +} + +func TestInjectRecipeLineReplacesExisting(t *testing.T) { + in := "TYPE=foo\nRECIPE=old\nDOMAIN=example.com\n" + want := "TYPE=foo\nRECIPE=org/foo\nDOMAIN=example.com\n" + got := injectRecipeLine(in, "org/foo") + if got != want { + t.Errorf("injectRecipeLine should replace existing RECIPE line\nwant:\n%q\ngot:\n%q", want, got) + } +} + +func TestInjectRecipeLineNoTypeAppends(t *testing.T) { + in := "DOMAIN=example.com\n" + want := "DOMAIN=example.com\nRECIPE=org/foo\n" + got := injectRecipeLine(in, "org/foo") + if got != want { + t.Errorf("injectRecipeLine should append when TYPE is missing\nwant:\n%q\ngot:\n%q", want, got) + } +} diff --git a/pkg/formatter/formatter.go b/pkg/formatter/formatter.go index 1ffca2f7..ab5f0f39 100644 --- a/pkg/formatter/formatter.go +++ b/pkg/formatter/formatter.go @@ -27,10 +27,16 @@ var BoldUnderlineStyle = lipgloss.NewStyle(). Underline(true) func ShortenID(str string) string { + if len(str) < 12 { + return str + } return str[:12] } func SmallSHA(hash string) string { + if len(hash) < 8 { + return hash + } return hash[:8] } diff --git a/pkg/git/read.go b/pkg/git/read.go index 0a54d4fc..1364b6fe 100644 --- a/pkg/git/read.go +++ b/pkg/git/read.go @@ -36,6 +36,11 @@ func IsClean(repoPath string) (bool, error) { return false, err } + // Ignore the abra-managed sidecar file that records the canonical + // source URL for externally-cloned recipes; it lives alongside the + // recipe but is not part of the upstream tree. + patterns = append(patterns, gitignore.ParsePattern(".abra-source", nil)) + if len(patterns) > 0 { worktree.Excludes = append(patterns, worktree.Excludes...) } diff --git a/pkg/git/read_test.go b/pkg/git/read_test.go index 5ad07cfe..366ac33d 100644 --- a/pkg/git/read_test.go +++ b/pkg/git/read_test.go @@ -2,6 +2,8 @@ package git import ( "errors" + "os" + "path/filepath" "testing" "github.com/go-git/go-git/v5" @@ -13,3 +15,37 @@ func TestIsClean(t *testing.T) { assert.Equal(t, isClean, false) assert.True(t, errors.Is(err, git.ErrRepositoryNotExists)) } + +// TestIsCleanIgnoresAbraSource confirms that the .abra-source sidecar +// file written by abra next to externally-cloned recipes does not cause +// IsClean to report the worktree as dirty. +func TestIsCleanIgnoresAbraSource(t *testing.T) { + dir := t.TempDir() + + if _, err := git.PlainInit(dir, false); err != nil { + t.Fatalf("git init failed: %s", err) + } + + sidecar := filepath.Join(dir, ".abra-source") + if err := os.WriteFile(sidecar, []byte("git.example.com/u/recipe\n"), 0o644); err != nil { + t.Fatalf("writing sidecar failed: %s", err) + } + + isClean, err := IsClean(dir) + if err != nil { + t.Fatalf("IsClean returned error: %s", err) + } + assert.True(t, isClean, "expected worktree with only .abra-source to be reported clean") + + // Sanity check: an unrelated untracked file should still mark it dirty. + other := filepath.Join(dir, "random.txt") + if err := os.WriteFile(other, []byte("hello"), 0o644); err != nil { + t.Fatalf("writing extra file failed: %s", err) + } + + isClean, err = IsClean(dir) + if err != nil { + t.Fatalf("IsClean returned error: %s", err) + } + assert.False(t, isClean, "expected worktree with unrelated untracked file to be reported dirty") +} diff --git a/pkg/recipe/git.go b/pkg/recipe/git.go index eff8f5f8..4159fa71 100644 --- a/pkg/recipe/git.go +++ b/pkg/recipe/git.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path" "slices" "sort" "strings" @@ -19,6 +20,11 @@ import ( "github.com/go-git/go-git/v5/plumbing" ) +// SourceFile is the sidecar file written next to an externally-cloned +// recipe so the canonical "host/path" name can be recovered later (the +// on-disk directory name escapes "/" and "." to "_", which is lossy). +const SourceFile = ".abra-source" + type EnsureContext struct { Chaos bool Offline bool @@ -78,6 +84,12 @@ func (r Recipe) EnsureExists() error { if err := gitPkg.Clone(r.Dir, r.GitURL); err != nil { return err } + if strings.Contains(r.Name, "/") { + sidecar := path.Join(r.Dir, SourceFile) + if err := os.WriteFile(sidecar, []byte(r.Name+"\n"), 0o644); err != nil { + log.Debug(i18n.G("failed to write recipe source sidecar %s: %s", sidecar, err)) + } + } } if err := gitPkg.EnsureGitRepo(r.Dir); err != nil { @@ -87,6 +99,18 @@ func (r Recipe) EnsureExists() error { return nil } +// ReadRecipeSource returns the canonical name recorded in the .abra-source +// sidecar inside the given recipe directory, or the empty string if no +// sidecar exists. This lets callers recover the unescaped "host/path" +// form for externally-cloned recipes. +func ReadRecipeSource(recipeDir string) string { + data, err := os.ReadFile(path.Join(recipeDir, SourceFile)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + // IsChaosCommit determines if a version sttring is a chaos commit or not. func (r Recipe) IsChaosCommit(version string) (bool, error) { isChaosCommit := false @@ -163,6 +187,9 @@ func (r Recipe) EnsureVersion(version string) (bool, error) { hash, err := repo.ResolveRevision(plumbing.Revision(version)) if err != nil { + if isRemoteBranch(repo, version) { + log.Fatal(i18n.G("'%s' is a branch name; ':' only supports tags or commit hashes, not branches", version)) + } log.Fatal(i18n.G("unable to resolve '%s': %s", version, err)) } @@ -479,6 +506,28 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) { return versions, uniqueWarnings, nil } +// isRemoteBranch reports whether name matches a branch on any configured +// remote. Used to give a clearer error when a user passes a branch as the +// ":version" suffix, which is unsupported. +func isRemoteBranch(repo *git.Repository, name string) bool { + remotes, err := repo.Remotes() + if err != nil { + return false + } + for _, remote := range remotes { + refs, err := remote.List(&git.ListOptions{}) + if err != nil { + continue + } + for _, ref := range refs { + if ref.Name().IsBranch() && ref.Name().Short() == name { + return true + } + } + } + return false +} + // Head retrieves latest HEAD metadata. func (r Recipe) Head() (*plumbing.Reference, error) { repo, err := git.PlainOpen(r.Dir) diff --git a/pkg/recipe/recipe.go b/pkg/recipe/recipe.go index 592e2452..ade0c47b 100644 --- a/pkg/recipe/recipe.go +++ b/pkg/recipe/recipe.go @@ -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, ".", "_") diff --git a/pkg/recipe/recipe_test.go b/pkg/recipe/recipe_test.go index 9f3c5748..5320b2a9 100644 --- a/pkg/recipe/recipe_test.go +++ b/pkg/recipe/recipe_test.go @@ -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() })