* ci: consolidate duplicate workflows and merge CodeQL configs Remove 17 duplicate workflow files that were split copies of the combined originals. Each family (CI, CodeQL, Coverage, PR Build, Alpha Release) had the same job duplicated across separate push/pull_request/schedule/manual trigger files. Merge codeql.yml and codescan.yml into a single codeql.yml with a language matrix covering go, javascript-typescript, python, and actions — matching the previous default setup coverage. Remaining workflows (one per family): - ci.yml (push + PR + manual) - codeql.yml (push + PR + schedule, all languages) - coverage.yml (push + PR + manual) - alpha-release.yml (push + manual) - pr-build.yml (PR + manual) - release.yml (tag push) - agent-verify.yml, auto-label.yml, auto-project.yml Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add collect, config, crypt, plugin packages and fix all lint issues Add four new infrastructure packages with CLI commands: - pkg/config: layered configuration (defaults → file → env → flags) - pkg/crypt: crypto primitives (Argon2id, AES-GCM, ChaCha20, HMAC, checksums) - pkg/plugin: plugin system with GitHub-based install/update/remove - pkg/collect: collection subsystem (GitHub, BitcoinTalk, market, papers, excavate) Fix all golangci-lint issues across the entire codebase (~100 errcheck, staticcheck SA1012/SA1019/ST1005, unused, ineffassign fixes) so that `core go qa` passes with 0 issues. Closes #167, #168, #170, #250, #251, #252, #253, #254, #255, #256 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
438 lines
9.8 KiB
Go
438 lines
9.8 KiB
Go
package container
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
goio "io"
|
|
"os"
|
|
"os/exec"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/host-uk/core/pkg/io"
|
|
)
|
|
|
|
// LinuxKitManager implements the Manager interface for LinuxKit VMs.
|
|
type LinuxKitManager struct {
|
|
state *State
|
|
hypervisor Hypervisor
|
|
}
|
|
|
|
// NewLinuxKitManager creates a new LinuxKit manager with auto-detected hypervisor.
|
|
func NewLinuxKitManager() (*LinuxKitManager, error) {
|
|
statePath, err := DefaultStatePath()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to determine state path: %w", err)
|
|
}
|
|
|
|
state, err := LoadState(statePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load state: %w", err)
|
|
}
|
|
|
|
hypervisor, err := DetectHypervisor()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &LinuxKitManager{
|
|
state: state,
|
|
hypervisor: hypervisor,
|
|
}, nil
|
|
}
|
|
|
|
// NewLinuxKitManagerWithHypervisor creates a manager with a specific hypervisor.
|
|
func NewLinuxKitManagerWithHypervisor(state *State, hypervisor Hypervisor) *LinuxKitManager {
|
|
return &LinuxKitManager{
|
|
state: state,
|
|
hypervisor: hypervisor,
|
|
}
|
|
}
|
|
|
|
// Run starts a new LinuxKit VM from the given image.
|
|
func (m *LinuxKitManager) Run(ctx context.Context, image string, opts RunOptions) (*Container, error) {
|
|
// Validate image exists
|
|
if !io.Local.IsFile(image) {
|
|
return nil, fmt.Errorf("image not found: %s", image)
|
|
}
|
|
|
|
// Detect image format
|
|
format := DetectImageFormat(image)
|
|
if format == FormatUnknown {
|
|
return nil, fmt.Errorf("unsupported image format: %s", image)
|
|
}
|
|
|
|
// Generate container ID
|
|
id, err := GenerateID()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate container ID: %w", err)
|
|
}
|
|
|
|
// Apply defaults
|
|
if opts.Memory <= 0 {
|
|
opts.Memory = 1024
|
|
}
|
|
if opts.CPUs <= 0 {
|
|
opts.CPUs = 1
|
|
}
|
|
if opts.SSHPort <= 0 {
|
|
opts.SSHPort = 2222
|
|
}
|
|
|
|
// Use name or generate from ID
|
|
name := opts.Name
|
|
if name == "" {
|
|
name = id[:8]
|
|
}
|
|
|
|
// Ensure logs directory exists
|
|
if err := EnsureLogsDir(); err != nil {
|
|
return nil, fmt.Errorf("failed to create logs directory: %w", err)
|
|
}
|
|
|
|
// Get log file path
|
|
logPath, err := LogPath(id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to determine log path: %w", err)
|
|
}
|
|
|
|
// Build hypervisor options
|
|
hvOpts := &HypervisorOptions{
|
|
Memory: opts.Memory,
|
|
CPUs: opts.CPUs,
|
|
LogFile: logPath,
|
|
SSHPort: opts.SSHPort,
|
|
Ports: opts.Ports,
|
|
Volumes: opts.Volumes,
|
|
Detach: opts.Detach,
|
|
}
|
|
|
|
// Build the command
|
|
cmd, err := m.hypervisor.BuildCommand(ctx, image, hvOpts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to build hypervisor command: %w", err)
|
|
}
|
|
|
|
// Create log file
|
|
logFile, err := os.Create(logPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create log file: %w", err)
|
|
}
|
|
|
|
// Create container record
|
|
container := &Container{
|
|
ID: id,
|
|
Name: name,
|
|
Image: image,
|
|
Status: StatusRunning,
|
|
StartedAt: time.Now(),
|
|
Ports: opts.Ports,
|
|
Memory: opts.Memory,
|
|
CPUs: opts.CPUs,
|
|
}
|
|
|
|
if opts.Detach {
|
|
// Run in background
|
|
cmd.Stdout = logFile
|
|
cmd.Stderr = logFile
|
|
|
|
// Start the process
|
|
if err := cmd.Start(); err != nil {
|
|
_ = logFile.Close()
|
|
return nil, fmt.Errorf("failed to start VM: %w", err)
|
|
}
|
|
|
|
container.PID = cmd.Process.Pid
|
|
|
|
// Save state
|
|
if err := m.state.Add(container); err != nil {
|
|
// Try to kill the process we just started
|
|
_ = cmd.Process.Kill()
|
|
_ = logFile.Close()
|
|
return nil, fmt.Errorf("failed to save state: %w", err)
|
|
}
|
|
|
|
// Close log file handle (process has its own)
|
|
_ = logFile.Close()
|
|
|
|
// Start a goroutine to wait for process exit and update state
|
|
go m.waitForExit(container.ID, cmd)
|
|
|
|
return container, nil
|
|
}
|
|
|
|
// Run in foreground
|
|
// Tee output to both log file and stdout
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
_ = logFile.Close()
|
|
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
|
|
}
|
|
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
_ = logFile.Close()
|
|
return nil, fmt.Errorf("failed to get stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
_ = logFile.Close()
|
|
return nil, fmt.Errorf("failed to start VM: %w", err)
|
|
}
|
|
|
|
container.PID = cmd.Process.Pid
|
|
|
|
// Save state before waiting
|
|
if err := m.state.Add(container); err != nil {
|
|
_ = cmd.Process.Kill()
|
|
_ = logFile.Close()
|
|
return nil, fmt.Errorf("failed to save state: %w", err)
|
|
}
|
|
|
|
// Copy output to both log and stdout
|
|
go func() {
|
|
mw := goio.MultiWriter(logFile, os.Stdout)
|
|
_, _ = goio.Copy(mw, stdout)
|
|
}()
|
|
go func() {
|
|
mw := goio.MultiWriter(logFile, os.Stderr)
|
|
_, _ = goio.Copy(mw, stderr)
|
|
}()
|
|
|
|
// Wait for the process to complete
|
|
if err := cmd.Wait(); err != nil {
|
|
container.Status = StatusError
|
|
} else {
|
|
container.Status = StatusStopped
|
|
}
|
|
|
|
_ = logFile.Close()
|
|
if err := m.state.Update(container); err != nil {
|
|
return container, fmt.Errorf("update container state: %w", err)
|
|
}
|
|
|
|
return container, nil
|
|
}
|
|
|
|
// waitForExit monitors a detached process and updates state when it exits.
|
|
func (m *LinuxKitManager) waitForExit(id string, cmd *exec.Cmd) {
|
|
err := cmd.Wait()
|
|
|
|
container, ok := m.state.Get(id)
|
|
if ok {
|
|
if err != nil {
|
|
container.Status = StatusError
|
|
} else {
|
|
container.Status = StatusStopped
|
|
}
|
|
_ = m.state.Update(container)
|
|
}
|
|
}
|
|
|
|
// Stop stops a running container by sending SIGTERM.
|
|
func (m *LinuxKitManager) Stop(ctx context.Context, id string) error {
|
|
container, ok := m.state.Get(id)
|
|
if !ok {
|
|
return fmt.Errorf("container not found: %s", id)
|
|
}
|
|
|
|
if container.Status != StatusRunning {
|
|
return fmt.Errorf("container is not running: %s", id)
|
|
}
|
|
|
|
// Find the process
|
|
process, err := os.FindProcess(container.PID)
|
|
if err != nil {
|
|
// Process doesn't exist, update state
|
|
container.Status = StatusStopped
|
|
_ = m.state.Update(container)
|
|
return nil
|
|
}
|
|
|
|
// Send SIGTERM
|
|
if err := process.Signal(syscall.SIGTERM); err != nil {
|
|
// Process might already be gone
|
|
container.Status = StatusStopped
|
|
_ = m.state.Update(container)
|
|
return nil
|
|
}
|
|
|
|
// Wait for graceful shutdown with timeout
|
|
done := make(chan struct{})
|
|
go func() {
|
|
_, _ = process.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Process exited gracefully
|
|
case <-time.After(10 * time.Second):
|
|
// Force kill
|
|
_ = process.Signal(syscall.SIGKILL)
|
|
<-done
|
|
case <-ctx.Done():
|
|
// Context cancelled
|
|
_ = process.Signal(syscall.SIGKILL)
|
|
return ctx.Err()
|
|
}
|
|
|
|
container.Status = StatusStopped
|
|
return m.state.Update(container)
|
|
}
|
|
|
|
// List returns all known containers, verifying process state.
|
|
func (m *LinuxKitManager) List(ctx context.Context) ([]*Container, error) {
|
|
containers := m.state.All()
|
|
|
|
// Verify each running container's process is still alive
|
|
for _, c := range containers {
|
|
if c.Status == StatusRunning {
|
|
if !isProcessRunning(c.PID) {
|
|
c.Status = StatusStopped
|
|
_ = m.state.Update(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
return containers, nil
|
|
}
|
|
|
|
// isProcessRunning checks if a process with the given PID is still running.
|
|
func isProcessRunning(pid int) bool {
|
|
process, err := os.FindProcess(pid)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// On Unix, FindProcess always succeeds, so we need to send signal 0 to check
|
|
err = process.Signal(syscall.Signal(0))
|
|
return err == nil
|
|
}
|
|
|
|
// Logs returns a reader for the container's log output.
|
|
func (m *LinuxKitManager) Logs(ctx context.Context, id string, follow bool) (goio.ReadCloser, error) {
|
|
_, ok := m.state.Get(id)
|
|
if !ok {
|
|
return nil, fmt.Errorf("container not found: %s", id)
|
|
}
|
|
|
|
logPath, err := LogPath(id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to determine log path: %w", err)
|
|
}
|
|
|
|
if !io.Local.IsFile(logPath) {
|
|
return nil, fmt.Errorf("no logs available for container: %s", id)
|
|
}
|
|
|
|
if !follow {
|
|
// Simple case: just open and return the file
|
|
return os.Open(logPath)
|
|
}
|
|
|
|
// Follow mode: create a reader that tails the file
|
|
return newFollowReader(ctx, logPath)
|
|
}
|
|
|
|
// followReader implements goio.ReadCloser for following log files.
|
|
type followReader struct {
|
|
file *os.File
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
reader *bufio.Reader
|
|
}
|
|
|
|
func newFollowReader(ctx context.Context, path string) (*followReader, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Seek to end
|
|
_, _ = file.Seek(0, goio.SeekEnd)
|
|
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
|
|
return &followReader{
|
|
file: file,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
reader: bufio.NewReader(file),
|
|
}, nil
|
|
}
|
|
|
|
func (f *followReader) Read(p []byte) (int, error) {
|
|
for {
|
|
select {
|
|
case <-f.ctx.Done():
|
|
return 0, goio.EOF
|
|
default:
|
|
}
|
|
|
|
n, err := f.reader.Read(p)
|
|
if n > 0 {
|
|
return n, nil
|
|
}
|
|
if err != nil && err != goio.EOF {
|
|
return 0, err
|
|
}
|
|
|
|
// No data available, wait a bit and try again
|
|
select {
|
|
case <-f.ctx.Done():
|
|
return 0, goio.EOF
|
|
case <-time.After(100 * time.Millisecond):
|
|
// Reset reader to pick up new data
|
|
f.reader.Reset(f.file)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *followReader) Close() error {
|
|
f.cancel()
|
|
return f.file.Close()
|
|
}
|
|
|
|
// Exec executes a command inside the container via SSH.
|
|
func (m *LinuxKitManager) Exec(ctx context.Context, id string, cmd []string) error {
|
|
container, ok := m.state.Get(id)
|
|
if !ok {
|
|
return fmt.Errorf("container not found: %s", id)
|
|
}
|
|
|
|
if container.Status != StatusRunning {
|
|
return fmt.Errorf("container is not running: %s", id)
|
|
}
|
|
|
|
// Default SSH port
|
|
sshPort := 2222
|
|
|
|
// Build SSH command
|
|
sshArgs := []string{
|
|
"-p", fmt.Sprintf("%d", sshPort),
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "UserKnownHostsFile=~/.core/known_hosts",
|
|
"-o", "LogLevel=ERROR",
|
|
"root@localhost",
|
|
}
|
|
sshArgs = append(sshArgs, cmd...)
|
|
|
|
sshCmd := exec.CommandContext(ctx, "ssh", sshArgs...)
|
|
sshCmd.Stdin = os.Stdin
|
|
sshCmd.Stdout = os.Stdout
|
|
sshCmd.Stderr = os.Stderr
|
|
|
|
return sshCmd.Run()
|
|
}
|
|
|
|
// State returns the manager's state (for testing).
|
|
func (m *LinuxKitManager) State() *State {
|
|
return m.state
|
|
}
|
|
|
|
// Hypervisor returns the manager's hypervisor (for testing).
|
|
func (m *LinuxKitManager) Hypervisor() Hypervisor {
|
|
return m.hypervisor
|
|
}
|