Move avoidable daemon shell-outs into Go
Reduce the control plane's dependency on helper scripts while keeping the hard Linux integration points in the approved shell-out layer. Replace the bash-driven image build path with a native Go builder that clones and optionally resizes the rootfs, boots a temporary Firecracker VM, provisions the guest over SSH, installs packages and modules, and preserves the package-manifest sidecar. Also replace a few small convenience shell-outs with Go helpers: read process stats from /proc, use os.Truncate for ext4 image growth, add file-clone and normalized-line helpers, drop the sh -c work-disk flattening path, and launch Firecracker via a direct sudo command. Add tests for the new SSH/archive and system helpers, plus a policy test that keeps os/exec imports confined to cli/firecracker/system. Update the docs to describe customize.sh as a manual helper rather than the daemon's image-build backend. Validated with go mod tidy, go test ./..., and make build.
This commit is contained in:
parent
0a0b0b617b
commit
942d242c03
17 changed files with 936 additions and 145 deletions
74
internal/system/files.go
Normal file
74
internal/system/files.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func CopyFilePreferClone(sourcePath, targetPath string) error {
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
info, err := source.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target, err := os.OpenFile(targetPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer target.Close()
|
||||
|
||||
if err := unix.IoctlFileClone(int(target.Fd()), int(source.Fd())); err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := source.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := target.Seek(0, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(target, source); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := target.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := target.Chmod(info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReadNormalizedLines(path string) ([]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasSuffix(line, "\r") {
|
||||
line = strings.TrimSuffix(line, "\r")
|
||||
}
|
||||
if idx := strings.Index(line, "#"); idx >= 0 {
|
||||
line = line[:idx]
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("file has no entries: %s", path)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -105,25 +106,48 @@ type ProcessStats struct {
|
|||
}
|
||||
|
||||
func ReadProcessStats(ctx context.Context, pid int) (ProcessStats, error) {
|
||||
_ = ctx
|
||||
if pid <= 0 {
|
||||
return ProcessStats{}, errors.New("pid is required")
|
||||
}
|
||||
runner := NewRunner()
|
||||
out, err := runner.Run(ctx, "ps", "-p", strconv.Itoa(pid), "-o", "%cpu=,rss=,vsz=")
|
||||
statData, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
|
||||
if err != nil {
|
||||
return ProcessStats{}, err
|
||||
}
|
||||
fields := strings.Fields(string(out))
|
||||
if len(fields) < 3 {
|
||||
return ProcessStats{}, fmt.Errorf("unexpected ps output: %q", string(out))
|
||||
statmData, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "statm"))
|
||||
if err != nil {
|
||||
return ProcessStats{}, err
|
||||
}
|
||||
cpu, _ := strconv.ParseFloat(fields[0], 64)
|
||||
rssKB, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
vszKB, _ := strconv.ParseInt(fields[2], 10, 64)
|
||||
uptimeData, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return ProcessStats{}, err
|
||||
}
|
||||
|
||||
procStat, err := parseProcStat(string(statData))
|
||||
if err != nil {
|
||||
return ProcessStats{}, err
|
||||
}
|
||||
memStat, err := parseProcStatm(string(statmData))
|
||||
if err != nil {
|
||||
return ProcessStats{}, err
|
||||
}
|
||||
uptimeSeconds, err := parseProcUptime(string(uptimeData))
|
||||
if err != nil {
|
||||
return ProcessStats{}, err
|
||||
}
|
||||
|
||||
const ticksPerSecond = 100.0
|
||||
elapsedSeconds := uptimeSeconds - (float64(procStat.startTicks) / ticksPerSecond)
|
||||
cpuPercent := 0.0
|
||||
if elapsedSeconds > 0 {
|
||||
totalCPUSeconds := float64(procStat.userTicks+procStat.systemTicks) / ticksPerSecond
|
||||
cpuPercent = math.Max(0, (totalCPUSeconds/elapsedSeconds)*100)
|
||||
}
|
||||
pageSize := int64(os.Getpagesize())
|
||||
return ProcessStats{
|
||||
CPUPercent: cpu,
|
||||
RSSBytes: rssKB * 1024,
|
||||
VSZBytes: vszKB * 1024,
|
||||
CPUPercent: cpuPercent,
|
||||
RSSBytes: memStat.residentPages * pageSize,
|
||||
VSZBytes: memStat.sizePages * pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -179,7 +203,7 @@ func CopyDirContents(ctx context.Context, runner CommandRunner, sourceDir, targe
|
|||
}
|
||||
|
||||
func ResizeExt4Image(ctx context.Context, runner CommandRunner, path string, bytes int64) error {
|
||||
if _, err := runner.Run(ctx, "truncate", "-s", strconv.FormatInt(bytes, 10), path); err != nil {
|
||||
if err := os.Truncate(path, bytes); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := runner.Run(ctx, "e2fsck", "-p", "-f", path); err != nil {
|
||||
|
|
@ -324,3 +348,67 @@ func CopyStream(dst io.Writer, cmd *exec.Cmd) error {
|
|||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
type procStat struct {
|
||||
userTicks uint64
|
||||
systemTicks uint64
|
||||
startTicks uint64
|
||||
}
|
||||
|
||||
type procStatm struct {
|
||||
sizePages int64
|
||||
residentPages int64
|
||||
}
|
||||
|
||||
func parseProcStat(raw string) (procStat, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
end := strings.LastIndex(raw, ")")
|
||||
if end == -1 || end+2 >= len(raw) {
|
||||
return procStat{}, fmt.Errorf("unexpected /proc stat format: %q", raw)
|
||||
}
|
||||
fields := strings.Fields(raw[end+2:])
|
||||
if len(fields) < 20 {
|
||||
return procStat{}, fmt.Errorf("unexpected /proc stat field count: %q", raw)
|
||||
}
|
||||
userTicks, err := strconv.ParseUint(fields[11], 10, 64)
|
||||
if err != nil {
|
||||
return procStat{}, err
|
||||
}
|
||||
systemTicks, err := strconv.ParseUint(fields[12], 10, 64)
|
||||
if err != nil {
|
||||
return procStat{}, err
|
||||
}
|
||||
startTicks, err := strconv.ParseUint(fields[19], 10, 64)
|
||||
if err != nil {
|
||||
return procStat{}, err
|
||||
}
|
||||
return procStat{
|
||||
userTicks: userTicks,
|
||||
systemTicks: systemTicks,
|
||||
startTicks: startTicks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseProcStatm(raw string) (procStatm, error) {
|
||||
fields := strings.Fields(strings.TrimSpace(raw))
|
||||
if len(fields) < 2 {
|
||||
return procStatm{}, fmt.Errorf("unexpected /proc statm format: %q", raw)
|
||||
}
|
||||
sizePages, err := strconv.ParseInt(fields[0], 10, 64)
|
||||
if err != nil {
|
||||
return procStatm{}, err
|
||||
}
|
||||
residentPages, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
return procStatm{}, err
|
||||
}
|
||||
return procStatm{sizePages: sizePages, residentPages: residentPages}, nil
|
||||
}
|
||||
|
||||
func parseProcUptime(raw string) (float64, error) {
|
||||
fields := strings.Fields(strings.TrimSpace(raw))
|
||||
if len(fields) == 0 {
|
||||
return 0, fmt.Errorf("unexpected /proc uptime format: %q", raw)
|
||||
}
|
||||
return strconv.ParseFloat(fields[0], 64)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,46 +81,64 @@ func TestResizeExt4ImageStopsAtFirstFailure(t *testing.T) {
|
|||
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []systemStep
|
||||
setup func(t *testing.T) string
|
||||
steps func(path string) []systemStep
|
||||
wantErr string
|
||||
wantCalls int
|
||||
}{
|
||||
{
|
||||
name: "truncate failure",
|
||||
steps: []systemStep{
|
||||
{call: systemCall{name: "truncate", args: []string{"-s", "4096", "/tmp/root.ext4"}}, err: errors.New("truncate failed")},
|
||||
setup: func(t *testing.T) string {
|
||||
return t.TempDir()
|
||||
},
|
||||
wantErr: "truncate failed",
|
||||
wantCalls: 1,
|
||||
wantErr: "",
|
||||
wantCalls: 0,
|
||||
},
|
||||
{
|
||||
name: "e2fsck failure",
|
||||
steps: []systemStep{
|
||||
{call: systemCall{name: "truncate", args: []string{"-s", "4096", "/tmp/root.ext4"}}},
|
||||
{call: systemCall{name: "e2fsck", args: []string{"-p", "-f", "/tmp/root.ext4"}}, err: errors.New("e2fsck failed")},
|
||||
steps: func(path string) []systemStep {
|
||||
return []systemStep{
|
||||
{call: systemCall{name: "e2fsck", args: []string{"-p", "-f", path}}, err: errors.New("e2fsck failed")},
|
||||
}
|
||||
},
|
||||
wantErr: "e2fsck failed",
|
||||
wantCalls: 2,
|
||||
wantCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "resize2fs failure",
|
||||
steps: []systemStep{
|
||||
{call: systemCall{name: "truncate", args: []string{"-s", "4096", "/tmp/root.ext4"}}},
|
||||
{call: systemCall{name: "e2fsck", args: []string{"-p", "-f", "/tmp/root.ext4"}}},
|
||||
{call: systemCall{name: "resize2fs", args: []string{"/tmp/root.ext4"}}, err: errors.New("resize2fs failed")},
|
||||
steps: func(path string) []systemStep {
|
||||
return []systemStep{
|
||||
{call: systemCall{name: "e2fsck", args: []string{"-p", "-f", path}}},
|
||||
{call: systemCall{name: "resize2fs", args: []string{path}}, err: errors.New("resize2fs failed")},
|
||||
}
|
||||
},
|
||||
wantErr: "resize2fs failed",
|
||||
wantCalls: 3,
|
||||
wantCalls: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runner := &scriptedRunner{t: t, steps: tt.steps}
|
||||
err := ResizeExt4Image(context.Background(), runner, "/tmp/root.ext4", 4096)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
path := "/tmp/root.ext4"
|
||||
if tt.setup != nil {
|
||||
path = tt.setup(t)
|
||||
} else {
|
||||
path = filepath.Join(t.TempDir(), "root.ext4")
|
||||
if err := os.WriteFile(path, []byte("seed"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%s): %v", path, err)
|
||||
}
|
||||
}
|
||||
var steps []systemStep
|
||||
if tt.steps != nil {
|
||||
steps = tt.steps(path)
|
||||
}
|
||||
runner := &scriptedRunner{t: t, steps: steps}
|
||||
err := ResizeExt4Image(context.Background(), runner, path, 4096)
|
||||
if err == nil {
|
||||
t.Fatal("ResizeExt4Image() succeeded, want error")
|
||||
}
|
||||
if tt.wantErr != "" && !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("ResizeExt4Image() error = %v, want %q", err, tt.wantErr)
|
||||
}
|
||||
if len(runner.calls) != tt.wantCalls {
|
||||
|
|
@ -131,6 +149,24 @@ func TestResizeExt4ImageStopsAtFirstFailure(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestReadNormalizedLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "packages.apt")
|
||||
if err := os.WriteFile(path, []byte("\n# comment\n git \nless # trailing\n\r\ntmux\r\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got, err := ReadNormalizedLines(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadNormalizedLines: %v", err)
|
||||
}
|
||||
want := []string{"git", "less", "tmux"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("lines = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteExt4FileRemovesTempFileAndReturnsCopyError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -218,6 +254,34 @@ func TestMountTempDirUsesLoopForRegularFilesAndCleanupUsesBackgroundContext(t *t
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseProcHelpers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stat, err := parseProcStat("1234 (firecracker) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22")
|
||||
if err != nil {
|
||||
t.Fatalf("parseProcStat: %v", err)
|
||||
}
|
||||
if stat.userTicks != 11 || stat.systemTicks != 12 || stat.startTicks != 19 {
|
||||
t.Fatalf("proc stat = %+v", stat)
|
||||
}
|
||||
|
||||
statm, err := parseProcStatm("200 50 0 0 0 0 0")
|
||||
if err != nil {
|
||||
t.Fatalf("parseProcStatm: %v", err)
|
||||
}
|
||||
if statm.sizePages != 200 || statm.residentPages != 50 {
|
||||
t.Fatalf("proc statm = %+v", statm)
|
||||
}
|
||||
|
||||
uptime, err := parseProcUptime("321.50 42.10")
|
||||
if err != nil {
|
||||
t.Fatalf("parseProcUptime: %v", err)
|
||||
}
|
||||
if uptime != 321.50 {
|
||||
t.Fatalf("uptime = %v, want 321.50", uptime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountTempDirRemovesTempDirWhenMountFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue