Compare commits
13 Commits
a52b2d13f0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3be4ead1d | ||
|
|
8765608603 | ||
|
|
9d0240bf3f | ||
|
|
b86c76a2fc | ||
|
|
392d4925cf | ||
|
|
ec65a99b99 | ||
| 920c910c23 | |||
| 1db56d6741 | |||
| 58df1359e1 | |||
| 971c62afc5 | |||
| 1d37f50604 | |||
| a43658af84 | |||
| ca62651c3c |
@@ -1,6 +1,6 @@
|
||||
# Gitea Workflows
|
||||
|
||||
This directory contains Gitea Actions workflows for CI/CD.
|
||||
This directory contains Gitea Actions workflows for CI/CD Pipelines.
|
||||
|
||||
## Workflows
|
||||
|
||||
|
||||
@@ -35,11 +35,17 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
- name: Configure Docker Socket
|
||||
run: |
|
||||
# Detect Docker socket location (handles rootless Docker)
|
||||
if [ -S "/run/user/$(id -u)/docker.sock" ]; then
|
||||
# Prefer DOCKER_HOST if set (runner injects the real host socket path).
|
||||
# This ensures pack passes the correct host path to lifecycle containers,
|
||||
# which Podman can bind-mount without "mkdir permission denied".
|
||||
if [ -n "$DOCKER_HOST" ]; then
|
||||
echo "PACK_DOCKER_HOST=$DOCKER_HOST" >> $GITEA_ENV
|
||||
elif [ -S "/run/user/$(id -u)/docker.sock" ]; then
|
||||
echo "PACK_DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock" >> $GITEA_ENV
|
||||
elif [ -S "/var/run/docker.sock" ]; then
|
||||
echo "PACK_DOCKER_HOST=unix:///var/run/docker.sock" >> $GITEA_ENV
|
||||
@@ -62,7 +68,6 @@ jobs:
|
||||
pack config default-builder paketobuildpacks/builder-jammy-tiny:latest
|
||||
|
||||
- name: Prepare build environment
|
||||
working-directory: backend
|
||||
run: |
|
||||
# Create .env.production for build (no secrets, just structure)
|
||||
cat > .env.production << EOF
|
||||
@@ -77,15 +82,15 @@ jobs:
|
||||
run: |
|
||||
PACK_ARGS=(
|
||||
"${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
--path backend
|
||||
--path .
|
||||
)
|
||||
|
||||
if [ -n "$PACK_DOCKER_HOST" ]; then
|
||||
PACK_ARGS+=(--docker-host "$PACK_DOCKER_HOST")
|
||||
fi
|
||||
|
||||
if [ -f "backend/.env.production" ]; then
|
||||
PACK_ARGS+=(--env-file backend/.env.production)
|
||||
if [ -f ".env.production" ]; then
|
||||
PACK_ARGS+=(--env-file .env.production)
|
||||
fi
|
||||
|
||||
pack build "${PACK_ARGS[@]}"
|
||||
@@ -107,16 +112,26 @@ jobs:
|
||||
- name: Push to registry
|
||||
if: env.REGISTRY != ''
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
# docker login sends POST /auth to Podman which incorrectly tries HTTPS even for
|
||||
# insecure registries. Pre-populate config.json instead — docker push goes through
|
||||
# the Podman daemon which correctly uses HTTP (insecure=true in registries.conf).
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_PASSWORD }}" | base64 -w 0)
|
||||
# Auth key must be the registry hostname only (e.g. host:port), not the full path
|
||||
REGISTRY_HOST=$(echo "${{ env.REGISTRY }}" | cut -d'/' -f1)
|
||||
echo "{\"auths\":{\"${REGISTRY_HOST}\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
docker push "${{ steps.image.outputs.full }}"
|
||||
|
||||
- name: Save image artifact
|
||||
- name: Save image to file
|
||||
if: env.REGISTRY == ''
|
||||
run: |
|
||||
docker save "${IMAGE_NAME}:${IMAGE_TAG}" -o /tmp/image.tar
|
||||
|
||||
- name: Upload image artifact
|
||||
if: env.REGISTRY == ''
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docker-image
|
||||
path: /tmp/image.tar
|
||||
retention-days: 1
|
||||
if: env.REGISTRY == ''
|
||||
run: |
|
||||
docker save "${IMAGE_NAME}:${IMAGE_TAG}" -o /tmp/image.tar
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
environment: production
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
- name: Prepare deployment files
|
||||
run: |
|
||||
mkdir -p deployment/tmp
|
||||
|
||||
|
||||
# Create .env.production
|
||||
cat > deployment/tmp/.env.production << EOF
|
||||
PORT=${{ secrets.PORT || '8080' }}
|
||||
@@ -67,38 +67,38 @@ jobs:
|
||||
FIREBASE_STORAGE_BUCKET=${{ secrets.FIREBASE_STORAGE_BUCKET }}
|
||||
FIREBASE_CREDENTIALS_FILE=${{ secrets.FIREBASE_CREDENTIALS_FILE_PATH || './firebase-credentials.json' }}
|
||||
EOF
|
||||
|
||||
|
||||
# Create deployment script
|
||||
cat > deployment/tmp/deploy.sh << 'DEPLOY_SCRIPT'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
|
||||
IMAGE_NAME="${{ env.IMAGE_NAME }}"
|
||||
IMAGE_TAG="${{ env.IMAGE_TAG }}"
|
||||
CONTAINER_NAME="jd-book-uploader"
|
||||
|
||||
|
||||
set -a
|
||||
source .env.production
|
||||
set +a
|
||||
|
||||
|
||||
# Stop existing container
|
||||
if podman ps -a --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then
|
||||
podman stop "${CONTAINER_NAME}" 2>/dev/null || true
|
||||
podman rm "${CONTAINER_NAME}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
|
||||
# Load image if artifact provided
|
||||
if [ -f image.tar ]; then
|
||||
podman load -i image.tar
|
||||
rm -f image.tar
|
||||
fi
|
||||
|
||||
|
||||
# Pull from registry if configured
|
||||
if [ -n "${{ env.REGISTRY }}" ]; then
|
||||
podman pull "${{ env.REGISTRY }}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
podman pull --tls-verify=false "${{ env.REGISTRY }}/${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
podman tag "${{ env.REGISTRY }}/${IMAGE_NAME}:${IMAGE_TAG}" "${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
fi
|
||||
|
||||
|
||||
# Build run command
|
||||
PODMAN_CMD=(
|
||||
podman run -d
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
--user root
|
||||
--restart=unless-stopped
|
||||
)
|
||||
|
||||
|
||||
# Add environment variables
|
||||
while IFS='=' read -r key value; do
|
||||
[[ "$key" =~ ^#.*$ ]] && continue
|
||||
@@ -117,19 +117,19 @@ jobs:
|
||||
PODMAN_CMD+=(-e "${key}=${value}")
|
||||
fi
|
||||
done < .env.production
|
||||
|
||||
|
||||
# Mount Firebase credentials
|
||||
FIREBASE_CREDS="${FIREBASE_CREDENTIALS_FILE}"
|
||||
if [ -f "$FIREBASE_CREDS" ]; then
|
||||
PODMAN_CMD+=(-v "${FIREBASE_CREDS}:/app/firebase-credentials.json:ro,z")
|
||||
PODMAN_CMD+=(-e "FIREBASE_CREDENTIALS_FILE=/app/firebase-credentials.json")
|
||||
fi
|
||||
|
||||
|
||||
PODMAN_CMD+=("${IMAGE_NAME}:${IMAGE_TAG}")
|
||||
|
||||
|
||||
"${PODMAN_CMD[@]}"
|
||||
sleep 3
|
||||
|
||||
|
||||
if podman ps --format "{{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo "✓ Container started"
|
||||
podman logs "${CONTAINER_NAME}" --tail 20
|
||||
@@ -139,32 +139,40 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
DEPLOY_SCRIPT
|
||||
|
||||
|
||||
chmod +x deployment/tmp/deploy.sh
|
||||
|
||||
- name: Transfer files
|
||||
run: |
|
||||
scp -r deployment/tmp/* ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/deployment/
|
||||
# Ensure remote deployment directory exists
|
||||
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} "mkdir -p ${{ secrets.DEPLOY_PATH }}/deployment"
|
||||
# Copy files explicitly — glob (*) skips dotfiles like .env.production
|
||||
scp deployment/tmp/deploy.sh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/deployment/
|
||||
scp deployment/tmp/.env.production ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/deployment/
|
||||
if [ -f image.tar ]; then
|
||||
scp image.tar ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/image.tar
|
||||
fi
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
ssh ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << ENDSSH
|
||||
ssh -T ${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << ENDSSH
|
||||
set -e
|
||||
cd ${{ secrets.DEPLOY_PATH }}
|
||||
|
||||
|
||||
if [ -f image.tar ]; then
|
||||
podman load -i image.tar
|
||||
rm -f image.tar
|
||||
fi
|
||||
|
||||
|
||||
if [ ! -f "${{ secrets.FIREBASE_CREDENTIALS_FILE_PATH || './firebase-credentials.json' }}" ]; then
|
||||
echo "Error: Firebase credentials not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
if [ -n "${{ env.REGISTRY }}" ]; then
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | podman login "${{ env.REGISTRY }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin --tls-verify=false
|
||||
fi
|
||||
|
||||
cd deployment
|
||||
./deploy.sh
|
||||
ENDSSH
|
||||
@@ -173,16 +181,15 @@ jobs:
|
||||
run: |
|
||||
sleep 5
|
||||
HEALTH_URL="http://${{ secrets.DEPLOY_HOST }}:${{ secrets.PORT || '8080' }}/api/health"
|
||||
|
||||
|
||||
for i in {1..10}; do
|
||||
if curl -f -s "$HEALTH_URL" > /dev/null; then
|
||||
echo "✓ Health check passed"
|
||||
curl -s "$HEALTH_URL" | jq .
|
||||
curl -s "$HEALTH_URL"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
|
||||
|
||||
echo "✗ Health check failed"
|
||||
exit 1
|
||||
|
||||
|
||||
@@ -7,14 +7,20 @@ on:
|
||||
- production
|
||||
- develop
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- '**/*.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- '.gitea/workflows/test.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- production
|
||||
- develop
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- '**/*.go'
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- '.gitea/workflows/test.yml'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -34,29 +40,28 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('backend/go.sum') }}
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: backend
|
||||
run: go mod download
|
||||
|
||||
- name: Run tests
|
||||
working-directory: backend
|
||||
run: go test -v -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
if: always()
|
||||
with:
|
||||
file: ./backend/coverage.out
|
||||
flags: backend
|
||||
name: backend-coverage
|
||||
# - name: Upload coverage
|
||||
# uses: codecov/codecov-action@v4
|
||||
# if: always()
|
||||
# with:
|
||||
# file: ./coverage.out
|
||||
# flags: backend
|
||||
# name: backend-coverage
|
||||
|
||||
lint:
|
||||
name: Lint Code
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # Allow failure until golangci-lint supports Go 1.25
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -67,8 +72,13 @@ jobs:
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
||||
- name: Install golangci-lint from source
|
||||
run: |
|
||||
# Install golangci-lint from source using Go 1.25 to ensure compatibility
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v4
|
||||
with:
|
||||
version: latest
|
||||
working-directory: backend
|
||||
run: |
|
||||
golangci-lint --version
|
||||
golangci-lint run --timeout=5m
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -49,5 +49,7 @@ build/
|
||||
.env.production
|
||||
.env.local
|
||||
.env.production.example
|
||||
|
||||
# Gitea workflows
|
||||
.gitea/workflows/deploy.yml
|
||||
.gitea/workflows/build.yml
|
||||
|
||||
@@ -22,18 +22,37 @@ func TestUploadBook(t *testing.T) {
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
// Add form fields
|
||||
writer.WriteField("book_name", "Test Book")
|
||||
writer.WriteField("cost", "10.50")
|
||||
writer.WriteField("price", "15.99")
|
||||
writer.WriteField("quantity", "100")
|
||||
writer.WriteField("publisher_author", "Test Publisher")
|
||||
writer.WriteField("category", "Fiction")
|
||||
if err := writer.WriteField("book_name", "Test Book"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("cost", "10.50"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("price", "15.99"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("quantity", "100"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("publisher_author", "Test Publisher"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("category", "Fiction"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
|
||||
// Add image file
|
||||
part, _ := writer.CreateFormFile("image", "test.png")
|
||||
part.Write([]byte("fake image data"))
|
||||
part, err := writer.CreateFormFile("image", "test.png")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create form file: %v", err)
|
||||
}
|
||||
if _, err := part.Write([]byte("fake image data")); err != nil {
|
||||
t.Fatalf("Failed to write image data: %v", err)
|
||||
}
|
||||
|
||||
writer.Close()
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Failed to close writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/books", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
@@ -55,8 +74,12 @@ func TestUploadBook_ValidationErrors(t *testing.T) {
|
||||
// Test missing required field
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
writer.WriteField("book_name", "") // Empty book name
|
||||
writer.Close()
|
||||
if err := writer.WriteField("book_name", ""); err != nil { // Empty book name
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Failed to close writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/books", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
@@ -22,18 +22,37 @@ func TestUploadStationery(t *testing.T) {
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
// Add form fields
|
||||
writer.WriteField("stationery_name", "Test Pen")
|
||||
writer.WriteField("cost", "2.50")
|
||||
writer.WriteField("price", "5.99")
|
||||
writer.WriteField("quantity", "200")
|
||||
writer.WriteField("category", "Writing")
|
||||
writer.WriteField("color", "Blue")
|
||||
if err := writer.WriteField("stationery_name", "Test Pen"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("cost", "2.50"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("price", "5.99"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("quantity", "200"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("category", "Writing"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("color", "Blue"); err != nil {
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
|
||||
// Add image file
|
||||
part, _ := writer.CreateFormFile("image", "test.png")
|
||||
part.Write([]byte("fake image data"))
|
||||
part, err := writer.CreateFormFile("image", "test.png")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create form file: %v", err)
|
||||
}
|
||||
if _, err := part.Write([]byte("fake image data")); err != nil {
|
||||
t.Fatalf("Failed to write image data: %v", err)
|
||||
}
|
||||
|
||||
writer.Close()
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Failed to close writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/stationery", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
@@ -55,8 +74,12 @@ func TestUploadStationery_ValidationErrors(t *testing.T) {
|
||||
// Test missing required field
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
writer.WriteField("stationery_name", "") // Empty stationery name
|
||||
writer.Close()
|
||||
if err := writer.WriteField("stationery_name", ""); err != nil { // Empty stationery name
|
||||
t.Fatalf("Failed to write field: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("Failed to close writer: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/stationery", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
2
main.go
2
main.go
@@ -26,7 +26,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
defer services.CloseDB()
|
||||
// Note: CloseDB is called explicitly in graceful shutdown, not in defer
|
||||
log.Println("Database connected successfully")
|
||||
|
||||
// Initialize Firebase
|
||||
|
||||
@@ -43,23 +43,25 @@ func ErrorHandler(c *fiber.Ctx) error {
|
||||
// RecoverHandler recovers from panics and returns a proper error response
|
||||
func RecoverHandler() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Log panic with context
|
||||
log.Printf("Panic recovered: %v | Method: %s | Path: %s | IP: %s",
|
||||
r,
|
||||
c.Method(),
|
||||
c.Path(),
|
||||
c.IP(),
|
||||
)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Log panic with context
|
||||
log.Printf("Panic recovered: %v | Method: %s | Path: %s | IP: %s",
|
||||
r,
|
||||
c.Method(),
|
||||
c.Path(),
|
||||
c.IP(),
|
||||
)
|
||||
|
||||
// Return error response
|
||||
c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"error": "Internal Server Error",
|
||||
})
|
||||
}
|
||||
}()
|
||||
// Return error response
|
||||
if err := c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"error": "Internal Server Error",
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send error response: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user