init
This commit is contained in:
commit
23c7133fa9
33 files changed
+2896
No files matched your search
@@ -0,0 +1,153 @@
|
||||
// Package store persists model.State to disk as YAML under a file lock and
|
||||
// keeps a git history of every change, so the whole configuration (groups,
|
||||
// hosts, user metadata) can be restored on a fresh host after a disaster.
|
||||
//
|
||||
// Deliberately NOT stored here: private keys. Those are generated, packed
|
||||
// into an export archive, and wiped from disk in the same operation — only
|
||||
// public-key fingerprints are kept in state, so a stolen backup cannot leak
|
||||
// anyone's private key.
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"ssh-access-tui/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
stateFileName = "state.yaml"
|
||||
lockFileName = "state.lock"
|
||||
)
|
||||
|
||||
// Store wraps a state directory (git repo) holding state.yaml.
|
||||
type Store struct {
|
||||
Dir string
|
||||
}
|
||||
|
||||
// New returns a Store rooted at dir, creating the directory and git repo
|
||||
// (via Init) if they do not exist yet.
|
||||
func New(dir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create state dir: %w", err)
|
||||
}
|
||||
s := &Store{Dir: dir}
|
||||
if err := s.ensureGitRepo(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) statePath() string { return filepath.Join(s.Dir, stateFileName) }
|
||||
func (s *Store) lockPath() string { return filepath.Join(s.Dir, lockFileName) }
|
||||
|
||||
func (s *Store) ensureGitRepo() error {
|
||||
if _, err := os.Stat(filepath.Join(s.Dir, ".git")); err == nil {
|
||||
return nil
|
||||
}
|
||||
cmd := exec.Command("git", "init", "-q", s.Dir)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git init: %w: %s", err, out)
|
||||
}
|
||||
// Local commit identity so this works even with no global gitconfig.
|
||||
_ = runGit(s.Dir, "config", "user.name", "ssh-access-tui")
|
||||
_ = runGit(s.Dir, "config", "user.email", "ssh-access-tui@localhost")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load reads state.yaml, returning an empty State if it does not exist yet.
|
||||
// Callers should hold a lock (see WithLock) around Load+mutate+Save.
|
||||
func (s *Store) Load() (*model.State, error) {
|
||||
data, err := os.ReadFile(s.statePath())
|
||||
if os.IsNotExist(err) {
|
||||
return &model.State{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read state: %w", err)
|
||||
}
|
||||
var st model.State
|
||||
if err := yaml.Unmarshal(data, &st); err != nil {
|
||||
return nil, fmt.Errorf("parse state: %w", err)
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// Save writes state.yaml and commits the change to the local git repo with
|
||||
// the given message (should describe the admin action, e.g. "add user bob").
|
||||
func (s *Store) Save(st *model.State, commitMsg string) error {
|
||||
data, err := yaml.Marshal(st)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal state: %w", err)
|
||||
}
|
||||
tmp := s.statePath() + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o640); err != nil {
|
||||
return fmt.Errorf("write state: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, s.statePath()); err != nil {
|
||||
return fmt.Errorf("commit state file: %w", err)
|
||||
}
|
||||
if err := runGit(s.Dir, "add", stateFileName); err != nil {
|
||||
return fmt.Errorf("git add: %w", err)
|
||||
}
|
||||
// A no-op commit (nothing changed) is not an error.
|
||||
if err := runGit(s.Dir, "commit", "-q", "-m", commitMsg); err != nil {
|
||||
if !isNothingToCommit(err) {
|
||||
return fmt.Errorf("git commit: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithLock takes an exclusive file lock for the duration of fn, protecting
|
||||
// against a second concurrent admin session corrupting state.yaml. The tool
|
||||
// is designed for one admin operating at a time; this lock turns an
|
||||
// accidental second session into a clear "someone else is editing" error
|
||||
// instead of silent data loss.
|
||||
func (s *Store) WithLock(fn func() error) error {
|
||||
fl := flock.New(s.lockPath())
|
||||
locked, err := fl.TryLock()
|
||||
if err != nil {
|
||||
return fmt.Errorf("acquire lock: %w", err)
|
||||
}
|
||||
if !locked {
|
||||
return fmt.Errorf("state is locked by another ssh-access-tui session (lock file: %s)", s.lockPath())
|
||||
}
|
||||
defer fl.Unlock()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func runGit(dir string, args ...string) error {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%v: %s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isNothingToCommit(err error) bool {
|
||||
// git exits non-zero with "nothing to commit" when the working tree is
|
||||
// unchanged; treat that as success rather than surfacing it as failure.
|
||||
return err != nil && (contains(err.Error(), "nothing to commit") || contains(err.Error(), "nothing added"))
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (func() bool {
|
||||
for i := 0; i+len(substr) <= len(s); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})()
|
||||
}
|
||||
|
||||
// Now is a var so tests can override it; production code just wants time.Now.
|
||||
var Now = time.Now
|
||||
Reference in new issue
Block a user