github.com/caos/orbos@v1.5.14-0.20221103111702-e6cd0cea7ad4/pkg/git/client.go (about)

     1  package git
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"io/ioutil"
     9  	"os"
    10  	"path/filepath"
    11  	"strings"
    12  	"time"
    13  
    14  	"errors"
    15  
    16  	"github.com/go-git/go-billy/v5"
    17  	"github.com/go-git/go-billy/v5/memfs"
    18  	gogit "github.com/go-git/go-git/v5"
    19  	"github.com/go-git/go-git/v5/config"
    20  	"github.com/go-git/go-git/v5/plumbing"
    21  	"github.com/go-git/go-git/v5/plumbing/object"
    22  	gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
    23  	"github.com/go-git/go-git/v5/storage/memory"
    24  	"golang.org/x/crypto/ssh"
    25  	"gopkg.in/yaml.v3"
    26  
    27  	"github.com/caos/orbos/internal/operator/common"
    28  	"github.com/caos/orbos/mntr"
    29  	"github.com/caos/orbos/pkg/tree"
    30  )
    31  
    32  type DesiredFile string
    33  
    34  func (d DesiredFile) WOExtension() string {
    35  	return strings.Split(string(d), ".")[0]
    36  }
    37  
    38  const (
    39  	writeCheckTag = "writecheck"
    40  
    41  	OrbiterFile    DesiredFile = "orbiter.yml"
    42  	BoomFile       DesiredFile = "boom.yml"
    43  	NetworkingFile DesiredFile = "networking.yml"
    44  	DatabaseFile   DesiredFile = "database.yml"
    45  	ZitadelFile    DesiredFile = "zitadel.yml"
    46  )
    47  
    48  type Client struct {
    49  	monitor   mntr.Monitor
    50  	ctx       context.Context
    51  	committer string
    52  	email     string
    53  	auth      *gitssh.PublicKeys
    54  	repo      *gogit.Repository
    55  	fs        billy.Filesystem
    56  	storage   *memory.Storage
    57  	workTree  *gogit.Worktree
    58  	progress  io.Writer
    59  	repoURL   string
    60  	cloned    bool
    61  }
    62  
    63  func New(ctx context.Context, monitor mntr.Monitor, committer, email string) *Client {
    64  	newClient := &Client{
    65  		ctx:       ctx,
    66  		committer: committer,
    67  		email:     email,
    68  		monitor:   monitor,
    69  		storage:   memory.NewStorage(),
    70  		fs:        memfs.New(),
    71  	}
    72  
    73  	if monitor.IsVerbose() {
    74  		newClient.progress = os.Stdout
    75  	}
    76  	return newClient
    77  }
    78  
    79  func (g *Client) GetURL() string {
    80  	return g.repoURL
    81  }
    82  
    83  func (g *Client) Configure(repoURL string, deploykey []byte) error {
    84  	signer, err := ssh.ParsePrivateKey(deploykey)
    85  	if err != nil {
    86  		return mntr.ToUserError(fmt.Errorf("parsing deployment key failed: %w", err))
    87  	}
    88  
    89  	if repoURL != g.repoURL {
    90  		g.repoURL = repoURL
    91  		g.cloned = false
    92  	}
    93  	g.monitor = g.monitor.WithField("repository", repoURL)
    94  
    95  	g.auth = &gitssh.PublicKeys{
    96  		User:   "git",
    97  		Signer: signer,
    98  	}
    99  
   100  	// TODO: Fix
   101  	g.auth.HostKeyCallback = ssh.InsecureIgnoreHostKey()
   102  
   103  	return nil
   104  }
   105  
   106  func (g *Client) Check() error {
   107  	if !g.cloned {
   108  		return nil
   109  	}
   110  	if err := g.readCheck(); err != nil {
   111  		return err
   112  	}
   113  
   114  	return g.writeCheck()
   115  }
   116  
   117  func (g *Client) readCheck() error {
   118  
   119  	rem := gogit.NewRemote(memory.NewStorage(), &config.RemoteConfig{
   120  		Name: "origin",
   121  		URLs: []string{g.repoURL},
   122  	})
   123  
   124  	// We can then use every Remote functions to retrieve wanted information
   125  	_, err := rem.List(&gogit.ListOptions{
   126  		Auth: g.auth,
   127  	})
   128  	if err != nil {
   129  		return mntr.ToUserError(fmt.Errorf("read check failed: %w", err))
   130  	}
   131  
   132  	g.monitor.Info("Read check success")
   133  	return nil
   134  }
   135  
   136  func (g *Client) writeCheck() (err error) {
   137  
   138  	defer func() {
   139  		err = mntr.ToUserError(err)
   140  	}()
   141  
   142  	head, err := g.repo.Head()
   143  	if err != nil {
   144  		return fmt.Errorf("failed to get head: %w", err)
   145  	}
   146  	localWriteCheckTag := strings.Join([]string{writeCheckTag, g.committer}, "-")
   147  
   148  	ref, createErr := g.repo.CreateTag(localWriteCheckTag, head.Hash(), nil)
   149  	if createErr == gogit.ErrTagExists {
   150  		if ref, err = g.repo.Tag(localWriteCheckTag); err != nil {
   151  			return err
   152  		}
   153  		createErr = nil
   154  	}
   155  
   156  	if createErr != nil {
   157  		return fmt.Errorf("write-check failed: %w", createErr)
   158  	}
   159  
   160  	if pushErr := g.repo.Push(&gogit.PushOptions{
   161  		RemoteName: "origin",
   162  		RefSpecs: []config.RefSpec{
   163  			config.RefSpec("+" + ref.Name() + ":" + ref.Name()),
   164  		},
   165  		Auth: g.auth,
   166  	}); pushErr != nil && pushErr != gogit.NoErrAlreadyUpToDate {
   167  		return fmt.Errorf("write-check failed: %w", pushErr)
   168  	}
   169  
   170  	g.monitor.Debug("Write check tag created")
   171  
   172  	if deleteErr := g.repo.DeleteTag(localWriteCheckTag); deleteErr != nil && deleteErr != gogit.ErrTagNotFound {
   173  		return fmt.Errorf("write-check cleanup delete tag failed: %w", deleteErr)
   174  	}
   175  
   176  	if err := g.repo.Push(&gogit.PushOptions{
   177  		RemoteName: "origin",
   178  		RefSpecs: []config.RefSpec{
   179  			config.RefSpec(":" + ref.Name()),
   180  		},
   181  		Auth: g.auth,
   182  	}); err != nil {
   183  		return fmt.Errorf("write-check cleanup failed: %w", err)
   184  	}
   185  
   186  	g.monitor.Debug("Write check tag cleaned up")
   187  	g.monitor.Info("Write check success")
   188  	return nil
   189  }
   190  
   191  func (g *Client) Clone() (err error) {
   192  	for i := 0; i < 10; i++ {
   193  		if err = g.clone(); err == nil {
   194  			return nil
   195  		}
   196  		time.Sleep(time.Second)
   197  	}
   198  	return err
   199  }
   200  
   201  func (g *Client) clone() error {
   202  	g.fs = memfs.New()
   203  
   204  	g.monitor.Debug("Cloning")
   205  	var err error
   206  	g.repo, err = gogit.CloneContext(g.ctx, memory.NewStorage(), g.fs, &gogit.CloneOptions{
   207  		URL:          g.repoURL,
   208  		Auth:         g.auth,
   209  		SingleBranch: true,
   210  		Depth:        1,
   211  		Progress:     g.progress,
   212  	})
   213  	if err != nil {
   214  		return mntr.ToUserError(fmt.Errorf("cloning repository from %s failed: %w", g.repoURL, err))
   215  	}
   216  	g.monitor.Debug("Cloned")
   217  
   218  	g.workTree, err = g.repo.Worktree()
   219  	if err != nil {
   220  		panic(err)
   221  	}
   222  
   223  	g.cloned = true
   224  
   225  	return nil
   226  }
   227  
   228  func (g *Client) Read(path string) []byte {
   229  
   230  	readmonitor := g.monitor.WithFields(map[string]interface{}{
   231  		"path": path,
   232  	})
   233  	readmonitor.Debug("Reading file")
   234  	file, err := g.fs.Open(path)
   235  	if err != nil {
   236  		if os.IsNotExist(err) {
   237  			return make([]byte, 0)
   238  		}
   239  		panic(err)
   240  	}
   241  	defer file.Close()
   242  	fileBytes, err := ioutil.ReadAll(file)
   243  	if err != nil {
   244  		panic(err)
   245  	}
   246  	if readmonitor.IsVerbose() {
   247  		readmonitor.Debug("File read")
   248  		fmt.Println(string(fileBytes))
   249  	}
   250  	return fileBytes
   251  }
   252  
   253  func (g *Client) ReadYamlIntoStruct(path string, struc interface{}) error {
   254  	data := g.Read(path)
   255  
   256  	err := yaml.Unmarshal(data, struc)
   257  	if err != nil {
   258  		err = fmt.Errorf("unmarshaling yaml %s to struct failed: %w", path, err)
   259  	}
   260  
   261  	return err
   262  }
   263  
   264  func (g *Client) ExistsFolder(path string) (bool, error) {
   265  	monitor := g.monitor.WithFields(map[string]interface{}{
   266  		"path": path,
   267  	})
   268  	monitor.Debug("Reading folder")
   269  	_, err := g.fs.ReadDir(path)
   270  	if err != nil {
   271  		if os.IsNotExist(err) {
   272  			return false, nil
   273  		}
   274  		return false, fmt.Errorf("opening %s from worktree failed: %w", path, err)
   275  	}
   276  
   277  	return true, nil
   278  }
   279  
   280  func (g *Client) EmptyFolder(path string) (bool, error) {
   281  	monitor := g.monitor.WithFields(map[string]interface{}{
   282  		"path": path,
   283  	})
   284  	monitor.Debug("Reading folder")
   285  	files, err := g.fs.ReadDir(path)
   286  	if err != nil {
   287  		return false, fmt.Errorf("opening %s from worktree failed: %w", path, err)
   288  	}
   289  	if len(files) == 0 {
   290  		return true, nil
   291  	}
   292  	return false, nil
   293  }
   294  
   295  func (g *Client) ReadFolder(path string) (map[string][]byte, []string, error) {
   296  	monitor := g.monitor.WithFields(map[string]interface{}{
   297  		"path": path,
   298  	})
   299  	monitor.Debug("Reading folder")
   300  	dirBytes := make(map[string][]byte, 0)
   301  	files, err := g.fs.ReadDir(path)
   302  	if err != nil {
   303  		if os.IsNotExist(err) {
   304  			return make(map[string][]byte, 0), nil, nil
   305  		}
   306  		return nil, nil, fmt.Errorf("opening %s from worktree failed: %w", path, err)
   307  	}
   308  	subdirs := make([]string, 0)
   309  	for _, file := range files {
   310  		if !file.IsDir() {
   311  			filePath := filepath.Join(path, file.Name())
   312  			fileBytes := g.Read(filePath)
   313  			dirBytes[file.Name()] = fileBytes
   314  		} else {
   315  			subdirs = append(subdirs, file.Name())
   316  		}
   317  	}
   318  
   319  	if monitor.IsVerbose() {
   320  		monitor.Debug("Folder read")
   321  		fmt.Println(dirBytes)
   322  	}
   323  	return dirBytes, subdirs, nil
   324  }
   325  
   326  type File struct {
   327  	Path    string
   328  	Content []byte
   329  }
   330  
   331  func (g *Client) stageAndCommit(msg string, files ...File) (bool, error) {
   332  	if g.stage(files...) {
   333  		return false, nil
   334  	}
   335  
   336  	return true, g.Commit(msg)
   337  }
   338  
   339  func (g *Client) UpdateRemote(msg string, whenCloned func() []File) error {
   340  
   341  	if err := g.Clone(); err != nil {
   342  		return fmt.Errorf("recloning before committing changes failed: %w", err)
   343  	}
   344  
   345  	changed, err := g.stageAndCommit(msg, whenCloned()...)
   346  	if err != nil {
   347  		return err
   348  	}
   349  
   350  	if !changed {
   351  		g.monitor.Info("No changes")
   352  		return nil
   353  	}
   354  	err = g.Push()
   355  	if err != nil &&
   356  		(errors.Is(err, plumbing.ErrObjectNotFound) ||
   357  			strings.Contains(err.Error(), "cannot lock ref")) {
   358  		g.monitor.WithField("response", err.Error()).Info("Git collision detected, retrying")
   359  		return g.UpdateRemote(msg, whenCloned)
   360  	}
   361  	return err
   362  }
   363  
   364  func (g *Client) stage(files ...File) bool {
   365  	for _, f := range files {
   366  		updatemonitor := g.monitor.WithFields(map[string]interface{}{
   367  			"path": f.Path,
   368  		})
   369  
   370  		updatemonitor.Debug("Overwriting local index")
   371  
   372  		file, err := g.fs.Create(f.Path)
   373  		if err != nil {
   374  			panic(err)
   375  		}
   376  		//noinspection GoDeferInLoop
   377  		defer file.Close()
   378  
   379  		if _, err := io.Copy(file, bytes.NewReader(f.Content)); err != nil {
   380  			panic(err)
   381  		}
   382  
   383  		_, err = g.workTree.Add(f.Path)
   384  		if err != nil {
   385  			panic(err)
   386  		}
   387  	}
   388  
   389  	status, err := g.workTree.Status()
   390  	if err != nil {
   391  		panic(err)
   392  	}
   393  
   394  	return status.IsClean()
   395  }
   396  
   397  func (g *Client) Commit(msg string) error {
   398  
   399  	if _, err := g.workTree.Commit(msg, &gogit.CommitOptions{
   400  		Author: &object.Signature{
   401  			Name:  g.committer,
   402  			Email: g.email,
   403  			When:  time.Now(),
   404  		},
   405  	}); err != nil {
   406  		return fmt.Errorf("committing changes failed: %w", err)
   407  	}
   408  	g.monitor.Debug("Changes commited")
   409  	return nil
   410  }
   411  
   412  func (g *Client) Push() error {
   413  
   414  	if err := g.repo.PushContext(g.ctx, &gogit.PushOptions{
   415  		RemoteName: "origin",
   416  		//			RefSpecs:   refspecs,
   417  		Auth:     g.auth,
   418  		Progress: g.progress,
   419  	}); err != nil {
   420  		return fmt.Errorf("pushing repository failed: %w", err)
   421  	}
   422  
   423  	g.monitor.Info("Repository pushed")
   424  	return nil
   425  }
   426  
   427  func (g *Client) Exists(path DesiredFile) bool {
   428  	of := g.Read(string(path))
   429  	if of != nil && len(of) > 0 {
   430  		return true
   431  	}
   432  	return false
   433  }
   434  
   435  func (g *Client) ReadTree(path DesiredFile) (*tree.Tree, error) {
   436  	tree := &tree.Tree{}
   437  	return tree, yaml.Unmarshal(g.Read(string(path)), tree)
   438  }
   439  
   440  type GitDesiredState struct {
   441  	Desired *tree.Tree
   442  	Path    DesiredFile
   443  }
   444  
   445  func (g *Client) PushGitDesiredStates(monitor mntr.Monitor, msg string, desireds []GitDesiredState) (err error) {
   446  	monitor.OnChange = func(_ string, fields map[string]string) {
   447  		err = g.UpdateRemote(mntr.SprintCommit(msg, fields), func() []File {
   448  			gitFiles := make([]File, len(desireds))
   449  			for i := range desireds {
   450  				desired := desireds[i]
   451  				gitFiles[i] = File{
   452  					Path:    string(desired.Path),
   453  					Content: common.MarshalYAML(desired.Desired),
   454  				}
   455  			}
   456  			return gitFiles
   457  		})
   458  	}
   459  	monitor.Changed(msg)
   460  	return err
   461  }
   462  
   463  func (g *Client) PushDesiredFunc(file DesiredFile, desired *tree.Tree) func(mntr.Monitor) error {
   464  	return func(monitor mntr.Monitor) error {
   465  		monitor.WithField("file", file).Info("Writing desired state")
   466  		return g.PushGitDesiredStates(monitor, fmt.Sprintf("Desired state written to %s", file), []GitDesiredState{{
   467  			Desired: desired,
   468  			Path:    file,
   469  		}})
   470  	}
   471  }