Sync Git identity into guest VMs
Populate guest /root/.gitconfig from host git config --global during work-disk preparation so plain VM shells can commit. Resolve user.name and user.email from the source repo for vm run and write them only into the imported checkout, preserving repo-specific identity overrides. Update mounted guest .gitconfig through a host temp file plus sudo install instead of direct git config --file writes, since the mounted root-owned work disk blocks Git lockfile creation. Validated with GOCACHE=/tmp/banger-gocache go test ./..., make build, and a live alpine vm create smoke check for guest git config.
This commit is contained in:
parent
f798e1db33
commit
42b4a18c63
5 changed files with 308 additions and 0 deletions
|
|
@ -207,6 +207,9 @@ func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.
|
|||
if err := d.ensureAuthorizedKeyOnWorkDisk(ctx, vm, image, prep); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.ensureOpencodeAuthOnWorkDisk(ctx, vm)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,11 +32,18 @@ var (
|
|||
)
|
||||
|
||||
const (
|
||||
workDiskGitConfigRelativePath = ".gitconfig"
|
||||
workDiskOpencodeAuthDirRelativePath = ".local/share/opencode"
|
||||
workDiskOpencodeAuthRelativePath = workDiskOpencodeAuthDirRelativePath + "/auth.json"
|
||||
hostGlobalGitIdentitySource = "git config --global"
|
||||
hostOpencodeAuthDefaultDisplayPath = "~/" + workDiskOpencodeAuthRelativePath
|
||||
)
|
||||
|
||||
type gitIdentity struct {
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm model.VMRecord, err error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
|
@ -933,6 +940,32 @@ func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VM
|
|||
return nil
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
|
||||
runner := d.runner
|
||||
if runner == nil {
|
||||
runner = system.NewRunner()
|
||||
}
|
||||
|
||||
identity, err := resolveHostGlobalGitIdentity(ctx, runner)
|
||||
if err != nil {
|
||||
d.warnGitIdentitySyncSkipped(*vm, hostGlobalGitIdentitySource, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
vmCreateStage(ctx, "prepare_work_disk", "syncing git identity")
|
||||
workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanupWork()
|
||||
|
||||
if err := d.flattenNestedWorkHome(ctx, workMount); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeGitIdentity(ctx, runner, filepath.Join(workMount, workDiskGitConfigRelativePath), identity)
|
||||
}
|
||||
|
||||
func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
|
||||
hostAuthPath, err := resolveHostOpencodeAuthPath()
|
||||
if err != nil {
|
||||
|
|
@ -990,6 +1023,69 @@ func resolveHostOpencodeAuthPath() (string, error) {
|
|||
return filepath.Join(home, workDiskOpencodeAuthRelativePath), nil
|
||||
}
|
||||
|
||||
func resolveHostGlobalGitIdentity(ctx context.Context, runner system.CommandRunner) (gitIdentity, error) {
|
||||
name, err := gitConfigValue(ctx, runner, nil, "user.name")
|
||||
if err != nil {
|
||||
return gitIdentity{}, err
|
||||
}
|
||||
if name == "" {
|
||||
return gitIdentity{}, errors.New("host git user.name is empty")
|
||||
}
|
||||
|
||||
email, err := gitConfigValue(ctx, runner, nil, "user.email")
|
||||
if err != nil {
|
||||
return gitIdentity{}, err
|
||||
}
|
||||
if email == "" {
|
||||
return gitIdentity{}, errors.New("host git user.email is empty")
|
||||
}
|
||||
|
||||
return gitIdentity{Name: name, Email: email}, nil
|
||||
}
|
||||
|
||||
func gitConfigValue(ctx context.Context, runner system.CommandRunner, extraArgs []string, key string) (string, error) {
|
||||
args := []string{"config"}
|
||||
args = append(args, extraArgs...)
|
||||
args = append(args, "--default", "", "--get", key)
|
||||
out, err := runner.Run(ctx, "git", args...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func writeGitIdentity(ctx context.Context, runner system.CommandRunner, gitConfigPath string, identity gitIdentity) error {
|
||||
existing, err := runner.RunSudo(ctx, "cat", gitConfigPath)
|
||||
if err != nil {
|
||||
existing = nil
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "banger-gitconfig-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
if _, err := tmpFile.Write(existing); err != nil {
|
||||
_ = tmpFile.Close()
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.name", identity.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.email", identity.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = runner.RunSudo(ctx, "install", "-m", "644", tmpPath, gitConfigPath)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Daemon) warnOpencodeAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) {
|
||||
if d.logger == nil || err == nil {
|
||||
return
|
||||
|
|
@ -997,6 +1093,13 @@ func (d *Daemon) warnOpencodeAuthSyncSkipped(vm model.VMRecord, hostPath string,
|
|||
d.logger.Warn("guest opencode auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
|
||||
}
|
||||
|
||||
func (d *Daemon) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) {
|
||||
if d.logger == nil || err == nil {
|
||||
return
|
||||
}
|
||||
d.logger.Warn("guest git identity sync skipped", append(vmLogAttrs(vm), "source", source, "error", err.Error())...)
|
||||
}
|
||||
|
||||
func mergeAuthorizedKey(existing, managed []byte) []byte {
|
||||
managedLine := strings.TrimSpace(string(managed))
|
||||
if managedLine == "" {
|
||||
|
|
|
|||
|
|
@ -808,6 +808,121 @@ func TestEnsureAuthorizedKeyOnWorkDiskRepairsNestedRootLayout(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEnsureGitIdentityOnWorkDiskCopiesHostGlobalIdentity(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
|
||||
hostConfigPath := filepath.Join(t.TempDir(), "host.gitconfig")
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", hostConfigPath)
|
||||
testSetGitConfig(t, "user.name", "Banger Host")
|
||||
testSetGitConfig(t, "user.email", "host@example.com")
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
d := &Daemon{runner: &filesystemRunner{t: t}}
|
||||
vm := testVM("git-identity", "image-git-identity", "172.16.0.67")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensureGitIdentityOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensureGitIdentityOnWorkDisk: %v", err)
|
||||
}
|
||||
|
||||
guestConfigPath := filepath.Join(workDiskDir, workDiskGitConfigRelativePath)
|
||||
if got := testGitConfigValue(t, guestConfigPath, "user.name"); got != "Banger Host" {
|
||||
t.Fatalf("guest user.name = %q, want Banger Host", got)
|
||||
}
|
||||
if got := testGitConfigValue(t, guestConfigPath, "user.email"); got != "host@example.com" {
|
||||
t.Fatalf("guest user.email = %q, want host@example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureGitIdentityOnWorkDiskPreservesExistingGuestConfig(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
|
||||
hostConfigPath := filepath.Join(t.TempDir(), "host.gitconfig")
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", hostConfigPath)
|
||||
testSetGitConfig(t, "user.name", "Fresh Name")
|
||||
testSetGitConfig(t, "user.email", "fresh@example.com")
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
guestConfigPath := filepath.Join(workDiskDir, workDiskGitConfigRelativePath)
|
||||
if err := os.WriteFile(guestConfigPath, []byte("[safe]\n\tdirectory = /root/repo\n[user]\n\tname = stale\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(guest .gitconfig): %v", err)
|
||||
}
|
||||
|
||||
d := &Daemon{runner: &filesystemRunner{t: t}}
|
||||
vm := testVM("git-identity-preserve", "image-git-identity", "172.16.0.68")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensureGitIdentityOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensureGitIdentityOnWorkDisk: %v", err)
|
||||
}
|
||||
|
||||
if got := testGitConfigValue(t, guestConfigPath, "user.name"); got != "Fresh Name" {
|
||||
t.Fatalf("guest user.name = %q, want Fresh Name", got)
|
||||
}
|
||||
if got := testGitConfigValue(t, guestConfigPath, "user.email"); got != "fresh@example.com" {
|
||||
t.Fatalf("guest user.email = %q, want fresh@example.com", got)
|
||||
}
|
||||
if got := testGitConfigValue(t, guestConfigPath, "safe.directory"); got != "/root/repo" {
|
||||
t.Fatalf("guest safe.directory = %q, want /root/repo", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureGitIdentityOnWorkDiskWarnsAndSkipsWhenHostIdentityIncomplete(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
|
||||
hostConfigPath := filepath.Join(t.TempDir(), "host.gitconfig")
|
||||
t.Setenv("GIT_CONFIG_GLOBAL", hostConfigPath)
|
||||
testSetGitConfig(t, "user.name", "Only Name")
|
||||
|
||||
workDiskDir := t.TempDir()
|
||||
guestConfigPath := filepath.Join(workDiskDir, workDiskGitConfigRelativePath)
|
||||
original := []byte("[user]\n\temail = keep@example.com\n")
|
||||
if err := os.WriteFile(guestConfigPath, original, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(guest .gitconfig): %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
logger, _, err := newDaemonLogger(&buf, "info")
|
||||
if err != nil {
|
||||
t.Fatalf("newDaemonLogger: %v", err)
|
||||
}
|
||||
|
||||
d := &Daemon{
|
||||
runner: &filesystemRunner{t: t},
|
||||
logger: logger,
|
||||
}
|
||||
vm := testVM("git-identity-missing", "image-git-identity", "172.16.0.69")
|
||||
vm.Runtime.WorkDiskPath = workDiskDir
|
||||
|
||||
if err := d.ensureGitIdentityOnWorkDisk(context.Background(), &vm); err != nil {
|
||||
t.Fatalf("ensureGitIdentityOnWorkDisk: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(guestConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(guest .gitconfig): %v", err)
|
||||
}
|
||||
if string(got) != string(original) {
|
||||
t.Fatalf("guest .gitconfig = %q, want preserved %q", string(got), string(original))
|
||||
}
|
||||
|
||||
entries := parseLogEntries(t, buf.Bytes())
|
||||
if !hasLogEntry(entries, map[string]string{
|
||||
"msg": "guest git identity sync skipped",
|
||||
"vm_name": vm.Name,
|
||||
"source": hostGlobalGitIdentitySource,
|
||||
"error": "host git user.email is empty",
|
||||
}) {
|
||||
t.Fatalf("expected warn log, got %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOpencodeAuthOnWorkDiskCopiesHostAuth(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
|
|
@ -1599,6 +1714,27 @@ func startHTTPSServerOnTCP4(t *testing.T, handler http.Handler) *net.TCPAddr {
|
|||
return listener.Addr().(*net.TCPAddr)
|
||||
}
|
||||
|
||||
func testSetGitConfig(t *testing.T, key, value string) {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("git", "config", "--global", key, value)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git config --global %s: %v: %s", key, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
|
||||
func testGitConfigValue(t *testing.T, configPath, key string) string {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command("git", "config", "--file", configPath, "--get", key)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git config --file %s --get %s: %v: %s", configPath, key, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
type processKillingRunner struct {
|
||||
*scriptedRunner
|
||||
proc *exec.Cmd
|
||||
|
|
@ -1610,6 +1746,20 @@ type filesystemRunner struct {
|
|||
|
||||
func (r *filesystemRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
r.t.Helper()
|
||||
if name == "git" {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
if stderr.Len() > 0 {
|
||||
return stdout.Bytes(), fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return stdout.Bytes(), err
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected Run call: %s %v", name, args)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue