766 lines
21 KiB
Go
766 lines
21 KiB
Go
package gitops
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.send.nrw/sendnrw/dockwatch/internal/nodes"
|
|
"git.send.nrw/sendnrw/dockwatch/internal/stacks"
|
|
)
|
|
|
|
var stackNameRx = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
|
|
|
type Source struct {
|
|
ID int64 `json:"id"`
|
|
NodeID *int64 `json:"node_id,omitempty"`
|
|
StackName string `json:"stack_name"`
|
|
RepoURL string `json:"repo_url"`
|
|
Branch string `json:"branch"`
|
|
Workdir string `json:"workdir"`
|
|
ComposeFile string `json:"compose_file"`
|
|
AutoDeploy bool `json:"auto_deploy"`
|
|
LastCommit string `json:"last_commit"`
|
|
LastSyncAt *int64 `json:"last_sync_at,omitempty"`
|
|
LastError string `json:"last_error"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
UpdatedAt int64 `json:"updated_at"`
|
|
}
|
|
type Input struct {
|
|
NodeID *int64 `json:"node_id"`
|
|
StackName string `json:"stack_name"`
|
|
RepoURL string `json:"repo_url"`
|
|
Branch string `json:"branch"`
|
|
Workdir string `json:"workdir"`
|
|
ComposeFile string `json:"compose_file"`
|
|
AutoDeploy bool `json:"auto_deploy"`
|
|
}
|
|
type Service struct {
|
|
db *sql.DB
|
|
key []byte
|
|
stacks *stacks.Service
|
|
nodes *nodes.Manager
|
|
locks sync.Map
|
|
}
|
|
|
|
func New(db *sql.DB, key []byte, ss *stacks.Service, nm *nodes.Manager) *Service {
|
|
return &Service{db: db, key: key, stacks: ss, nodes: nm}
|
|
}
|
|
func normalize(in *Input) error {
|
|
in.StackName = strings.TrimSpace(in.StackName)
|
|
in.RepoURL = strings.TrimSpace(in.RepoURL)
|
|
in.Branch = strings.TrimSpace(in.Branch)
|
|
in.Workdir = filepath.Clean(strings.TrimSpace(in.Workdir))
|
|
in.ComposeFile = filepath.Clean(strings.TrimSpace(in.ComposeFile))
|
|
if in.StackName == "" || in.RepoURL == "" {
|
|
return errors.New("stack_name and repo_url required")
|
|
}
|
|
if !stackNameRx.MatchString(in.StackName) || strings.ContainsAny(in.RepoURL, "\r\n") || strings.HasPrefix(in.RepoURL, "-") {
|
|
return errors.New("invalid stack name or repository URL")
|
|
}
|
|
if len(in.RepoURL) > 4096 || len(in.Branch) > 255 || strings.ContainsAny(in.Branch, "\r\n") {
|
|
return errors.New("git source fields too long")
|
|
}
|
|
if in.Branch == "" {
|
|
in.Branch = "main"
|
|
}
|
|
if in.Workdir == "." || in.Workdir == "" {
|
|
in.Workdir = "."
|
|
}
|
|
if strings.HasPrefix(in.Workdir, "..") || filepath.IsAbs(in.Workdir) {
|
|
return errors.New("invalid workdir")
|
|
}
|
|
if in.ComposeFile == "." || in.ComposeFile == "" {
|
|
in.ComposeFile = "compose.yaml"
|
|
}
|
|
if strings.HasPrefix(in.ComposeFile, "..") || filepath.IsAbs(in.ComposeFile) {
|
|
return errors.New("invalid compose_file")
|
|
}
|
|
return nil
|
|
}
|
|
func (s *Service) List(ctx context.Context) ([]Source, error) {
|
|
rows, e := s.db.QueryContext(ctx, `SELECT id,node_id,stack_name,repo_url,branch,workdir,compose_file,auto_deploy,last_commit,last_sync_at,last_error,created_at,updated_at FROM git_sources ORDER BY stack_name`)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
defer rows.Close()
|
|
out := []Source{}
|
|
for rows.Next() {
|
|
var x Source
|
|
var sync, node sql.NullInt64
|
|
if e := rows.Scan(&x.ID, &node, &x.StackName, &x.RepoURL, &x.Branch, &x.Workdir, &x.ComposeFile, &x.AutoDeploy, &x.LastCommit, &sync, &x.LastError, &x.CreatedAt, &x.UpdatedAt); e != nil {
|
|
return nil, e
|
|
}
|
|
if node.Valid {
|
|
v := node.Int64
|
|
x.NodeID = &v
|
|
}
|
|
if sync.Valid {
|
|
v := sync.Int64
|
|
x.LastSyncAt = &v
|
|
}
|
|
out = append(out, x)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
func (s *Service) Get(ctx context.Context, id int64) (Source, error) {
|
|
var x Source
|
|
var sync, node sql.NullInt64
|
|
e := s.db.QueryRowContext(ctx, `SELECT id,node_id,stack_name,repo_url,branch,workdir,compose_file,auto_deploy,last_commit,last_sync_at,last_error,created_at,updated_at FROM git_sources WHERE id=?`, id).Scan(&x.ID, &node, &x.StackName, &x.RepoURL, &x.Branch, &x.Workdir, &x.ComposeFile, &x.AutoDeploy, &x.LastCommit, &sync, &x.LastError, &x.CreatedAt, &x.UpdatedAt)
|
|
if node.Valid {
|
|
v := node.Int64
|
|
x.NodeID = &v
|
|
}
|
|
if sync.Valid {
|
|
v := sync.Int64
|
|
x.LastSyncAt = &v
|
|
}
|
|
return x, e
|
|
}
|
|
func (s *Service) Create(ctx context.Context, in Input) (Source, string, error) {
|
|
if e := normalize(&in); e != nil {
|
|
return Source{}, "", e
|
|
}
|
|
secret := make([]byte, 32)
|
|
if _, e := rand.Read(secret); e != nil {
|
|
return Source{}, "", e
|
|
}
|
|
sec := hex.EncodeToString(secret)
|
|
enc, e := s.encrypt([]byte(sec))
|
|
if e != nil {
|
|
return Source{}, "", e
|
|
}
|
|
now := time.Now().Unix()
|
|
r, e := s.db.ExecContext(ctx, `INSERT INTO git_sources(node_id,stack_name,repo_url,branch,workdir,compose_file,auto_deploy,webhook_secret_enc,last_commit,last_error,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,'','',?,?)`, in.NodeID, in.StackName, in.RepoURL, in.Branch, in.Workdir, in.ComposeFile, in.AutoDeploy, enc, now, now)
|
|
if e != nil {
|
|
return Source{}, "", e
|
|
}
|
|
id, _ := r.LastInsertId()
|
|
x, e := s.Get(ctx, id)
|
|
return x, sec, e
|
|
}
|
|
func (s *Service) Update(ctx context.Context, id int64, in Input) (Source, error) {
|
|
if e := normalize(&in); e != nil {
|
|
return Source{}, e
|
|
}
|
|
now := time.Now().Unix()
|
|
r, e := s.db.ExecContext(ctx, `UPDATE git_sources SET node_id=?,stack_name=?,repo_url=?,branch=?,workdir=?,compose_file=?,auto_deploy=?,updated_at=? WHERE id=?`, in.NodeID, in.StackName, in.RepoURL, in.Branch, in.Workdir, in.ComposeFile, in.AutoDeploy, now, id)
|
|
if e != nil {
|
|
return Source{}, e
|
|
}
|
|
n, _ := r.RowsAffected()
|
|
if n == 0 {
|
|
return Source{}, sql.ErrNoRows
|
|
}
|
|
return s.Get(ctx, id)
|
|
}
|
|
func (s *Service) Delete(ctx context.Context, id int64) error {
|
|
_, e := s.db.ExecContext(ctx, `DELETE FROM git_sources WHERE id=?`, id)
|
|
return e
|
|
}
|
|
func (s *Service) RotateSecret(ctx context.Context, id int64) (string, error) {
|
|
secret := make([]byte, 32)
|
|
if _, e := rand.Read(secret); e != nil {
|
|
return "", e
|
|
}
|
|
sec := hex.EncodeToString(secret)
|
|
enc, e := s.encrypt([]byte(sec))
|
|
if e != nil {
|
|
return "", e
|
|
}
|
|
_, e = s.db.ExecContext(ctx, `UPDATE git_sources SET webhook_secret_enc=?,updated_at=? WHERE id=?`, enc, time.Now().Unix(), id)
|
|
return sec, e
|
|
}
|
|
func (s *Service) VerifyWebhook(ctx context.Context, id int64, body []byte, signature, token string) error {
|
|
var enc []byte
|
|
if e := s.db.QueryRowContext(ctx, `SELECT webhook_secret_enc FROM git_sources WHERE id=?`, id).Scan(&enc); e != nil {
|
|
return e
|
|
}
|
|
plain, e := s.decrypt(enc)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
secret := string(plain)
|
|
if token != "" && hmac.Equal([]byte(token), []byte(secret)) {
|
|
return nil
|
|
}
|
|
signature = strings.TrimPrefix(signature, "sha256=")
|
|
if signature != "" {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
_, _ = mac.Write(body)
|
|
want := hex.EncodeToString(mac.Sum(nil))
|
|
if hmac.Equal([]byte(strings.ToLower(signature)), []byte(want)) {
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("invalid webhook signature")
|
|
}
|
|
func (s *Service) lockFor(id int64) *sync.Mutex {
|
|
v, _ := s.locks.LoadOrStore(id, &sync.Mutex{})
|
|
return v.(*sync.Mutex)
|
|
}
|
|
|
|
func (s *Service) Sync(ctx context.Context, id int64) (Source, error) {
|
|
mu := s.lockFor(id)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
x, e := s.Get(ctx, id)
|
|
if e != nil {
|
|
return x, e
|
|
}
|
|
in := Input{NodeID: x.NodeID, StackName: x.StackName, RepoURL: x.RepoURL, Branch: x.Branch, Workdir: x.Workdir, ComposeFile: x.ComposeFile, AutoDeploy: x.AutoDeploy}
|
|
var commit string
|
|
if x.NodeID != nil {
|
|
if s.nodes == nil {
|
|
return s.syncFailed(ctx, x, errors.New("node manager unavailable"))
|
|
}
|
|
b, _, err := s.nodes.Do(ctx, *x.NodeID, "POST", "/agent/v1/git/sync", in)
|
|
if err != nil {
|
|
return s.syncFailed(ctx, x, err)
|
|
}
|
|
var resp struct {
|
|
Commit string `json:"commit"`
|
|
}
|
|
if err = json.Unmarshal(b, &resp); err != nil {
|
|
return s.syncFailed(ctx, x, err)
|
|
}
|
|
commit = resp.Commit
|
|
} else {
|
|
commit, e = s.SyncTransient(ctx, in)
|
|
if e != nil {
|
|
return s.syncFailed(ctx, x, e)
|
|
}
|
|
}
|
|
now := time.Now().Unix()
|
|
_, _ = s.db.ExecContext(ctx, `UPDATE git_sources SET last_commit=?,last_sync_at=?,last_error='',updated_at=? WHERE id=?`, commit, now, now, id)
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
// SyncTransient performs a Git-backed stack synchronization on the current
|
|
// Docker environment. Agents expose this operation to the master without
|
|
// persisting Git source metadata locally.
|
|
func (s *Service) SyncTransient(ctx context.Context, in Input) (string, error) {
|
|
if e := normalize(&in); e != nil {
|
|
return "", e
|
|
}
|
|
tmp, e := os.MkdirTemp("", "dockwatch-git-*")
|
|
if e != nil {
|
|
return "", e
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
cloneDir := filepath.Join(tmp, "repo")
|
|
cctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(cctx, "git", "clone", "--depth", "1", "--branch", in.Branch, "--single-branch", in.RepoURL, cloneDir)
|
|
out, e := cmd.CombinedOutput()
|
|
if e != nil {
|
|
return "", fmt.Errorf("git clone: %w: %s", e, strings.TrimSpace(string(out)))
|
|
}
|
|
commitRaw, e := exec.CommandContext(cctx, "git", "-C", cloneDir, "rev-parse", "HEAD").Output()
|
|
if e != nil {
|
|
return "", e
|
|
}
|
|
src := filepath.Join(cloneDir, in.Workdir)
|
|
if fi, e := os.Stat(src); e != nil || !fi.IsDir() {
|
|
return "", errors.New("git workdir not found")
|
|
}
|
|
if _, e := os.Stat(filepath.Join(src, in.ComposeFile)); e != nil {
|
|
return "", fmt.Errorf("compose file not found: %s", in.ComposeFile)
|
|
}
|
|
dst := filepath.Join(s.stacks.Root(), in.StackName)
|
|
stage := filepath.Join(tmp, "stage")
|
|
if e := copyDir(src, stage); e != nil {
|
|
return "", e
|
|
}
|
|
if filepath.Clean(in.ComposeFile) != "compose.yaml" {
|
|
b, e := os.ReadFile(filepath.Join(stage, in.ComposeFile))
|
|
if e != nil {
|
|
return "", e
|
|
}
|
|
if e = os.WriteFile(filepath.Join(stage, "compose.yaml"), b, 0640); e != nil {
|
|
return "", e
|
|
}
|
|
}
|
|
if e := s.stacks.ValidateProject(ctx, in.StackName, stage, "compose.yaml"); e != nil {
|
|
return "", e
|
|
}
|
|
if e := syncManagedTree(stage, dst); e != nil {
|
|
return "", e
|
|
}
|
|
if in.AutoDeploy {
|
|
if _, e = s.stacks.Action(ctx, in.StackName, "up"); e != nil {
|
|
return "", e
|
|
}
|
|
}
|
|
return strings.TrimSpace(string(commitRaw)), nil
|
|
}
|
|
|
|
func (s *Service) syncFailed(ctx context.Context, x Source, e error) (Source, error) {
|
|
_, _ = s.db.ExecContext(ctx, `UPDATE git_sources SET last_error=?,updated_at=? WHERE id=?`, e.Error(), time.Now().Unix(), x.ID)
|
|
x.LastError = e.Error()
|
|
return x, e
|
|
}
|
|
|
|
const gitManifestPath = ".dockwatch/git-manifest.json"
|
|
|
|
type gitManifest struct {
|
|
Files []string `json:"files"`
|
|
}
|
|
|
|
// syncManagedTree applies a Git checkout without treating the stack directory as
|
|
// disposable storage. Only files previously managed by Git and files present in
|
|
// the new checkout are changed. Unrelated files (for example bind-mount data)
|
|
// survive a sync. The touched files are snapshotted so a failed apply can be
|
|
// rolled back without copying or deleting the whole stack directory.
|
|
func syncManagedTree(stage, dst string) error {
|
|
files, err := collectManagedFiles(stage)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ensureSafeRoot(dst); err != nil {
|
|
return err
|
|
}
|
|
old, err := readGitManifest(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
backup, err := os.MkdirTemp("", "dockwatch-git-rollback-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(backup)
|
|
|
|
affected := map[string]struct{}{}
|
|
for _, rel := range old {
|
|
affected[rel] = struct{}{}
|
|
}
|
|
for _, rel := range files {
|
|
affected[rel] = struct{}{}
|
|
}
|
|
manifestAbs := filepath.Join(dst, filepath.FromSlash(gitManifestPath))
|
|
manifestBackup := filepath.Join(backup, "manifest.json")
|
|
manifestExisted := false
|
|
if info, err := os.Lstat(manifestAbs); err == nil {
|
|
if !info.Mode().IsRegular() {
|
|
return errors.New("Git manifest path is not a regular file")
|
|
}
|
|
if err := copyRegularFile(manifestAbs, manifestBackup, info.Mode().Perm()); err != nil {
|
|
return err
|
|
}
|
|
manifestExisted = true
|
|
} else if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
|
|
backedUp := map[string]bool{}
|
|
for rel := range affected {
|
|
target, err := safeManagedPath(dst, rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ensureSafeParent(dst, filepath.Dir(target)); err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Lstat(target)
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("refusing to replace symlink in Git-managed path %q", rel)
|
|
}
|
|
if info.Mode().IsRegular() {
|
|
bp := filepath.Join(backup, filepath.FromSlash(rel))
|
|
if err := copyRegularFile(target, bp, info.Mode().Perm()); err != nil {
|
|
return err
|
|
}
|
|
backedUp[rel] = true
|
|
}
|
|
}
|
|
|
|
rollback := func() {
|
|
for rel := range affected {
|
|
target, err := safeManagedPath(dst, rel)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if info, err := os.Lstat(target); err == nil && info.Mode().IsRegular() {
|
|
_ = os.Remove(target)
|
|
}
|
|
if backedUp[rel] {
|
|
bp := filepath.Join(backup, filepath.FromSlash(rel))
|
|
if info, err := os.Stat(bp); err == nil {
|
|
_ = copyRegularFile(bp, target, info.Mode().Perm())
|
|
}
|
|
}
|
|
}
|
|
_ = os.Remove(manifestAbs)
|
|
if manifestExisted {
|
|
if info, err := os.Stat(manifestBackup); err == nil {
|
|
_ = copyRegularFile(manifestBackup, manifestAbs, info.Mode().Perm())
|
|
}
|
|
}
|
|
}
|
|
|
|
newSet := make(map[string]struct{}, len(files))
|
|
for _, rel := range files {
|
|
newSet[rel] = struct{}{}
|
|
}
|
|
// Remove files that disappeared from Git, but never recursively remove a
|
|
// directory. This deliberately leaves unrelated data untouched.
|
|
for _, rel := range old {
|
|
if _, ok := newSet[rel]; ok {
|
|
continue
|
|
}
|
|
target, err := safeManagedPath(dst, rel)
|
|
if err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
if info, err := os.Lstat(target); err == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) {
|
|
rollback()
|
|
return fmt.Errorf("refusing to remove non-regular Git-managed path %q", rel)
|
|
}
|
|
if info.Mode().IsRegular() {
|
|
if err := os.Remove(target); err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
rollback()
|
|
return err
|
|
}
|
|
}
|
|
|
|
for _, rel := range files {
|
|
source, err := safeManagedPath(stage, rel)
|
|
if err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
target, err := safeManagedPath(dst, rel)
|
|
if err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
if err := ensureSafeParent(dst, filepath.Dir(target)); err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
if info, err := os.Lstat(target); err == nil && info.IsDir() {
|
|
// A file replacing a directory is safe only when that directory is
|
|
// empty. os.Remove intentionally refuses non-empty directories.
|
|
if err := os.Remove(target); err != nil {
|
|
rollback()
|
|
return fmt.Errorf("Git file %q conflicts with existing directory containing unmanaged data: %w", rel, err)
|
|
}
|
|
} else if err == nil && info.Mode()&os.ModeSymlink != 0 {
|
|
rollback()
|
|
return fmt.Errorf("refusing to replace symlink in Git-managed path %q", rel)
|
|
} else if err != nil && !os.IsNotExist(err) {
|
|
rollback()
|
|
return err
|
|
}
|
|
info, err := os.Stat(source)
|
|
if err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
if err := copyRegularFile(source, target, info.Mode().Perm()); err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := writeGitManifest(dst, files); err != nil {
|
|
rollback()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func collectManagedFiles(root string) ([]string, error) {
|
|
out := []string{}
|
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if path == root {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if rel == gitManifestPath {
|
|
return nil
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("Git stack contains unsupported symlink %q", rel)
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
if info.Mode().IsRegular() {
|
|
out = append(out, rel)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.Strings(out)
|
|
return out, nil
|
|
}
|
|
|
|
func readGitManifest(dst string) ([]string, error) {
|
|
path := filepath.Join(dst, filepath.FromSlash(gitManifestPath))
|
|
if err := ensureSafeParent(dst, filepath.Dir(path)); err != nil {
|
|
return nil, err
|
|
}
|
|
b, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return []string{}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var m gitManifest
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return nil, fmt.Errorf("invalid Git-managed file manifest: %w", err)
|
|
}
|
|
out := make([]string, 0, len(m.Files))
|
|
seen := map[string]bool{}
|
|
for _, rel := range m.Files {
|
|
rel = filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel)))
|
|
if rel == "." || rel == gitManifestPath || strings.HasPrefix(rel, "../") || filepath.IsAbs(filepath.FromSlash(rel)) || seen[rel] {
|
|
continue
|
|
}
|
|
seen[rel] = true
|
|
out = append(out, rel)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func writeGitManifest(dst string, files []string) error {
|
|
path := filepath.Join(dst, filepath.FromSlash(gitManifestPath))
|
|
if err := ensureSafeParent(dst, filepath.Dir(path)); err != nil {
|
|
return err
|
|
}
|
|
b, err := json.MarshalIndent(gitManifest{Files: files}, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := os.CreateTemp(filepath.Dir(path), ".dockwatch-git-manifest-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := f.Name()
|
|
defer os.Remove(tmp)
|
|
if err := f.Chmod(0640); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
if _, err := f.Write(append(b, '\n')); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
func safeManagedPath(root, rel string) (string, error) {
|
|
rel = filepath.Clean(filepath.FromSlash(rel))
|
|
if rel == "." || filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
|
return "", errors.New("invalid Git-managed path")
|
|
}
|
|
return filepath.Join(root, rel), nil
|
|
}
|
|
|
|
func ensureSafeRoot(root string) error {
|
|
info, err := os.Lstat(root)
|
|
if os.IsNotExist(err) {
|
|
return os.MkdirAll(root, 0750)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return errors.New("Git stack destination must be a real directory")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureSafeParent(root, parent string) error {
|
|
rel, err := filepath.Rel(root, parent)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
|
return errors.New("path escapes stack root")
|
|
}
|
|
cur := root
|
|
if err := ensureSafeRoot(root); err != nil {
|
|
return err
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
for _, part := range strings.Split(rel, string(os.PathSeparator)) {
|
|
cur = filepath.Join(cur, part)
|
|
info, err := os.Lstat(cur)
|
|
if os.IsNotExist(err) {
|
|
if err := os.Mkdir(cur, 0750); err != nil && !os.IsExist(err) {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
|
return fmt.Errorf("unsafe parent path %q", cur)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyRegularFile(src, dst string, mode os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil {
|
|
return err
|
|
}
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(dst), ".dockwatch-git-write-*")
|
|
if err != nil {
|
|
_ = in.Close()
|
|
return err
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer os.Remove(tmpName)
|
|
if err := tmp.Chmod(mode); err != nil {
|
|
_ = in.Close()
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
_, copyErr := io.Copy(tmp, in)
|
|
inErr := in.Close()
|
|
if copyErr == nil {
|
|
copyErr = tmp.Sync()
|
|
}
|
|
outErr := tmp.Close()
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
if inErr != nil {
|
|
return inErr
|
|
}
|
|
if outErr != nil {
|
|
return outErr
|
|
}
|
|
return os.Rename(tmpName, dst)
|
|
}
|
|
|
|
func copyDir(src, dst string) error {
|
|
return filepath.Walk(src, func(path string, info os.FileInfo, e error) error {
|
|
if e != nil {
|
|
return e
|
|
}
|
|
rel, e := filepath.Rel(src, path)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if rel == ".git" || strings.HasPrefix(rel, ".git"+string(os.PathSeparator)) {
|
|
if info.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
if info.IsDir() {
|
|
return os.MkdirAll(target, info.Mode().Perm())
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
if e := os.MkdirAll(filepath.Dir(target), 0750); e != nil {
|
|
return e
|
|
}
|
|
in, e := os.Open(path)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
out, e := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
|
|
if e != nil {
|
|
_ = in.Close()
|
|
return e
|
|
}
|
|
_, copyErr := io.Copy(out, in)
|
|
inErr := in.Close()
|
|
outErr := out.Close()
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
if inErr != nil {
|
|
return inErr
|
|
}
|
|
return outErr
|
|
})
|
|
}
|
|
func (s *Service) encrypt(p []byte) ([]byte, error) {
|
|
b, e := aes.NewCipher(s.key)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
g, e := cipher.NewGCM(b)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
nonce := make([]byte, g.NonceSize())
|
|
if _, e = rand.Read(nonce); e != nil {
|
|
return nil, e
|
|
}
|
|
return g.Seal(nonce, nonce, p, nil), nil
|
|
}
|
|
func (s *Service) decrypt(v []byte) ([]byte, error) {
|
|
b, e := aes.NewCipher(s.key)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
g, e := cipher.NewGCM(b)
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
if len(v) < g.NonceSize() {
|
|
return nil, errors.New("invalid encrypted secret")
|
|
}
|
|
return g.Open(nil, v[:g.NonceSize()], v[g.NonceSize():], nil)
|
|
}
|