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/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/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/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 08875757..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