Files
abra/pkg/recipe/git.go
Linus Gasser f230f89dcb 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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:40:10 +02:00

545 lines
13 KiB
Go

package recipe
import (
"errors"
"fmt"
"os"
"path"
"slices"
"sort"
"strings"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/tagcmp"
"github.com/distribution/reference"
"github.com/go-git/go-git/v5"
"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
IgnoreEnvVersion bool
}
// Ensure makes sure the recipe exists, is up to date and has the specific
// version checked out.
func (r Recipe) Ensure(ctx EnsureContext) error {
if err := r.EnsureExists(); err != nil {
return err
}
// NOTE(d1): if we cannot parse the .env.sample then there is a
// fundamental problem which requires solving right now
if _, err := r.SampleEnv(); err != nil {
return err
}
if ctx.Chaos {
return nil
}
if err := r.EnsureIsClean(); err != nil {
return err
}
if !ctx.Offline {
if err := r.EnsureUpToDate(); err != nil {
return err
}
}
if r.EnvVersion != "" && !ctx.IgnoreEnvVersion {
log.Debug(i18n.G("ensuring env version %s", r.EnvVersion))
if strings.Contains(r.EnvVersion, "+U") {
return errors.New(i18n.G(`cannot redeploy previous chaos version (%s), did you mean to use "--chaos"?`))
}
if _, err := r.EnsureVersion(r.EnvVersion); err != nil {
return err
}
return nil
}
if err := r.EnsureLatest(); err != nil {
return err
}
return nil
}
// EnsureExists ensures that the recipe is locally cloned
func (r Recipe) EnsureExists() error {
if _, err := os.Stat(r.Dir); os.IsNotExist(err) {
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 {
return err
}
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
if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
return isChaosCommit, err
}
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return isChaosCommit, err
}
tags, err := repo.Tags()
if err != nil {
return isChaosCommit, err
}
var tagRef plumbing.ReferenceName
if err := tags.ForEach(func(ref *plumbing.Reference) (err error) {
if ref.Name().Short() == version {
tagRef = ref.Name()
}
return nil
}); err != nil {
return isChaosCommit, err
}
if tagRef.String() == "" {
isChaosCommit = true
}
return isChaosCommit, nil
}
// EnsureVersion checks whether a specific version exists for a recipe.
func (r Recipe) EnsureVersion(version string) (bool, error) {
isChaosCommit := false
if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
return isChaosCommit, err
}
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return isChaosCommit, err
}
tags, err := repo.Tags()
if err != nil {
return isChaosCommit, err
}
var parsedTags []string
var tagRef plumbing.ReferenceName
if err := tags.ForEach(func(ref *plumbing.Reference) (err error) {
parsedTags = append(parsedTags, ref.Name().Short())
if ref.Name().Short() == version {
tagRef = ref.Name()
}
return nil
}); err != nil {
return isChaosCommit, err
}
joinedTags := strings.Join(parsedTags, ", ")
if joinedTags != "" {
log.Debug(i18n.G("read %s as tags for recipe %s", joinedTags, r.Name))
}
var opts *git.CheckoutOptions
if tagRef.String() == "" {
log.Debug(i18n.G("attempting to checkout '%s' as chaos commit", version))
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))
}
opts = &git.CheckoutOptions{Hash: *hash, Create: false, Force: true}
isChaosCommit = true
} else {
opts = &git.CheckoutOptions{Branch: tagRef, Create: false, Force: true}
}
worktree, err := repo.Worktree()
if err != nil {
return isChaosCommit, nil
}
if err := worktree.Checkout(opts); err != nil {
return isChaosCommit, nil
}
log.Debug(i18n.G("successfully checked %s out to %s in %s", r.Name, tagRef.Short(), r.Dir))
return isChaosCommit, nil
}
// EnsureIsClean makes sure that the recipe repository has no unstaged changes.
func (r Recipe) EnsureIsClean() error {
isClean, err := gitPkg.IsClean(r.Dir)
if err != nil {
return errors.New(i18n.G("unable to check git clean status in %s: %s", r.Dir, err))
}
if !isClean {
return errors.New(i18n.G("%s (%s) has locally unstaged changes?", r.Name, r.Dir))
}
return nil
}
// EnsureLatest makes sure the latest commit is checked out for the local recipe repository
func (r Recipe) EnsureLatest() error {
if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
return err
}
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return err
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
branch, err := gitPkg.GetDefaultBranch(repo, r.Dir)
if err != nil {
return err
}
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(branch),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
log.Debug(i18n.G("failed to check out %s in %s", branch, r.Dir))
return err
}
return nil
}
// EnsureUpToDate ensures that the local repo is synced to the remote
func (r Recipe) EnsureUpToDate() error {
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return errors.New(i18n.G("unable to open %s: %s", r.Dir, err))
}
remotes, err := repo.Remotes()
if err != nil {
return errors.New(i18n.G("unable to read remotes in %s: %s", r.Dir, err))
}
if len(remotes) == 0 {
log.Debug(i18n.G("cannot ensure %s is up-to-date, no git remotes configured", r.Name))
return nil
}
worktree, err := repo.Worktree()
if err != nil {
return errors.New(i18n.G("unable to open git work tree in %s: %s", r.Dir, err))
}
branch, err := gitPkg.CheckoutDefaultBranch(repo, r.Dir)
if err != nil {
return errors.New(i18n.G("unable to check out default branch in %s: %s", r.Dir, err))
}
fetchOpts := &git.FetchOptions{Tags: git.AllTags}
if err := repo.Fetch(fetchOpts); err != nil {
if !strings.Contains(err.Error(), "already up-to-date") {
return errors.New(i18n.G("unable to fetch tags in %s: %s", r.Dir, err))
}
}
opts := &git.PullOptions{
Force: true,
ReferenceName: branch,
SingleBranch: true,
}
if err := worktree.Pull(opts); err != nil {
if !strings.Contains(err.Error(), "already up-to-date") {
return errors.New(i18n.G("unable to git pull in %s: %s", r.Dir, err))
}
}
log.Debug(i18n.G("fetched latest git changes for %s", r.Name))
return nil
}
// IsDirty checks whether a recipe is dirty or not.
func (r *Recipe) IsDirty() (bool, error) {
isClean, err := gitPkg.IsClean(r.Dir)
if err != nil {
return false, err
}
return !isClean, nil
}
// ChaosVersion constructs a chaos mode recipe version.
func (r *Recipe) ChaosVersion() (string, error) {
var version string
head, err := r.Head()
if err != nil {
return version, err
}
version = formatter.SmallSHA(head.String())
dirty, err := r.IsDirty()
if err != nil {
return "", err
}
if dirty {
return fmt.Sprintf("%s%s", version, config.DIRTY_DEFAULT), nil
}
return version, nil
}
// Push pushes the latest changes to a SSH URL remote. You need to have your
// local SSH configuration for git.coopcloud.tech working for this to work
func (r Recipe) Push(dryRun bool) error {
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return err
}
if err := gitPkg.CreateRemote(repo, "origin-ssh", r.SSHURL, dryRun); err != nil {
return err
}
if err := gitPkg.Push(r.Dir, "origin-ssh", true, dryRun); err != nil {
return err
}
return nil
}
// Tags list the recipe tags
func (r Recipe) Tags() ([]string, error) {
var tags []string
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return tags, err
}
gitTags, err := repo.Tags()
if err != nil {
return tags, err
}
if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
tags = append(tags, strings.TrimPrefix(string(ref.Name()), "refs/tags/"))
return nil
}); err != nil {
return tags, err
}
sort.Slice(tags, func(i, j int) bool {
version1, err := tagcmp.Parse(tags[i])
if err != nil {
return false
}
version2, err := tagcmp.Parse(tags[j])
if err != nil {
return false
}
return version1.IsLessThan(version2)
})
log.Debug(i18n.G("detected %s as tags for recipe %s", strings.Join(tags, ", "), r.Name))
return tags, nil
}
// GetRecipeVersions retrieves all recipe versions.
func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
var warnMsg []string
versions := RecipeVersions{}
log.Debug(i18n.G("git: opening repository in %s", r.Dir))
repo, err := git.PlainOpen(r.Dir)
if err != nil {
return versions, warnMsg, nil
}
worktree, err := repo.Worktree()
if err != nil {
return versions, warnMsg, nil
}
gitTags, err := repo.Tags()
if err != nil {
return versions, warnMsg, nil
}
if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
tag := strings.TrimPrefix(string(ref.Name()), "refs/tags/")
log.Debug(i18n.G("processing %s for %s", tag, r.Name))
checkOutOpts := &git.CheckoutOptions{
Create: false,
Force: true,
Branch: plumbing.ReferenceName(ref.Name()),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
log.Debug(i18n.G("failed to check out %s in %s: %s", tag, r.Dir, err))
warnMsg = append(warnMsg, i18n.G("skipping tag %s: checkout failed: %s", tag, err))
return nil
}
log.Debug(i18n.G("git checkout: %s in %s", ref.Name(), r.Dir))
config, err := r.GetComposeConfig(nil)
if err != nil {
log.Debug(i18n.G("failed to get compose config for %s: %s", tag, err))
warnMsg = append(warnMsg, i18n.G("skipping tag %s: invalid compose config: %s", tag, err))
return nil
}
versionMeta := make(map[string]ServiceMeta)
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
log.Debug(i18n.G("failed to parse image for %s in %s: %s", service.Name, tag, err))
warnMsg = append(warnMsg, i18n.G("skipping tag %s: invalid image reference in service %s: %s", tag, service.Name, err))
return nil
}
path := reference.Path(img)
path = formatter.StripTagMeta(path)
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
warnMsg = append(warnMsg, i18n.G("%s service is missing image tag?", path))
continue
}
versionMeta[service.Name] = ServiceMeta{
Image: path,
Tag: tag,
}
}
versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta})
return nil
}); err != nil {
log.Warn(i18n.G("GetRecipeVersions encountered error for %s: %s (collected %d versions)", r.Name, err, len(versions)))
return versions, warnMsg, nil
}
_, err = gitPkg.CheckoutDefaultBranch(repo, r.Dir)
if err != nil {
return versions, warnMsg, nil
}
sortRecipeVersions(versions)
log.Debug(i18n.G("collected %s for %s", versions, r.Dir))
var uniqueWarnings []string
for _, w := range warnMsg {
if !slices.Contains(uniqueWarnings, w) {
uniqueWarnings = append(uniqueWarnings, w)
}
}
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)
if err != nil {
return nil, err
}
head, err := repo.Head()
if err != nil {
return nil, err
}
return head, nil
}