From f4bb309ab8e1b8a990a154c912202a91d6861bc8 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 24 Jun 2026 23:25:39 +1000 Subject: [PATCH 1/3] Drop Darwin agent support Amp-Thread-ID: https://ampcode.com/threads/T-019ef9bb-d4fe-724b-ac1c-64aa47147da4 Co-authored-by: Amp --- .github/workflows/agent-tip-release.yml | 4 - .github/workflows/release.yml | 4 - agent/MACOS.md | 136 ---- agent/README.md | 4 - agent/internal/agent/agent.go | 11 - agent/internal/build/build.go | 3 +- .../container/{logs_linux.go => logs.go} | 2 - agent/internal/container/logs_darwin.go | 128 ---- .../{runtime_linux.go => runtime.go} | 55 -- agent/internal/container/runtime_darwin.go | 614 ------------------ agent/internal/dns/{dns_linux.go => dns.go} | 6 - agent/internal/dns/dns_darwin.go | 100 --- agent/internal/dns/server.go | 27 +- .../paths/{paths_linux.go => paths.go} | 2 - agent/internal/paths/paths_darwin.go | 25 - agent/internal/traefik/routes.go | 112 ---- agent/internal/traefik/types.go | 41 +- 17 files changed, 14 insertions(+), 1260 deletions(-) delete mode 100644 agent/MACOS.md rename agent/internal/container/{logs_linux.go => logs.go} (99%) delete mode 100644 agent/internal/container/logs_darwin.go rename agent/internal/container/{runtime_linux.go => runtime.go} (89%) delete mode 100644 agent/internal/container/runtime_darwin.go rename agent/internal/dns/{dns_linux.go => dns.go} (96%) delete mode 100644 agent/internal/dns/dns_darwin.go rename agent/internal/paths/{paths_linux.go => paths.go} (94%) delete mode 100644 agent/internal/paths/paths_darwin.go diff --git a/.github/workflows/agent-tip-release.yml b/.github/workflows/agent-tip-release.yml index 4732add..8b73a6d 100644 --- a/.github/workflows/agent-tip-release.yml +++ b/.github/workflows/agent-tip-release.yml @@ -21,10 +21,6 @@ jobs: goarch: amd64 - goos: linux goarch: arm64 - - goos: darwin - goarch: amd64 - - goos: darwin - goarch: arm64 steps: - name: Checkout diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9248cf..bf208de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,10 +19,6 @@ jobs: goarch: amd64 - goos: linux goarch: arm64 - - goos: darwin - goarch: amd64 - - goos: darwin - goarch: arm64 steps: - name: Checkout diff --git a/agent/MACOS.md b/agent/MACOS.md deleted file mode 100644 index e063987..0000000 --- a/agent/MACOS.md +++ /dev/null @@ -1,136 +0,0 @@ -# macOS Setup Guide - -On macOS, containers run inside OrbStack/Docker which has isolated networking. Additional setup is required for WireGuard traffic to reach containers. - -## Prerequisites - -- OrbStack or Docker Desktop -- WireGuard (`brew install wireguard-tools`) -- BuildKit client (`brew install buildkit`) - -## Enable IP Forwarding - -```bash -sudo sysctl -w net.inet.ip.forwarding=1 -``` - -To persist across reboots: -```bash -echo "net.inet.ip.forwarding=1" | sudo tee -a /etc/sysctl.conf -``` - -## NAT Setup for Container Traffic - -Containers only respond to IPs on their local subnet. Traffic from other servers via WireGuard needs NAT. - -**1. Create NAT rule file:** - -Replace `X` with your subnet ID (check your WireGuard IP - if it's 10.100.5.1, your subnet ID is 5): - -```bash -echo 'nat on bridge101 from 10.100.0.0/16 to 10.200.X.0/24 -> (bridge101)' | sudo tee /etc/pf.anchors/wireguard-nat -``` - -**2. Backup pf.conf:** - -```bash -sudo cp /etc/pf.conf /etc/pf.conf.backup -``` - -**3. Add anchor to pf.conf:** - -```bash -sudo nano /etc/pf.conf -``` - -Add these lines near the top (after existing `nat-anchor` lines): - -``` -nat-anchor "wireguard-nat" -load anchor "wireguard-nat" from "/etc/pf.anchors/wireguard-nat" -``` - -**4. Load the config:** - -```bash -sudo pfctl -f /etc/pf.conf -``` - -**5. Verify:** - -```bash -sudo pfctl -a wireguard-nat -s nat -``` - -## BuildKit Setup - -On macOS, BuildKit daemon (buildkitd) must run inside a Linux VM or container. The Homebrew formula only includes the client tools. - -**Using OrbStack/Docker (recommended):** - -```bash -docker run -d --name buildkitd --privileged moby/buildkit:latest -``` - -Then run the agent with the `BUILDKIT_HOST` env var. Use `sudo -E` to preserve environment variables: - -```bash -sudo BUILDKIT_HOST=docker-container://buildkitd ./agent --url -``` - -Or with `-E`: - -```bash -BUILDKIT_HOST=docker-container://buildkitd sudo -E ./agent --url -``` - -## Insecure Registry (HTTP) - -If you see errors like: -``` -Error response from daemon: Get "https://registry:5000/v2/": http: server gave HTTP response to HTTPS client -``` - -Docker is trying to use HTTPS for a registry that only supports HTTP. Configure OrbStack to allow insecure registries: - -1. Open OrbStack → Settings → Docker -2. Add `registry:5000` (or your registry address) to "Insecure registries" -3. Restart Docker from the OrbStack menu bar - -Alternatively, edit `~/.orbstack/config/docker.json`: -```json -{ - "insecure-registries": ["registry:5000"] -} -``` - -## WireGuard Commands - -```bash -sudo wg show -wg-quick down wg0 && wg-quick up wg0 -``` - -## Debugging Network Issues - -Check if packets arrive on WireGuard interface: -```bash -sudo tcpdump -i utun5 icmp -n -``` - -Check if packets reach Docker bridge: -```bash -sudo tcpdump -i bridge101 icmp -n -``` - -Test connectivity: -```bash -# Ping from Mac to container -ping -c 3 10.200.5.3 - -# Check IP forwarding is enabled -sysctl net.inet.ip.forwarding - -# Verify NAT rule -sudo pfctl -a wireguard-nat -s nat -``` diff --git a/agent/README.md b/agent/README.md index 94e5ab1..fc1386e 100644 --- a/agent/README.md +++ b/agent/README.md @@ -263,7 +263,3 @@ sudo journalctl -u techulus-agent -f ```bash podman ps -a --format "table {{.Names}}\t{{.State}}\t{{.Labels}}" ``` - -## macOS - -See [MACOS.md](./MACOS.md) for macOS-specific setup and troubleshooting. diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go index fcb7826..59458e0 100644 --- a/agent/internal/agent/agent.go +++ b/agent/internal/agent/agent.go @@ -25,17 +25,6 @@ const ( StateProcessing ) -func (s AgentState) String() string { - switch s { - case StateIdle: - return "IDLE" - case StateProcessing: - return "PROCESSING" - default: - return "UNKNOWN" - } -} - type Config struct { ServerID string `json:"serverId"` SubnetID int `json:"subnetId"` diff --git a/agent/internal/build/build.go b/agent/internal/build/build.go index d846ffb..b55f281 100644 --- a/agent/internal/build/build.go +++ b/agent/internal/build/build.go @@ -159,8 +159,7 @@ func (b *Builder) clone(ctx context.Context, config *Config, buildDir string) er b.sendLog(config, fmt.Sprintf("Checking out commit %s", truncateStr(config.CommitSha, 8))) cmd = exec.CommandContext(ctx, "git", "-C", buildDir, "fetch", "origin", config.CommitSha, "--depth", "1") - output, err = b.runCommand(cmd, config) - if err != nil { + if _, err := b.runCommand(cmd, config); err != nil { log.Printf("[build:%s] fetch specific sha failed (might be HEAD): %v", truncateStr(config.BuildID, 8), err) } diff --git a/agent/internal/container/logs_linux.go b/agent/internal/container/logs.go similarity index 99% rename from agent/internal/container/logs_linux.go rename to agent/internal/container/logs.go index efc795a..e637800 100644 --- a/agent/internal/container/logs_linux.go +++ b/agent/internal/container/logs.go @@ -1,5 +1,3 @@ -//go:build linux - package container import ( diff --git a/agent/internal/container/logs_darwin.go b/agent/internal/container/logs_darwin.go deleted file mode 100644 index 71cb57b..0000000 --- a/agent/internal/container/logs_darwin.go +++ /dev/null @@ -1,128 +0,0 @@ -//go:build darwin - -package container - -import ( - "bufio" - "context" - "log" - "os/exec" - "strconv" - "strings" - "time" -) - -func StreamLogs(ctx context.Context, opts LogsOptions, entryCh chan<- LogEntry, errCh chan<- error) { - defer close(entryCh) - defer close(errCh) - - args := []string{"logs", "--timestamps"} - - if opts.Follow { - args = append(args, "-f") - } - - if opts.Tail > 0 { - args = append(args, "--tail", strconv.Itoa(opts.Tail)) - } else if opts.Tail != -1 { - args = append(args, "--tail", "100") - } - - if opts.Since != "" { - args = append(args, "--since", opts.Since) - } - - if opts.Until != "" { - args = append(args, "--until", opts.Until) - } - - args = append(args, opts.ContainerID) - - cmd := exec.CommandContext(ctx, "docker", args...) - - stdout, err := cmd.StdoutPipe() - if err != nil { - errCh <- err - return - } - - stderr, err := cmd.StderrPipe() - if err != nil { - errCh <- err - return - } - - if err := cmd.Start(); err != nil { - errCh <- err - return - } - - done := make(chan struct{}) - - var droppedCount int - sendEntry := func(entry LogEntry) bool { - select { - case entryCh <- entry: - return true - case <-time.After(100 * time.Millisecond): - droppedCount++ - if droppedCount%100 == 1 { - log.Printf("[logs] Dropping log entries due to backpressure (total dropped: %d)", droppedCount) - } - return true - case <-ctx.Done(): - return false - } - } - - go func() { - scanner := bufio.NewScanner(stdout) - scanner.Buffer(make([]byte, 64*1024), 1024*1024) - for scanner.Scan() { - entry := parseLogLine(scanner.Bytes(), "stdout") - if !sendEntry(entry) { - return - } - } - }() - - go func() { - scanner := bufio.NewScanner(stderr) - scanner.Buffer(make([]byte, 64*1024), 1024*1024) - for scanner.Scan() { - entry := parseLogLine(scanner.Bytes(), "stderr") - if !sendEntry(entry) { - return - } - } - }() - - go func() { - cmd.Wait() - close(done) - }() - - select { - case <-ctx.Done(): - cmd.Process.Kill() - case <-done: - } -} - -func parseLogLine(data []byte, stream string) LogEntry { - timestamp := time.Now() - message := data - - if idx := strings.Index(string(data), " "); idx > 0 && idx < 40 { - if t, err := time.Parse(time.RFC3339Nano, string(data[:idx])); err == nil { - timestamp = t - message = data[idx+1:] - } - } - - return LogEntry{ - Stream: stream, - Timestamp: timestamp, - Message: message, - } -} diff --git a/agent/internal/container/runtime_linux.go b/agent/internal/container/runtime.go similarity index 89% rename from agent/internal/container/runtime_linux.go rename to agent/internal/container/runtime.go index 840a947..21a8ba5 100644 --- a/agent/internal/container/runtime_linux.go +++ b/agent/internal/container/runtime.go @@ -1,5 +1,3 @@ -//go:build linux - package container import ( @@ -360,44 +358,6 @@ func Start(containerID string) error { return nil } -func Pause(containerID string) error { - exists, err := ContainerExists(containerID) - if err != nil { - return fmt.Errorf("failed to check container existence: %w", err) - } - if !exists { - return fmt.Errorf("container does not exist: %s", containerID) - } - - log.Printf("[podman:pause] pausing container %s", containerID) - pauseCmd := exec.Command("podman", "pause", containerID) - if output, err := pauseCmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to pause container: %s: %w", string(output), err) - } - - log.Printf("[podman:pause] container %s paused successfully", containerID) - return nil -} - -func Unpause(containerID string) error { - exists, err := ContainerExists(containerID) - if err != nil { - return fmt.Errorf("failed to check container existence: %w", err) - } - if !exists { - return fmt.Errorf("container does not exist: %s", containerID) - } - - log.Printf("[podman:unpause] unpausing container %s", containerID) - unpauseCmd := exec.Command("podman", "unpause", containerID) - if output, err := unpauseCmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to unpause container: %s: %w", string(output), err) - } - - log.Printf("[podman:unpause] container %s unpaused successfully", containerID) - return nil -} - func GetHealthStatus(containerID string) string { cmd := exec.Command("podman", "inspect", "-f", "{{.State.Health.Status}}", containerID) output, err := cmd.CombinedOutput() @@ -542,21 +502,6 @@ func List() ([]Container, error) { return containers, nil } -func Exec(containerID string, cmd []string) ([]byte, error) { - args := append([]string{"exec", containerID}, cmd...) - output, err := exec.Command("podman", args...).CombinedOutput() - return output, err -} - -func CopyToContainer(containerID, srcPath, destPath string) error { - cmd := exec.Command("podman", "cp", srcPath, fmt.Sprintf("%s:%s", containerID, destPath)) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to copy to container: %s: %w", string(output), err) - } - return nil -} - func EnsureNetwork(subnetId int) error { subnet := fmt.Sprintf("10.200.%d.0/24", subnetId) gateway := fmt.Sprintf("10.200.%d.1", subnetId) diff --git a/agent/internal/container/runtime_darwin.go b/agent/internal/container/runtime_darwin.go deleted file mode 100644 index 0cd1e0e..0000000 --- a/agent/internal/container/runtime_darwin.go +++ /dev/null @@ -1,614 +0,0 @@ -//go:build darwin - -package container - -import ( - "bufio" - "bytes" - "context" - "encoding/base64" - "encoding/json" - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "techulus/cloud-agent/internal/dns" - "techulus/cloud-agent/internal/retry" -) - -func ContainerExists(containerID string) (bool, error) { - cmd := exec.Command("docker", "inspect", "--format", "json", containerID) - output, err := cmd.CombinedOutput() - if err != nil { - outputStr := string(output) - if strings.Contains(outputStr, "No such object") || - strings.Contains(outputStr, "no such container") || - strings.Contains(outputStr, "Error: No such") { - return false, nil - } - return false, fmt.Errorf("failed to inspect container: %s: %w", outputStr, err) - } - return true, nil -} - -func IsContainerRunning(containerID string) (bool, error) { - cmd := exec.Command("docker", "inspect", "--format", "json", containerID) - output, err := cmd.CombinedOutput() - if err != nil { - outputStr := string(output) - if strings.Contains(outputStr, "No such object") || - strings.Contains(outputStr, "no such container") || - strings.Contains(outputStr, "Error: No such") { - return false, nil - } - return false, fmt.Errorf("failed to inspect container: %s: %w", outputStr, err) - } - - var containers []containerInspect - if err := json.Unmarshal(output, &containers); err != nil { - return false, fmt.Errorf("failed to parse container inspect: %w", err) - } - - if len(containers) == 0 { - return false, nil - } - - return containers[0].State.Running, nil -} - -func IsContainerStopped(containerID string) (bool, error) { - running, err := IsContainerRunning(containerID) - if err != nil { - return false, err - } - return !running, nil -} - -func Deploy(config *DeployConfig) (*DeployResult, error) { - logFunc := config.LogFunc - if logFunc == nil { - logFunc = func(stream string, message string) {} - } - - image := config.Image - - exec.Command("docker", "rm", "-f", config.Name).Run() - - logFunc("stdout", fmt.Sprintf("Pulling image: %s", image)) - - pullCmd := exec.Command("docker", "pull", image) - pullOutput, err := pullCmd.CombinedOutput() - if err != nil { - logFunc("stderr", fmt.Sprintf("Pull failed: %s", string(pullOutput))) - return nil, fmt.Errorf("failed to pull image: %s: %w", string(pullOutput), err) - } - logFunc("stdout", string(pullOutput)) - - for _, vm := range config.VolumeMounts { - if err := os.MkdirAll(vm.HostPath, 0755); err != nil { - logFunc("stderr", fmt.Sprintf("Failed to create volume directory %s: %s", vm.HostPath, err)) - return nil, fmt.Errorf("failed to create volume directory %s: %w", vm.HostPath, err) - } - logFunc("stdout", fmt.Sprintf("Created volume directory: %s (ensure your user owns this directory for Docker access)", vm.HostPath)) - } - - args := []string{ - "run", "-d", - "--name", config.Name, - "--restart", "on-failure:5", - "--cap-drop", "ALL", - "--cap-add", "CHOWN", - "--cap-add", "DAC_OVERRIDE", - "--cap-add", "FOWNER", - "--cap-add", "SETPCAP", - "--cap-add", "SETUID", - "--cap-add", "SETGID", - "--cap-add", "NET_BIND_SERVICE", - "--cap-add", "NET_RAW", - "--log-driver", "local", - "--log-opt", "max-size=10m", - "--log-opt", "max-file=3", - } - - args = append(args, - "--label", fmt.Sprintf("techulus.service.id=%s", config.ServiceID), - "--label", fmt.Sprintf("techulus.service.name=%s", config.ServiceName), - "--label", fmt.Sprintf("techulus.deployment.id=%s", config.DeploymentID), - ) - - if config.IPAddress != "" { - args = append(args, "--network", NetworkName, "--ip", config.IPAddress) - } else { - for _, pm := range config.PortMappings { - portMapping := fmt.Sprintf("%s:%d:%d", config.WireGuardIP, pm.HostPort, pm.ContainerPort) - args = append(args, "-p", portMapping) - } - } - - if dnsIP := dns.GetContainerDNS(); dnsIP != "" { - args = append(args, "--dns", dnsIP) - } - - if config.HealthCheck != nil && config.HealthCheck.Cmd != "" { - args = append(args, "--health-cmd", config.HealthCheck.Cmd) - args = append(args, "--health-interval", fmt.Sprintf("%ds", config.HealthCheck.Interval)) - args = append(args, "--health-timeout", fmt.Sprintf("%ds", config.HealthCheck.Timeout)) - args = append(args, "--health-retries", fmt.Sprintf("%d", config.HealthCheck.Retries)) - args = append(args, "--health-start-period", fmt.Sprintf("%ds", config.HealthCheck.StartPeriod)) - } - - if config.MemoryLimitMb != nil && *config.MemoryLimitMb > 0 { - args = append(args, "--memory", fmt.Sprintf("%dm", *config.MemoryLimitMb)) - } - if config.CPULimit != nil && *config.CPULimit > 0 { - args = append(args, "--cpus", fmt.Sprintf("%.2f", *config.CPULimit)) - } - - for _, vm := range config.VolumeMounts { - args = append(args, "-v", fmt.Sprintf("%s:%s", vm.HostPath, vm.ContainerPath)) - } - - for key, value := range config.Env { - args = append(args, "-e", fmt.Sprintf("%s=%s", key, value)) - } - - if config.StartCommand != "" { - args = append(args, "--entrypoint", "/bin/sh") - args = append(args, image) - args = append(args, "-c", config.StartCommand) - } else { - args = append(args, image) - } - - logFunc("stdout", fmt.Sprintf("Starting container: %s", config.Name)) - - runCmd := exec.Command("docker", args...) - output, err := runCmd.CombinedOutput() - if err != nil { - logFunc("stderr", fmt.Sprintf("Start failed: %s", string(output))) - return nil, fmt.Errorf("failed to run container: %s: %w", string(output), err) - } - - containerID := strings.TrimSpace(string(output)) - logFunc("stdout", fmt.Sprintf("Container started: %s", containerID)) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - logFunc("stdout", "Verifying container is running...") - err = retry.WithBackoff(ctx, retry.DeployBackoff, func() (bool, error) { - running, err := IsContainerRunning(containerID) - if err != nil { - return false, err - } - return running, nil - }) - - if err != nil { - logsCmd := exec.Command("docker", "logs", "--tail", "50", containerID) - logsOutput, _ := logsCmd.CombinedOutput() - logFunc("stderr", fmt.Sprintf("Container failed to stay running. Logs:\n%s", string(logsOutput))) - return nil, fmt.Errorf("container failed to stay running after start: %w", err) - } - - logFunc("stdout", "Container verified running") - - return &DeployResult{ - ContainerID: containerID, - }, nil -} - -func Stop(containerID string) error { - exists, err := ContainerExists(containerID) - if err != nil { - return fmt.Errorf("failed to check container existence: %w", err) - } - if !exists { - return nil - } - - log.Printf("[docker:stop] stopping container %s", containerID) - stopCmd := exec.Command("docker", "stop", containerID) - if output, err := stopCmd.CombinedOutput(); err != nil { - outputStr := string(output) - if strings.Contains(outputStr, "No such container") || - strings.Contains(outputStr, "no such container") { - return nil - } - return fmt.Errorf("failed to stop container: %s: %w", outputStr, err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) - defer cancel() - - log.Printf("[docker:stop] verifying container %s stopped", containerID) - err = retry.WithBackoff(ctx, retry.StopBackoff, func() (bool, error) { - stopped, err := IsContainerStopped(containerID) - if err != nil { - return false, err - } - return stopped, nil - }) - - if err != nil { - return fmt.Errorf("container did not stop after verification: %w", err) - } - - log.Printf("[docker:stop] container %s stopped successfully", containerID) - return nil -} - -func ForceRemove(containerID string) error { - exists, err := ContainerExists(containerID) - if err != nil { - return fmt.Errorf("failed to check container existence: %w", err) - } - if !exists { - return nil - } - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) - defer cancel() - - log.Printf("[docker:force-remove] force removing container %s", containerID) - - var lastErr error - err = retry.WithBackoff(ctx, retry.ForceRemoveBackoff, func() (bool, error) { - cmd := exec.Command("docker", "rm", "-f", containerID) - output, err := cmd.CombinedOutput() - outputStr := string(output) - - if err == nil { - exists, checkErr := ContainerExists(containerID) - if checkErr != nil { - lastErr = checkErr - return false, checkErr - } - if !exists { - return true, nil - } - lastErr = fmt.Errorf("container still exists after rm -f") - return false, nil - } - - if strings.Contains(outputStr, "No such container") || - strings.Contains(outputStr, "no such container") { - return true, nil - } - - lastErr = fmt.Errorf("%s: %w", outputStr, err) - return false, nil - }) - - if err != nil { - if lastErr != nil { - return fmt.Errorf("failed to force remove container: %w", lastErr) - } - return fmt.Errorf("failed to force remove container: %w", err) - } - - log.Printf("[docker:force-remove] container %s removed successfully", containerID) - return nil -} - -func Restart(containerID string) error { - exists, err := ContainerExists(containerID) - if err != nil { - return fmt.Errorf("failed to check container existence: %w", err) - } - if !exists { - return fmt.Errorf("container does not exist: %s", containerID) - } - - log.Printf("[docker:restart] restarting container %s", containerID) - restartCmd := exec.Command("docker", "restart", containerID) - if output, err := restartCmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to restart container: %s: %w", string(output), err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - log.Printf("[docker:restart] verifying container %s is running", containerID) - err = retry.WithBackoff(ctx, retry.DeployBackoff, func() (bool, error) { - running, err := IsContainerRunning(containerID) - if err != nil { - return false, err - } - return running, nil - }) - - if err != nil { - return fmt.Errorf("container failed to restart: %w", err) - } - - log.Printf("[docker:restart] container %s restarted successfully", containerID) - return nil -} - -func Start(containerID string) error { - exists, err := ContainerExists(containerID) - if err != nil { - return fmt.Errorf("failed to check container existence: %w", err) - } - if !exists { - return fmt.Errorf("container does not exist: %s", containerID) - } - - log.Printf("[docker:start] starting container %s", containerID) - startCmd := exec.Command("docker", "start", containerID) - if output, err := startCmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to start container: %s: %w", string(output), err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - log.Printf("[docker:start] verifying container %s is running", containerID) - err = retry.WithBackoff(ctx, retry.DeployBackoff, func() (bool, error) { - running, err := IsContainerRunning(containerID) - if err != nil { - return false, err - } - return running, nil - }) - - if err != nil { - return fmt.Errorf("container failed to start: %w", err) - } - - log.Printf("[docker:start] container %s started successfully", containerID) - return nil -} - -func Pause(containerID string) error { - exists, err := ContainerExists(containerID) - if err != nil { - return fmt.Errorf("failed to check container existence: %w", err) - } - if !exists { - return fmt.Errorf("container does not exist: %s", containerID) - } - - log.Printf("[docker:pause] pausing container %s", containerID) - pauseCmd := exec.Command("docker", "pause", containerID) - if output, err := pauseCmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to pause container: %s: %w", string(output), err) - } - - log.Printf("[docker:pause] container %s paused successfully", containerID) - return nil -} - -func Unpause(containerID string) error { - exists, err := ContainerExists(containerID) - if err != nil { - return fmt.Errorf("failed to check container existence: %w", err) - } - if !exists { - return fmt.Errorf("container does not exist: %s", containerID) - } - - log.Printf("[docker:unpause] unpausing container %s", containerID) - unpauseCmd := exec.Command("docker", "unpause", containerID) - if output, err := unpauseCmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to unpause container: %s: %w", string(output), err) - } - - log.Printf("[docker:unpause] container %s unpaused successfully", containerID) - return nil -} - -func GetHealthStatus(containerID string) string { - cmd := exec.Command("docker", "inspect", "-f", "{{.State.Health.Status}}", containerID) - output, err := cmd.CombinedOutput() - if err != nil { - return "none" - } - status := strings.TrimSpace(string(output)) - if status == "" || status == "" { - return "none" - } - return status -} - -func CheckPrerequisites() error { - if _, err := exec.LookPath("docker"); err != nil { - return fmt.Errorf("docker not found: %w", err) - } - return nil -} - -func Login(registryURL, username, password string, insecure bool) error { - if registryURL == "" || username == "" { - return nil - } - - log.Printf("[docker:login] logging in to registry %s", registryURL) - - args := []string{"login", "-u", username, "-p", password, registryURL} - - cmd := exec.Command("docker", args...) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to login to registry: %s: %w", string(output), err) - } - - log.Printf("[docker:login] successfully logged in to registry %s", registryURL) - - if err := writeDockerConfig(registryURL, username, password); err != nil { - log.Printf("[registry] failed to write docker config: %v", err) - } - - registryHost := strings.TrimPrefix(registryURL, "https://") - registryHost = strings.TrimPrefix(registryHost, "http://") - registryHost = strings.TrimSuffix(registryHost, "/") - - craneArgs := []string{"auth", "login", "-u", username, "-p", password, registryHost} - craneCmd := exec.Command("/opt/homebrew/bin/crane", craneArgs...) - if out, err := craneCmd.CombinedOutput(); err != nil { - log.Printf("[crane:login] failed: %s: %v", string(out), err) - } else { - log.Printf("[crane:login] successfully logged in to %s", registryHost) - } - - return nil -} - -func writeDockerConfig(registryURL, username, password string) error { - registryHost := strings.TrimPrefix(registryURL, "https://") - registryHost = strings.TrimPrefix(registryHost, "http://") - registryHost = strings.TrimSuffix(registryHost, "/") - - homeDir, err := os.UserHomeDir() - if err != nil { - return err - } - - dockerDir := filepath.Join(homeDir, ".docker") - if err := os.MkdirAll(dockerDir, 0700); err != nil { - return err - } - - auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) - config := map[string]interface{}{ - "auths": map[string]interface{}{ - registryHost: map[string]string{ - "auth": auth, - }, - }, - } - - configBytes, err := json.MarshalIndent(config, "", " ") - if err != nil { - return err - } - - configPath := filepath.Join(dockerDir, "config.json") - if err := os.WriteFile(configPath, configBytes, 0600); err != nil { - return err - } - - log.Printf("[registry] wrote docker config to %s", configPath) - return nil -} - -func ImagePrune() { - exec.Command("docker", "image", "prune", "-a", "-f", "--filter", "until=168h").Run() -} - -type dockerContainer struct { - ID string `json:"ID"` - Names string `json:"Names"` - Image string `json:"Image"` - State string `json:"State"` - Labels string `json:"Labels"` -} - -func List() ([]Container, error) { - cmd := exec.Command("docker", "ps", "-a", "--filter", "label=techulus.service.id", "--format", "json") - output, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("failed to list containers: %s: %w", string(output), err) - } - - output = bytes.TrimSpace(output) - if len(output) == 0 { - return []Container{}, nil - } - - var containers []Container - scanner := bufio.NewScanner(bytes.NewReader(output)) - for scanner.Scan() { - line := scanner.Bytes() - if len(line) == 0 { - continue - } - - var dc dockerContainer - if err := json.Unmarshal(line, &dc); err != nil { - return nil, fmt.Errorf("failed to parse container: %w", err) - } - - labels := parseDockerLabels(dc.Labels) - containers = append(containers, Container{ - ID: dc.ID, - Name: dc.Names, - Image: dc.Image, - State: dc.State, - Labels: labels, - DeploymentID: labels["techulus.deployment.id"], - ServiceID: labels["techulus.service.id"], - }) - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("failed to scan container output: %w", err) - } - - return containers, nil -} - -func parseDockerLabels(labelsStr string) map[string]string { - labels := make(map[string]string) - if labelsStr == "" { - return labels - } - - pairs := strings.Split(labelsStr, ",") - for _, pair := range pairs { - kv := strings.SplitN(pair, "=", 2) - if len(kv) == 2 { - labels[kv[0]] = kv[1] - } - } - return labels -} - -func Exec(containerID string, cmd []string) ([]byte, error) { - args := append([]string{"exec", containerID}, cmd...) - output, err := exec.Command("docker", args...).CombinedOutput() - return output, err -} - -func CopyToContainer(containerID, srcPath, destPath string) error { - cmd := exec.Command("docker", "cp", srcPath, fmt.Sprintf("%s:%s", containerID, destPath)) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to copy to container: %s: %w", string(output), err) - } - return nil -} - -func EnsureNetwork(subnetId int) error { - subnet := fmt.Sprintf("10.200.%d.0/24", subnetId) - gateway := fmt.Sprintf("10.200.%d.1", subnetId) - - checkCmd := exec.Command("docker", "network", "inspect", NetworkName) - if err := checkCmd.Run(); err == nil { - return nil - } - - args := []string{ - "network", "create", - "--driver", "bridge", - "--subnet", subnet, - "--gateway", gateway, - NetworkName, - } - - createCmd := exec.Command("docker", args...) - output, err := createCmd.CombinedOutput() - if err != nil { - if strings.Contains(string(output), "already exists") { - return nil - } - return fmt.Errorf("failed to create network: %s: %w", string(output), err) - } - - return nil -} diff --git a/agent/internal/dns/dns_linux.go b/agent/internal/dns/dns.go similarity index 96% rename from agent/internal/dns/dns_linux.go rename to agent/internal/dns/dns.go index 2ca2001..812e43e 100644 --- a/agent/internal/dns/dns_linux.go +++ b/agent/internal/dns/dns.go @@ -1,5 +1,3 @@ -//go:build linux - package dns import ( @@ -41,10 +39,6 @@ func SetupLocalDNS(subnetID int) error { return nil } -func GetContainerDNS() string { - return containerDNSIP -} - func ConfigureClientDNS(dnsIP string) error { if err := os.MkdirAll(paths.ResolvedDir, 0o755); err != nil { return fmt.Errorf("failed to create resolved.conf.d: %w", err) diff --git a/agent/internal/dns/dns_darwin.go b/agent/internal/dns/dns_darwin.go deleted file mode 100644 index 5f08982..0000000 --- a/agent/internal/dns/dns_darwin.go +++ /dev/null @@ -1,100 +0,0 @@ -//go:build darwin - -package dns - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "os" - "sort" - "strings" - - "techulus/cloud-agent/internal/paths" -) - -var ( - resolverPath = paths.ResolverDir + "/internal" - globalServer *Server - darwinDNSPort = 5533 -) - -type DnsRecord struct { - Name string - Ips []string -} - -func SetupLocalDNS(subnetID int) error { - if err := ConfigureClientDNS("127.0.0.1"); err != nil { - return fmt.Errorf("failed to configure local DNS: %w", err) - } - - globalServer = NewServer(darwinDNSPort, "127.0.0.1") - if err := globalServer.Start(context.Background()); err != nil { - return fmt.Errorf("failed to start DNS server: %w", err) - } - - return nil -} - -func GetContainerDNS() string { - return "" -} - -func ConfigureClientDNS(dnsIP string) error { - if err := os.MkdirAll(paths.ResolverDir, 0o755); err != nil { - return fmt.Errorf("failed to create resolver dir: %w", err) - } - - config := fmt.Sprintf("nameserver %s\nport %d\n", dnsIP, darwinDNSPort) - - if err := os.WriteFile(resolverPath, []byte(config), 0o644); err != nil { - return fmt.Errorf("failed to write resolver config: %w", err) - } - - return nil -} - -func UpdateDnsRecords(records []DnsRecord) error { - if globalServer == nil { - return fmt.Errorf("DNS server not initialized") - } - globalServer.UpdateRecords(records) - return nil -} - -func GetCurrentConfigHash() string { - if globalServer == nil { - return HashRecords(nil) - } - return globalServer.GetRecordsHash() -} - -func StopDNSServer(ctx context.Context) error { - if globalServer != nil { - return globalServer.Stop(ctx) - } - return nil -} - -func HashRecords(records []DnsRecord) string { - sortedRecords := make([]DnsRecord, len(records)) - copy(sortedRecords, records) - sort.Slice(sortedRecords, func(i, j int) bool { - return sortedRecords[i].Name < sortedRecords[j].Name - }) - - var sb strings.Builder - for _, r := range sortedRecords { - sb.WriteString(r.Name) - sb.WriteString(":") - sortedIps := make([]string, len(r.Ips)) - copy(sortedIps, r.Ips) - sort.Strings(sortedIps) - sb.WriteString(strings.Join(sortedIps, ",")) - sb.WriteString("|") - } - hash := sha256.Sum256([]byte(sb.String())) - return hex.EncodeToString(hash[:]) -} diff --git a/agent/internal/dns/server.go b/agent/internal/dns/server.go index 28a88e4..55d42e5 100644 --- a/agent/internal/dns/server.go +++ b/agent/internal/dns/server.go @@ -13,22 +13,20 @@ import ( const DNSPort = 53 type Server struct { - store *RecordStore - udpServer *dns.Server - tcpServer *dns.Server - listenAddr string - port int - started atomic.Bool - startedChan chan struct{} - mu sync.Mutex + store *RecordStore + udpServer *dns.Server + tcpServer *dns.Server + listenAddr string + port int + started atomic.Bool + mu sync.Mutex } func NewServer(port int, listenAddr string) *Server { return &Server{ - store: NewRecordStore(), - listenAddr: listenAddr, - port: port, - startedChan: make(chan struct{}), + store: NewRecordStore(), + listenAddr: listenAddr, + port: port, } } @@ -98,7 +96,6 @@ func (s *Server) Start(ctx context.Context) error { } s.started.Store(true) - close(s.startedChan) log.Printf("[dns] embedded DNS server started on %s (UDP+TCP)", addr) return nil @@ -141,7 +138,3 @@ func (s *Server) UpdateRecords(records []DnsRecord) { func (s *Server) GetRecordsHash() string { return s.store.Hash() } - -func (s *Server) WaitReady() { - <-s.startedChan -} diff --git a/agent/internal/paths/paths_linux.go b/agent/internal/paths/paths.go similarity index 94% rename from agent/internal/paths/paths_linux.go rename to agent/internal/paths/paths.go index 9d32fdd..e96dc8f 100644 --- a/agent/internal/paths/paths_linux.go +++ b/agent/internal/paths/paths.go @@ -1,5 +1,3 @@ -//go:build linux - package paths var DataDir = "/var/lib/techulus-agent" diff --git a/agent/internal/paths/paths_darwin.go b/agent/internal/paths/paths_darwin.go deleted file mode 100644 index 939df65..0000000 --- a/agent/internal/paths/paths_darwin.go +++ /dev/null @@ -1,25 +0,0 @@ -//go:build darwin - -package paths - -import ( - "os" - "path/filepath" -) - -var DataDir = func() string { - home, err := os.UserHomeDir() - if err != nil { - panic("failed to get user home directory: " + err.Error()) - } - return filepath.Join(home, ".techulus-agent") -}() - -const ( - BuildKitSocket = "unix:///opt/homebrew/var/run/buildkit/buildkitd.sock" - WireGuardDir = "/opt/homebrew/etc/wireguard" - ResolverDir = "/etc/resolver" - BuildctlPath = "/opt/homebrew/bin/buildctl" - RailpackPath = "/usr/local/bin/railpack" - CranePath = "/opt/homebrew/bin/crane" -) diff --git a/agent/internal/traefik/routes.go b/agent/internal/traefik/routes.go index ab4f3bc..acb8c88 100644 --- a/agent/internal/traefik/routes.go +++ b/agent/internal/traefik/routes.go @@ -5,122 +5,10 @@ import ( "encoding/hex" "fmt" "log" - "os" - "path/filepath" "sort" "strings" - - "gopkg.in/yaml.v3" ) -func UpdateHttpRoutes(routes []TraefikRoute) error { - config := traefikConfig{ - HTTP: httpConfig{ - Routers: make(map[string]router), - Services: make(map[string]service), - }, - } - - for _, route := range routes { - if len(route.Upstreams) == 0 { - continue - } - - config.HTTP.Routers[route.ServiceId] = router{ - Rule: fmt.Sprintf("Host(`%s`)", route.Domain), - EntryPoints: []string{"websecure"}, - Service: route.ServiceId, - TLS: &tlsConfig{}, - } - - servers := make([]server, len(route.Upstreams)) - for i, upstream := range route.Upstreams { - srv := server{URL: fmt.Sprintf("http://%s", upstream.URL)} - if upstream.Weight > 0 { - srv.Weight = &upstream.Weight - } - servers[i] = srv - } - - config.HTTP.Services[route.ServiceId] = service{ - LoadBalancer: loadBalancer{ - Servers: servers, - }, - } - } - - log.Printf("[traefik] updating %d routes", len(routes)) - - data, err := yaml.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal traefik config: %w", err) - } - - if err := os.MkdirAll(traefikDynamicDir, 0755); err != nil { - return fmt.Errorf("failed to create dynamic config dir: %w", err) - } - - routesPath := filepath.Join(traefikDynamicDir, routesFileName) - tmpPath := routesPath + ".tmp" - - if err := os.WriteFile(tmpPath, data, 0644); err != nil { - return fmt.Errorf("failed to write temp config: %w", err) - } - - if err := os.Rename(tmpPath, routesPath); err != nil { - os.Remove(tmpPath) - return fmt.Errorf("failed to rename config file: %w", err) - } - - log.Printf("[traefik] routes updated successfully") - return nil -} - -func VerifyRouteExists(routeID string, expectedDomain string) (bool, error) { - config, err := readCurrentConfig() - if err != nil { - return false, err - } - - router, exists := config.HTTP.Routers[routeID] - if !exists { - return false, nil - } - - expectedRule := fmt.Sprintf("Host(`%s`)", expectedDomain) - return router.Rule == expectedRule, nil -} - -func readCurrentConfig() (*traefikConfig, error) { - routesPath := filepath.Join(traefikDynamicDir, routesFileName) - data, err := os.ReadFile(routesPath) - if err != nil { - if os.IsNotExist(err) { - return &traefikConfig{ - HTTP: httpConfig{ - Routers: make(map[string]router), - Services: make(map[string]service), - }, - }, nil - } - return nil, err - } - - var config traefikConfig - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, err - } - - if config.HTTP.Routers == nil { - config.HTTP.Routers = make(map[string]router) - } - if config.HTTP.Services == nil { - config.HTTP.Services = make(map[string]service) - } - - return &config, nil -} - func HashRoutes(routes []TraefikRoute) string { sortedRoutes := make([]TraefikRoute, len(routes)) copy(sortedRoutes, routes) diff --git a/agent/internal/traefik/types.go b/agent/internal/traefik/types.go index 853ead0..6fabff4 100644 --- a/agent/internal/traefik/types.go +++ b/agent/internal/traefik/types.go @@ -33,23 +33,6 @@ type TraefikUDPRoute struct { ExternalPort int } -type traefikConfig struct { - HTTP httpConfig `yaml:"http"` -} - -type httpConfig struct { - Routers map[string]router `yaml:"routers,omitempty"` - Services map[string]service `yaml:"services,omitempty"` -} - -type router struct { - Rule string `yaml:"rule"` - EntryPoints []string `yaml:"entryPoints"` - Service string `yaml:"service"` - TLS *tlsConfig `yaml:"tls,omitempty"` - Priority int `yaml:"priority,omitempty"` -} - type tlsConfig struct{} type tlsFileConfig struct { @@ -79,9 +62,9 @@ type server struct { } type middleware struct { - RedirectScheme *redirectScheme `yaml:"redirectScheme,omitempty"` - StripPrefix *stripPrefix `yaml:"stripPrefix,omitempty"` - ReplacePathRegex *replacePathRegex `yaml:"replacePathRegex,omitempty"` + RedirectScheme *redirectScheme `yaml:"redirectScheme,omitempty"` + StripPrefix *stripPrefix `yaml:"stripPrefix,omitempty"` + ReplacePathRegex *replacePathRegex `yaml:"replacePathRegex,omitempty"` Headers *headersMiddleware `yaml:"headers,omitempty"` } @@ -103,10 +86,6 @@ type stripPrefix struct { Prefixes []string `yaml:"prefixes"` } -type addPrefix struct { - Prefix string `yaml:"prefix"` -} - type httpConfigWithMiddlewares struct { Routers map[string]routerWithMiddleware `yaml:"routers,omitempty"` Services map[string]service `yaml:"services,omitempty"` @@ -176,22 +155,8 @@ type udpServer struct { Address string `yaml:"address"` } -type traefikFullConfig struct { - HTTP httpConfig `yaml:"http,omitempty"` - TCP tcpConfig `yaml:"tcp,omitempty"` - UDP udpConfig `yaml:"udp,omitempty"` -} - type traefikFullConfigWithMiddlewares struct { HTTP httpConfigWithMiddlewares `yaml:"http,omitempty"` TCP tcpConfig `yaml:"tcp,omitempty"` UDP udpConfig `yaml:"udp,omitempty"` } - -type staticConfig struct { - EntryPoints map[string]entryPoint `yaml:"entryPoints"` -} - -type entryPoint struct { - Address string `yaml:"address"` -} From 3e7dcf17850c2c749c995c2af73f5f92314193fa Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 24 Jun 2026 23:31:03 +1000 Subject: [PATCH 2/3] Add agent baseline CI Amp-Thread-ID: https://ampcode.com/threads/T-019ef9bb-d4fe-724b-ac1c-64aa47147da4 Co-authored-by: Amp --- .github/workflows/agent-ci.yml | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/agent-ci.yml diff --git a/.github/workflows/agent-ci.yml b/.github/workflows/agent-ci.yml new file mode 100644 index 0000000..fd344b5 --- /dev/null +++ b/.github/workflows/agent-ci.yml @@ -0,0 +1,49 @@ +name: Agent CI + +on: + pull_request: + paths: + - "agent/**" + - ".github/workflows/agent-ci.yml" + push: + branches: + - main + paths: + - "agent/**" + - ".github/workflows/agent-ci.yml" + +permissions: + contents: read + +concurrency: + group: agent-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: agent + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: agent/go.mod + cache-dependency-path: agent/go.sum + + - name: Test + run: go test ./... + + - name: Vet + run: go vet ./... + + - name: Install staticcheck + run: GOBIN=$PWD/.bin go install honnef.co/go/tools/cmd/staticcheck@v0.7.0 + + - name: Staticcheck + run: ./.bin/staticcheck ./... From 16f3813843accd46089596bf1200f37e9fb8097e Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 24 Jun 2026 23:31:46 +1000 Subject: [PATCH 3/3] Use latest staticcheck in agent CI Amp-Thread-ID: https://ampcode.com/threads/T-019ef9bb-d4fe-724b-ac1c-64aa47147da4 Co-authored-by: Amp --- .github/workflows/agent-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/agent-ci.yml b/.github/workflows/agent-ci.yml index fd344b5..273bd2a 100644 --- a/.github/workflows/agent-ci.yml +++ b/.github/workflows/agent-ci.yml @@ -43,7 +43,7 @@ jobs: run: go vet ./... - name: Install staticcheck - run: GOBIN=$PWD/.bin go install honnef.co/go/tools/cmd/staticcheck@v0.7.0 + run: GOBIN=$PWD/.bin go install honnef.co/go/tools/cmd/staticcheck@latest - name: Staticcheck run: ./.bin/staticcheck ./...