WIP: Better support for remote recipes #870
+7
-1
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -16,7 +16,7 @@ func main() {
|
||||
Version = "dev"
|
||||
}
|
||||
if Commit == "" {
|
||||
Commit = " "
|
||||
Commit = "unknown-commit"
|
||||
}
|
||||
|
||||
cli.Run(Version, Commit)
|
||||
|
||||
+111
-51
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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