Hello World!
+Go + PostgreSQL + Next.js
+ +Loading...
} + {error &&Error: {error}
} + {data && ( + <> +{data.message}
++ Database: + + {data.db_status} + +
+ > + )} +diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..30a68ee --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# PostgreSQL +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=hellodb + +# Backend +PORT=8080 + +# Frontend +NEXT_PUBLIC_API_URL=http://localhost:8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00b8087 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Dependencies +node_modules/ + +# Build +.next/ +out/ +dist/ + +# Environment +.env +.env.local +.env.*.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Go +backend/main +*.exe + +# Logs +*.log +npm-debug.log* diff --git a/.woodpecker.yaml b/.woodpecker.yaml new file mode 100644 index 0000000..b2eaab7 --- /dev/null +++ b/.woodpecker.yaml @@ -0,0 +1,78 @@ +# Woodpecker CI Pipeline +# Go Backend + Next.js Frontend + PostgreSQL + +when: + - event: [push, pull_request] + branch: main + +steps: + # 1. Backend Docker image build + build-backend: + image: docker:24-dind + privileged: true + commands: + - docker build -t hell-world-backend:${CI_COMMIT_SHA:0:8} ./backend + - docker tag hell-world-backend:${CI_COMMIT_SHA:0:8} registry.gecore.mn/library/hell-world-backend:${CI_COMMIT_SHA:0:8} + - docker tag hell-world-backend:${CI_COMMIT_SHA:0:8} registry.gecore.mn/library/hell-world-backend:latest + + # 2. Frontend Docker image build + build-frontend: + image: docker:24-dind + privileged: true + commands: + - docker build -t hell-world-frontend:${CI_COMMIT_SHA:0:8} ./frontend + - docker tag hell-world-frontend:${CI_COMMIT_SHA:0:8} registry.gecore.mn/library/hell-world-frontend:${CI_COMMIT_SHA:0:8} + - docker tag hell-world-frontend:${CI_COMMIT_SHA:0:8} registry.gecore.mn/library/hell-world-frontend:latest + + # 3. Push backend to Harbor registry + push-backend: + image: docker:24-dind + privileged: true + commands: + - echo "$HARBOR_PASSWORD" | docker login registry.gecore.mn -u "$HARBOR_USER" --password-stdin + - docker push registry.gecore.mn/library/hell-world-backend:${CI_COMMIT_SHA:0:8} + - docker push registry.gecore.mn/library/hell-world-backend:latest + secrets: [harbor_user, harbor_password] + when: + event: push + branch: main + + # 4. Push frontend to Harbor registry + push-frontend: + image: docker:24-dind + privileged: true + commands: + - echo "$HARBOR_PASSWORD" | docker login registry.gecore.mn -u "$HARBOR_USER" --password-stdin + - docker push registry.gecore.mn/library/hell-world-frontend:${CI_COMMIT_SHA:0:8} + - docker push registry.gecore.mn/library/hell-world-frontend:latest + secrets: [harbor_user, harbor_password] + when: + event: push + branch: main + + # 5. Update Kubernetes manifests with new image tags + update-manifests: + image: alpine:latest + commands: + - apk add --no-cache sed git + - sed -i "s|image:.*hell-world-backend.*|image: registry.gecore.mn/library/hell-world-backend:${CI_COMMIT_SHA:0:8}|g" manifests/backend-deployment.yaml + - sed -i "s|image:.*hell-world-frontend.*|image: registry.gecore.mn/library/hell-world-frontend:${CI_COMMIT_SHA:0:8}|g" manifests/frontend-deployment.yaml + - git config user.email "ci@gecore.mn" + - git config user.name "Woodpecker CI" + - git add manifests/ + - git commit -m "ci: update images to ${CI_COMMIT_SHA:0:8}" || true + - git push origin main || true + when: + event: push + branch: main + + # 6. Deploy notification + notify: + image: alpine:latest + commands: + - echo "✅ Build completed for commit ${CI_COMMIT_SHA:0:8}" + - echo "📦 Backend: registry.gecore.mn/library/hell-world-backend:${CI_COMMIT_SHA:0:8}" + - echo "📦 Frontend: registry.gecore.mn/library/hell-world-frontend:${CI_COMMIT_SHA:0:8}" + - echo "🔗 ArgoCD will sync automatically" + when: + status: [success, failure] diff --git a/README.md b/README.md index 79b75cf..3b2a557 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,126 @@ -# hell-world +# Hello World - Go + PostgreSQL + Next.js +Full-stack Hello World application using Go backend, PostgreSQL database, and Next.js frontend. + +## Project Structure + +``` +. +├── backend/ # Go API server +│ ├── main.go +│ ├── go.mod +│ └── Dockerfile +├── frontend/ # Next.js application +│ ├── app/ +│ ├── package.json +│ └── Dockerfile +├── docker-compose.yml +└── README.md +``` + +## Quick Start with Docker + +```bash +docker-compose up --build +``` + +Services: +- Frontend: http://localhost:3000 +- Backend API: http://localhost:8080 +- PostgreSQL: localhost:5432 + +## Manual Setup + +### Backend (Go) + +```bash +cd backend +go mod tidy +DB_HOST=localhost DB_PORT=5432 DB_USER=postgres DB_PASSWORD=postgres DB_NAME=hellodb go run main.go +``` + +### Frontend (Next.js) + +```bash +cd frontend +npm install +NEXT_PUBLIC_API_URL=http://localhost:8080 npm run dev +``` + +### PostgreSQL + +```bash +docker run -d \ + --name hello-postgres \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=hellodb \ + -p 5432:5432 \ + postgres:16-alpine +``` + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/hello` | GET | Returns hello message and DB status | +| `/api/health` | GET | Health check | + +## Tech Stack + +- **Backend**: Go 1.21 +- **Database**: PostgreSQL 16 +- **Frontend**: Next.js 14, React 18, TypeScript + +## CI/CD Pipeline + +### Woodpecker CI + +`.woodpecker.yaml` файл нь дараах алхмуудыг гүйцэтгэнэ: + +1. **build-backend** - Go backend Docker image build +2. **build-frontend** - Next.js frontend Docker image build +3. **push-backend** - Harbor registry рүү backend push +4. **push-frontend** - Harbor registry рүү frontend push +5. **update-manifests** - Kubernetes manifest дахь image tag шинэчлэх +6. **notify** - Deployment notification + +### ArgoCD (GitOps) + +ArgoCD application тохируулах: + +```bash +kubectl apply -f argocd-application.yaml -n argocd +``` + +ArgoCD нь `manifests/` directory-г автоматаар sync хийнэ. + +## Kubernetes Deployment + +### Manifests + +``` +manifests/ +├── namespace.yaml # hell-world namespace +├── postgres-secret.yaml # Database credentials +├── postgres-pvc.yaml # PostgreSQL storage +├── postgres-deployment.yaml # PostgreSQL deployment +├── postgres-service.yaml # PostgreSQL service +├── backend-deployment.yaml # Go backend deployment +├── backend-service.yaml # Backend service +├── frontend-deployment.yaml # Next.js frontend deployment +├── frontend-service.yaml # Frontend service +├── ingress.yaml # Ingress with TLS +└── kustomization.yaml # Kustomize config +``` + +### Manual Deploy + +```bash +kubectl apply -k manifests/ +``` + +### URLs (Production) + +- Frontend: https://hell-world.gecore.mn +- Backend API: https://hell-world-api.gecore.mn diff --git a/argocd-application.yaml b/argocd-application.yaml new file mode 100644 index 0000000..a532950 --- /dev/null +++ b/argocd-application.yaml @@ -0,0 +1,35 @@ +# ArgoCD Application - GitOps deployment +# kubectl apply -f argocd-application.yaml -n argocd +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: hell-world + namespace: argocd + finalizers: + - resources-finalizer.argocd.argoproj.io +spec: + project: default + + source: + # Gitea repository URL + repoURL: https://git.gecore.mn/admin/hell-world.git + targetRevision: HEAD + path: manifests + + destination: + server: https://kubernetes.default.svc + namespace: hell-world + + syncPolicy: + automated: + prune: true # Устгагдсан resource-уудыг автоматаар устгана + selfHeal: true # Гараар өөрчилсөн зүйлсийг автоматаар сэргээнэ + syncOptions: + - CreateNamespace=true + - PruneLast=true + retry: + limit: 5 + backoff: + duration: 5s + factor: 2 + maxDuration: 3m diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..af383cb --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +COPY go.mod ./ +RUN go mod download + +COPY . . +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o main . + +FROM alpine:latest + +WORKDIR /app + +COPY --from=builder /app/main . + +EXPOSE 8080 + +CMD ["./main"] diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..9c8ffda --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,5 @@ +module hello-world-backend + +go 1.21 + +require github.com/lib/pq v1.10.9 diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..4263094 --- /dev/null +++ b/backend/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + + _ "github.com/lib/pq" +) + +type Response struct { + Message string `json:"message"` + DBStatus string `json:"db_status"` +} + +var db *sql.DB + +func main() { + // PostgreSQL connection + dbHost := getEnv("DB_HOST", "localhost") + dbPort := getEnv("DB_PORT", "5432") + dbUser := getEnv("DB_USER", "postgres") + dbPassword := getEnv("DB_PASSWORD", "postgres") + dbName := getEnv("DB_NAME", "hellodb") + + connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + dbHost, dbPort, dbUser, dbPassword, dbName) + + var err error + db, err = sql.Open("postgres", connStr) + if err != nil { + log.Printf("Warning: Could not connect to database: %v", err) + } else { + defer db.Close() + } + + // HTTP handlers + http.HandleFunc("/api/hello", corsMiddleware(helloHandler)) + http.HandleFunc("/api/health", corsMiddleware(healthHandler)) + + port := getEnv("PORT", "8080") + log.Printf("Server starting on port %s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +func helloHandler(w http.ResponseWriter, r *http.Request) { + dbStatus := "disconnected" + + if db != nil { + err := db.Ping() + if err == nil { + dbStatus = "connected" + } + } + + response := Response{ + Message: "Hello World! - Go + PostgreSQL + Next.js", + DBStatus: dbStatus, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func corsMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next(w, r) + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ecab8f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + postgres: + image: postgres:16-alpine + container_name: hello-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: hellodb + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: ./backend + container_name: hello-backend + ports: + - "8080:8080" + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: hellodb + PORT: 8080 + depends_on: + postgres: + condition: service_healthy + + frontend: + build: ./frontend + container_name: hello-frontend + ports: + - "3000:3000" + environment: + NEXT_PUBLIC_API_URL: http://localhost:8080 + depends_on: + - backend + +volumes: + postgres_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..5f4200a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..818f168 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Hello World App', + description: 'Go + PostgreSQL + Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + +
{children} + + ) +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..d314514 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,118 @@ +'use client' + +import { useEffect, useState } from 'react' + +interface ApiResponse { + message: string + db_status: string +} + +export default function Home() { + const [data, setData] = useStateGo + PostgreSQL + Next.js
+ +Loading...
} + {error &&Error: {error}
} + {data && ( + <> +{data.message}
++ Database: + + {data.db_status} + +
+ > + )} +