diff --git a/.github/workflows/agent-release.yml b/.github/workflows/agent-tip-release.yml similarity index 78% rename from .github/workflows/agent-release.yml rename to .github/workflows/agent-tip-release.yml index 8fbfd81..4732add 100644 --- a/.github/workflows/agent-release.yml +++ b/.github/workflows/agent-tip-release.yml @@ -1,4 +1,4 @@ -name: Build and Release Agent +name: Agent Tip Release on: push: @@ -6,13 +6,13 @@ on: - main paths: - "agent/**" - workflow_dispatch: + - ".github/workflows/agent-tip-release.yml" permissions: contents: write jobs: - build: + agent: runs-on: ubuntu-latest strategy: matrix: @@ -28,10 +28,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version-file: agent/go.mod cache-dependency-path: agent/go.sum @@ -43,21 +43,21 @@ jobs: GOARCH: ${{ matrix.goarch }} CGO_ENABLED: 0 run: | - go build -ldflags="-s -w" -o agent-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/agent + go build -ldflags="-s -w -X techulus/cloud-agent/internal/agent.Version=${{ github.sha }}" -o agent-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/agent - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: agent-${{ matrix.goos }}-${{ matrix.goarch }} path: agent/agent-${{ matrix.goos }}-${{ matrix.goarch }} retention-days: 1 release: - needs: build + needs: agent runs-on: ubuntu-latest steps: - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: binaries merge-multiple: true @@ -67,7 +67,7 @@ jobs: cd binaries sha256sum agent-* > checksums.txt - - name: Delete existing release assets + - name: Update tip release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -76,10 +76,6 @@ jobs: gh api -X DELETE repos/${{ github.repository }}/releases/assets/$asset_id done - - name: Upload binaries to Tip release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | for binary in binaries/agent-*; do chmod +x "$binary" gh release upload tip "$binary" --repo ${{ github.repository }} --clobber diff --git a/.github/workflows/control-plane-release.yml b/.github/workflows/control-plane-release.yml deleted file mode 100644 index c7f27c7..0000000 --- a/.github/workflows/control-plane-release.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Build and Push Images - -on: - push: - branches: - - release - workflow_dispatch: - -permissions: - contents: read - packages: write - -jobs: - web: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: web - push: true - tags: ghcr.io/${{ github.repository }}/web:tip - platforms: linux/amd64,linux/arm64 - cache-from: type=gha,scope=web - cache-to: type=gha,mode=max,scope=web - - registry: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: registry - push: true - tags: ghcr.io/${{ github.repository }}/registry:tip - platforms: linux/amd64,linux/arm64 - cache-from: type=gha,scope=registry - cache-to: type=gha,mode=max,scope=registry diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b6110f5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,135 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + packages: write + +jobs: + agent: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + + 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: Build + working-directory: agent + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + go build -ldflags="-s -w -X techulus/cloud-agent/internal/agent.Version=${{ github.ref_name }}" -o agent-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/agent + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: agent-${{ matrix.goos }}-${{ matrix.goarch }} + path: agent/agent-${{ matrix.goos }}-${{ matrix.goarch }} + retention-days: 1 + + release: + needs: agent + runs-on: ubuntu-latest + steps: + - name: Download agent artifacts + uses: actions/download-artifact@v8 + with: + path: binaries + pattern: agent-* + merge-multiple: true + + - name: Generate SHA256 checksums + run: | + cd binaries + sha256sum agent-* > checksums.txt + + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" \ + --repo ${{ github.repository }} \ + --title "${{ github.ref_name }}" \ + --generate-notes \ + binaries/agent-* binaries/checksums.txt + + web: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: web + push: true + tags: | + ghcr.io/${{ github.repository }}/web:${{ github.ref_name }} + ghcr.io/${{ github.repository }}/web:tip + build-args: | + APP_VERSION=${{ github.ref_name }} + platforms: linux/amd64,linux/arm64 + cache-from: type=gha,scope=web + cache-to: type=gha,mode=max,scope=web + + registry: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: registry + push: true + tags: | + ghcr.io/${{ github.repository }}/registry:${{ github.ref_name }} + ghcr.io/${{ github.repository }}/registry:tip + platforms: linux/amd64,linux/arm64 + cache-from: type=gha,scope=registry + cache-to: type=gha,mode=max,scope=registry diff --git a/agent/MACOS.md b/agent/MACOS.md index c3c1a32..e063987 100644 --- a/agent/MACOS.md +++ b/agent/MACOS.md @@ -75,13 +75,13 @@ 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 --data-dir /var/lib/techulus-agent +sudo BUILDKIT_HOST=docker-container://buildkitd ./agent --url ``` Or with `-E`: ```bash -BUILDKIT_HOST=docker-container://buildkitd sudo -E ./agent --url --data-dir /var/lib/techulus-agent +BUILDKIT_HOST=docker-container://buildkitd sudo -E ./agent --url ``` ## Insecure Registry (HTTP) diff --git a/agent/README.md b/agent/README.md index 87a3f95..94e5ab1 100644 --- a/agent/README.md +++ b/agent/README.md @@ -165,24 +165,24 @@ GOOS=linux GOARCH=amd64 go build -o bin/agent-linux-amd64 ./cmd/agent Worker node: ```bash -sudo ./agent --url --token --data-dir /var/lib/techulus-agent +sudo ./agent --url --token ``` Proxy node: ```bash -sudo ./agent --url --token --data-dir /var/lib/techulus-agent --proxy +sudo ./agent --url --token --proxy ``` ### Subsequent Runs Worker node: ```bash -sudo ./agent --url --data-dir /var/lib/techulus-agent +sudo ./agent --url ``` Proxy node: ```bash -sudo ./agent --url --data-dir /var/lib/techulus-agent --proxy +sudo ./agent --url --proxy ``` ### Run as systemd Service @@ -199,7 +199,7 @@ After=network.target buildkitd.service [Service] Type=simple -ExecStart=/usr/local/bin/agent --url --data-dir /var/lib/techulus-agent +ExecStart=/usr/local/bin/agent --url Restart=always RestartSec=5 KillMode=process @@ -216,7 +216,7 @@ After=network.target traefik.service buildkitd.service [Service] Type=simple -ExecStart=/usr/local/bin/agent --url --data-dir /var/lib/techulus-agent --proxy +ExecStart=/usr/local/bin/agent --url --proxy Restart=always RestartSec=5 KillMode=process @@ -239,7 +239,6 @@ sudo systemctl start techulus-agent |------|---------|-------------| | `--url` | (required) | Control plane URL | | `--token` | | Registration token (required for first run) | -| `--data-dir` | `/var/lib/techulus-agent` | Data directory for agent state | | `--logs-endpoint` | | VictoriaLogs endpoint | | `--proxy` | `false` | Run as proxy node (handles TLS and public traffic) | diff --git a/agent/cmd/agent/main.go b/agent/cmd/agent/main.go index b4a7941..d013f7b 100644 --- a/agent/cmd/agent/main.go +++ b/agent/cmd/agent/main.go @@ -2,37 +2,35 @@ package main import ( "context" - "encoding/json" "flag" - "io" "log" - "net/http" "os" "os/signal" "path/filepath" "runtime" - "strings" "syscall" "time" "techulus/cloud-agent/internal/agent" "techulus/cloud-agent/internal/api" "techulus/cloud-agent/internal/build" + "techulus/cloud-agent/internal/configuration" "techulus/cloud-agent/internal/container" "techulus/cloud-agent/internal/crypto" "techulus/cloud-agent/internal/dns" agenthttp "techulus/cloud-agent/internal/http" "techulus/cloud-agent/internal/logs" + "techulus/cloud-agent/internal/network" "techulus/cloud-agent/internal/paths" "techulus/cloud-agent/internal/reconcile" "techulus/cloud-agent/internal/traefik" "techulus/cloud-agent/internal/wireguard" - - "github.com/hashicorp/go-sockaddr" ) -var httpClient *api.Client -var dataDir string +var ( + httpClient *api.Client + dataDir string = paths.DataDir +) func main() { var ( @@ -45,7 +43,6 @@ func main() { flag.StringVar(&controlPlaneURL, "url", "", "Control plane URL (required)") flag.StringVar(&token, "token", "", "Registration token (required for first run)") - flag.StringVar(&dataDir, "data-dir", paths.DataDir, "Data directory for agent state") flag.BoolVar(&isProxy, "proxy", false, "Run as proxy node (handles TLS and public traffic)") flag.StringVar(&logsEndpointFlag, "logs-endpoint", "", "Override logs endpoint URL (optional)") flag.BoolVar(&disableDNS, "no-dns", false, "Disable local DNS server") @@ -82,12 +79,11 @@ func main() { log.Fatalf("Build prerequisites check failed: %v", err) } - if err := os.MkdirAll(dataDir, 0700); err != nil { + if err := os.MkdirAll(dataDir, 0o700); err != nil { log.Fatalf("Failed to create data directory: %v", err) } keyDir := filepath.Join(dataDir, "keys") - configPath := filepath.Join(dataDir, "config.json") httpClient = api.NewClient(controlPlaneURL) @@ -95,14 +91,16 @@ func main() { var config *agent.Config var err error - if crypto.KeyPairExists(keyDir) { + setupComplete := crypto.KeyPairExists(keyDir) + + if setupComplete { log.Println("Loading existing signing key pair...") signingKeyPair, err = crypto.LoadKeyPair(keyDir) if err != nil { log.Fatalf("Failed to load signing key pair: %v", err) } - config, err = loadConfig(configPath) + config, err = configuration.Load() if err != nil { log.Fatalf("Failed to load config: %v", err) } @@ -115,12 +113,12 @@ func main() { logsEndpoint = config.LoggingEndpoint } - if err := container.EnsureNetwork(config.SubnetID); err != nil { + if err = container.EnsureNetwork(config.SubnetID); err != nil { log.Printf("Warning: Failed to ensure container network: %v", err) } if !disableDNS { - if err := dns.SetupLocalDNS(config.SubnetID); err != nil { + if err = dns.SetupLocalDNS(config.SubnetID); err != nil { log.Printf("Warning: Failed to setup local DNS: %v", err) } } else { @@ -147,13 +145,13 @@ func main() { log.Fatalf("Failed to generate WireGuard key pair: %v", err) } - if err := wireguard.SavePrivateKey(dataDir, wgPrivateKey); err != nil { + if err = wireguard.SavePrivateKey(dataDir, wgPrivateKey); err != nil { log.Fatalf("Failed to save WireGuard private key: %v", err) } log.Println("Registering with control plane...") - publicIP := getPublicIP() - privateIP := getPrivateIP() + publicIP := network.PublicIP() + privateIP := network.PrivateIP() resp, err := httpClient.Register(token, wgPublicKey, signingKeyPair.PublicKeyBase64(), publicIP, privateIP, isProxy) if err != nil { log.Fatalf("Failed to register: %v", err) @@ -194,7 +192,7 @@ func main() { logsEndpoint = respLoggingEndpoint } - if err := saveConfig(configPath, config); err != nil { + if err = configuration.Save(config); err != nil { log.Fatalf("Failed to save config: %v", err) } @@ -209,19 +207,19 @@ func main() { } log.Println("Writing WireGuard config...") - if err := wireguard.WriteConfig(wireguard.DefaultInterface, wgConfig); err != nil { + if err = wireguard.WriteConfig(wireguard.DefaultInterface, wgConfig); err != nil { log.Fatalf("Failed to write WireGuard config: %v", err) } log.Println("Bringing up WireGuard interface...") - if err := wireguard.Up(wireguard.DefaultInterface); err != nil { + if err = wireguard.Up(wireguard.DefaultInterface); err != nil { log.Fatalf("Failed to bring up WireGuard: %v", err) } log.Println("WireGuard interface is up!") log.Println("Ensuring container network exists...") - if err := container.EnsureNetwork(config.SubnetID); err != nil { + if err = container.EnsureNetwork(config.SubnetID); err != nil { log.Printf("Warning: Failed to create container network: %v", err) } else { log.Println("Container network ready") @@ -229,7 +227,7 @@ func main() { if !disableDNS { log.Println("Setting up local DNS...") - if err := dns.SetupLocalDNS(config.SubnetID); err != nil { + if err = dns.SetupLocalDNS(config.SubnetID); err != nil { log.Printf("Warning: Failed to setup local DNS: %v", err) } else { log.Println("Local DNS configured successfully") @@ -253,6 +251,7 @@ func main() { var traefikLogCollector *logs.TraefikCollector var logsSender *logs.VictoriaLogsSender var agentLogWriter *logs.AgentLogWriter + if logsEndpoint != "" { log.Println("[logs] log collection enabled, endpoint:", logsEndpoint) logsSender = logs.NewVictoriaLogsSender(logsEndpoint, config.ServerID) @@ -291,9 +290,9 @@ func main() { agentLogFlusherDone = agentLogWriter.StartFlusher(ctx) } - publicIP := getPublicIP() - privateIP := getPrivateIP() - log.Printf("Agent started. Public IP: %s, Private IP: %s. Tick interval: %v", publicIP, privateIP, agent.TickInterval) + publicIP := network.PublicIP() + privateIP := network.PrivateIP() + log.Printf("Agent v%s started. Public IP: %s, Private IP: %s. Tick interval: %v", agent.Version, publicIP, privateIP, agent.TickInterval) agentInstance := agent.NewAgent(client, reconciler, config, publicIP, privateIP, dataDir, logCollector, traefikLogCollector, builder, config.IsProxy, disableDNS) agentInstance.Run(ctx) @@ -312,69 +311,3 @@ func main() { log.Println("Agent stopped") } - -func loadConfig(path string) (*agent.Config, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - var config agent.Config - if err := json.Unmarshal(data, &config); err != nil { - return nil, err - } - - return &config, nil -} - -func saveConfig(path string, config *agent.Config) error { - data, err := json.MarshalIndent(config, "", " ") - if err != nil { - return err - } - - return os.WriteFile(path, data, 0600) -} - -func getPublicIP() string { - ip, err := sockaddr.GetPublicIP() - if err == nil && ip != "" { - return ip - } - - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get("https://api.ipify.org") - if err != nil { - log.Printf("Failed to get public IP from ipify: %v", err) - return "" - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - log.Printf("Failed to read ipify response: %v", err) - return "" - } - - return strings.TrimSpace(string(body)) -} - -func getPrivateIP() string { - ips, err := sockaddr.GetPrivateIPs() - if err != nil { - log.Printf("Failed to get private IPs: %v", err) - return "" - } - - for _, ip := range strings.Split(ips, " ") { - if ip == "" { - continue - } - if strings.HasPrefix(ip, "10.100.") || strings.HasPrefix(ip, "10.200.") { - continue - } - return ip - } - - return "" -} diff --git a/agent/internal/agent/drift.go b/agent/internal/agent/drift.go index 6a71d42..81e8002 100644 --- a/agent/internal/agent/drift.go +++ b/agent/internal/agent/drift.go @@ -415,7 +415,10 @@ func (a *Agent) reconcileOne(actual *ActualState) error { Endpoint: p.Endpoint, } } - if wireguard.HashPeers(expectedWgPeers) != actual.WireguardHash { + wgPeersChanged := wireguard.HashPeers(expectedWgPeers) != actual.WireguardHash + wgIsUp := wireguard.IsUp(wireguard.DefaultInterface) + + if wgPeersChanged { log.Printf("[reconcile] updating WireGuard peers") if err := a.reconcileWireguard(expectedWgPeers); err != nil { return fmt.Errorf("failed to update WireGuard: %w", err) @@ -423,6 +426,14 @@ func (a *Agent) reconcileOne(actual *ActualState) error { return nil } + if !wgIsUp { + log.Printf("[reconcile] WireGuard interface is down, bringing it up") + if err := wireguard.Up(wireguard.DefaultInterface); err != nil { + return fmt.Errorf("failed to bring up WireGuard: %w", err) + } + return nil + } + return nil } diff --git a/agent/internal/agent/reporting.go b/agent/internal/agent/reporting.go index 6da1c5e..4513604 100644 --- a/agent/internal/agent/reporting.go +++ b/agent/internal/agent/reporting.go @@ -16,17 +16,14 @@ import ( "github.com/shirou/gopsutil/v3/mem" ) +var Version = "dev" + var ( agentStartTime = time.Now() - agentVersion = "dev" lastHealthCollect time.Time healthCollectMu sync.Mutex ) -func SetAgentVersion(version string) { - agentVersion = version -} - func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport { report := &agenthttp.StatusReport{ PublicIP: a.PublicIP, @@ -46,7 +43,7 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport report.NetworkHealth = health.CollectNetworkHealth("wg0") report.ContainerHealth = health.CollectContainerHealth() report.AgentHealth = &agenthttp.AgentHealth{ - Version: agentVersion, + Version: Version, UptimeSecs: int64(time.Since(agentStartTime).Seconds()), } lastHealthCollect = time.Now() diff --git a/agent/internal/build/build.go b/agent/internal/build/build.go index ff64287..95def39 100644 --- a/agent/internal/build/build.go +++ b/agent/internal/build/build.go @@ -409,7 +409,7 @@ func (b *Builder) Cleanup() error { } } - log.Printf("[build:cleanup] pruning dangling images") + log.Printf("[build:cleanup] pruning unused images") container.ImagePrune() return nil diff --git a/agent/internal/configuration/configuration.go b/agent/internal/configuration/configuration.go new file mode 100644 index 0000000..7ae6d16 --- /dev/null +++ b/agent/internal/configuration/configuration.go @@ -0,0 +1,36 @@ +// Package configuration handles loading and saving agent configuration files. +package configuration + +import ( + "encoding/json" + "os" + "path/filepath" + + "techulus/cloud-agent/internal/agent" + "techulus/cloud-agent/internal/paths" +) + +var configPath = filepath.Join(paths.DataDir, "config.json") + +func Load() (*agent.Config, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var config agent.Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + + return &config, nil +} + +func Save(config *agent.Config) error { + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configPath, data, 0o600) +} diff --git a/agent/internal/container/runtime_darwin.go b/agent/internal/container/runtime_darwin.go index e7f8168..59d13bc 100644 --- a/agent/internal/container/runtime_darwin.go +++ b/agent/internal/container/runtime_darwin.go @@ -494,7 +494,7 @@ func writeDockerConfig(registryURL, username, password string) error { } func ImagePrune() { - exec.Command("docker", "image", "prune", "-f").Run() + exec.Command("docker", "image", "prune", "-a", "-f").Run() } type dockerContainer struct { diff --git a/agent/internal/container/runtime_linux.go b/agent/internal/container/runtime_linux.go index 81069a5..3175254 100644 --- a/agent/internal/container/runtime_linux.go +++ b/agent/internal/container/runtime_linux.go @@ -494,7 +494,7 @@ func writeDockerConfig(registryURL, username, password string) error { } func ImagePrune() { - exec.Command("podman", "image", "prune", "-f").Run() + exec.Command("podman", "image", "prune", "-a", "-f").Run() } type podmanContainer struct { diff --git a/agent/internal/logs/state.go b/agent/internal/logs/state.go index 54764d2..2e5309b 100644 --- a/agent/internal/logs/state.go +++ b/agent/internal/logs/state.go @@ -1,7 +1,9 @@ +// Package logs provides log collection and state tracking for containers. package logs import ( "encoding/json" + "maps" "os" "path/filepath" "sync" @@ -73,9 +75,8 @@ func (s *State) Save(dataDir string) error { sf := stateFile{ Positions: make(map[string]string, len(s.positions)), } - for k, v := range s.positions { - sf.Positions[k] = v - } + + maps.Copy(sf.Positions, s.positions) s.mu.RUnlock() data, err := json.MarshalIndent(sf, "", " ") @@ -84,5 +85,5 @@ func (s *State) Save(dataDir string) error { } path := filepath.Join(dataDir, stateFileName) - return os.WriteFile(path, data, 0600) + return os.WriteFile(path, data, 0o600) } diff --git a/agent/internal/logs/traefik_collector.go b/agent/internal/logs/traefik_collector.go index 3e39be8..7a3dc5a 100644 --- a/agent/internal/logs/traefik_collector.go +++ b/agent/internal/logs/traefik_collector.go @@ -79,6 +79,7 @@ type TraefikCollector struct { cancel context.CancelFunc wg sync.WaitGroup lastPos int64 + initialized bool droppedCount int } @@ -128,8 +129,15 @@ func (c *TraefikCollector) tailFile() error { } defer file.Close() - if c.lastPos == 0 { - log.Printf("[traefik-logs] started tailing %s", traefikLogPath) + if !c.initialized { + endPos, err := file.Seek(0, io.SeekEnd) + if err != nil { + return err + } + c.lastPos = endPos + c.initialized = true + log.Printf("[traefik-logs] started tailing %s (skipped to position %d)", traefikLogPath, endPos) + return nil } stat, err := file.Stat() diff --git a/agent/internal/network/network.go b/agent/internal/network/network.go new file mode 100644 index 0000000..3a7f7d1 --- /dev/null +++ b/agent/internal/network/network.go @@ -0,0 +1,55 @@ +// Package network provides utilities for discovering public and private IP addresses. +package network + +import ( + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/hashicorp/go-sockaddr" +) + +func PublicIP() string { + ip, err := sockaddr.GetPublicIP() + if err == nil && ip != "" { + return ip + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://api.ipify.org") + if err != nil { + log.Printf("Failed to get public IP from ipify: %v", err) + return "" + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("Failed to read ipify response: %v", err) + return "" + } + + return strings.TrimSpace(string(body)) +} + +func PrivateIP() string { + ips, err := sockaddr.GetPrivateIPs() + if err != nil { + log.Printf("Failed to get private IPs: %v", err) + return "" + } + + for ip := range strings.SplitSeq(ips, " ") { + if ip == "" { + continue + } + if strings.HasPrefix(ip, "10.100.") || strings.HasPrefix(ip, "10.200.") { + continue + } + return ip + } + + return "" +} diff --git a/compose.dev.yml b/compose.dev.yml index e02a2b5..a3681ce 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -49,6 +49,14 @@ services: command: server /data --console-address ":9001" restart: unless-stopped + inngest: + image: inngest/inngest + ports: + - "8288:8288" + - "8289:8289" + command: inngest dev -u http://host.docker.internal:3000/api/inngest + restart: unless-stopped + volumes: registry-data: victoria-logs-data: diff --git a/deployment/compose.postgres.yml b/deployment/compose.postgres.yml index ed4a0f1..8996e0e 100644 --- a/deployment/compose.postgres.yml +++ b/deployment/compose.postgres.yml @@ -35,7 +35,7 @@ services: restart: unless-stopped web: - image: ghcr.io/techulus/cloud/web:tip + image: ghcr.io/techulus/cloud/web:latest env_file: - ./.env environment: @@ -63,7 +63,7 @@ services: restart: unless-stopped registry: - image: ghcr.io/techulus/cloud/registry:tip + image: ghcr.io/techulus/cloud/registry:latest env_file: - ./.env volumes: diff --git a/deployment/compose.production.yml b/deployment/compose.production.yml index 9399fab..4e0d294 100644 --- a/deployment/compose.production.yml +++ b/deployment/compose.production.yml @@ -24,7 +24,7 @@ services: restart: unless-stopped web: - image: ghcr.io/techulus/cloud/web:tip + image: ghcr.io/techulus/cloud/web:latest env_file: - ./.env environment: @@ -51,7 +51,7 @@ services: restart: unless-stopped registry: - image: ghcr.io/techulus/cloud/registry:tip + image: ghcr.io/techulus/cloud/registry:latest env_file: - ./.env volumes: diff --git a/docs/AGENT.md b/docs/AGENT.md index d81870b..90fa35d 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -66,7 +66,6 @@ Containers without `techulus.deployment.id` are considered orphans and will be c |------|---------|-------------| | `--url` | (required) | Control plane URL | | `--token` | | Registration token (required for first run) | -| `--data-dir` | `/var/lib/techulus-agent` | Data directory for agent state | | `--logs-endpoint` | | VictoriaLogs endpoint for log shipping | | `--proxy` | `false` | Run as proxy node (handles TLS/public traffic) | diff --git a/web/Dockerfile b/web/Dockerfile index 63f2990..7c3ea01 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -6,11 +6,13 @@ RUN pnpm install --frozen-lockfile FROM node:24-slim AS builder WORKDIR /app +ARG APP_VERSION=dev COPY --from=deps /app/node_modules ./node_modules COPY . . ENV DATABASE_URL=postgres://build:build@localhost:5432/build ENV BETTER_AUTH_SECRET=build-secret ENV BETTER_AUTH_URL=http://localhost:3000 +ENV NEXT_PUBLIC_APP_VERSION=$APP_VERSION RUN npx next build FROM node:24-slim AS drizzle diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 862f069..510bfa3 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -1047,14 +1047,12 @@ export async function abortRollout(serviceId: string) { }); if (workToDelete.length > 0) { - await db - .delete(workQueue) - .where( - inArray( - workQueue.id, - workToDelete.map((w) => w.id), - ), - ); + await db.delete(workQueue).where( + inArray( + workQueue.id, + workToDelete.map((w) => w.id), + ), + ); } return { success: true }; diff --git a/web/app/(dashboard)/dashboard/servers/[id]/page.tsx b/web/app/(dashboard)/dashboard/servers/[id]/page.tsx index ee6b95f..f2bee83 100644 --- a/web/app/(dashboard)/dashboard/servers/[id]/page.tsx +++ b/web/app/(dashboard)/dashboard/servers/[id]/page.tsx @@ -14,21 +14,44 @@ import { ServerDangerZone } from "@/components/server/server-danger-zone"; import { ServerHeader } from "@/components/server/server-header"; import { ServerHealthDetails } from "@/components/server/server-health-details"; import { ServerServices } from "@/components/server/server-services"; +import { AgentUpdateNudge } from "@/components/server/agent-update-nudge"; import { formatRelativeTime } from "@/lib/date"; +async function getLatestAgentVersion(): Promise { + try { + const res = await fetch( + "https://api.github.com/repos/techulus/cloud/releases/latest", + { + headers: { Accept: "application/vnd.github.v3+json" }, + }, + ); + if (!res.ok) return null; + const data = await res.json(); + return data.tag_name ?? null; + } catch { + return null; + } +} + export default async function ServerDetailPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; - const server = await getServerDetails(id); + const [server, latestVersion] = await Promise.all([ + getServerDetails(id), + getLatestAgentVersion(), + ]); if (!server) { notFound(); } const isUnregistered = !server.wireguardIp && server.agentToken; + const currentVersion = server.agentHealth?.version; + const hasUpdate = + currentVersion && latestVersion && currentVersion !== latestVersion; return ( <> @@ -41,6 +64,14 @@ export default async function ServerDetailPage({
+ {hasUpdate && ( + + )} + {isUnregistered && ( diff --git a/web/app/(dashboard)/dashboard/settings/page.tsx b/web/app/(dashboard)/dashboard/settings/page.tsx index d754876..849df55 100644 --- a/web/app/(dashboard)/dashboard/settings/page.tsx +++ b/web/app/(dashboard)/dashboard/settings/page.tsx @@ -24,7 +24,11 @@ export default async function SettingsPage() {

- + ); diff --git a/web/app/api/github/manifest/callback/route.ts b/web/app/api/github/manifest/callback/route.ts deleted file mode 100644 index b3f4afb..0000000 --- a/web/app/api/github/manifest/callback/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -export async function GET(request: NextRequest) { - const searchParams = request.nextUrl.searchParams; - const code = searchParams.get("code"); - - if (!code || !/^[a-zA-Z0-9]+$/.test(code)) { - return NextResponse.redirect( - new URL("/dashboard/settings?github_error=missing_code", request.url), - ); - } - - try { - const response = await fetch( - `https://api.github.com/app-manifests/${code}/conversions`, - { - method: "POST", - headers: { - Accept: "application/vnd.github+json", - }, - }, - ); - - if (!response.ok) { - const error = await response.text(); - console.error("GitHub manifest conversion failed:", error); - return NextResponse.redirect( - new URL( - "/dashboard/settings?github_error=conversion_failed", - request.url, - ), - ); - } - - const data = await response.json(); - - const credentials = { - id: data.id, - slug: data.slug, - pem: Buffer.from(data.pem).toString("base64"), - webhookSecret: data.webhook_secret, - ownerType: data.owner?.type, - ownerLogin: data.owner?.login, - }; - - const credentialsParam = encodeURIComponent(JSON.stringify(credentials)); - - return NextResponse.redirect( - new URL( - `/dashboard/settings?github_credentials=${credentialsParam}`, - request.url, - ), - ); - } catch (error) { - console.error("GitHub manifest callback error:", error); - return NextResponse.redirect( - new URL("/dashboard/settings?github_error=unknown", request.url), - ); - } -} diff --git a/web/components/github/github-app-setup.tsx b/web/components/github/github-app-setup.tsx deleted file mode 100644 index 2b914fa..0000000 --- a/web/components/github/github-app-setup.tsx +++ /dev/null @@ -1,371 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import { toast } from "sonner"; -import isFQDN from "validator/es/lib/isFQDN"; -import { - Github, - TriangleAlert, - ExternalLink, - Copy, - RotateCcw, -} from "lucide-react"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; - -const HOSTNAME_KEY = "techulus_github_hostname"; -const STORAGE_TTL = 10 * 60 * 1000; - -type GitHubCredentials = { - id: string; - slug: string; - pem: string; - webhookSecret: string; - ownerType?: string; - ownerLogin?: string; -}; - -function getStoredHostname(): string { - if (typeof window === "undefined") return ""; - const item = sessionStorage.getItem(HOSTNAME_KEY); - if (!item) return ""; - try { - const { value, expires } = JSON.parse(item); - if (Date.now() > expires) { - sessionStorage.removeItem(HOSTNAME_KEY); - return ""; - } - return value; - } catch { - return ""; - } -} - -function setStoredHostname(value: string) { - const item = { value, expires: Date.now() + STORAGE_TTL }; - sessionStorage.setItem(HOSTNAME_KEY, JSON.stringify(item)); -} - -export function GitHubAppSetup() { - const searchParams = useSearchParams(); - const router = useRouter(); - - const [hostname, setHostname] = useState(""); - const [useOrg, setUseOrg] = useState(false); - const [orgId, setOrgId] = useState(""); - const [credentials, setCredentials] = useState( - null, - ); - const [copied, setCopied] = useState(false); - const [loading, setLoading] = useState(false); - - useEffect(() => { - const storedHostname = getStoredHostname(); - if (storedHostname) { - setHostname(storedHostname); - } - - const credentialsParam = searchParams.get("github_credentials"); - const errorParam = searchParams.get("github_error"); - - if (credentialsParam) { - try { - const parsed = JSON.parse(decodeURIComponent(credentialsParam)); - setCredentials(parsed); - setLoading(false); - } catch { - toast.error("Failed to parse GitHub credentials"); - } - router.replace("/dashboard/settings?tab=github", { scroll: false }); - } - - if (errorParam) { - const messages: Record = { - missing_code: "GitHub did not return an authorization code", - conversion_failed: - "Failed to exchange code for credentials. The code may have expired.", - unknown: "An unknown error occurred", - }; - toast.error(messages[errorParam] || "GitHub setup failed"); - setLoading(false); - router.replace("/dashboard/settings?tab=github", { scroll: false }); - } - }, [searchParams, router]); - - const isHostnameValid = (() => { - const h = hostname.trim().toLowerCase(); - if (!h) return false; - if (h === "localhost") return true; - return isFQDN(h); - })(); - - const isOrgValid = - !useOrg || /^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$/i.test(orgId.trim()); - - const canSubmit = isHostnameValid && isOrgValid && !loading; - - const githubUrl = - useOrg && orgId.trim() - ? `https://github.com/organizations/${orgId.trim()}/settings/apps/new` - : "https://github.com/settings/apps/new"; - - const generateManifest = () => { - const h = hostname.trim().toLowerCase(); - const protocol = - h === "localhost" || h.endsWith(".local") ? "http" : "https"; - const appBaseUrl = `${protocol}://${h}`; - const callbackUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/api/github/manifest/callback`; - const appName = `techulus-cloud-${useOrg && orgId ? orgId.trim() : Math.random().toString(36).substring(2, 8)}`; - - return JSON.stringify({ - name: appName, - url: appBaseUrl, - hook_attributes: { - url: - h === "localhost" - ? "http://example.com/api/webhooks/github" - : `${appBaseUrl}/api/webhooks/github`, - active: true, - }, - redirect_url: callbackUrl, - callback_urls: [ - `${appBaseUrl}/api/github/authorize/callback`, - `${appBaseUrl}/auth/github/callback`, - ], - setup_url: `${appBaseUrl}/api/github/setup`, - setup_on_update: true, - public: true, - default_permissions: { - administration: "write", - checks: "write", - contents: "write", - deployments: "write", - issues: "write", - metadata: "read", - pull_requests: "write", - repository_hooks: "write", - statuses: "write", - emails: "read", - }, - default_events: ["installation_target", "push", "repository"], - }); - }; - - const handleSubmit = (e: React.FormEvent) => { - if (!canSubmit) { - e.preventDefault(); - return; - } - setStoredHostname(hostname.trim().toLowerCase()); - }; - - const envOutput = credentials - ? `GITHUB_APP_ID="${credentials.id}" -GITHUB_APP_PRIVATE_KEY="${credentials.pem}" -GITHUB_WEBHOOK_SECRET="${credentials.webhookSecret}"` - : ""; - - const githubAppUrl = credentials - ? credentials.ownerType === "Organization" - ? `https://github.com/organizations/${credentials.ownerLogin}/settings/apps/${credentials.slug}` - : `https://github.com/settings/apps/${credentials.slug}` - : ""; - - const copyToClipboard = async () => { - try { - await navigator.clipboard.writeText(envOutput); - setCopied(true); - toast.success("Copied to clipboard"); - setTimeout(() => setCopied(false), 2000); - } catch { - toast.error("Failed to copy to clipboard"); - } - }; - - const reset = () => { - setCredentials(null); - setHostname(""); - setOrgId(""); - setUseOrg(false); - setCopied(false); - setLoading(false); - sessionStorage.removeItem(HOSTNAME_KEY); - }; - - const isLocalhost = hostname.trim().toLowerCase() === "localhost"; - - if (credentials || loading) { - return ( -
- - - - - - GitHub App Credentials - - -
-

- Copy the following environment variables into your{" "} - .env file. -

- - {loading && ( -
- - Retrieving credentials... - -
- )} - - {!loading && credentials && ( - <> - {isLocalhost && ( - - - Update webhook URL - - The webhook URL was set to a placeholder because GitHub - cannot reach localhost. Use a tunnel (e.g., ngrok) and{" "} - - update the webhook URL in the GitHub App settings - - . - - - )} - -