Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
retention-days: 1

release:
needs: agent
needs: [agent, web, registry, updater]
runs-on: ubuntu-latest
steps:
- name: Download agent artifacts
Expand Down Expand Up @@ -129,3 +129,31 @@ jobs:
platforms: linux/amd64,linux/arm64
cache-from: type=gha,scope=registry
cache-to: type=gha,mode=max,scope=registry

updater:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

- name: Login to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v7
with:
context: deployment/updater
push: true
tags: |
ghcr.io/${{ github.repository }}/updater:${{ github.ref_name }}
ghcr.io/${{ github.repository }}/updater:tip
platforms: linux/amd64,linux/arm64
cache-from: type=gha,scope=updater
cache-to: type=gha,mode=max,scope=updater
2 changes: 2 additions & 0 deletions agent/internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ type Agent struct {
workMutex sync.Mutex
activeWorkItem *agenthttp.WorkQueueItem
pendingWorkResults []agenthttp.CompletedWorkItem
deploymentErrorMutex sync.Mutex
pendingDeploymentErrors []agenthttp.DeploymentError
Client *agenthttp.Client
Reconciler *reconcile.Reconciler
Config *Config
Expand Down
5 changes: 4 additions & 1 deletion agent/internal/agent/drift.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,11 @@ func (a *Agent) handleProcessing() {
return
}

if err := a.applyReconcileAction(actions[0]); err != nil {
action := actions[0]
if err := a.applyReconcileAction(action); err != nil {
log.Printf("[processing] reconciliation failed: %v, transitioning to IDLE", err)
a.RecordDeploymentError(action.DeploymentID, err)
a.RequestStatusReport("reconcile failed")
a.transitionToIdle()
return
}
Expand Down
35 changes: 35 additions & 0 deletions agent/internal/agent/reporting.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,44 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport
}
}

report.DeploymentErrors = a.SnapshotDeploymentErrors()

return report
}

func (a *Agent) RecordDeploymentError(deploymentID string, err error) {
if deploymentID == "" || err == nil {
return
}

a.deploymentErrorMutex.Lock()
defer a.deploymentErrorMutex.Unlock()

a.pendingDeploymentErrors = append(a.pendingDeploymentErrors, agenthttp.DeploymentError{
DeploymentID: deploymentID,
Message: err.Error(),
})
}

func (a *Agent) SnapshotDeploymentErrors() []agenthttp.DeploymentError {
a.deploymentErrorMutex.Lock()
defer a.deploymentErrorMutex.Unlock()

return append([]agenthttp.DeploymentError(nil), a.pendingDeploymentErrors...)
}

func (a *Agent) ClearReportedDeploymentErrors(count int) {
a.deploymentErrorMutex.Lock()
defer a.deploymentErrorMutex.Unlock()

if count >= len(a.pendingDeploymentErrors) {
a.pendingDeploymentErrors = nil
return
}

a.pendingDeploymentErrors = a.pendingDeploymentErrors[count:]
}

func (a *Agent) CollectLogs() {
if a.LogCollector == nil {
return
Expand Down
2 changes: 2 additions & 0 deletions agent/internal/agent/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,14 @@ func (a *Agent) RequestStatusReport(reason string) {

func (a *Agent) reportStatus(reason string) {
report := a.BuildStatusReport(true)
reportedDeploymentErrorCount := len(report.DeploymentErrors)
completed, active := a.SnapshotWorkStatus()
response, err := a.Client.ReportStatus(report, completed, active)
if err != nil {
log.Printf("[status] failed to report (%s): %v", reason, err)
return
}
a.ClearReportedDeploymentErrors(reportedDeploymentErrorCount)
a.AcknowledgeWorkResults(response.AcceptedWorkItemResults, response.RejectedWorkItemResults)
a.LogRejectedActiveWorkItems(response.RejectedActiveWorkItems)
a.AcceptLeasedWorkItems(response.WorkItems)
Expand Down
24 changes: 15 additions & 9 deletions agent/internal/http/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ type ContainerStatus struct {
HealthStatus string `json:"healthStatus"`
}

type DeploymentError struct {
DeploymentID string `json:"deploymentId"`
Message string `json:"message"`
}

type Resources struct {
CpuCores int `json:"cpuCores"`
MemoryMb int `json:"memoryMb"`
Expand All @@ -245,15 +250,16 @@ type AgentHealth struct {
}

type StatusReport struct {
Resources *Resources `json:"resources,omitempty"`
PublicIP string `json:"publicIp,omitempty"`
PrivateIP string `json:"privateIp,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
Containers []ContainerStatus `json:"containers"`
DnsInSync bool `json:"dnsInSync,omitempty"`
NetworkHealth *health.NetworkHealth `json:"networkHealth,omitempty"`
ContainerHealth *health.ContainerHealth `json:"containerHealth,omitempty"`
AgentHealth *AgentHealth `json:"agentHealth,omitempty"`
Resources *Resources `json:"resources,omitempty"`
PublicIP string `json:"publicIp,omitempty"`
PrivateIP string `json:"privateIp,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
Containers []ContainerStatus `json:"containers"`
DeploymentErrors []DeploymentError `json:"deploymentErrors,omitempty"`
DnsInSync bool `json:"dnsInSync,omitempty"`
NetworkHealth *health.NetworkHealth `json:"networkHealth,omitempty"`
ContainerHealth *health.ContainerHealth `json:"containerHealth,omitempty"`
AgentHealth *AgentHealth `json:"agentHealth,omitempty"`
}

type CompletedWorkItem struct {
Expand Down
6 changes: 6 additions & 0 deletions deployment/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ REGISTRY_HTTP_SECRET=your-registry-http-secret
INNGEST_SIGNING_KEY=signkey-xxx
INNGEST_EVENT_KEY=xxx

# Control plane deployment
# Use compose.production.yml for external PostgreSQL.
COMPOSE_FILE=compose.production.yml
TECHULUS_CLOUD_VERSION=v0.0.0
CONTROL_PLANE_UPDATER_TOKEN=generate-with-openssl-rand-hex-32

# GitHub App Integration (optional)
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
Expand Down
28 changes: 25 additions & 3 deletions deployment/compose.postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,11 @@ services:
restart: unless-stopped

migrate:
image: ghcr.io/techulus/cloud/web:tip
image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}
env_file:
- ./.env
environment:
- TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip}
- DATABASE_URL=${DATABASE_URL}
- BETTER_AUTH_URL=https://${ROOT_DOMAIN}
- APP_URL=https://${ROOT_DOMAIN}
Expand All @@ -86,6 +87,8 @@ services:
- INNGEST_BASE_URL=http://inngest:8288
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
- INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
- CONTROL_PLANE_UPDATER_URL=http://control-plane-updater:8080
- CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN}
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-false}
depends_on:
postgres:
Expand All @@ -94,11 +97,12 @@ services:
restart: on-failure

web:
image: ghcr.io/techulus/cloud/web:tip
image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}
scale: ${WEB_REPLICAS:-1}
env_file:
- ./.env
environment:
- TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip}
- DATABASE_URL=${DATABASE_URL}
- BETTER_AUTH_URL=https://${ROOT_DOMAIN}
- APP_URL=https://${ROOT_DOMAIN}
Expand All @@ -111,6 +115,8 @@ services:
- INNGEST_BASE_URL=http://inngest:8288
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
- INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
- CONTROL_PLANE_UPDATER_URL=http://control-plane-updater:8080
- CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN}
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-false}
depends_on:
migrate:
Expand Down Expand Up @@ -141,8 +147,24 @@ services:
start_period: 30s
restart: unless-stopped

control-plane-updater:
image: ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:-tip}
environment:
- DEPLOY_DIR=/opt/techulus-cloud
- CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN}
- WEB_HEALTH_URL=http://web:3000/api/health
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/techulus-cloud:/opt/techulus-cloud
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped

registry:
image: ghcr.io/techulus/cloud/registry:tip
image: ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:-tip}
env_file:
- ./.env
volumes:
Expand Down
28 changes: 25 additions & 3 deletions deployment/compose.production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ services:
restart: unless-stopped

migrate:
image: ghcr.io/techulus/cloud/web:tip
image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}
env_file:
- ./.env
environment:
- TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip}
- DATABASE_URL=${DATABASE_URL}
- BETTER_AUTH_URL=https://${ROOT_DOMAIN}
- APP_URL=https://${ROOT_DOMAIN}
Expand All @@ -68,16 +69,19 @@ services:
- INNGEST_BASE_URL=http://inngest:8288
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
- INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
- CONTROL_PLANE_UPDATER_URL=http://control-plane-updater:8080
- CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN}
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-false}
command: ["npx", "drizzle-kit", "push", "--force"]
restart: on-failure

web:
image: ghcr.io/techulus/cloud/web:tip
image: ghcr.io/techulus/cloud/web:${TECHULUS_CLOUD_VERSION:-tip}
scale: ${WEB_REPLICAS:-1}
env_file:
- ./.env
environment:
- TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip}
- DATABASE_URL=${DATABASE_URL}
- BETTER_AUTH_URL=https://${ROOT_DOMAIN}
- APP_URL=https://${ROOT_DOMAIN}
Expand All @@ -90,6 +94,8 @@ services:
- INNGEST_BASE_URL=http://inngest:8288
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
- INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
- CONTROL_PLANE_UPDATER_URL=http://control-plane-updater:8080
- CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN}
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-false}
depends_on:
migrate:
Expand Down Expand Up @@ -120,8 +126,24 @@ services:
start_period: 30s
restart: unless-stopped

control-plane-updater:
image: ghcr.io/techulus/cloud/updater:${TECHULUS_CLOUD_VERSION:-tip}
environment:
- DEPLOY_DIR=/opt/techulus-cloud
- CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN}
- WEB_HEALTH_URL=http://web:3000/api/health
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/techulus-cloud:/opt/techulus-cloud
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped

registry:
image: ghcr.io/techulus/cloud/registry:tip
image: ghcr.io/techulus/cloud/registry:${TECHULUS_CLOUD_VERSION:-tip}
env_file:
- ./.env
volumes:
Expand Down
4 changes: 4 additions & 0 deletions deployment/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ configure_interactive() {
REGISTRY_HTTP_SECRET="$(openssl rand -hex 32)"
INNGEST_SIGNING_KEY="$(openssl rand -hex 32)"
INNGEST_EVENT_KEY="$(openssl rand -hex 16)"
CONTROL_PLANE_UPDATER_TOKEN="$(openssl rand -hex 32)"

if [[ "$USE_BUNDLED_PG" == "true" ]]; then
COMPOSE_FILE="compose.postgres.yml"
Expand Down Expand Up @@ -377,8 +378,11 @@ REGISTRY_HTTP_SECRET=${REGISTRY_HTTP_SECRET}
INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}

CONTROL_PLANE_UPDATER_TOKEN=${CONTROL_PLANE_UPDATER_TOKEN}

WEB_REPLICAS=1
ALLOW_SIGNUP=true
TECHULUS_CLOUD_VERSION=${TECHULUS_CLOUD_VERSION:-tip}

COMPOSE_FILE=${COMPOSE_FILE}
EOF
Expand Down
16 changes: 16 additions & 0 deletions deployment/updater/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM golang:1.25-alpine AS builder
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/control-plane-updater .

FROM docker:28-cli

RUN apk add --no-cache bash curl docker-cli-compose postgresql-client

WORKDIR /app
COPY --from=builder /out/control-plane-updater ./control-plane-updater

ENV PORT=8080
EXPOSE 8080

CMD ["/app/control-plane-updater"]
3 changes: 3 additions & 0 deletions deployment/updater/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module techulus/cloud-updater

go 1.25.5
Loading
Loading