This simplifies the deploy overview, to only show 3 version fields: - CURRENT DEPLOYMENT - CURRENT ENV - NEW DEPLOYMENT It also fixes a few errors around version detection Reviewed-on: #508 Co-authored-by: p4u1 <p4u1_f4u1@riseup.net> Co-committed-by: p4u1 <p4u1_f4u1@riseup.net>
482 lines
10 KiB
Go
482 lines
10 KiB
Go
package recipe
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"coopcloud.tech/abra/pkg/config"
|
|
"coopcloud.tech/abra/pkg/formatter"
|
|
gitPkg "coopcloud.tech/abra/pkg/git"
|
|
"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"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
if ctx.Chaos {
|
|
return nil
|
|
}
|
|
|
|
if err := r.EnsureIsClean(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !ctx.Offline {
|
|
if err := r.EnsureUpToDate(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if r.EnvVersion != "" && !ctx.IgnoreEnvVersion {
|
|
log.Debugf("ensuring env version %s", r.EnvVersion)
|
|
if strings.Contains(r.EnvVersion, "+U") {
|
|
log.Fatalf("can not redeploy chaos version (%s) without --chaos", r.EnvVersion)
|
|
}
|
|
|
|
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 err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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.Debugf("read %s as tags for recipe %s", joinedTags, r.Name)
|
|
}
|
|
|
|
var opts *git.CheckoutOptions
|
|
if tagRef.String() == "" {
|
|
log.Debugf("attempting to checkout '%s' as chaos commit", version)
|
|
|
|
hash, err := repo.ResolveRevision(plumbing.Revision(version))
|
|
if err != nil {
|
|
log.Fatalf("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.Debugf("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 fmt.Errorf("unable to check git clean status in %s: %s", r.Dir, err)
|
|
}
|
|
|
|
if !isClean {
|
|
return fmt.Errorf("%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.Debugf("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 fmt.Errorf("unable to open %s: %s", r.Dir, err)
|
|
}
|
|
|
|
remotes, err := repo.Remotes()
|
|
if err != nil {
|
|
return fmt.Errorf("unable to read remotes in %s: %s", r.Dir, err)
|
|
}
|
|
|
|
if len(remotes) == 0 {
|
|
log.Debugf("cannot ensure %s is up-to-date, no git remotes configured", r.Name)
|
|
return nil
|
|
}
|
|
|
|
worktree, err := repo.Worktree()
|
|
if err != nil {
|
|
return fmt.Errorf("unable to open git work tree in %s: %s", r.Dir, err)
|
|
}
|
|
|
|
branch, err := gitPkg.CheckoutDefaultBranch(repo, r.Dir)
|
|
if err != nil {
|
|
return fmt.Errorf("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 fmt.Errorf("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 fmt.Errorf("unable to git pull in %s: %s", r.Dir, err)
|
|
}
|
|
}
|
|
|
|
log.Debugf("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.Debugf("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.Debugf("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.Debugf("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.Debugf("failed to check out %s in %s", tag, r.Dir)
|
|
return err
|
|
}
|
|
|
|
log.Debugf("git checkout: %s in %s", ref.Name(), r.Dir)
|
|
|
|
config, err := r.GetComposeConfig(nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
versionMeta := make(map[string]ServiceMeta)
|
|
for _, service := range config.Services {
|
|
|
|
img, err := reference.ParseNormalizedNamed(service.Image)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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, fmt.Sprintf("%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 {
|
|
return versions, warnMsg, nil
|
|
}
|
|
|
|
_, err = gitPkg.CheckoutDefaultBranch(repo, r.Dir)
|
|
if err != nil {
|
|
return versions, warnMsg, nil
|
|
}
|
|
|
|
sortRecipeVersions(versions)
|
|
|
|
log.Debugf("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
|
|
}
|
|
|
|
// 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
|
|
}
|