diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..96c6b1f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +README.md +node_modules +frontend/node_modules +frontend/dist +*.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4c388cc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,61 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "monthly" + time: "06:00" + commit-message: + prefix: "chore" + labels: + - "dependencies" + open-pull-requests-limit: 5 + groups: + dependencies: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + time: "06:00" + commit-message: + prefix: "chore" + labels: + - "dependencies" + open-pull-requests-limit: 5 + groups: + dependencies: + patterns: + - "*" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "monthly" + time: "06:00" + commit-message: + prefix: "chore" + labels: + - "dependencies" + open-pull-requests-limit: 5 + groups: + dependencies: + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/frontend" + schedule: + interval: "monthly" + time: "06:00" + commit-message: + prefix: "chore" + labels: + - "dependencies" + open-pull-requests-limit: 5 + groups: + dependencies: + patterns: + - "*" diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..39a6b4a --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,74 @@ +name: Quality + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +jobs: + backend: + name: Backend Quality Checks + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25" + cache: true + + - name: Verify dependencies + run: go mod verify + + - name: Build + run: go build -v ./... + + - name: Check format + run: go mod tidy && gofmt -s -w . && git diff --exit-code + + - name: Check vet + run: go vet ./... + + - name: Run Tests + run: go test -v -race -coverprofile=coverage.txt ./... + + frontend: + name: Frontend Quality Checks + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Use Node.js 24.x + uses: actions/setup-node@v6 + with: + node-version: 24.x + cache: 'npm' + cache-dependency-path: ./frontend/package-lock.json + + - name: Setup Aikido Safe Chain + working-directory: ./frontend + run: | + npm i -g @aikidosec/safe-chain + safe-chain setup-ci + + - name: Install dependencies + working-directory: ./frontend + run: npm ci --safe-chain-skip-minimum-package-age + + - name: Run Linter + working-directory: ./frontend + run: node --run lint + + - name: Check Formatting + working-directory: ./frontend + run: node --run check:format + + - name: Type Checking + working-directory: ./frontend + run: node --run check:types \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8e4c209 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release Please + +on: + push: + branches: + - main + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + new-release-created: ${{ steps.release-please-action.outputs.releases_created }} + tag-name: ${{ steps.release-please-action.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@v4 + id: release-please-action + with: + release-type: go + + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + needs: release-please + if: needs.release-please.outputs.new-release-created == 'true' + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}},value=${{ needs.release-please.outputs.tag-name }} + type=semver,pattern={{major}}.{{minor}}.{{patch}},value=${{ needs.release-please.outputs.tag-name }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.tag-name }} + type=semver,pattern={{major}},value=${{ needs.release-please.outputs.tag-name }} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: true + sbom: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6da4904 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# data directory for SQLite +data/ +*.db + +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work + +# compiled binaries +bin/ +*.pb.go \ No newline at end of file diff --git a/Dockerfile.agent b/Dockerfile.agent new file mode 100644 index 0000000..96cfef6 --- /dev/null +++ b/Dockerfile.agent @@ -0,0 +1,46 @@ +# Build stage for Go agent +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache gcc musl-dev + +# Copy go mod files +COPY go.mod go.sum* ./ + +# Download dependencies +RUN go mod download + +# Copy proto files +COPY api/ ./api/ + +# Copy source code +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ + +# Define build arguments +ARG VERSION=dev +ARG BUILD_TIME=unknown +ARG GIT_COMMIT=unknown + +# Build the Agent application +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-X 'github.com/OrcaCD/orca-cd/internal/config.Version=${VERSION}' \ + -X 'github.com/OrcaCD/orca-cd/internal/config.BuildTime=${BUILD_TIME}' \ + -X 'github.com/OrcaCD/orca-cd/internal/config.GitCommit=${GIT_COMMIT}'" \ + -o agent ./cmd/agent + +# Final stage - minimal image for agent +FROM alpine:3.22 + +RUN apk --no-cache add ca-certificates docker-cli kubectl git + +WORKDIR /root/ + +# Copy the Agent binary from builder +COPY --from=builder /app/agent . + +# Run the agent +# Note: --hub and --id flags can be passed via CMD or environment variables +CMD ["./agent"] diff --git a/Dockerfile.hub b/Dockerfile.hub new file mode 100644 index 0000000..6f4968f --- /dev/null +++ b/Dockerfile.hub @@ -0,0 +1,85 @@ +# Build stage for frontend +FROM node:24-trixie-slim AS frontend-builder + +WORKDIR /app/frontend + +# Copy frontend package files +COPY frontend/package*.json ./ + +# Install dependencies +RUN npm install + +# Copy frontend source +COPY frontend/ ./ + +# Build frontend +RUN npm run build + +# Build stage for Go backend +FROM golang:1.25-alpine AS backend-builder + +WORKDIR /app + +# Install build dependencies for SQLite and protobuf +RUN apk add --no-cache gcc musl-dev sqlite-dev protobuf + +# Copy go mod files +COPY go.mod go.sum* ./ + +# Download dependencies +RUN go mod download + +# Copy proto files and generate +COPY api/ ./api/ + +# Copy source code +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ + +# Define build arguments +ARG VERSION=dev +ARG BUILD_TIME=unknown +ARG GIT_COMMIT=unknown + +# Build the Hub application +RUN CGO_ENABLED=1 GOOS=linux go build \ + -ldflags="-X 'github.com/OrcaCD/orca-cd/internal/config.Version=${VERSION}' \ + -X 'github.com/OrcaCD/orca-cd/internal/config.BuildTime=${BUILD_TIME}' \ + -X 'github.com/OrcaCD/orca-cd/internal/config.GitCommit=${GIT_COMMIT}'" \ + -o hub ./cmd/hub + +# Build the CLI application +RUN CGO_ENABLED=1 GOOS=linux go build \ + -ldflags="-X 'github.com/OrcaCD/orca-cd/internal/config.Version=${VERSION}' \ + -X 'github.com/OrcaCD/orca-cd/internal/config.BuildTime=${BUILD_TIME}' \ + -X 'github.com/OrcaCD/orca-cd/internal/config.GitCommit=${GIT_COMMIT}'" \ + -o orca-cli ./cmd/cli + +# Final stage +FROM alpine:3.22 + +RUN apk --no-cache add ca-certificates sqlite-libs + +WORKDIR /root/ + +# Copy the Hub binary from backend builder +COPY --from=backend-builder /app/hub . + +# Copy the CLI binary from backend builder +COPY --from=backend-builder /app/orca-cli . + +# Copy the built frontend from frontend builder +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +# Create data directory for SQLite database +RUN mkdir -p ./data + +VOLUME ["/data"] + +# Expose HTTP and gRPC ports +EXPOSE 8080 9090 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["./hub", "health"] + +# Run the hub server +CMD ["./hub"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..35f4bb3 --- /dev/null +++ b/Makefile @@ -0,0 +1,82 @@ +.PHONY: all proto build build-hub build-agent build-cli docker docker-hub docker-agent clean test + +VERSION ?= dev +BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") + +LDFLAGS := -X 'github.com/OrcaCD/orca-cd/internal/config.Version=$(VERSION)' \ + -X 'github.com/OrcaCD/orca-cd/internal/config.BuildTime=$(BUILD_TIME)' \ + -X 'github.com/OrcaCD/orca-cd/internal/config.GitCommit=$(GIT_COMMIT)' + +all: proto build + +# Generate protobuf code +proto: + protoc --go_out=. --go_opt=paths=source_relative \ + --go-grpc_out=. --go-grpc_opt=paths=source_relative \ + api/proto/hub.proto + +# Build all binaries +build: build-hub build-agent build-cli + +build-hub: + CGO_ENABLED=1 go build -ldflags="$(LDFLAGS)" -o bin/hub ./cmd/hub + +build-agent: + CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o bin/agent ./cmd/agent + +build-cli: + CGO_ENABLED=1 go build -ldflags="$(LDFLAGS)" -o bin/orca-cli ./cmd/cli + +# Build Docker images +docker: docker-hub docker-agent + +docker-hub: + docker build -f Dockerfile.hub \ + --build-arg VERSION=$(VERSION) \ + --build-arg BUILD_TIME=$(BUILD_TIME) \ + --build-arg GIT_COMMIT=$(GIT_COMMIT) \ + -t orcacd/hub:$(VERSION) . + +docker-agent: + docker build -f Dockerfile.agent \ + --build-arg VERSION=$(VERSION) \ + --build-arg BUILD_TIME=$(BUILD_TIME) \ + --build-arg GIT_COMMIT=$(GIT_COMMIT) \ + -t orcacd/agent:$(VERSION) . + +# Run tests +test: + go test -v ./... + +# Clean build artifacts +clean: + rm -rf bin/ + go clean + +# Install protoc plugins (run once) +install-proto-tools: + go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + +# Development: run hub locally +run-hub: + go run ./cmd/hub -p 8080 -g 9090 + +# Development: run agent locally +run-agent: + go run ./cmd/agent --hub localhost:9090 + +# Development: run CLI locally +run-cli: + go run ./cmd/cli $(ARGS) + +# Docker compose commands +up: + docker compose up -d + +down: + docker compose down + +logs: + docker compose logs -f diff --git a/README.md b/README.md index 1637e5f..b1fef6d 100644 --- a/README.md +++ b/README.md @@ -1 +1,163 @@ # OrcaCD + +A GitOps continuous delivery platform with a hub/agent architecture. + +## Architecture + +OrcaCD uses a hub/agent architecture where: + +- **Hub**: Central server that serves the frontend, provides REST API, manages the database, and coordinates agents via gRPC streaming +- **Agent**: Lightweight worker that connects to the hub and executes tasks (deployments, builds, shell commands) + +``` +┌─────────────────┐ gRPC Streaming ┌─────────────────┐ +│ │◄──────────────────────────────►│ │ +│ Hub │ │ Agent │ +│ │ │ │ +│ - REST API │ gRPC Streaming │ - Task Exec │ +│ - Frontend │◄──────────────────────────────►│ - Deploy │ +│ - Database │ │ - Build │ +│ - gRPC Server │ gRPC Streaming │ │ +│ │◄──────────────────────────────►│ Agent │ +└─────────────────┘ └─────────────────┘ +``` + +## Quick Start + +### Using Docker Compose + +```bash +docker compose up -d +``` + +This starts: +- Hub on port 8080 (HTTP) and 9090 (gRPC) +- One agent connected to the hub + +### Building Locally + +```bash +# Install dependencies and generate protobuf code +make proto + +# Build both binaries +make build + +# Run hub +./bin/hub --port 8080 --grpc-port 9090 + +# Run agent (in another terminal) +./bin/agent --hub localhost:9090 +``` + +## Docker Images + +### Build Images + +```bash +# Build both images +make docker + +# Or individually +make docker-hub +make docker-agent +``` + +### Hub Image (Dockerfile.hub) + +The hub image includes: +- Go backend binary +- Frontend static files +- SQLite support + +```bash +docker build -f Dockerfile.hub -t orcacd/hub:latest . +docker run -p 8080:8080 -p 9090:9090 orcacd/hub:latest +``` + +### Agent Image (Dockerfile.agent) + +The agent image is minimal and includes: +- Go agent binary +- Docker CLI, kubectl, git for task execution + +```bash +docker build -f Dockerfile.agent -t orcacd/agent:latest . +docker run orcacd/agent:latest --hub hub:9090 +``` + +## Configuration + +### Hub + +| Flag | Env | Default | Description | +|------|-----|---------|-------------| +| `--port, -p` | `PORT` | `8080` | HTTP port for REST API and frontend | +| `--grpc-port, -g` | `GRPC_PORT` | `9090` | gRPC port for agent communication | +| | `DB_DRIVER` | `sqlite` | Database driver (`sqlite` or `postgres`) | +| | `DB_PATH` | `./data/app.db` | SQLite database path | +| | `DATABASE_URL` | | PostgreSQL connection string | + +### Agent + +| Flag | Env | Default | Description | +|------|-----|---------|-------------| +| `--hub, -H` | `HUB_ADDR` | `localhost:9090` | Hub gRPC address | +| `--id, -i` | `AGENT_ID` | (auto-generated) | Agent ID | + +## API Endpoints + +### Health +- `GET /api/health` - Health check + +### Messages +- `GET /api/messages` - List messages +- `POST /api/messages` - Create message + +### Agents +- `GET /api/agents` - List connected agents +- `GET /api/agents/:id` - Get agent details + +## Development + +```bash +# Install protobuf tools +make install-proto-tools + +# Generate protobuf code +make proto + +# Run hub locally +make run-hub + +# Run agent locally (in another terminal) +make run-agent + +# Run tests +make test +``` + +## Project Structure + +``` +├── api/proto/ # gRPC protobuf definitions +├── cmd/ +│ ├── hub/ # Hub entrypoint +│ └── agent/ # Agent entrypoint +├── internal/ +│ ├── agent/ # Agent implementation +│ │ ├── grpc/ # Agent gRPC client +│ │ └── executor/ # Task executor +│ ├── hub/ # Hub implementation +│ │ └── grpc/ # Hub gRPC server +│ ├── config/ # Configuration +│ ├── controllers/ # HTTP controllers +│ ├── database/ # Database connection +│ ├── middleware/ # HTTP middleware +│ ├── models/ # Data models +│ └── utils/ # Utilities +├── frontend/ # React frontend +├── Dockerfile.hub # Hub Docker image +├── Dockerfile.agent # Agent Docker image +└── docker-compose.yml # Docker Compose config +``` diff --git a/api/proto/hub.proto b/api/proto/hub.proto new file mode 100644 index 0000000..ed31e46 --- /dev/null +++ b/api/proto/hub.proto @@ -0,0 +1,108 @@ +syntax = "proto3"; + +package hub; + +option go_package = "github.com/OrcaCD/orca-cd/api/proto"; + +// HubService defines the gRPC streaming service for hub-agent communication +service HubService { + // AgentStream is a bidirectional streaming RPC for agent-hub communication + // The agent connects and maintains a persistent stream with the hub + rpc AgentStream(stream AgentMessage) returns (stream HubMessage); +} + +// AgentMessage represents messages sent from agent to hub +message AgentMessage { + string agent_id = 1; + oneof payload { + AgentRegistration registration = 2; + Heartbeat heartbeat = 3; + TaskResult task_result = 4; + TaskProgress task_progress = 5; + } +} + +// HubMessage represents messages sent from hub to agent +message HubMessage { + oneof payload { + RegistrationAck registration_ack = 1; + TaskAssignment task_assignment = 2; + TaskCancel task_cancel = 3; + } +} + +// AgentRegistration is sent when an agent first connects +message AgentRegistration { + string agent_id = 1; + string version = 2; + repeated string capabilities = 3; + map labels = 4; +} + +// RegistrationAck acknowledges agent registration +message RegistrationAck { + bool success = 1; + string message = 2; +} + +// Heartbeat is sent periodically to maintain connection +message Heartbeat { + int64 timestamp = 1; + AgentStatus status = 2; +} + +// AgentStatus represents the current state of an agent +message AgentStatus { + enum State { + UNKNOWN = 0; + IDLE = 1; + BUSY = 2; + ERROR = 3; + } + State state = 1; + int32 running_tasks = 2; + double cpu_usage = 3; + double memory_usage = 4; +} + +// TaskAssignment is sent from hub to agent to assign a task +message TaskAssignment { + string task_id = 1; + string task_type = 2; + bytes payload = 3; // JSON-encoded task-specific payload + int64 timeout_seconds = 4; + int32 priority = 5; +} + +// TaskCancel is sent to cancel a running task +message TaskCancel { + string task_id = 1; + string reason = 2; +} + +// TaskResult is sent when a task completes +message TaskResult { + string task_id = 1; + TaskStatus status = 2; + bytes output = 3; // JSON-encoded task output + string error_message = 4; + int64 started_at = 5; + int64 completed_at = 6; +} + +// TaskProgress is sent during task execution +message TaskProgress { + string task_id = 1; + int32 percent_complete = 2; + string message = 3; + bytes data = 4; // Optional intermediate data +} + +// TaskStatus represents the completion status of a task +enum TaskStatus { + TASK_STATUS_UNKNOWN = 0; + TASK_STATUS_SUCCESS = 1; + TASK_STATUS_FAILED = 2; + TASK_STATUS_CANCELLED = 3; + TASK_STATUS_TIMEOUT = 4; +} diff --git a/cmd/agent/main.go b/cmd/agent/main.go new file mode 100644 index 0000000..a963930 --- /dev/null +++ b/cmd/agent/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + "time" + + "github.com/OrcaCD/orca-cd/internal/agent" + "github.com/OrcaCD/orca-cd/internal/utils" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func main() { + log.Logger = log.Logger.With().Caller().Logger() + if !utils.ShoudLogJSON(os.Environ(), os.Args) { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) + } + + agent.Run() +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..0f0ddb1 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + "time" + + "github.com/OrcaCD/orca-cd/internal/cli" + "github.com/OrcaCD/orca-cd/internal/utils" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func main() { + log.Logger = log.Logger.With().Caller().Logger() + if !utils.ShoudLogJSON(os.Environ(), os.Args) { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) + } + + cli.Run() +} diff --git a/cmd/hub/main.go b/cmd/hub/main.go new file mode 100644 index 0000000..4d42488 --- /dev/null +++ b/cmd/hub/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + "time" + + "github.com/OrcaCD/orca-cd/internal/hub" + "github.com/OrcaCD/orca-cd/internal/utils" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +func main() { + log.Logger = log.Logger.With().Caller().Logger() + if !utils.ShoudLogJSON(os.Environ(), os.Args) { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339}) + } + + hub.Run() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3630fa3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + hub: + build: + context: . + dockerfile: Dockerfile.hub + ports: + - "8080:8080" + - "9090:9090" + environment: + - GIN_MODE=release + - DB_DRIVER=sqlite + - DB_PATH=/data/app.db + volumes: + - hub-data:/data + restart: unless-stopped + networks: + - orca-network + + agent: + build: + context: . + dockerfile: Dockerfile.agent + command: ["./agent", "--hub", "hub:9090"] + environment: + - LOG_JSON=true + depends_on: + - hub + restart: unless-stopped + networks: + - orca-network + # Mount docker socket for container management capabilities + volumes: + - /var/run/docker.sock:/var/run/docker.sock + +networks: + orca-network: + driver: bridge + +volumes: + hub-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8946d1d --- /dev/null +++ b/go.mod @@ -0,0 +1,60 @@ +module github.com/OrcaCD/orca-cd + +go 1.25.5 + +require ( + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.11.0 + github.com/google/uuid v1.6.0 + github.com/rs/zerolog v1.34.0 + github.com/spf13/cobra v1.10.2 + google.golang.org/grpc v1.72.2 + google.golang.org/protobuf v1.36.9 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..40eb26a --- /dev/null +++ b/go.sum @@ -0,0 +1,157 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= +google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= +google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/agent/executor/executor.go b/internal/agent/executor/executor.go new file mode 100644 index 0000000..e29451d --- /dev/null +++ b/internal/agent/executor/executor.go @@ -0,0 +1,205 @@ +package executor + +import ( + "context" + "encoding/json" + "os/exec" + "sync" + "time" + + pb "github.com/OrcaCD/orca-cd/api/proto" + "github.com/rs/zerolog/log" +) + +// Executor handles task execution on the agent +type Executor struct { + runningTasks sync.Map // map[taskID]*runningTask + mu sync.RWMutex +} + +type runningTask struct { + ID string + Cancel context.CancelFunc + StartedAt time.Time +} + +// NewExecutor creates a new task executor +func NewExecutor() *Executor { + return &Executor{} +} + +// RunningTaskCount returns the number of currently running tasks +func (e *Executor) RunningTaskCount() int { + count := 0 + e.runningTasks.Range(func(key, value any) bool { + count++ + return true + }) + return count +} + +// Execute runs a task and returns the result +func (e *Executor) Execute(task *pb.TaskAssignment) *pb.TaskResult { + startedAt := time.Now() + + ctx, cancel := context.WithCancel(context.Background()) + if task.TimeoutSeconds > 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Duration(task.TimeoutSeconds)*time.Second) + } + defer cancel() + + // Track running task + rt := &runningTask{ + ID: task.TaskId, + Cancel: cancel, + StartedAt: startedAt, + } + e.runningTasks.Store(task.TaskId, rt) + defer e.runningTasks.Delete(task.TaskId) + + log.Info(). + Str("task_id", task.TaskId). + Str("type", task.TaskType). + Msg("Executing task") + + result := &pb.TaskResult{ + TaskId: task.TaskId, + StartedAt: startedAt.Unix(), + CompletedAt: 0, + } + + var output []byte + var err error + + switch task.TaskType { + case "shell": + output, err = e.executeShell(ctx, task.Payload) + case "deploy": + output, err = e.executeDeploy(ctx, task.Payload) + case "build": + output, err = e.executeBuild(ctx, task.Payload) + default: + result.Status = pb.TaskStatus_TASK_STATUS_FAILED + result.ErrorMessage = "unknown task type: " + task.TaskType + result.CompletedAt = time.Now().Unix() + return result + } + + result.CompletedAt = time.Now().Unix() + + if ctx.Err() == context.DeadlineExceeded { + result.Status = pb.TaskStatus_TASK_STATUS_TIMEOUT + result.ErrorMessage = "task timed out" + } else if ctx.Err() == context.Canceled { + result.Status = pb.TaskStatus_TASK_STATUS_CANCELLED + result.ErrorMessage = "task was cancelled" + } else if err != nil { + result.Status = pb.TaskStatus_TASK_STATUS_FAILED + result.ErrorMessage = err.Error() + } else { + result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS + } + + result.Output = output + + log.Info(). + Str("task_id", task.TaskId). + Int32("status", int32(result.Status)). + Dur("duration", time.Duration(result.CompletedAt-result.StartedAt)*time.Second). + Msg("Task completed") + + return result +} + +// Cancel cancels a running task +func (e *Executor) Cancel(taskID string) { + if val, ok := e.runningTasks.Load(taskID); ok { + rt := val.(*runningTask) + rt.Cancel() + log.Info().Str("task_id", taskID).Msg("Task cancelled") + } +} + +// ShellPayload represents the payload for shell tasks +type ShellPayload struct { + Command string `json:"command"` + Args []string `json:"args"` + Dir string `json:"dir"` +} + +func (e *Executor) executeShell(ctx context.Context, payload []byte) ([]byte, error) { + var p ShellPayload + if err := json.Unmarshal(payload, &p); err != nil { + return nil, err + } + + cmd := exec.CommandContext(ctx, p.Command, p.Args...) + if p.Dir != "" { + cmd.Dir = p.Dir + } + + output, err := cmd.CombinedOutput() + return output, err +} + +// DeployPayload represents the payload for deploy tasks +type DeployPayload struct { + Image string `json:"image"` + Tag string `json:"tag"` + Environment string `json:"environment"` + Config map[string]string `json:"config"` +} + +func (e *Executor) executeDeploy(ctx context.Context, payload []byte) ([]byte, error) { + var p DeployPayload + if err := json.Unmarshal(payload, &p); err != nil { + return nil, err + } + + // TODO: Implement actual deployment logic + log.Info(). + Str("image", p.Image). + Str("tag", p.Tag). + Str("env", p.Environment). + Msg("Would deploy image") + + result := map[string]string{ + "status": "deployed", + "image": p.Image, + "tag": p.Tag, + "env": p.Environment, + } + + return json.Marshal(result) +} + +// BuildPayload represents the payload for build tasks +type BuildPayload struct { + Repository string `json:"repository"` + Branch string `json:"branch"` + Dockerfile string `json:"dockerfile"` + Tag string `json:"tag"` +} + +func (e *Executor) executeBuild(ctx context.Context, payload []byte) ([]byte, error) { + var p BuildPayload + if err := json.Unmarshal(payload, &p); err != nil { + return nil, err + } + + // TODO: Implement actual build logic + log.Info(). + Str("repo", p.Repository). + Str("branch", p.Branch). + Str("tag", p.Tag). + Msg("Would build image") + + result := map[string]string{ + "status": "built", + "repo": p.Repository, + "branch": p.Branch, + "tag": p.Tag, + } + + return json.Marshal(result) +} diff --git a/internal/agent/grpc/client.go b/internal/agent/grpc/client.go new file mode 100644 index 0000000..f2243c0 --- /dev/null +++ b/internal/agent/grpc/client.go @@ -0,0 +1,291 @@ +package grpc + +import ( + "context" + "sync" + "time" + + pb "github.com/OrcaCD/orca-cd/api/proto" + "github.com/OrcaCD/orca-cd/internal/agent/executor" + "github.com/OrcaCD/orca-cd/internal/config" + "github.com/rs/zerolog/log" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/keepalive" +) + +// Client represents the agent's gRPC client for communicating with the hub +type Client struct { + agentID string + hubAddr string + executor *executor.Executor + conn *grpc.ClientConn + stream pb.HubService_AgentStreamClient + mu sync.RWMutex + done chan struct{} +} + +// NewClient creates a new agent gRPC client +func NewClient(agentID, hubAddr string, exec *executor.Executor) *Client { + return &Client{ + agentID: agentID, + hubAddr: hubAddr, + executor: exec, + done: make(chan struct{}), + } +} + +// Connect establishes a connection to the hub and starts streaming +func (c *Client) Connect(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-c.done: + return nil + default: + } + + if err := c.connectOnce(ctx); err != nil { + log.Error().Err(err).Msg("Connection failed, retrying in 5 seconds") + time.Sleep(5 * time.Second) + continue + } + } +} + +func (c *Client) connectOnce(ctx context.Context) error { + // Establish gRPC connection + conn, err := grpc.NewClient(c.hubAddr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithKeepaliveParams(keepalive.ClientParameters{ + Time: 10 * time.Second, + Timeout: 3 * time.Second, + PermitWithoutStream: true, + }), + ) + if err != nil { + return err + } + + c.mu.Lock() + c.conn = conn + c.mu.Unlock() + + defer func() { + c.mu.Lock() + c.conn = nil + c.mu.Unlock() + conn.Close() + }() + + client := pb.NewHubServiceClient(conn) + + // Start the bidirectional stream + stream, err := client.AgentStream(ctx) + if err != nil { + return err + } + + c.mu.Lock() + c.stream = stream + c.mu.Unlock() + + // Send registration + if err := c.register(); err != nil { + return err + } + + // Start heartbeat goroutine + heartbeatCtx, cancelHeartbeat := context.WithCancel(ctx) + defer cancelHeartbeat() + go c.heartbeatLoop(heartbeatCtx) + + // Process incoming messages + return c.receiveLoop() +} + +func (c *Client) register() error { + msg := &pb.AgentMessage{ + AgentId: c.agentID, + Payload: &pb.AgentMessage_Registration{ + Registration: &pb.AgentRegistration{ + AgentId: c.agentID, + Version: config.Version, + Capabilities: []string{"shell", "deploy", "build"}, + Labels: map[string]string{ + "os": "linux", + }, + }, + }, + } + + c.mu.RLock() + stream := c.stream + c.mu.RUnlock() + + if stream == nil { + return ErrNotConnected + } + + return stream.Send(msg) +} + +func (c *Client) heartbeatLoop(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-c.done: + return + case <-ticker.C: + if err := c.sendHeartbeat(); err != nil { + log.Error().Err(err).Msg("Failed to send heartbeat") + } + } + } +} + +func (c *Client) sendHeartbeat() error { + c.mu.RLock() + stream := c.stream + c.mu.RUnlock() + + if stream == nil { + return ErrNotConnected + } + + runningTasks := c.executor.RunningTaskCount() + + msg := &pb.AgentMessage{ + AgentId: c.agentID, + Payload: &pb.AgentMessage_Heartbeat{ + Heartbeat: &pb.Heartbeat{ + Timestamp: time.Now().Unix(), + Status: &pb.AgentStatus{ + State: pb.AgentStatus_IDLE, + RunningTasks: int32(runningTasks), + }, + }, + }, + } + + if runningTasks > 0 { + msg.Payload.(*pb.AgentMessage_Heartbeat).Heartbeat.Status.State = pb.AgentStatus_BUSY + } + + return stream.Send(msg) +} + +func (c *Client) receiveLoop() error { + c.mu.RLock() + stream := c.stream + c.mu.RUnlock() + + for { + msg, err := stream.Recv() + if err != nil { + return err + } + + switch payload := msg.Payload.(type) { + case *pb.HubMessage_RegistrationAck: + c.handleRegistrationAck(payload.RegistrationAck) + + case *pb.HubMessage_TaskAssignment: + c.handleTaskAssignment(payload.TaskAssignment) + + case *pb.HubMessage_TaskCancel: + c.handleTaskCancel(payload.TaskCancel) + } + } +} + +func (c *Client) handleRegistrationAck(ack *pb.RegistrationAck) { + if ack.Success { + log.Info().Msg("Successfully registered with hub") + } else { + log.Error().Str("message", ack.Message).Msg("Registration failed") + } +} + +func (c *Client) handleTaskAssignment(task *pb.TaskAssignment) { + log.Info(). + Str("task_id", task.TaskId). + Str("type", task.TaskType). + Msg("Received task assignment") + + // Execute the task + go func() { + result := c.executor.Execute(task) + if err := c.sendTaskResult(result); err != nil { + log.Error().Err(err).Str("task_id", task.TaskId).Msg("Failed to send task result") + } + }() +} + +func (c *Client) handleTaskCancel(cancel *pb.TaskCancel) { + log.Info(). + Str("task_id", cancel.TaskId). + Str("reason", cancel.Reason). + Msg("Received task cancellation") + + c.executor.Cancel(cancel.TaskId) +} + +func (c *Client) sendTaskResult(result *pb.TaskResult) error { + c.mu.RLock() + stream := c.stream + c.mu.RUnlock() + + if stream == nil { + return ErrNotConnected + } + + msg := &pb.AgentMessage{ + AgentId: c.agentID, + Payload: &pb.AgentMessage_TaskResult{ + TaskResult: result, + }, + } + + return stream.Send(msg) +} + +// SendProgress sends task progress update to the hub +func (c *Client) SendProgress(progress *pb.TaskProgress) error { + c.mu.RLock() + stream := c.stream + c.mu.RUnlock() + + if stream == nil { + return ErrNotConnected + } + + msg := &pb.AgentMessage{ + AgentId: c.agentID, + Payload: &pb.AgentMessage_TaskProgress{ + TaskProgress: progress, + }, + } + + return stream.Send(msg) +} + +// Shutdown gracefully shuts down the client +func (c *Client) Shutdown() { + close(c.done) + + c.mu.Lock() + defer c.mu.Unlock() + + if c.stream != nil { + c.stream.CloseSend() + } + if c.conn != nil { + c.conn.Close() + } +} diff --git a/internal/agent/grpc/errors.go b/internal/agent/grpc/errors.go new file mode 100644 index 0000000..a3d6f2a --- /dev/null +++ b/internal/agent/grpc/errors.go @@ -0,0 +1,8 @@ +package grpc + +import "errors" + +var ( + // ErrNotConnected is returned when the client is not connected + ErrNotConnected = errors.New("not connected to hub") +) diff --git a/internal/agent/root.go b/internal/agent/root.go new file mode 100644 index 0000000..f3ae3f9 --- /dev/null +++ b/internal/agent/root.go @@ -0,0 +1,32 @@ +package agent + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "agent", + Short: "OrcaCD Agent", + Long: `The OrcaCD Agent executes tasks assigned by the Hub and reports results back via gRPC streaming.`, + Run: func(cmd *cobra.Command, args []string) { + hubAddr, _ := cmd.Flags().GetString("hub") + agentID, _ := cmd.Flags().GetString("id") + StartAgent(hubAddr, agentID) + }, +} + +func init() { + rootCmd.Flags().StringP("hub", "H", "localhost:9090", "Hub gRPC address") + rootCmd.Flags().StringP("id", "i", "", "Agent ID (auto-generated if empty)") + rootCmd.AddCommand(versionCmd) +} + +func Run() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/internal/agent/server.go b/internal/agent/server.go new file mode 100644 index 0000000..6864654 --- /dev/null +++ b/internal/agent/server.go @@ -0,0 +1,55 @@ +package agent + +import ( + "context" + "os" + "os/signal" + "syscall" + + agentgrpc "github.com/OrcaCD/orca-cd/internal/agent/grpc" + "github.com/OrcaCD/orca-cd/internal/agent/executor" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +func StartAgent(hubAddr, agentID string) { + if agentID == "" { + agentID = uuid.New().String() + } + + log.Info(). + Str("agent_id", agentID). + Str("hub", hubAddr). + Msg("Starting OrcaCD Agent") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Setup signal handling + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Create task executor + exec := executor.NewExecutor() + + // Create and start the agent client + client := agentgrpc.NewClient(agentID, hubAddr, exec) + + go func() { + if err := client.Connect(ctx); err != nil { + log.Error().Err(err).Msg("Agent connection error") + cancel() + } + }() + + // Wait for shutdown signal + select { + case sig := <-sigChan: + log.Info().Str("signal", sig.String()).Msg("Received shutdown signal") + case <-ctx.Done(): + log.Info().Msg("Context cancelled") + } + + client.Shutdown() + log.Info().Msg("Agent shutdown complete") +} diff --git a/internal/agent/version.go b/internal/agent/version.go new file mode 100644 index 0000000..5911d48 --- /dev/null +++ b/internal/agent/version.go @@ -0,0 +1,20 @@ +package agent + +import ( + "fmt" + + "github.com/OrcaCD/orca-cd/internal/config" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number", + Long: `Print the version number, build time, and git commit of the agent.`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("OrcaCD Agent\n") + fmt.Printf("Version: %s\n", config.Version) + fmt.Printf("Build Time: %s\n", config.BuildTime) + fmt.Printf("Git Commit: %s\n", config.GitCommit) + }, +} diff --git a/internal/cli/one_time_access_token.go b/internal/cli/one_time_access_token.go new file mode 100644 index 0000000..c8fd6f2 --- /dev/null +++ b/internal/cli/one_time_access_token.go @@ -0,0 +1,89 @@ +package cli + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "time" + + "github.com/OrcaCD/orca-cd/internal/database" + "github.com/OrcaCD/orca-cd/internal/models" + "github.com/spf13/cobra" +) + +var tokenExpiry time.Duration + +var oneTimeAccessTokenCmd = &cobra.Command{ + Use: "one-time-access-token ", + Short: "Generate a one-time access token for account recovery", + Long: `Generate a one-time access token that allows a user to log in without their password. +This is useful for account recovery when a user has lost access to their account. + +The token can be used only once and expires after the specified duration (default: 1 hour). + +Example: + orca-cli one-time-access-token john@example.com + orca-cli one-time-access-token johndoe --expiry 24h`, + Args: cobra.ExactArgs(1), + Run: runOneTimeAccessToken, +} + +func init() { + oneTimeAccessTokenCmd.Flags().DurationVarP(&tokenExpiry, "expiry", "e", time.Hour, "Token expiry duration (e.g., 1h, 24h, 30m)") +} + +func runOneTimeAccessToken(cmd *cobra.Command, args []string) { + userIdentifier := args[0] + + // Connect to database + database.Connect() + + // Find user by username or email + var user models.User + result := database.DB.Where("username = ? OR email = ?", userIdentifier, userIdentifier).First(&user) + if result.Error != nil { + fmt.Fprintf(os.Stderr, "Error: User not found with username or email: %s\n", userIdentifier) + os.Exit(1) + } + + // Generate a secure random token + token, err := generateSecureToken(32) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating token: %v\n", err) + os.Exit(1) + } + + // Create the one-time access token + otat := models.OneTimeAccessToken{ + Token: token, + UserID: user.ID, + ExpiresAt: time.Now().Add(tokenExpiry), + } + + if err := database.DB.Create(&otat).Error; err != nil { + fmt.Fprintf(os.Stderr, "Error creating access token: %v\n", err) + os.Exit(1) + } + + // Output the token + fmt.Println() + fmt.Println("One-time access token generated successfully!") + fmt.Println() + fmt.Printf(" User: %s (%s)\n", user.Username, user.Email) + fmt.Printf(" Token: %s\n", token) + fmt.Printf(" Expires at: %s\n", otat.ExpiresAt.Format(time.RFC3339)) + fmt.Println() + fmt.Println("Share this token with the user. It can only be used once.") + fmt.Printf("Recovery URL: /auth/recover?token=%s\n", token) + fmt.Println() +} + +// generateSecureToken generates a cryptographically secure random token +func generateSecureToken(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..0208b29 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,26 @@ +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "orca-cli", + Short: "OrcaCD CLI", + Long: `The OrcaCD CLI provides administrative commands for account recovery and management.`, +} + +func init() { + rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(oneTimeAccessTokenCmd) +} + +func Run() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..15a9ae0 --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,19 @@ +package cli + +import ( + "fmt" + + "github.com/OrcaCD/orca-cd/internal/config" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version of OrcaCD CLI", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("OrcaCD CLI\n") + fmt.Printf(" Version: %s\n", config.Version) + fmt.Printf(" Build Time: %s\n", config.BuildTime) + fmt.Printf(" Git Commit: %s\n", config.GitCommit) + }, +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ab0d234 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,10 @@ +package config + +var ( + // Version is the application version, set at build time + Version = "dev" + // BuildTime is when the binary was built, set at build time + BuildTime = "unknown" + // GitCommit is the git commit hash, set at build time + GitCommit = "unknown" +) diff --git a/internal/controllers/agent.go b/internal/controllers/agent.go new file mode 100644 index 0000000..3cffd0d --- /dev/null +++ b/internal/controllers/agent.go @@ -0,0 +1,56 @@ +package controllers + +import ( + "net/http" + + hubgrpc "github.com/OrcaCD/orca-cd/internal/hub/grpc" + "github.com/gin-gonic/gin" +) + +// AgentResponse represents an agent in API responses +type AgentResponse struct { + ID string `json:"id"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Labels map[string]string `json:"labels"` + Connected bool `json:"connected"` +} + +// GetAgents returns a list of all connected agents +func GetAgents(c *gin.Context) { + agents := hubgrpc.DefaultHubService.GetConnectedAgents() + + response := make([]AgentResponse, 0, len(agents)) + for _, agent := range agents { + response = append(response, AgentResponse{ + ID: agent.ID, + Version: agent.Version, + Capabilities: agent.Capabilities, + Labels: agent.Labels, + Connected: true, + }) + } + + c.JSON(http.StatusOK, response) +} + +// GetAgent returns a specific agent by ID +func GetAgent(c *gin.Context) { + id := c.Param("id") + + agent, found := hubgrpc.DefaultHubService.GetAgent(id) + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + + response := AgentResponse{ + ID: agent.ID, + Version: agent.Version, + Capabilities: agent.Capabilities, + Labels: agent.Labels, + Connected: true, + } + + c.JSON(http.StatusOK, response) +} diff --git a/internal/controllers/health.go b/internal/controllers/health.go new file mode 100644 index 0000000..e334b20 --- /dev/null +++ b/internal/controllers/health.go @@ -0,0 +1,15 @@ +package controllers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +func HealthCheck(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "time": time.Now(), + }) +} diff --git a/internal/controllers/message.go b/internal/controllers/message.go new file mode 100644 index 0000000..706da0c --- /dev/null +++ b/internal/controllers/message.go @@ -0,0 +1,44 @@ +package controllers + +import ( + "github.com/OrcaCD/orca-cd/internal/database" + "github.com/OrcaCD/orca-cd/internal/models" + "net/http" + + "github.com/gin-gonic/gin" +) + +type CreateMessageRequest struct { + Text string `json:"text" binding:"required"` +} + +func GetMessages(c *gin.Context) { + var messages []models.Message + + if err := database.DB.Order("created_at desc").Find(&messages).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"}) + return + } + + c.JSON(http.StatusOK, messages) +} + +func CreateMessage(c *gin.Context) { + var req CreateMessageRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + message := models.Message{ + Text: req.Text, + } + + if err := database.DB.Create(&message).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create message"}) + return + } + + c.JSON(http.StatusCreated, message) +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..ac50cbd --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,92 @@ +package database + +import ( + "fmt" + "log" + "os" + + "github.com/OrcaCD/orca-cd/internal/models" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func Connect() { + var err error + + // Configure GORM + config := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + } + + // Get database driver from environment (default: sqlite) + dbDriver := os.Getenv("DB_DRIVER") + if dbDriver == "" { + dbDriver = "sqlite" + } + + // Connect based on driver type + switch dbDriver { + case "postgres", "postgresql": + // Get PostgreSQL connection string + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + dsn = "host=localhost user=postgres password=postgres dbname=go_react_demo port=5432 sslmode=disable" + } + DB, err = gorm.Open(postgres.Open(dsn), config) + if err != nil { + log.Fatal("Failed to connect to PostgreSQL:", err) + } + fmt.Println("PostgreSQL database connected successfully") + + case "sqlite": + // Get database path from environment or use default + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./data/app.db" + } + DB, err = gorm.Open(sqlite.Open(dbPath), config) + if err != nil { + log.Fatal("Failed to connect to SQLite:", err) + } + fmt.Println("SQLite database connected successfully") + + default: + log.Fatalf("Unsupported database driver: %s (supported: sqlite, postgres)", dbDriver) + } + + // Auto migrate models + if err := DB.AutoMigrate( + &models.Message{}, + &models.User{}, + &models.OneTimeAccessToken{}, + ); err != nil { + log.Fatal("Failed to migrate database:", err) + } + + // Seed initial data if database is empty + seedData() +} + +func seedData() { + var count int64 + DB.Model(&models.Message{}).Count(&count) + + if count == 0 { + initialMessages := []models.Message{ + {Text: "Hello from Go backend!"}, + {Text: "This is a demo application"}, + } + + DB.Create(&initialMessages) + fmt.Println("Database seeded with initial data") + } +} + +func GetDB() *gorm.DB { + return DB +} diff --git a/internal/hub/grpc/errors.go b/internal/hub/grpc/errors.go new file mode 100644 index 0000000..409812b --- /dev/null +++ b/internal/hub/grpc/errors.go @@ -0,0 +1,8 @@ +package grpc + +import "errors" + +var ( + // ErrAgentNotFound is returned when an agent is not found + ErrAgentNotFound = errors.New("agent not found") +) diff --git a/internal/hub/grpc/register.go b/internal/hub/grpc/register.go new file mode 100644 index 0000000..a8044f8 --- /dev/null +++ b/internal/hub/grpc/register.go @@ -0,0 +1,18 @@ +package grpc + +import ( + pb "github.com/OrcaCD/orca-cd/api/proto" + "google.golang.org/grpc" +) + +// RegisterHubServiceServer registers the HubService with a gRPC server +func RegisterHubServiceServer(s *grpc.Server, srv *HubService) { + pb.RegisterHubServiceServer(s, srv) +} + +// Global hub service instance for use by controllers +var DefaultHubService *HubService + +func init() { + DefaultHubService = NewHubService() +} diff --git a/internal/hub/grpc/service.go b/internal/hub/grpc/service.go new file mode 100644 index 0000000..b208f50 --- /dev/null +++ b/internal/hub/grpc/service.go @@ -0,0 +1,193 @@ +package grpc + +import ( + "io" + "sync" + + pb "github.com/OrcaCD/orca-cd/api/proto" + "github.com/rs/zerolog/log" +) + +// ConnectedAgent represents an agent connected via gRPC stream +type ConnectedAgent struct { + ID string + Version string + Capabilities []string + Labels map[string]string + Stream pb.HubService_AgentStreamServer + mu sync.Mutex +} + +// Send sends a message to the agent +func (a *ConnectedAgent) Send(msg *pb.HubMessage) error { + a.mu.Lock() + defer a.mu.Unlock() + return a.Stream.Send(msg) +} + +// HubService implements the gRPC HubService server +type HubService struct { + pb.UnimplementedHubServiceServer + agents sync.Map // map[string]*ConnectedAgent +} + +// NewHubService creates a new HubService instance +func NewHubService() *HubService { + return &HubService{} +} + +// GetConnectedAgents returns a list of all connected agents +func (s *HubService) GetConnectedAgents() []*ConnectedAgent { + var agents []*ConnectedAgent + s.agents.Range(func(key, value any) bool { + if agent, ok := value.(*ConnectedAgent); ok { + agents = append(agents, agent) + } + return true + }) + return agents +} + +// GetAgent returns a specific agent by ID +func (s *HubService) GetAgent(id string) (*ConnectedAgent, bool) { + if val, ok := s.agents.Load(id); ok { + return val.(*ConnectedAgent), true + } + return nil, false +} + +// AgentStream handles bidirectional streaming with agents +func (s *HubService) AgentStream(stream pb.HubService_AgentStreamServer) error { + var agent *ConnectedAgent + + for { + msg, err := stream.Recv() + if err == io.EOF { + log.Info().Msg("Agent stream closed by client") + break + } + if err != nil { + log.Error().Err(err).Msg("Error receiving from agent stream") + break + } + + switch payload := msg.Payload.(type) { + case *pb.AgentMessage_Registration: + agent = s.handleRegistration(msg.AgentId, payload.Registration, stream) + + case *pb.AgentMessage_Heartbeat: + s.handleHeartbeat(msg.AgentId, payload.Heartbeat) + + case *pb.AgentMessage_TaskResult: + s.handleTaskResult(msg.AgentId, payload.TaskResult) + + case *pb.AgentMessage_TaskProgress: + s.handleTaskProgress(msg.AgentId, payload.TaskProgress) + } + } + + // Clean up agent connection + if agent != nil { + s.agents.Delete(agent.ID) + log.Info().Str("agent_id", agent.ID).Msg("Agent disconnected") + } + + return nil +} + +func (s *HubService) handleRegistration(agentID string, reg *pb.AgentRegistration, stream pb.HubService_AgentStreamServer) *ConnectedAgent { + agent := &ConnectedAgent{ + ID: agentID, + Version: reg.Version, + Capabilities: reg.Capabilities, + Labels: reg.Labels, + Stream: stream, + } + + s.agents.Store(agentID, agent) + + log.Info(). + Str("agent_id", agentID). + Str("version", reg.Version). + Strs("capabilities", reg.Capabilities). + Msg("Agent registered") + + // Send registration acknowledgment + ack := &pb.HubMessage{ + Payload: &pb.HubMessage_RegistrationAck{ + RegistrationAck: &pb.RegistrationAck{ + Success: true, + Message: "Registration successful", + }, + }, + } + + if err := agent.Send(ack); err != nil { + log.Error().Err(err).Str("agent_id", agentID).Msg("Failed to send registration ack") + } + + return agent +} + +func (s *HubService) handleHeartbeat(agentID string, hb *pb.Heartbeat) { + log.Debug(). + Str("agent_id", agentID). + Int64("timestamp", hb.Timestamp). + Msg("Heartbeat received") +} + +func (s *HubService) handleTaskResult(agentID string, result *pb.TaskResult) { + log.Info(). + Str("agent_id", agentID). + Str("task_id", result.TaskId). + Int32("status", int32(result.Status)). + Msg("Task result received") + + // TODO: Store task result in database and notify relevant services +} + +func (s *HubService) handleTaskProgress(agentID string, progress *pb.TaskProgress) { + log.Debug(). + Str("agent_id", agentID). + Str("task_id", progress.TaskId). + Int32("percent", progress.PercentComplete). + Str("message", progress.Message). + Msg("Task progress received") + + // TODO: Broadcast progress updates via WebSocket to frontend +} + +// AssignTask sends a task to a specific agent +func (s *HubService) AssignTask(agentID string, task *pb.TaskAssignment) error { + agent, ok := s.GetAgent(agentID) + if !ok { + return ErrAgentNotFound + } + + msg := &pb.HubMessage{ + Payload: &pb.HubMessage_TaskAssignment{ + TaskAssignment: task, + }, + } + + return agent.Send(msg) +} + +// CancelTask sends a cancellation request to an agent +func (s *HubService) CancelTask(agentID string, taskID string, reason string) error { + agent, ok := s.GetAgent(agentID) + if !ok { + return ErrAgentNotFound + } + + msg := &pb.HubMessage{ + Payload: &pb.HubMessage_TaskCancel{ + TaskCancel: &pb.TaskCancel{ + TaskId: taskID, + Reason: reason, + }, + }, + } + + return agent.Send(msg) +} diff --git a/internal/hub/health.go b/internal/hub/health.go new file mode 100644 index 0000000..7b8aa88 --- /dev/null +++ b/internal/hub/health.go @@ -0,0 +1,35 @@ +package hub + +import ( + "fmt" + "net/http" + "os" + + "github.com/spf13/cobra" +) + +var healthCmd = &cobra.Command{ + Use: "health", + Short: "Check the health of the hub server", + Long: `Check the health status of the running hub server.`, + Run: func(cmd *cobra.Command, args []string) { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + resp, err := http.Get(fmt.Sprintf("http://localhost:%s/api/health", port)) + if err != nil { + fmt.Printf("Health check failed: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + fmt.Println("Hub is healthy") + os.Exit(0) + } + fmt.Printf("Hub is unhealthy: status %d\n", resp.StatusCode) + os.Exit(1) + }, +} diff --git a/internal/hub/root.go b/internal/hub/root.go new file mode 100644 index 0000000..5376804 --- /dev/null +++ b/internal/hub/root.go @@ -0,0 +1,33 @@ +package hub + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "hub", + Short: "OrcaCD Hub Server", + Long: `The OrcaCD Hub is the central server that manages agents, serves the frontend, and provides the REST API.`, + Run: func(cmd *cobra.Command, args []string) { + port, _ := cmd.Flags().GetString("port") + grpcPort, _ := cmd.Flags().GetString("grpc-port") + StartHub(port, grpcPort) + }, +} + +func init() { + rootCmd.Flags().StringP("port", "p", "8080", "HTTP port for REST API and frontend") + rootCmd.Flags().StringP("grpc-port", "g", "9090", "gRPC port for agent communication") + rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(healthCmd) +} + +func Run() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/internal/hub/server.go b/internal/hub/server.go new file mode 100644 index 0000000..eb590ae --- /dev/null +++ b/internal/hub/server.go @@ -0,0 +1,89 @@ +package hub + +import ( + "fmt" + "net" + "sync" + + "github.com/OrcaCD/orca-cd/internal/controllers" + "github.com/OrcaCD/orca-cd/internal/database" + hubgrpc "github.com/OrcaCD/orca-cd/internal/hub/grpc" + "github.com/OrcaCD/orca-cd/internal/middleware" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + "google.golang.org/grpc" +) + +func StartHub(httpPort, grpcPort string) { + // Initialize database + database.Connect() + + var wg sync.WaitGroup + wg.Add(2) + + // Start gRPC server + go func() { + defer wg.Done() + startGRPCServer(grpcPort) + }() + + // Start HTTP server + go func() { + defer wg.Done() + startHTTPServer(httpPort) + }() + + wg.Wait() +} + +func startGRPCServer(port string) { + addr := fmt.Sprintf(":%s", port) + lis, err := net.Listen("tcp", addr) + if err != nil { + log.Fatal().Err(err).Msg("Failed to listen for gRPC") + } + + grpcServer := grpc.NewServer() + hubgrpc.RegisterHubServiceServer(grpcServer, hubgrpc.DefaultHubService) + + log.Info().Str("addr", addr).Msg("Starting gRPC server") + if err := grpcServer.Serve(lis); err != nil { + log.Fatal().Err(err).Msg("Failed to serve gRPC") + } +} + +func startHTTPServer(port string) { + // Setup Gin router + r := gin.Default() + + // Apply middleware + r.Use(middleware.CORS()) + + // API routes + api := r.Group("/api") + { + api.GET("/health", controllers.HealthCheck) + api.GET("/messages", controllers.GetMessages) + api.POST("/messages", controllers.CreateMessage) + + // Agent management endpoints + agents := api.Group("/agents") + { + agents.GET("", controllers.GetAgents) + agents.GET("/:id", controllers.GetAgent) + } + } + + // Serve static files from the frontend build + r.Static("/assets", "./frontend/dist/assets") + r.StaticFile("/", "./frontend/dist/index.html") + r.NoRoute(func(c *gin.Context) { + c.File("./frontend/dist/index.html") + }) + + // Start server + addr := fmt.Sprintf(":%s", port) + log.Info().Str("addr", addr).Msg("Starting HTTP server") + r.Run(addr) +} diff --git a/internal/hub/version.go b/internal/hub/version.go new file mode 100644 index 0000000..0c5d278 --- /dev/null +++ b/internal/hub/version.go @@ -0,0 +1,20 @@ +package hub + +import ( + "fmt" + + "github.com/OrcaCD/orca-cd/internal/config" + "github.com/spf13/cobra" +) + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number", + Long: `Print the version number, build time, and git commit of the hub.`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("OrcaCD Hub\n") + fmt.Printf("Version: %s\n", config.Version) + fmt.Printf("Build Time: %s\n", config.BuildTime) + fmt.Printf("Git Commit: %s\n", config.GitCommit) + }, +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..c8ec1eb --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func CORS() gin.HandlerFunc { + return cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + }) +} diff --git a/internal/models/message.go b/internal/models/message.go new file mode 100644 index 0000000..596c611 --- /dev/null +++ b/internal/models/message.go @@ -0,0 +1,15 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type Message struct { + ID uint `gorm:"primarykey" json:"id"` + Text string `gorm:"not null" json:"text"` + CreatedAt time.Time `json:"timestamp"` + UpdatedAt time.Time `json:"-"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/internal/models/one_time_access_token.go b/internal/models/one_time_access_token.go new file mode 100644 index 0000000..77a7728 --- /dev/null +++ b/internal/models/one_time_access_token.go @@ -0,0 +1,34 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type OneTimeAccessToken struct { + ID uint `gorm:"primarykey" json:"id"` + Token string `gorm:"uniqueIndex;not null" json:"token"` + UserID uint `gorm:"not null" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"-"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// IsExpired checks if the token has expired +func (t *OneTimeAccessToken) IsExpired() bool { + return time.Now().After(t.ExpiresAt) +} + +// IsUsed checks if the token has been used +func (t *OneTimeAccessToken) IsUsed() bool { + return t.UsedAt != nil +} + +// IsValid checks if the token is valid (not expired and not used) +func (t *OneTimeAccessToken) IsValid() bool { + return !t.IsExpired() && !t.IsUsed() +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..60a2c98 --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,18 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + ID uint `gorm:"primarykey" json:"id"` + Username string `gorm:"uniqueIndex;not null" json:"username"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + Password string `gorm:"not null" json:"-"` + IsAdmin bool `gorm:"default:false" json:"is_admin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"-"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} diff --git a/internal/utils/logging.go b/internal/utils/logging.go new file mode 100644 index 0000000..4dd2960 --- /dev/null +++ b/internal/utils/logging.go @@ -0,0 +1,25 @@ +package utils + +import "strings" + +// ShoudLogJSON checks if JSON logging should be used based on environment variables and command line arguments +func ShoudLogJSON(environ []string, args []string) bool { + // Check for production environment + for _, env := range environ { + if strings.HasPrefix(env, "GIN_MODE=") && strings.Contains(env, "release") { + return true + } + if strings.HasPrefix(env, "LOG_FORMAT=") && strings.Contains(env, "json") { + return true + } + } + + // Check for specific commands that should use JSON (e.g., health check for machine parsing) + for _, arg := range args { + if arg == "health" || arg == "--json" { + return true + } + } + + return false +}