diff --git a/.github/workflows/agent-ci.yml b/.github/workflows/agent-ci.yml new file mode 100644 index 00000000..273bd2aa --- /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@latest + + - name: Staticcheck + run: ./.bin/staticcheck ./... diff --git a/.github/workflows/agent-tip-release.yml b/.github/workflows/agent-tip-release.yml index 4732add6..8b73a6d8 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/react-doctor.yml b/.github/workflows/react-doctor.yml new file mode 100644 index 00000000..a54ea68e --- /dev/null +++ b/.github/workflows/react-doctor.yml @@ -0,0 +1,26 @@ +name: React Doctor + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: [main] + +permissions: + contents: read + pull-requests: write + issues: write + statuses: write + +concurrency: + group: react-doctor-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + react-doctor: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: millionco/react-doctor@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9248cfd..bf208de2 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/.gitignore b/.gitignore index 9cb7e702..6aff4e36 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ # misc .DS_Store +.idea/ *.pem # debug diff --git a/agent/MACOS.md b/agent/MACOS.md deleted file mode 100644 index e0639872..00000000 --- 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 94e5ab18..fc1386e6 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 fcb7826a..59458e07 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/agent/drift.go b/agent/internal/agent/drift.go index 80c7b0b5..6a192150 100644 --- a/agent/internal/agent/drift.go +++ b/agent/internal/agent/drift.go @@ -15,6 +15,32 @@ import ( "techulus/cloud-agent/internal/wireguard" ) +type reconcileActionKind string + +const ( + actionStopOrphanNoDeploymentID reconcileActionKind = "stop_orphan_no_deployment_id" + actionRemoveOrphanNoDeploymentID reconcileActionKind = "remove_orphan_no_deployment_id" + actionStopUnexpectedContainer reconcileActionKind = "stop_unexpected_container" + actionRemoveUnexpectedContainer reconcileActionKind = "remove_unexpected_container" + actionDeployMissingContainer reconcileActionKind = "deploy_missing_container" + actionStartContainer reconcileActionKind = "start_container" + actionRedeployContainer reconcileActionKind = "redeploy_container" + actionUpdateDNS reconcileActionKind = "update_dns" + actionUpdateTraefik reconcileActionKind = "update_traefik" + actionUpdateCertificates reconcileActionKind = "update_certificates" + actionWriteChallengeRoute reconcileActionKind = "write_challenge_route" + actionUpdateWireGuard reconcileActionKind = "update_wireguard" + actionStartWireGuard reconcileActionKind = "start_wireguard" +) + +type reconcileAction struct { + Kind reconcileActionKind + Description string + DeploymentID string + Expected *agenthttp.ExpectedContainer + Actual *container.Container +} + func (a *Agent) Tick() { switch a.GetState() { case StateIdle: @@ -60,7 +86,7 @@ func (a *Agent) transitionToIdle() { a.SetState(StateIdle) if a.consumeExpectedStateRefresh() { log.Printf("[processing] fetching latest expected state after pending refresh") - // A deploy wake can arrive while processing a previous snapshot. Run one + // A reconcile wake can arrive while processing a previous snapshot. Run one // immediate idle pass after processing to pick up the latest expected state. a.handleIdle() } @@ -85,11 +111,11 @@ func (a *Agent) handleIdle() { a.updateDnsInSync(expected, actual) - changes := a.detectChanges(expected, actual) - if len(changes) > 0 { - log.Printf("[idle] drift detected, %d change(s) to apply:", len(changes)) - for _, change := range changes { - log.Printf(" → %s", change) + actions := a.planReconcile(expected, actual) + if len(actions) > 0 { + log.Printf("[idle] drift detected, %d change(s) to apply:", len(actions)) + for _, action := range actions { + log.Printf(" → %s", action.Description) } log.Printf("[idle] transitioning to PROCESSING") a.expectedState = expected @@ -97,7 +123,6 @@ func (a *Agent) handleIdle() { a.SetState(StateProcessing) return } - } func (a *Agent) handleProcessing() { @@ -115,15 +140,15 @@ func (a *Agent) handleProcessing() { } a.updateDnsInSync(a.expectedState, actual) + actions := a.planReconcile(a.expectedState, actual) - if len(a.detectChanges(a.expectedState, actual)) == 0 { + if len(actions) == 0 { log.Printf("[processing] state converged, transitioning to IDLE") a.transitionToIdle() return } - err = a.reconcileOne(actual) - if err != nil { + if err := a.applyReconcileAction(actions[0]); err != nil { log.Printf("[processing] reconciliation failed: %v, transitioning to IDLE", err) a.transitionToIdle() return @@ -165,8 +190,8 @@ func (a *Agent) getActualState() (*ActualState, error) { return state, nil } -func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualState) []string { - var changes []string +func (a *Agent) planReconcile(expected *agenthttp.ExpectedState, actual *ActualState) []reconcileAction { + var actions []reconcileAction expectedMap := make(map[string]agenthttp.ExpectedContainer) for _, c := range expected.Containers { @@ -180,32 +205,86 @@ func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualS } } - for _, c := range actual.Containers { - if c.DeploymentID == "" { - changes = append(changes, fmt.Sprintf("STOP orphan container %s (no deployment ID)", c.Name)) + for i := range actual.Containers { + act := &actual.Containers[i] + if act.DeploymentID == "" { + if act.State == "running" { + actions = append(actions, reconcileAction{ + Kind: actionStopOrphanNoDeploymentID, + Description: fmt.Sprintf("STOP orphan container %s (no deployment ID)", act.Name), + Actual: act, + }) + } else { + actions = append(actions, reconcileAction{ + Kind: actionRemoveOrphanNoDeploymentID, + Description: fmt.Sprintf("REMOVE orphan container %s (no deployment ID)", act.Name), + Actual: act, + }) + } } } for id, act := range actualMap { if _, exists := expectedMap[id]; !exists { - changes = append(changes, fmt.Sprintf("STOP orphan container %s (deployment %s not in expected state)", act.Name, id[:8])) + actualContainer := act + if act.State == "running" { + actions = append(actions, reconcileAction{ + Kind: actionStopUnexpectedContainer, + Description: fmt.Sprintf("STOP orphan container %s (deployment %s not in expected state)", act.Name, id[:8]), + DeploymentID: id, + Actual: &actualContainer, + }) + } else { + actions = append(actions, reconcileAction{ + Kind: actionRemoveUnexpectedContainer, + Description: fmt.Sprintf("REMOVE orphan container %s (deployment %s not in expected state)", act.Name, id[:8]), + DeploymentID: id, + Actual: &actualContainer, + }) + } } } for id, exp := range expectedMap { if _, exists := actualMap[id]; !exists { - changes = append(changes, fmt.Sprintf("DEPLOY %s (%s)", exp.Name, exp.Image)) + expectedContainer := exp + actions = append(actions, reconcileAction{ + Kind: actionDeployMissingContainer, + Description: fmt.Sprintf("DEPLOY %s (%s)", exp.Name, exp.Image), + DeploymentID: id, + Expected: &expectedContainer, + }) } } for id, exp := range expectedMap { if act, exists := actualMap[id]; exists { + expectedContainer := exp + actualContainer := act if act.State == "created" || act.State == "exited" { - changes = append(changes, fmt.Sprintf("START %s (state: %s)", exp.Name, act.State)) + actions = append(actions, reconcileAction{ + Kind: actionStartContainer, + Description: fmt.Sprintf("START %s (state: %s)", exp.Name, act.State), + DeploymentID: id, + Expected: &expectedContainer, + Actual: &actualContainer, + }) } else if act.State != "running" { - changes = append(changes, fmt.Sprintf("RESTART %s (state: %s)", exp.Name, act.State)) + actions = append(actions, reconcileAction{ + Kind: actionRedeployContainer, + Description: fmt.Sprintf("REDEPLOY %s (state: %s)", exp.Name, act.State), + DeploymentID: id, + Expected: &expectedContainer, + Actual: &actualContainer, + }) } else if normalizeImage(exp.Image) != normalizeImage(act.Image) { - changes = append(changes, fmt.Sprintf("REDEPLOY %s (image: %s → %s)", exp.Name, act.Image, exp.Image)) + actions = append(actions, reconcileAction{ + Kind: actionRedeployContainer, + Description: fmt.Sprintf("REDEPLOY %s (image: %s → %s)", exp.Name, act.Image, exp.Image), + DeploymentID: id, + Expected: &expectedContainer, + Actual: &actualContainer, + }) } } } @@ -217,7 +296,10 @@ func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualS } expectedDnsHash := dns.HashRecords(expectedDnsRecords) if expectedDnsHash != actual.DnsConfigHash { - changes = append(changes, fmt.Sprintf("UPDATE DNS (%d records)", len(expected.Dns.Records))) + actions = append(actions, reconcileAction{ + Kind: actionUpdateDNS, + Description: fmt.Sprintf("UPDATE DNS (%d records)", len(expected.Dns.Records)), + }) } } @@ -225,14 +307,20 @@ func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualS expectedHttpRoutes := ConvertToHttpRoutes(expected.Traefik.HttpRoutes) expectedTraefikHash := traefik.HashRoutesWithServerName(expectedHttpRoutes, expected.ServerName) if expectedTraefikHash != actual.TraefikConfigHash { - changes = append(changes, fmt.Sprintf("UPDATE Traefik HTTP (%d routes)", len(expected.Traefik.HttpRoutes))) + actions = append(actions, reconcileAction{ + Kind: actionUpdateTraefik, + Description: fmt.Sprintf("UPDATE Traefik HTTP (%d routes)", len(expected.Traefik.HttpRoutes)), + }) } tcpRoutes := ConvertToTCPRoutes(expected.Traefik.TCPRoutes) udpRoutes := ConvertToUDPRoutes(expected.Traefik.UDPRoutes) expectedL4Hash := traefik.HashTCPRoutes(tcpRoutes) + traefik.HashUDPRoutes(udpRoutes) if expectedL4Hash != actual.L4ConfigHash { - changes = append(changes, fmt.Sprintf("UPDATE Traefik L4 (%d TCP, %d UDP routes)", len(tcpRoutes), len(udpRoutes))) + actions = append(actions, reconcileAction{ + Kind: actionUpdateTraefik, + Description: fmt.Sprintf("UPDATE Traefik L4 (%d TCP, %d UDP)", len(tcpRoutes), len(udpRoutes)), + }) } expectedCerts := make([]traefik.Certificate, len(expected.Traefik.Certificates)) @@ -241,11 +329,17 @@ func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualS } expectedCertsHash := traefik.HashCertificates(expectedCerts) if expectedCertsHash != actual.CertificatesHash { - changes = append(changes, fmt.Sprintf("UPDATE Certificates (%d certs)", len(expected.Traefik.Certificates))) + actions = append(actions, reconcileAction{ + Kind: actionUpdateCertificates, + Description: fmt.Sprintf("UPDATE Certificates (%d certs)", len(expected.Traefik.Certificates)), + }) } if expected.Traefik.ChallengeRoute != nil && !actual.ChallengeRouteWritten { - changes = append(changes, "WRITE Challenge Route") + actions = append(actions, reconcileAction{ + Kind: actionWriteChallengeRoute, + Description: "WRITE Challenge Route", + }) } } @@ -257,12 +351,21 @@ func (a *Agent) detectChanges(expected *agenthttp.ExpectedState, actual *ActualS Endpoint: p.Endpoint, } } - expectedWgHash := wireguard.HashPeers(expectedWgPeers) - if expectedWgHash != actual.WireguardHash { - changes = append(changes, fmt.Sprintf("UPDATE WireGuard (%d peers)", len(expected.Wireguard.Peers))) + if wireguard.HashPeers(expectedWgPeers) != actual.WireguardHash { + actions = append(actions, reconcileAction{ + Kind: actionUpdateWireGuard, + Description: fmt.Sprintf("UPDATE WireGuard (%d peers)", len(expected.Wireguard.Peers)), + }) + } + + if !wireguard.IsUp(wireguard.DefaultInterface) { + actions = append(actions, reconcileAction{ + Kind: actionStartWireGuard, + Description: "START WireGuard", + }) } - return changes + return actions } func normalizeImage(image string) string { @@ -283,204 +386,172 @@ func normalizeImage(image string) string { return image + digest } -func (a *Agent) reconcileOne(actual *ActualState) error { - expectedMap := make(map[string]agenthttp.ExpectedContainer) - for _, c := range a.expectedState.Containers { - expectedMap[c.DeploymentID] = c - } +func (a *Agent) applyReconcileAction(action reconcileAction) error { + log.Printf("[reconcile] %s", action.Description) - actualMap := make(map[string]container.Container) - for _, c := range actual.Containers { - if c.DeploymentID != "" { - actualMap[c.DeploymentID] = c + switch action.Kind { + case actionStopOrphanNoDeploymentID, actionStopUnexpectedContainer: + if action.Actual == nil { + return fmt.Errorf("missing actual container for %s", action.Kind) } - } + if err := container.Stop(action.Actual.ID); err != nil { + return fmt.Errorf("failed to stop orphan container: %w", err) + } + return nil - for _, act := range actual.Containers { - if act.DeploymentID == "" { - if act.State == "running" { - log.Printf("[reconcile] stopping orphan container %s (no deployment ID)", act.ID) - if err := container.Stop(act.ID); err != nil { - return fmt.Errorf("failed to stop orphan container: %w", err) - } - return nil - } else { - log.Printf("[reconcile] removing orphan container %s (no deployment ID)", act.ID) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - err := retry.WithBackoff(ctx, retry.ForceRemoveBackoff, func() (bool, error) { - if err := container.ForceRemove(act.ID); err != nil { - log.Printf("[reconcile] remove attempt failed: %v, retrying...", err) - return false, err - } - return true, nil - }) - cancel() - if err != nil { - log.Printf("[reconcile] warning: failed to remove orphan container after retries: %v", err) - } - return nil + case actionRemoveOrphanNoDeploymentID: + if action.Actual == nil { + return fmt.Errorf("missing actual container for %s", action.Kind) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + err := retry.WithBackoff(ctx, retry.ForceRemoveBackoff, func() (bool, error) { + if err := container.ForceRemove(action.Actual.ID); err != nil { + log.Printf("[reconcile] remove attempt failed: %v, retrying...", err) + return false, err } + return true, nil + }) + cancel() + if err != nil { + log.Printf("[reconcile] warning: failed to remove orphan container after retries: %v", err) } - } + return nil - for id, act := range actualMap { - if _, exists := expectedMap[id]; !exists { - if act.State == "running" { - log.Printf("[reconcile] stopping orphan container %s (deployment %s not in expected state)", act.Name, id[:8]) - if err := container.Stop(act.ID); err != nil { - return fmt.Errorf("failed to stop orphan container: %w", err) - } - return nil - } else { - log.Printf("[reconcile] removing orphan container %s (deployment %s not in expected state)", act.Name, id[:8]) - if err := container.ForceRemove(act.ID); err != nil { - log.Printf("[reconcile] warning: failed to remove orphan: %v", err) - } - return nil - } + case actionRemoveUnexpectedContainer: + if action.Actual == nil { + return fmt.Errorf("missing actual container for %s", action.Kind) } - } + if err := container.ForceRemove(action.Actual.ID); err != nil { + log.Printf("[reconcile] warning: failed to remove orphan: %v", err) + } + return nil - for id, exp := range expectedMap { - if _, exists := actualMap[id]; !exists { - log.Printf("[reconcile] deploying missing container for deployment %s", id) - if err := a.Reconciler.Deploy(exp); err != nil { - return fmt.Errorf("failed to deploy container: %w", err) - } - return nil + case actionDeployMissingContainer: + if action.Expected == nil { + return fmt.Errorf("missing expected container for %s", action.Kind) } - } + if err := a.Reconciler.Deploy(*action.Expected); err != nil { + return fmt.Errorf("failed to deploy container: %w", err) + } + return nil - for id, exp := range expectedMap { - if act, exists := actualMap[id]; exists { - if act.State == "created" || act.State == "exited" { - log.Printf("[reconcile] starting %s container %s for deployment %s", act.State, act.ID, id) - if err := container.Start(act.ID); err != nil { - log.Printf("[reconcile] start failed, will redeploy: %v", err) - if err := container.Stop(act.ID); err != nil { - log.Printf("[reconcile] warning: failed to stop old container: %v", err) - } - if err := a.Reconciler.Deploy(exp); err != nil { - return fmt.Errorf("failed to redeploy container: %w", err) - } - } - return nil + case actionStartContainer: + if action.Actual == nil || action.Expected == nil { + return fmt.Errorf("missing container state for %s", action.Kind) + } + if err := container.Start(action.Actual.ID); err != nil { + log.Printf("[reconcile] start failed, will redeploy: %v", err) + if err := container.Stop(action.Actual.ID); err != nil { + log.Printf("[reconcile] warning: failed to stop old container: %v", err) } - if act.State != "running" || normalizeImage(exp.Image) != normalizeImage(act.Image) { - log.Printf("[reconcile] redeploying container for deployment %s (state=%s)", id, act.State) - if err := container.Stop(act.ID); err != nil { - log.Printf("[reconcile] warning: failed to stop old container: %v", err) - } - if err := a.Reconciler.Deploy(exp); err != nil { - return fmt.Errorf("failed to redeploy container: %w", err) - } - return nil + if err := a.Reconciler.Deploy(*action.Expected); err != nil { + return fmt.Errorf("failed to redeploy container: %w", err) } } - } + return nil - if !a.DisableDNS { + case actionRedeployContainer: + if action.Actual == nil || action.Expected == nil { + return fmt.Errorf("missing container state for %s", action.Kind) + } + if err := container.Stop(action.Actual.ID); err != nil { + log.Printf("[reconcile] warning: failed to stop old container: %v", err) + } + if err := a.Reconciler.Deploy(*action.Expected); err != nil { + return fmt.Errorf("failed to redeploy container: %w", err) + } + return nil + + case actionUpdateDNS: expectedDnsRecords := make([]dns.DnsRecord, len(a.expectedState.Dns.Records)) for i, r := range a.expectedState.Dns.Records { expectedDnsRecords[i] = dns.DnsRecord{Name: r.Name, Ips: r.Ips} } - if dns.HashRecords(expectedDnsRecords) != actual.DnsConfigHash { - log.Printf("[reconcile] updating DNS records") - if err := dns.UpdateDnsRecords(expectedDnsRecords); err != nil { - return fmt.Errorf("failed to update DNS: %w", err) - } - return nil + if err := dns.UpdateDnsRecords(expectedDnsRecords); err != nil { + return fmt.Errorf("failed to update DNS: %w", err) } - } - - if a.IsProxy { - expectedHttpRoutes := ConvertToHttpRoutes(a.expectedState.Traefik.HttpRoutes) - tcpRoutes := ConvertToTCPRoutes(a.expectedState.Traefik.TCPRoutes) - udpRoutes := ConvertToUDPRoutes(a.expectedState.Traefik.UDPRoutes) - - httpDrift := traefik.HashRoutesWithServerName(expectedHttpRoutes, a.expectedState.ServerName) != actual.TraefikConfigHash - expectedL4Hash := traefik.HashTCPRoutes(tcpRoutes) + traefik.HashUDPRoutes(udpRoutes) - l4Drift := expectedL4Hash != actual.L4ConfigHash - - if httpDrift || l4Drift { - var tcpPorts, udpPorts []int - for _, r := range tcpRoutes { - tcpPorts = append(tcpPorts, r.ExternalPort) - } - for _, r := range udpRoutes { - udpPorts = append(udpPorts, r.ExternalPort) - } - - needsRestart := false - if len(tcpPorts) > 0 || len(udpPorts) > 0 { - log.Printf("[reconcile] ensuring L4 entry points: %d TCP, %d UDP", len(tcpPorts), len(udpPorts)) - var err error - needsRestart, err = traefik.EnsureEntryPoints(tcpPorts, udpPorts) - if err != nil { - return fmt.Errorf("failed to ensure entry points: %w", err) - } - } - - log.Printf("[reconcile] updating Traefik routes (HTTP: %d, TCP: %d, UDP: %d)", len(expectedHttpRoutes), len(tcpRoutes), len(udpRoutes)) - if err := traefik.UpdateHttpRoutesWithL4(expectedHttpRoutes, tcpRoutes, udpRoutes, a.expectedState.ServerName); err != nil { - return fmt.Errorf("failed to update Traefik: %w", err) - } + return nil - if needsRestart { - log.Printf("[reconcile] restarting Traefik to apply new entry points") - if err := traefik.ReloadTraefik(); err != nil { - return fmt.Errorf("failed to restart Traefik: %w", err) - } - } - return nil - } + case actionUpdateTraefik: + return a.updateTraefik() + case actionUpdateCertificates: expectedCerts := make([]traefik.Certificate, len(a.expectedState.Traefik.Certificates)) for i, c := range a.expectedState.Traefik.Certificates { expectedCerts[i] = traefik.Certificate{Domain: c.Domain, Certificate: c.Certificate, CertificateKey: c.CertificateKey} } - if traefik.HashCertificates(expectedCerts) != actual.CertificatesHash { - log.Printf("[reconcile] updating certificates") - if err := traefik.UpdateCertificates(expectedCerts); err != nil { - return fmt.Errorf("failed to update certificates: %w", err) - } - return nil + if err := traefik.UpdateCertificates(expectedCerts); err != nil { + return fmt.Errorf("failed to update certificates: %w", err) } + return nil - if a.expectedState.Traefik.ChallengeRoute != nil && !actual.ChallengeRouteWritten { - log.Printf("[reconcile] writing challenge route") - if err := traefik.WriteChallengeRoute(a.expectedState.Traefik.ChallengeRoute.ControlPlaneUrl); err != nil { - return fmt.Errorf("failed to write challenge route: %w", err) - } + case actionWriteChallengeRoute: + if a.expectedState.Traefik.ChallengeRoute == nil { return nil } - } - - expectedWgPeers := make([]wireguard.Peer, len(a.expectedState.Wireguard.Peers)) - for i, p := range a.expectedState.Wireguard.Peers { - expectedWgPeers[i] = wireguard.Peer{ - PublicKey: p.PublicKey, - AllowedIPs: p.AllowedIPs, - Endpoint: p.Endpoint, + if err := traefik.WriteChallengeRoute(a.expectedState.Traefik.ChallengeRoute.ControlPlaneUrl); err != nil { + return fmt.Errorf("failed to write challenge route: %w", err) } - } - wgPeersChanged := wireguard.HashPeers(expectedWgPeers) != actual.WireguardHash - wgIsUp := wireguard.IsUp(wireguard.DefaultInterface) + return nil - if wgPeersChanged { - log.Printf("[reconcile] updating WireGuard peers") + case actionUpdateWireGuard: + expectedWgPeers := make([]wireguard.Peer, len(a.expectedState.Wireguard.Peers)) + for i, p := range a.expectedState.Wireguard.Peers { + expectedWgPeers[i] = wireguard.Peer{ + PublicKey: p.PublicKey, + AllowedIPs: p.AllowedIPs, + Endpoint: p.Endpoint, + } + } if err := a.reconcileWireguard(expectedWgPeers); err != nil { return fmt.Errorf("failed to update WireGuard: %w", err) } return nil - } - if !wgIsUp { - log.Printf("[reconcile] WireGuard interface is down, bringing it up") + case actionStartWireGuard: if err := wireguard.Up(wireguard.DefaultInterface); err != nil { return fmt.Errorf("failed to bring up WireGuard: %w", err) } return nil + + default: + return fmt.Errorf("unknown reconcile action: %s", action.Kind) + } +} + +func (a *Agent) updateTraefik() error { + expectedHttpRoutes := ConvertToHttpRoutes(a.expectedState.Traefik.HttpRoutes) + tcpRoutes := ConvertToTCPRoutes(a.expectedState.Traefik.TCPRoutes) + udpRoutes := ConvertToUDPRoutes(a.expectedState.Traefik.UDPRoutes) + + var tcpPorts, udpPorts []int + for _, r := range tcpRoutes { + tcpPorts = append(tcpPorts, r.ExternalPort) + } + for _, r := range udpRoutes { + udpPorts = append(udpPorts, r.ExternalPort) + } + + needsRestart := false + if len(tcpPorts) > 0 || len(udpPorts) > 0 { + log.Printf("[reconcile] ensuring L4 entry points: %d TCP, %d UDP", len(tcpPorts), len(udpPorts)) + var err error + needsRestart, err = traefik.EnsureEntryPoints(tcpPorts, udpPorts) + if err != nil { + return fmt.Errorf("failed to ensure entry points: %w", err) + } + } + + log.Printf("[reconcile] updating Traefik routes (HTTP: %d, TCP: %d, UDP: %d)", len(expectedHttpRoutes), len(tcpRoutes), len(udpRoutes)) + if err := traefik.UpdateHttpRoutesWithL4(expectedHttpRoutes, tcpRoutes, udpRoutes, a.expectedState.ServerName); err != nil { + return fmt.Errorf("failed to update Traefik: %w", err) + } + + if needsRestart { + log.Printf("[reconcile] restarting Traefik to apply new entry points") + if err := traefik.ReloadTraefik(); err != nil { + return fmt.Errorf("failed to restart Traefik: %w", err) + } } return nil diff --git a/agent/internal/agent/handlers.go b/agent/internal/agent/handlers.go index 9cba7a68..5dd68cb0 100644 --- a/agent/internal/agent/handlers.go +++ b/agent/internal/agent/handlers.go @@ -3,6 +3,7 @@ package agent import ( "context" "encoding/json" + "errors" "fmt" "log" "os" @@ -67,16 +68,25 @@ func (a *Agent) ProcessForceCleanup(item agenthttp.WorkQueueItem) error { log.Printf("[force_cleanup] cleaning up %d containers for service %s", len(payload.ContainerIDs), Truncate(payload.ServiceID, 8)) + var cleanupErrors []error for _, containerID := range payload.ContainerIDs { if err := container.Stop(containerID); err != nil { log.Printf("[force_cleanup] failed to stop %s: %v", Truncate(containerID, 12), err) + cleanupErrors = append( + cleanupErrors, + fmt.Errorf("stop %s: %w", Truncate(containerID, 12), err), + ) } if err := container.ForceRemove(containerID); err != nil { log.Printf("[force_cleanup] failed to remove %s: %v", Truncate(containerID, 12), err) + cleanupErrors = append( + cleanupErrors, + fmt.Errorf("remove %s: %w", Truncate(containerID, 12), err), + ) } } - return nil + return errors.Join(cleanupErrors...) } func (a *Agent) ProcessCleanupVolumes(item agenthttp.WorkQueueItem) error { @@ -127,9 +137,9 @@ func (a *Agent) ProcessBuild(item agenthttp.WorkQueueItem) error { a.buildMutex.Unlock() }() - buildDetails, err := a.Client.GetBuild(payload.BuildID) + buildDetails, err := a.Client.ClaimBuild(payload.BuildID) if err != nil { - return fmt.Errorf("failed to get build details: %w", err) + return fmt.Errorf("failed to claim build: %w", err) } timeoutMinutes := buildDetails.TimeoutMinutes diff --git a/agent/internal/agent/workqueue.go b/agent/internal/agent/workqueue.go index 6aa1690a..8eb02fa0 100644 --- a/agent/internal/agent/workqueue.go +++ b/agent/internal/agent/workqueue.go @@ -113,8 +113,8 @@ func (a *Agent) ProcessWorkItem(item agenthttp.WorkQueueItem) error { return a.ProcessRestart(item) case "stop": return a.ProcessStop(item) - case "deploy": - a.RequestReconcile("deploy work item " + Truncate(item.ID, 8)) + case "deploy", "reconcile": + a.RequestReconcile("reconcile work item " + Truncate(item.ID, 8)) return nil case "force_cleanup": return a.ProcessForceCleanup(item) diff --git a/agent/internal/build/build.go b/agent/internal/build/build.go index d846ffb6..b55f2816 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 efc795a4..e6378009 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 71cb57ba..00000000 --- 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 840a9478..21a8ba5d 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 0cd1e0ed..00000000 --- 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 2ca2001c..812e43ea 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 5f089825..00000000 --- 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 28a88e40..55d42e5e 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/http/client.go b/agent/internal/http/client.go index 838ebc91..28069496 100644 --- a/agent/internal/http/client.go +++ b/agent/internal/http/client.go @@ -286,8 +286,8 @@ type BuildDetails struct { TargetPlatforms []string `json:"targetPlatforms"` } -func (c *Client) GetBuild(buildID string) (*BuildDetails, error) { - req, err := http.NewRequest("GET", c.baseURL+"/api/v1/agent/builds/"+buildID, nil) +func (c *Client) ClaimBuild(buildID string) (*BuildDetails, error) { + req, err := http.NewRequest("POST", c.baseURL+"/api/v1/agent/builds/"+buildID, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -296,13 +296,13 @@ func (c *Client) GetBuild(buildID string) (*BuildDetails, error) { resp, err := c.client.Do(req) if err != nil { - return nil, fmt.Errorf("failed to get build: %w", err) + return nil, fmt.Errorf("failed to claim build: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("get build failed with status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("claim build failed with status %d: %s", resp.StatusCode, string(body)) } var result BuildDetails 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 9d32fddf..e96dc8fb 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 939df65a..00000000 --- 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 ab4f3bcd..acb8c88b 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 853ead0f..6fabff48 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"` -} diff --git a/cli/package.json b/cli/package.json index 1b8a94f8..6cefbb97 100644 --- a/cli/package.json +++ b/cli/package.json @@ -5,11 +5,11 @@ "type": "module", "scripts": { "dev": "node --import tsx src/main.ts", - "build": "bun build src/main.ts --compile --outfile dist/tcloud", - "build:linux-x64": "bun build src/main.ts --compile --target=bun-linux-x64 --outfile dist/tcloud-linux-x64", - "build:linux-arm64": "bun build src/main.ts --compile --target=bun-linux-arm64 --outfile dist/tcloud-linux-arm64", - "build:darwin-x64": "bun build src/main.ts --compile --target=bun-darwin-x64 --outfile dist/tcloud-darwin-x64", - "build:darwin-arm64": "bun build src/main.ts --compile --target=bun-darwin-arm64 --outfile dist/tcloud-darwin-arm64", + "build": "bun build src/main.ts --compile --outfile dist/tc", + "build:linux-x64": "bun build src/main.ts --compile --target=bun-linux-x64 --outfile dist/tc-linux-x64", + "build:linux-arm64": "bun build src/main.ts --compile --target=bun-linux-arm64 --outfile dist/tc-linux-arm64", + "build:darwin-x64": "bun build src/main.ts --compile --target=bun-darwin-x64 --outfile dist/tc-darwin-x64", + "build:darwin-arm64": "bun build src/main.ts --compile --target=bun-darwin-arm64 --outfile dist/tc-darwin-arm64", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml new file mode 100644 index 00000000..db827add --- /dev/null +++ b/cli/pnpm-lock.yaml @@ -0,0 +1,348 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + yaml: + specifier: ^2.8.2 + version: 2.9.0 + zod: + specifier: ^4.3.5 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^22.17.0 + version: 22.20.0 + tsx: + specifier: ^4.19.2 + version: 4.22.4 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + fsevents@2.3.3: + optional: true + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + yaml@2.9.0: {} + + zod@4.4.3: {} diff --git a/cli/src/main.ts b/cli/src/main.ts index 94814650..61bfca35 100644 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -14,6 +14,8 @@ import { const CLI_VERSION = "0.1.0"; const CLI_CLIENT_ID = "techulus-cli"; +const DEFAULT_LOG_TAIL = 100; +const LOG_POLL_INTERVAL_MS = 2000; type JsonRequestOptions = { method?: string; @@ -21,6 +23,12 @@ type JsonRequestOptions = { body?: unknown; }; +type ErrorResponse = { + error?: string; + message?: string; + code?: string; +}; + type LinkServiceTarget = { id: string; name: string; @@ -43,6 +51,13 @@ type LinkProjectTarget = { environments: LinkEnvironmentTarget[]; }; +type ServiceLog = { + deploymentId: string | undefined; + stream: string; + message: string; + timestamp: string; +}; + function normalizeHost(host: string) { const trimmed = host.trim().replace(/\/$/, ""); if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { @@ -63,13 +78,32 @@ async function requestJson(url: string, options: JsonRequestOptions = {}) { }); const text = await response.text(); - const data = text ? (JSON.parse(text) as T | { error?: string }) : null; + const data = text ? (JSON.parse(text) as T | ErrorResponse) : null; if (!response.ok) { - const message = - data && typeof data === "object" && "error" in data && data.error - ? data.error - : `Request failed with ${response.status}`; + const apiMessage = + data && typeof data === "object" + ? "message" in data && data.message + ? data.message + : "error" in data && data.error + ? data.error + : null + : null; + const code = + data && typeof data === "object" && "code" in data && data.code + ? ` (${data.code})` + : ""; + const message = apiMessage + ? `${apiMessage}${code}` + : `Request failed with ${response.status}`; + + if (response.status === 401 || response.status === 403) { + const host = normalizeHost(new URL(url).origin); + throw new Error( + `${message}\n\nYour CLI session is not authorized. Run:\n tc auth login --host ${host}`, + ); + } + throw new Error(message); } @@ -80,6 +114,35 @@ async function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function shortId(id: string) { + if (id.length <= 16) return id; + return `${id.slice(0, 8)}…${id.slice(-4)}`; +} + +function formatStatus(value: string) { + return value.replace(/_/g, " "); +} + +function formatTimestamp(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toISOString(); +} + +function printSection(title: string) { + console.log(`\n${title}`); + console.log("─".repeat(title.length)); +} + +function printField(label: string, value: string | number) { + console.log(` ${label.padEnd(10)} ${value}`); +} + +function printNext(command: string) { + printSection("Next"); + printField("Run", command); +} + function parseOption(args: string[], name: string) { const index = args.indexOf(name); if (index === -1) { @@ -94,16 +157,34 @@ function parseOption(args: string[], name: string) { return value; } +function parseLogLineLimit(args: string[]) { + const rawTail = parseOption(args, "-n") ?? parseOption(args, "--tail"); + if (!rawTail) return null; + + if (!/^\d+$/.test(rawTail)) { + throw new Error("log line count must be a positive integer"); + } + + const tail = Number.parseInt(rawTail, 10); + if (tail < 1 || tail > 1000) { + throw new Error("log line count must be between 1 and 1000"); + } + + return tail; +} + function printUsage() { console.log(`Usage: - tcloud auth login --host - tcloud auth logout - tcloud auth whoami - tcloud init - tcloud link [--force] - tcloud apply - tcloud deploy - tcloud status`); + tc auth login --host + tc auth logout + tc auth whoami + tc init + tc link [--force] + tc apply + tc deploy + tc logs + tc logs -n + tc status`); } async function pathExists(filePath: string) { @@ -140,7 +221,7 @@ async function selectFromList( } if (!process.stdin.isTTY || !process.stdout.isTTY) { - throw new Error("tcloud link requires an interactive terminal."); + throw new Error("tc link requires an interactive terminal."); } const rl = createInterface({ input, output }); @@ -181,7 +262,7 @@ async function ensureManifest(cwd: string) { } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { throw new Error( - "No techulus.yml found in the current directory. Run `tcloud init` to create one.", + "No techulus.yml found in the current directory. Run `tc init` to create one.", ); } throw new Error( @@ -201,7 +282,7 @@ function authHeaders(apiKey: string) { async function requireConfig() { const config = await readConfig(); if (!config) { - throw new Error("Not logged in. Run `tcloud auth login --host ` first."); + throw new Error("Not logged in. Run `tc auth login --host ` first."); } return config; @@ -232,9 +313,14 @@ async function commandAuthLogin(args: string[]) { }, }); - console.log(`Visit ${deviceCode.verification_uri}`); - console.log(`Enter code: ${deviceCode.user_code}`); - console.log("Open the verification URL in your browser to continue."); + const verificationUrl = + deviceCode.verification_uri_complete || deviceCode.verification_uri; + + printSection("Device login"); + printField("Host", host); + printField("URL", verificationUrl); + printField("Code", deviceCode.user_code); + console.log("\nOpen the verification URL in your browser to continue."); let accessToken = ""; let intervalMs = deviceCode.interval * 1000; @@ -288,7 +374,7 @@ async function commandAuthLogin(args: string[]) { } } - console.log("\nDevice login approved. Creating a CLI API key..."); + console.log("\n\nDevice approved. Creating a CLI API key..."); const machineName = os.hostname(); const platform = `${process.platform}/${process.arch}`; @@ -317,12 +403,17 @@ async function commandAuthLogin(args: string[]) { user: exchange.user, }); - console.log(`Signed in as ${exchange.user.email}`); + printSection("Signed in"); + printField("User", exchange.user.email); + printField("Name", exchange.user.name); + printField("Host", host); + printField("Key", exchange.keyId ? shortId(exchange.keyId) : "created"); } async function commandAuthLogout() { await deleteConfig(); - console.log("Signed out."); + printSection("Signed out"); + printField("Config", "removed"); } async function commandAuthWhoAmI() { @@ -333,9 +424,10 @@ async function commandAuthWhoAmI() { headers: authHeaders(config.apiKey), }); - console.log(`Signed in as ${whoami.user.email}`); - console.log(`Name: ${whoami.user.name}`); - console.log(`Host: ${config.host}`); + printSection("Account"); + printField("User", whoami.user.email); + printField("Name", whoami.user.name); + printField("Host", config.host); } async function commandInit(cwd: string) { @@ -369,7 +461,9 @@ service: `; await writeFile(manifestPath, manifest, "utf8"); - console.log(`Created ${manifestPath}`); + printSection("Manifest"); + printField("Created", manifestPath); + printNext("tc apply"); } async function commandLink(cwd: string, args: string[]) { @@ -379,7 +473,7 @@ async function commandLink(cwd: string, args: string[]) { if ((await pathExists(manifestPath)) && !force) { throw new Error( - "techulus.yml already exists. Run `tcloud link --force` to replace it.", + "techulus.yml already exists. Run `tc link --force` to replace it.", ); } @@ -459,11 +553,13 @@ async function commandLink(cwd: string, args: string[]) { await writeFile(manifestPath, stringifyManifest(result.manifest), "utf8"); - console.log( - `Linked ${result.service.project}/${result.service.environment}/${result.service.name}`, + printSection("Linked"); + printField( + "Service", + `${result.service.project}/${result.service.environment}/${result.service.name}`, ); - console.log(`Wrote ${manifestPath}`); - console.log("Next: run `tcloud status` or `tcloud apply`."); + printField("Manifest", manifestPath); + printNext("tc status or tc apply"); } function printApplyResult(result: { @@ -471,17 +567,20 @@ function printApplyResult(result: { serviceId: string; changes: Array<{ field: string; from: string; to: string }>; }) { - console.log(`Action: ${result.action}`); - console.log(`Service ID: ${result.serviceId}`); + printSection("Apply"); + printField("Action", result.action); + printField("Service", shortId(result.serviceId)); if (result.changes.length === 0) { - console.log("No changes."); + printField("Changes", "none"); return; } - console.log("Changes:"); + printSection(`Changes (${result.changes.length})`); for (const change of result.changes) { - console.log(`- ${change.field}: ${change.from} -> ${change.to}`); + console.log(` • ${change.field}`); + printField("From", change.from); + printField("To", change.to); } } @@ -514,11 +613,13 @@ async function commandDeploy(cwd: string) { body: manifest, }); - console.log(`Service ID: ${result.serviceId}`); - console.log(`Status: ${result.status}`); + printSection("Deploy"); + printField("Service", shortId(result.serviceId)); + printField("Status", formatStatus(result.status)); if (result.rolloutId) { - console.log(`Rollout ID: ${result.rolloutId}`); + printField("Rollout", shortId(result.rolloutId)); } + printNext("tc status"); } async function commandStatus(cwd: string) { @@ -550,26 +651,148 @@ async function commandStatus(cwd: string) { headers: authHeaders(config.apiKey), }); - console.log(`Service ID: ${status.service.id}`); - console.log(`Image: ${status.service.image}`); - console.log(`Hostname: ${status.service.hostname ?? "(none)"}`); - console.log(`Replicas: ${status.service.replicas}`); + console.log(`${manifest.project}/${manifest.environment}/${manifest.service.name}`); + + printSection("Service"); + printField("ID", shortId(status.service.id)); + printField("Image", status.service.image); + printField("Hostname", status.service.hostname ?? "none"); + printField("Replicas", status.service.replicas); + + printSection("Rollout"); if (status.latestRollout) { - console.log( - `Latest rollout: ${status.latestRollout.id} (${status.latestRollout.status}${status.latestRollout.currentStage ? `, ${status.latestRollout.currentStage}` : ""})`, + printField("ID", shortId(status.latestRollout.id)); + printField("Status", formatStatus(status.latestRollout.status)); + printField( + "Stage", + status.latestRollout.currentStage + ? formatStatus(status.latestRollout.currentStage) + : "none", ); } else { - console.log("Latest rollout: none"); + printField("Latest", "none"); } - console.log(`Deployments: ${status.deployments.length}`); + + printSection(`Deployments (${status.deployments.length})`); + if (status.deployments.length === 0) { + printField("Current", "none"); + return; + } + for (const deployment of status.deployments) { - console.log(`- ${deployment.id}: ${deployment.status} on ${deployment.serverId}`); + console.log(` • ${shortId(deployment.id)}`); + printField("Status", formatStatus(deployment.status)); + printField("Server", shortId(deployment.serverId)); + } +} + +function printLogs(logs: ServiceLog[]) { + for (const log of logs) { + const stream = `[${log.stream || "stdout"}]`.padEnd(9); + const message = log.message.replace(/\n+$/, ""); + console.log(`${formatTimestamp(log.timestamp)} ${stream} ${message}`); + } +} + +function getLogCursor(logs: ServiceLog[]) { + return logs.reduce((latest, log) => { + if (!latest) return log.timestamp; + return new Date(log.timestamp).getTime() > new Date(latest).getTime() + ? log.timestamp + : latest; + }, null); +} + +function getLogKey(log: ServiceLog) { + return `${log.timestamp}:${log.stream}:${log.deploymentId ?? ""}:${log.message}`; +} + +async function fetchManifestLogs( + config: Awaited>, + manifest: TechulusManifest, + options: { tail: number; after?: string | null }, +) { + const params = new URLSearchParams({ + project: manifest.project, + environment: manifest.environment, + service: manifest.service.name, + tail: String(options.tail), + }); + if (options.after) { + params.set("after", options.after); + } + + return requestJson<{ + loggingEnabled: boolean; + logs: ServiceLog[]; + }>(`${config.host}/api/v1/manifest/logs?${params.toString()}`, { + headers: authHeaders(config.apiKey), + }); +} + +async function commandLogs(cwd: string, args: string[]) { + const lineLimit = parseLogLineLimit(args); + const config = await requireConfig(); + const { manifest } = await ensureManifest(cwd); + const result = await fetchManifestLogs(config, manifest, { + tail: lineLimit ?? DEFAULT_LOG_TAIL, + }); + + console.log(`${manifest.project}/${manifest.environment}/${manifest.service.name}`); + + if (!result.loggingEnabled) { + printSection("Logs"); + printField("Status", "disabled"); + return; + } + + if (lineLimit && result.logs.length === 0) { + printSection("Logs"); + printField("Lines", "none"); + return; + } + + if (lineLimit) { + printSection(`Logs (${result.logs.length})`); + printLogs(result.logs); + return; + } + + printSection("Logs"); + if (result.logs.length > 0) { + printLogs(result.logs); + } else { + printField("Waiting", "new log lines"); + } + + let after = getLogCursor(result.logs) ?? new Date().toISOString(); + const seen = new Set(result.logs.map(getLogKey)); + + while (true) { + await sleep(LOG_POLL_INTERVAL_MS); + const next = await fetchManifestLogs(config, manifest, { + tail: DEFAULT_LOG_TAIL, + after, + }); + const logs = next.logs.filter((log) => !seen.has(getLogKey(log))); + if (logs.length === 0) continue; + + printLogs(logs); + for (const log of logs) { + seen.add(getLogKey(log)); + } + after = getLogCursor(logs) ?? after; } } async function main() { - const [command, subcommand, ...rest] = process.argv.slice(2); - const cwd = process.cwd(); + const argv = process.argv.slice(2); + if (argv[0] === "--") { + argv.shift(); + } + + const [command, subcommand, ...rest] = argv; + const cwd = process.env.INIT_CWD || process.cwd(); if (!command) { printUsage(); @@ -604,6 +827,9 @@ async function main() { case "deploy": await commandDeploy(cwd); return; + case "logs": + await commandLogs(cwd, [subcommand, ...rest].filter(Boolean)); + return; case "status": await commandStatus(cwd); return; diff --git a/web/.env.example b/web/.env.example index 67fac890..74dbcc30 100644 --- a/web/.env.example +++ b/web/.env.example @@ -9,7 +9,7 @@ BETTER_AUTH_SECRET=your-secret-key-here ENCRYPTION_KEY=your-64-character-hex-string # Public URL -APP_URL=https://your-domain.com +APP_URL=http://localhost:3000 # Logging (optional) VICTORIA_LOGS_URL=http://username:password@victoria-logs:9428 @@ -25,10 +25,11 @@ VM_RETENTION=30d # Docker Registry for builds (optional) REGISTRY_HOST=registry.example.com -# Inngest (required for production) -INNGEST_BASE_URL=http://inngest:8288 -INNGEST_SIGNING_KEY=signkey-xxx -INNGEST_EVENT_KEY=xxx +# Inngest (local dev via ../compose.dev.yml) +INNGEST_BASE_URL=http://localhost:8288 +INNGEST_DEV=1 +INNGEST_SIGNING_KEY= +INNGEST_EVENT_KEY= # GitHub App Integration (optional) GITHUB_APP_ID=your-github-app-id diff --git a/web/README.md b/web/README.md index 5c32c69f..7bcb0a37 100644 --- a/web/README.md +++ b/web/README.md @@ -6,10 +6,13 @@ Next.js-based control plane for Techulus Cloud container deployment platform. ```bash pnpm install +cp .env.example .env +docker compose -f ../compose.dev.yml up -d pnpm dev ``` Open [http://localhost:3000](http://localhost:3000) to access the control plane. +Open [http://localhost:8288](http://localhost:8288) to access the Inngest dev server. ## Stack diff --git a/web/actions/backups.ts b/web/actions/backups.ts index 28657cc3..181cae94 100644 --- a/web/actions/backups.ts +++ b/web/actions/backups.ts @@ -1,16 +1,18 @@ "use server"; -import { desc, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { db } from "@/db"; import { getBackupStorageConfig } from "@/db/queries"; -import { servers, volumeBackups } from "@/db/schema"; +import { volumeBackups } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; import { triggerBackup } from "@/lib/backups/trigger-backup"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; import { deleteFromS3 } from "@/lib/s3"; export async function createBackup(serviceId: string, volumeId: string) { + await requireAuth(); const result = await triggerBackup({ serviceId, volumeId, @@ -29,31 +31,12 @@ export async function createBackup(serviceId: string, volumeId: string) { return { success: true, backupId: result.backupId }; } -export async function listBackups(serviceId: string) { - const backups = await db - .select({ - id: volumeBackups.id, - volumeName: volumeBackups.volumeName, - status: volumeBackups.status, - sizeBytes: volumeBackups.sizeBytes, - createdAt: volumeBackups.createdAt, - completedAt: volumeBackups.completedAt, - errorMessage: volumeBackups.errorMessage, - serverName: servers.name, - }) - .from(volumeBackups) - .leftJoin(servers, eq(volumeBackups.serverId, servers.id)) - .where(eq(volumeBackups.serviceId, serviceId)) - .orderBy(desc(volumeBackups.createdAt)); - - return backups; -} - export async function restoreBackup( serviceId: string, backupId: string, targetServerId?: string, ) { + await requireAuth(); await inngest.send( inngestEvents.restoreTrigger.create({ serviceId, @@ -70,6 +53,7 @@ export async function deleteBackup( backupId: string, options: { revalidate?: boolean } = {}, ) { + await requireAuth(); const backup = await db .select({ status: volumeBackups.status, diff --git a/web/actions/builds.ts b/web/actions/builds.ts index d480fed7..cd457084 100644 --- a/web/actions/builds.ts +++ b/web/actions/builds.ts @@ -3,10 +3,12 @@ import { and, eq, isNull } from "drizzle-orm"; import { db } from "@/db"; import { builds, githubRepos, services } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; export async function cancelBuild(buildId: string) { + await requireAuth(); const [build] = await db.select().from(builds).where(eq(builds.id, buildId)); if (!build) { @@ -40,6 +42,7 @@ export async function cancelBuild(buildId: string) { } export async function retryBuild(buildId: string) { + await requireAuth(); const [build] = await db.select().from(builds).where(eq(builds.id, buildId)); if (!build) { @@ -78,6 +81,7 @@ export async function triggerBuild( serviceId: string, trigger: "manual" | "scheduled" = "manual", ) { + await requireAuth(); const [service] = await db .select() .from(services) diff --git a/web/actions/compose.ts b/web/actions/compose.ts index 515b2de5..ec4c39bc 100644 --- a/web/actions/compose.ts +++ b/web/actions/compose.ts @@ -1,17 +1,17 @@ "use server"; -import { randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { db } from "@/db"; import { services } from "@/db/schema"; -import { parseComposeYaml, type ParsedService } from "@/lib/compose-parser"; +import { requireAuth } from "@/lib/auth"; +import { parseComposeYaml } from "@/lib/compose-parser"; import { + addServiceVolume, createService, - validateDockerImage, updateServiceHealthCheck, updateServiceResourceLimits, updateServiceStartCommand, - addServiceVolume, + validateDockerImage, } from "./projects"; import { createSecretsBatch } from "./secrets"; @@ -39,12 +39,14 @@ export type ImportComposeResult = { }; export async function parseComposeFile(yaml: string) { + await requireAuth(); return parseComposeYaml(yaml); } export async function importCompose( input: ImportComposeInput, ): Promise { + await requireAuth(); const { projectId, environmentId, yaml, serviceOverrides = {} } = input; const parseResult = parseComposeYaml(yaml); diff --git a/web/actions/migrations.ts b/web/actions/migrations.ts index 0a69e322..f4b9e0bc 100644 --- a/web/actions/migrations.ts +++ b/web/actions/migrations.ts @@ -1,104 +1,26 @@ "use server"; -import { and, eq } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { db } from "@/db"; -import { getBackupStorageConfig } from "@/db/queries"; -import { deployments, services, serviceVolumes } from "@/db/schema"; +import { services } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; +import { startMigrationInternal } from "@/lib/migrations"; export async function startMigration( serviceId: string, targetServerId: string, ) { - const storageConfig = await getBackupStorageConfig(); - if (!storageConfig) { - throw new Error( - "Backup storage not configured. Configure it in Settings first.", - ); - } - - const service = await db - .select() - .from(services) - .where(eq(services.id, serviceId)) - .then((r) => r[0]); - - if (!service) { - throw new Error("Service not found"); - } - - if (!service.stateful) { - throw new Error("Only stateful services can be migrated"); - } - - if (service.migrationStatus) { - throw new Error("Migration already in progress"); - } - - const volumes = await db - .select() - .from(serviceVolumes) - .where(eq(serviceVolumes.serviceId, serviceId)); - - if (volumes.length === 0) { - throw new Error("No volumes found for this service"); - } - - const deployment = await db - .select({ - id: deployments.id, - serverId: deployments.serverId, - containerId: deployments.containerId, - }) - .from(deployments) - .where( - and( - eq(deployments.serviceId, serviceId), - eq(deployments.status, "running"), - ), - ) - .then((r) => r[0]); - - if (!deployment?.serverId) { - throw new Error("No running deployment found"); - } - - if (!deployment.containerId) { - throw new Error("Deployment is missing container ID"); - } - - if (deployment.serverId === targetServerId) { - throw new Error("Service is already running on the target server"); - } - - await db - .update(services) - .set({ - migrationStatus: "backing_up", - migrationTargetServerId: targetServerId, - migrationBackupId: null, - migrationError: null, - }) - .where(eq(services.id, serviceId)); - - await inngest.send( - inngestEvents.migrationStarted.create({ - serviceId, - targetServerId, - sourceServerId: deployment.serverId, - sourceDeploymentId: deployment.id, - sourceContainerId: deployment.containerId, - volumes: volumes.map((v) => ({ id: v.id, name: v.name })), - }), - ); - + await requireAuth(); + await startMigrationInternal(serviceId, targetServerId); revalidatePath(`/dashboard/projects`); return { success: true }; } export async function cancelMigration(serviceId: string) { + await requireAuth(); await inngest.send(inngestEvents.migrationCancelled.create({ serviceId })); await db @@ -114,16 +36,3 @@ export async function cancelMigration(serviceId: string) { revalidatePath(`/dashboard/projects`); return { success: true }; } - -export async function getMigrationStatus(serviceId: string) { - const service = await db - .select({ - migrationStatus: services.migrationStatus, - migrationError: services.migrationError, - }) - .from(services) - .where(eq(services.id, serviceId)) - .then((r) => r[0]); - - return service ?? null; -} diff --git a/web/actions/projects.ts b/web/actions/projects.ts index e2ad7ad5..057e8788 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -28,7 +28,10 @@ import { volumeBackups, workQueue, } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants"; +import { deployServiceInternal } from "@/lib/deploy-service"; +import { markDeploymentUndesired } from "@/lib/deployment-status"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; import { allocatePort } from "@/lib/port-allocation"; @@ -45,7 +48,6 @@ import type { import { getZodErrorMessage, slugify } from "@/lib/utils"; import { enqueueWork } from "@/lib/work-queue"; import { deleteBackup } from "./backups"; -import { startMigration } from "./migrations"; function isValidImageReferencePart(reference: string): boolean { const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; @@ -114,6 +116,7 @@ function parseImageReference(image: string): { export async function validateDockerImage( image: string, ): Promise<{ valid: boolean; error?: string }> { + await requireAuth(); try { const { registry, namespace, repository, tag, digest } = parseImageReference(image); @@ -223,6 +226,7 @@ export async function validateDockerImage( } export async function createProject(name: string) { + await requireAuth(); try { const validatedName = nameSchema.parse(name); const id = randomUUID(); @@ -252,6 +256,7 @@ export async function createProject(name: string) { } export async function deleteProject(id: string) { + await requireAuth(); const projectServices = await db .select() .from(services) @@ -304,6 +309,7 @@ async function deleteBackupsForServices(serviceIds: string[]) { } export async function updateProjectName(projectId: string, name: string) { + await requireAuth(); try { const validatedName = nameSchema.parse(name); @@ -322,6 +328,7 @@ export async function updateProjectName(projectId: string, name: string) { } export async function updateProjectSlug(projectId: string, slug: string) { + await requireAuth(); const sanitized = slugify(slug); if (!sanitized) { throw new Error("Invalid slug"); @@ -345,6 +352,7 @@ export async function updateProjectSlug(projectId: string, slug: string) { } export async function createEnvironment(projectId: string, name: string) { + await requireAuth(); const sanitizedName = slugify(name); if (!sanitizedName) { throw new Error("Invalid environment name"); @@ -375,6 +383,7 @@ export async function createEnvironment(projectId: string, name: string) { } export async function deleteEnvironment(environmentId: string) { + await requireAuth(); const env = await getEnvironment(environmentId); if (!env) { @@ -414,6 +423,7 @@ type CreateServiceInput = { }; export async function createService(input: CreateServiceInput) { + await requireAuth(); const { projectId, environmentId, name, image, github } = input; const resourceLimits = input.resourceLimits ?? DEFAULT_RESOURCE_LIMITS; const env = await getEnvironment(environmentId); @@ -503,7 +513,7 @@ async function hardDeleteService(serviceId: string) { ) { await db .update(deployments) - .set({ status: "stopping" }) + .set(markDeploymentUndesired("stopping")) .where(eq(deployments.id, dep.id)); await enqueueWork(dep.serverId, "stop", { @@ -548,6 +558,7 @@ async function hardDeleteService(serviceId: string) { } export async function deleteService(serviceId: string) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -652,6 +663,7 @@ export async function deleteService(serviceId: string) { } export async function restoreDeletedService(serviceId: string) { + await requireAuth(); const service = await db .select() .from(services) @@ -769,6 +781,7 @@ export async function updateServiceHostname( serviceId: string, hostname: string, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -802,6 +815,7 @@ export async function updateServiceGithubRepo( branch: string, rootDir?: string, ) { + await requireAuth(); try { const service = await getService(serviceId); if (!service) { @@ -845,65 +859,12 @@ export async function updateServiceGithubRepo( } export async function deployService(serviceId: string) { - const service = await getService(serviceId); - if (!service) { - throw new Error("Service not found"); - } - - if (service.stateful) { - const configuredReplicas = await db - .select({ - serverId: serviceReplicas.serverId, - replicas: serviceReplicas.count, - }) - .from(serviceReplicas) - .where(eq(serviceReplicas.serviceId, serviceId)); - - const placements = configuredReplicas.filter((p) => p.replicas > 0); - const totalReplicas = placements.reduce((sum, p) => sum + p.replicas, 0); - - if (totalReplicas !== 1) { - throw new Error("Stateful services can only have exactly 1 replica"); - } - - const serverIds = placements.map((p) => p.serverId); - if (serverIds.length !== 1) { - throw new Error( - "Stateful services must be deployed to exactly one server", - ); - } - - const targetServerId = serverIds[0]; - if (service.lockedServerId && service.lockedServerId !== targetServerId) { - if (service.migrationStatus) { - throw new Error("Migration already in progress"); - } - await startMigration(serviceId, targetServerId); - revalidatePath(`/dashboard/projects`); - return { migrationStarted: true }; - } - } - - const rolloutId = randomUUID(); - - await db.insert(rollouts).values({ - id: rolloutId, - serviceId, - status: "in_progress", - currentStage: "queued", - }); - - await inngest.send( - inngestEvents.rolloutCreated.create({ - rolloutId, - serviceId, - }), - ); - - return { rolloutId }; + await requireAuth(); + return deployServiceInternal(serviceId); } export async function deleteDeployments(serviceId: string) { + await requireAuth(); await db.delete(deployments).where(eq(deployments.serviceId, serviceId)); return { success: true }; } @@ -920,6 +881,7 @@ export async function updateServiceHealthCheck( serviceId: string, config: HealthCheckConfig, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -943,6 +905,7 @@ export async function updateServiceStartCommand( serviceId: string, startCommand: string | null, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -976,6 +939,7 @@ export async function updateServiceResourceLimits( serviceId: string, limits: { cpuCores: number | null; memoryMb: number | null }, ) { + await requireAuth(); const validated = resourceLimitsSchema.parse(limits); const service = await getService(serviceId); @@ -998,6 +962,7 @@ export async function updateServiceSchedule( serviceId: string, schedule: string | null, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -1030,6 +995,7 @@ export async function updateServiceConfig( serviceId: string, config: ServiceConfigUpdate, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -1168,6 +1134,7 @@ export async function updateServiceConfig( } export async function stopService(serviceId: string) { + await requireAuth(); const runningDeployments = await db .select() .from(deployments) @@ -1183,7 +1150,7 @@ export async function stopService(serviceId: string) { await db .update(deployments) - .set({ status: "stopping" }) + .set(markDeploymentUndesired("stopping")) .where(eq(deployments.id, dep.id)); await enqueueWork(dep.serverId, "stop", { @@ -1196,6 +1163,7 @@ export async function stopService(serviceId: string) { } export async function restartService(serviceId: string) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -1225,6 +1193,7 @@ export async function restartService(serviceId: string) { } export async function abortRollout(serviceId: string) { + await requireAuth(); const updatedRollouts = await db .update(rollouts) .set({ @@ -1300,7 +1269,7 @@ export async function abortRollout(serviceId: string) { .where( and( eq(workQueue.status, "pending"), - eq(workQueue.type, "deploy"), + inArray(workQueue.type, ["deploy", "reconcile"]), inArray(workQueue.serverId, [...serverContainers.keys()]), ), ); @@ -1332,6 +1301,7 @@ export async function addServiceVolume( name: string, containerPath: string, ) { + await requireAuth(); try { const validatedName = volumeNameSchema.parse(name); const validatedPath = containerPathSchema.parse(containerPath); @@ -1396,6 +1366,7 @@ export async function addServiceVolume( } export async function removeServiceVolume(volumeId: string) { + await requireAuth(); const volume = await db .select() .from(serviceVolumes) @@ -1451,6 +1422,7 @@ export async function updateServiceBackupSettings( backupEnabled: boolean, backupSchedule: string | null, ) { + await requireAuth(); const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); diff --git a/web/actions/secrets.ts b/web/actions/secrets.ts index 5870270a..36dac571 100644 --- a/web/actions/secrets.ts +++ b/web/actions/secrets.ts @@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm"; import { ZodError } from "zod"; import { db } from "@/db"; import { secrets, services } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; import { encryptSecret } from "@/lib/crypto"; import { secretItemArraySchema } from "@/lib/schemas"; import { getZodErrorMessage } from "@/lib/utils"; @@ -13,6 +14,7 @@ export async function createSecretsBatch( serviceId: string, items: { key: string; value: string }[], ) { + await requireAuth(); if (items.length === 0) { return { created: 0, updated: 0 }; } @@ -78,6 +80,7 @@ export async function createSecretsBatch( } export async function deleteSecretsBatch(secretIds: string[]) { + await requireAuth(); if (secretIds.length === 0) { return { deleted: 0 }; } diff --git a/web/actions/servers.ts b/web/actions/servers.ts index 5a0ea49e..cda97409 100644 --- a/web/actions/servers.ts +++ b/web/actions/servers.ts @@ -1,10 +1,11 @@ "use server"; -import { db } from "@/db"; -import { servers } from "@/db/schema"; -import { eq } from "drizzle-orm"; import { randomBytes } from "node:crypto"; +import { eq } from "drizzle-orm"; import { ZodError } from "zod"; +import { db } from "@/db"; +import { servers } from "@/db/schema"; +import { requireAuth } from "@/lib/auth"; import { nameSchema } from "@/lib/schemas"; import { getZodErrorMessage } from "@/lib/utils"; @@ -17,6 +18,7 @@ function generateToken(): string { } export async function createServer(name: string) { + await requireAuth(); try { const validatedName = nameSchema.parse(name); const id = generateId(); @@ -45,14 +47,12 @@ export async function createServer(name: string) { } export async function deleteServer(id: string) { + await requireAuth(); await db.delete(servers).where(eq(servers.id, id)); } -export async function approveServer(id: string) { - await db.update(servers).set({ status: "pending" }).where(eq(servers.id, id)); -} - export async function updateServerName(id: string, name: string) { + await requireAuth(); try { const validatedName = nameSchema.parse(name); await db diff --git a/web/actions/settings.ts b/web/actions/settings.ts index 4ff75306..0f05a08e 100644 --- a/web/actions/settings.ts +++ b/web/actions/settings.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import isEmail from "validator/es/lib/isEmail"; import { ZodError } from "zod"; import { setSetting } from "@/db/queries"; +import { requireAuth } from "@/lib/auth"; import { buildTimeoutSchema } from "@/lib/schemas"; import { type EmailAlertsConfig, @@ -13,12 +14,14 @@ import { import { getZodErrorMessage } from "@/lib/utils"; export async function updateBuildServers(serverIds: string[]) { + await requireAuth(); await setSetting(SETTING_KEYS.SERVERS_ALLOWED_FOR_BUILDS, serverIds); revalidatePath("/dashboard/settings"); return { success: true }; } export async function updateBuildTimeout(minutes: number) { + await requireAuth(); try { const validatedMinutes = buildTimeoutSchema.parse(minutes); await setSetting(SETTING_KEYS.BUILD_TIMEOUT_MINUTES, validatedMinutes); @@ -33,6 +36,7 @@ export async function updateBuildTimeout(minutes: number) { } export async function updateAcmeEmail(email: string) { + await requireAuth(); const trimmed = email.trim(); if (trimmed && !isEmail(trimmed)) { throw new Error("Invalid email address"); @@ -43,6 +47,7 @@ export async function updateAcmeEmail(email: string) { } export async function updateProxyDomain(domain: string) { + await requireAuth(); const trimmed = domain.trim(); await setSetting(SETTING_KEYS.PROXY_DOMAIN, trimmed || null); revalidatePath("/dashboard/settings"); @@ -50,6 +55,7 @@ export async function updateProxyDomain(domain: string) { } export async function updateEmailAlertsConfig(config: EmailAlertsConfig) { + await requireAuth(); try { const validated = emailAlertsConfigSchema.parse(config); await setSetting(SETTING_KEYS.EMAIL_ALERTS_CONFIG, validated); diff --git a/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx new file mode 100644 index 00000000..f42210a7 --- /dev/null +++ b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx @@ -0,0 +1,180 @@ +import { SetBreadcrumbs } from "@/components/core/breadcrumb-data"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +function ServerDetailsSkeleton() { + return ( + + + Server Details + + +
+ {Array.from({ length: 9 }).map((_, index) => ( +
+ + +
+ ))} +
+
+
+ ); +} + +function HealthMetricSkeleton() { + return ( +
+
+ + +
+ + +
+ ); +} + +function HealthStatusSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} + +function SystemHealthSkeleton() { + return ( + + + System Health + + +
+ + + +
+
+
+ + + +
+
+
+
+ ); +} + +function RunningServicesSkeleton() { + return ( + + + + + + + + + + + +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ +
+ + +
+
+ ))} +
+
+
+ ); +} + +function AgentLogsSkeleton() { + return ( +
+

Agent Logs

+
+
+ + +
+
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+
+
+ ); +} + +function DangerZoneSkeleton() { + return ( + + + + + + + + + + + + + + ); +} + +export default function Loading() { + return ( + <> + + +
+ Loading server details +
+ + ); +} diff --git a/web/app/(dashboard)/layout-client.tsx b/web/app/(dashboard)/layout-client.tsx index a081f5da..12aa1b62 100644 --- a/web/app/(dashboard)/layout-client.tsx +++ b/web/app/(dashboard)/layout-client.tsx @@ -9,6 +9,7 @@ import { BreadcrumbDataProvider, useBreadcrumbs, } from "@/components/core/breadcrumb-data"; +import { DashboardPageSkeleton } from "@/components/dashboard/dashboard-page-skeleton"; import { OfflineServersBanner } from "@/components/server/offline-servers-banner"; import { Button } from "@/components/ui/button"; import { @@ -26,6 +27,10 @@ import { signOut, useSession } from "@/lib/auth-client"; function DashboardHeader({ email }: { email: string }) { const router = useRouter(); const breadcrumbs = useBreadcrumbs(); + const getBreadcrumbKey = ( + crumb: (typeof breadcrumbs)[number], + index: number, + ) => `${crumb.href}:${crumb.label}:${index}`; const mobileBreadcrumbs = breadcrumbs.length > 2 ? breadcrumbs.slice(-2) : breadcrumbs; @@ -48,7 +53,10 @@ function DashboardHeader({ email }: { email: string }) { <>