Compare commits

..

5 Commits

Author SHA1 Message Date
Linus Gasser cd25c346c1 fix(app): keep recipe version on a single env field and label
Store the deployed version on TYPE (or the legacy RECIPE key when TYPE is
absent) instead of duplicating it across both. For git-URL recipes RECIPE
now stays a bare source pointer, so the two can never drift apart. Drops
the dead dirtyVersion branch and the fragile substring skip in
WriteRecipeVersion.

Also stamp the coop-cloud.<stack>.version label ourselves on upgrade and
rollback rather than trusting the value baked into the recipe's
compose.yml: git-URL/WIP recipes carry a stale or missing label, which
otherwise left the live deployment reporting the wrong version. Stop
getReleaseNotes and the [version] arg validation from fataling when the
deployed version is "unknown".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:31:58 +02:00
Linus Gasser 0397443de3 feat(app): support git-URL recipes in 'abra app new'
Allow `abra app new <git-url>` to use a recipe from outside the
catalogue. On clone, a `.abra-source` sidecar records the canonical
host/path name (the on-disk directory escapes "/" and "." lossily), and
IsClean ignores it. When templating the app's .env, a `RECIPE=<canonical
name>` line is injected so a later `abra app deploy`, possibly on another
machine, re-fetches the recipe from the same git source. `recipe ls` now
shows a source column listing these external recipes alongside catalogue
ones.
2026-07-21 08:13:37 +02:00
Linus Gasser 22cd3e1307 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.
2026-07-21 08:13:11 +02:00
Linus Gasser 0ed1881374 fix(recipe): reject branch names as :version with a clear error
Passing a branch as the ":version" suffix previously failed with an
opaque "unable to resolve" error from go-git. Detect when the requested
revision matches a branch on a configured remote and fail with a message
explaining that ":<version>" only supports tags or commit hashes.
2026-07-21 08:12:57 +02:00
Linus Gasser 31fe2fe2a6 fix(formatter): guard ShortenID/SmallSHA against short input
ShortenID and SmallSHA sliced their input to a fixed length without
checking it was long enough, panicking on shorter strings. Return the
input unchanged when it is already shorter than the cut. Also replace the
blank Commit placeholder with an explicit "unknown-commit" sentinel.
2026-07-21 08:12:32 +02:00
16 changed files with 615 additions and 61 deletions
+7 -1
View File
@@ -222,7 +222,13 @@ synchronize your local app environment values with what is deployed live.`),
mergedEnv[k] = v
}
if !strings.Contains(recipeEnvVar, ":") {
// The recipe version is always carried by TYPE (RECIPE, when present,
// is a bare source pointer). Stamp the deployed version onto TYPE and
// leave RECIPE untouched so the two never drift apart.
if t, ok := mergedEnv["TYPE"]; ok {
name, _, _ := strings.Cut(t, ":")
mergedEnv["TYPE"] = fmt.Sprintf("%s:%s", name, version)
} else {
mergedEnv[recipeKey] = fmt.Sprintf("%s:%s", mergedEnv[recipeKey], version)
}
+17 -1
View File
@@ -32,6 +32,18 @@ deploy <domain>" 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=<canonical name> 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
}
+6
View File
@@ -188,6 +188,12 @@ beforehand. See "abra app backup" for more.`),
appPkg.SetChaosVersionLabel(compose, stackName, chosenDowngrade)
}
// NOTE: stamp the deployed version label ourselves rather than relying
// on the value baked into the recipe's compose.yml. Git-URL/WIP recipes
// often carry a stale or missing label, which would otherwise leave the
// live deployment reporting the wrong version after a rollback.
appPkg.SetVersionLabel(compose, stackName, chosenDowngrade)
// Gather secrets
secretInfo, err := deploy.GatherSecretsForDeploy(cl, app, internal.ShowUnchanged)
if err != nil {
+24 -4
View File
@@ -200,6 +200,12 @@ beforehand. See "abra app backup" for more.`),
appPkg.SetChaosVersionLabel(compose, stackName, chosenUpgrade)
}
// NOTE: stamp the deployed version label ourselves rather than relying
// on the value baked into the recipe's compose.yml. Git-URL/WIP recipes
// often carry a stale or missing label, which would otherwise leave the
// live deployment reporting the wrong version after an upgrade.
appPkg.SetVersionLabel(compose, stackName, chosenUpgrade)
envVars, err := appPkg.CheckEnv(app)
if err != nil {
log.Fatal(err)
@@ -346,9 +352,17 @@ func getReleaseNotes(
return errors.New(i18n.G("parsing chosen upgrade version failed: %s", err))
}
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
if err != nil {
return errors.New(i18n.G("parsing deployment version failed: %s", err))
// The deployed version can be "unknown" (e.g. deployed without a version
// label, as is common for git-URL/work-in-progress recipes). In that case
// there is no lower bound to filter by, so we gather notes for every
// version below the chosen one instead of failing.
var parsedDeployedVersion tagcmp.Tag
haveDeployedVersion := deployMeta.Version != config.UNKNOWN_DEFAULT
if haveDeployedVersion {
parsedDeployedVersion, err = tagcmp.Parse(deployMeta.Version)
if err != nil {
return errors.New(i18n.G("parsing deployment version failed: %s", err))
}
}
for _, version := range internal.SortVersionsDesc(versions) {
@@ -357,7 +371,7 @@ func getReleaseNotes(
return errors.New(i18n.G("parsing recipe version failed: %s", err))
}
if parsedVersion.IsGreaterThan(parsedDeployedVersion) &&
if (!haveDeployedVersion || parsedVersion.IsGreaterThan(parsedDeployedVersion)) &&
parsedVersion.IsLessThan(parsedChosenUpgrade) {
note, err := app.Recipe.GetReleaseNotes(version, app.Domain)
if err != nil {
@@ -420,6 +434,12 @@ func validateUpgradeVersionArg(
return errors.New(i18n.G("'%s' is not a known version for %s", specificVersion, app.Recipe.Name))
}
// Without a known deployed version we cannot tell whether the requested
// version is an upgrade; trust the explicit choice rather than failing.
if deployMeta.Version == config.UNKNOWN_DEFAULT {
return nil
}
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
if err != nil {
return errors.New(i18n.G("'%s' is not a known version", deployMeta.Version))
+9 -3
View File
@@ -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))
}
}
+45
View File
@@ -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
)
+1 -1
View File
@@ -16,7 +16,7 @@ func main() {
Version = "dev"
}
if Commit == "" {
Commit = " "
Commit = "unknown-commit"
}
cli.Run(Version, Commit)
+111 -51
View File
@@ -260,12 +260,30 @@ func ReadAppEnvFile(appFile AppFile, name AppName) (App, error) {
func NewApp(env envfile.AppEnv, name string, appFile AppFile) (App, error) {
domain := env["DOMAIN"]
recipeName, exists := env["RECIPE"]
if !exists {
recipeName, exists = env["TYPE"]
if !exists {
return App{}, errors.New(i18n.G("%s is missing the TYPE env var?", name))
}
typeVar, hasType := env["TYPE"]
recipeVar, hasRecipe := env["RECIPE"]
if !hasType && !hasRecipe {
return App{}, errors.New(i18n.G("%s is missing the TYPE env var?", name))
}
// The recipe identity is taken from RECIPE when present (it records the
// canonical git source for externally-sourced recipes), otherwise from
// TYPE. The deployed version is always carried by TYPE when a TYPE line
// exists; only legacy apps that set RECIPE alone carry it there. RECIPE
// itself must stay a bare source pointer, never a versioned value.
identity := typeVar
if hasRecipe {
identity = recipeVar
}
versionSource := identity
if hasType {
versionSource = typeVar
}
recipeName, _, _ := strings.Cut(identity, ":")
if _, version, found := strings.Cut(versionSource, ":"); found && version != "" {
recipeName = fmt.Sprintf("%s:%s", recipeName, version)
}
return App{
@@ -392,11 +410,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 +429,37 @@ func TemplateAppEnvSample(r recipe.Recipe, appName, server, domain string) error
return nil
}
// injectRecipeLine ensures the env file carries a RECIPE=<canonical name>
// 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 {
@@ -514,10 +567,22 @@ func ExposeAllEnv(
_, exists := service.Environment[k]
if !exists {
value := v
if k == "TYPE" || k == "RECIPE" {
switch k {
case "TYPE":
// NOTE(d1): don't use the wrong version from the app env
// since we are deploying a new version
value = toDeployVersion
// since we are deploying a new version. Keep the
// recipe name already recorded in TYPE and only
// swap in the version being deployed.
name, _, _ := strings.Cut(v, ":")
if _, version, found := strings.Cut(toDeployVersion, ":"); found {
value = fmt.Sprintf("%s:%s", name, version)
} else {
value = name
}
case "RECIPE":
// RECIPE records the canonical git source only; the
// version is carried by TYPE, so keep it bare here.
value, _, _ = strings.Cut(toDeployVersion, ":")
}
service.Environment[k] = &value
log.Debug(i18n.G("%s: %s: %s", stackName, k, value))
@@ -642,60 +707,55 @@ func (a App) WriteRecipeVersion(version string, dryRun bool) error {
if err != nil {
return err
}
defer file.Close()
var (
dirtyVersion string
skipped bool
lines []string
scanner = bufio.NewScanner(file)
lines []string
hasType bool
scanner = bufio.NewScanner(file)
)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "RECIPE=") && !strings.HasPrefix(line, "TYPE=") {
lines = append(lines, line)
continue
}
if strings.HasPrefix(line, "#") {
lines = append(lines, line)
continue
}
if strings.Contains(line, version) && !a.Recipe.Dirty && !strings.HasSuffix(line, config.DIRTY_DEFAULT) {
skipped = true
lines = append(lines, line)
continue
}
splitted := strings.Split(line, ":")
line = fmt.Sprintf("%s:%s", splitted[0], version)
lines = append(lines, line)
if strings.HasPrefix(line, "TYPE=") {
hasType = true
}
}
if err := scanner.Err(); err != nil {
file.Close()
log.Fatal(err)
}
file.Close()
// The recipe version is carried by a single field: TYPE when present (the
// standard recipe key), otherwise the legacy RECIPE key. When both exist
// (git-URL recipes) RECIPE stays a bare source pointer and only TYPE is
// versioned, so the two can never drift apart.
targetPrefix := "RECIPE="
if hasType {
targetPrefix = "TYPE="
}
for i, line := range lines {
if !strings.HasPrefix(line, targetPrefix) || strings.HasPrefix(line, "#") {
continue
}
name, _, _ := strings.Cut(line, ":")
lines[i] = fmt.Sprintf("%s:%s", name, version)
}
if dryRun {
log.Debug(i18n.G("skipping writing version %s because dry run", version))
return nil
}
if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil {
log.Fatal(err)
}
if a.Recipe.Dirty && dirtyVersion != "" {
version = dirtyVersion
}
if !dryRun {
if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil {
log.Fatal(err)
}
} else {
log.Debug(i18n.G("skipping writing version %s because dry run", version))
}
if !skipped {
log.Debug(i18n.G("version %s saved to %s.env", version, a.Domain))
} else {
log.Debug(i18n.G("skipping version %s write as already exists in %s.env", version, a.Domain))
}
log.Debug(i18n.G("version %s saved to %s.env", version, a.Domain))
return nil
}
+56
View File
@@ -3,6 +3,8 @@ package app_test
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"testing"
@@ -271,6 +273,60 @@ func TestWriteRecipeVersionOverwrite(t *testing.T) {
assert.Equal(t, "foo", app.Recipe.EnvVersion)
}
// TestWriteRecipeVersionSingleField ensures the version is written to exactly
// one field: TYPE when present (even alongside a RECIPE source pointer, as with
// git-URL recipes), otherwise the legacy RECIPE field. RECIPE must stay bare
// when a TYPE line exists so the two can never drift apart.
func TestWriteRecipeVersionSingleField(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{
name: "git recipe: TYPE and RECIPE both present",
input: "TYPE=myrecipe\nRECIPE=git.example.com/user/myrecipe\nDOMAIN=x\n",
want: "TYPE=myrecipe:1.2.3+4.5.6\nRECIPE=git.example.com/user/myrecipe\nDOMAIN=x",
},
{
name: "git recipe: re-versioning replaces only TYPE",
input: "TYPE=myrecipe:0.1.0+1.0.0\nRECIPE=git.example.com/user/myrecipe\nDOMAIN=x\n",
want: "TYPE=myrecipe:1.2.3+4.5.6\nRECIPE=git.example.com/user/myrecipe\nDOMAIN=x",
},
{
name: "legacy recipe: only RECIPE present",
input: "RECIPE=test_recipe\nDOMAIN=x\n",
want: "RECIPE=test_recipe:1.2.3+4.5.6\nDOMAIN=x",
},
{
name: "catalogue recipe: only TYPE present",
input: "TYPE=nextcloud\nDOMAIN=x\n",
want: "TYPE=nextcloud:1.2.3+4.5.6\nDOMAIN=x",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := filepath.Join(t.TempDir(), "app.env")
if err := os.WriteFile(p, []byte(tc.input), 0o644); err != nil {
t.Fatal(err)
}
app := appPkg.App{Path: p, Domain: "x"}
if err := app.WriteRecipeVersion("1.2.3+4.5.6", false); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, tc.want, string(got))
})
}
}
func TestWriteRecipeVersionUnknown(t *testing.T) {
test.Setup()
t.Cleanup(func() { test.Teardown() })
+30
View File
@@ -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)
}
}
+6
View File
@@ -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]
}
+5
View File
@@ -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...)
}
+36
View File
@@ -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")
}
+49
View File
@@ -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; ':<version>' 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)
+88
View File
@@ -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, ".", "_")
+125
View File
@@ -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() })