Compare commits
47 Commits
main
...
3cfa447a5a
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cfa447a5a | |||
| c3587119b7 | |||
| 57db3e0a32 | |||
| 2759dd15c7 | |||
| 9055c8c39b | |||
| edd08b4e1c | |||
| e12103c190 | |||
| 91890659cf | |||
| 6e2e9e3645 | |||
| a9de12cee1 | |||
| 3029e93175 | |||
| c6a2d63a6d | |||
| 9e4731ce7a | |||
| 10450054f7 | |||
| 38d4b04f4a | |||
| c93220d8c6 | |||
| 0f01d025b8 | |||
| 4be08d5f36 | |||
| de53c13ea8 | |||
| fd7ade77b8 | |||
| 4ffaa6191d | |||
| ea7f2ec93d | |||
| 4ff58569d0 | |||
| 36823ac112 | |||
| 816e1b7bc8 | |||
| 7c9dfdcc2a | |||
| 0cc2824222 | |||
| 77b7416d1f | |||
| a4153b8554 | |||
| c609a4ca48 | |||
| b21c5fb093 | |||
| d918f3b1c3 | |||
| 7154faa7f4 | |||
| 107bae528a | |||
| 760d1cc8b0 | |||
| db1b277464 | |||
| 79c9313fab | |||
| d661098c5c | |||
| fec3b46e50 | |||
| 0c0aea1557 | |||
| 40898edc52 | |||
| 8900949a88 | |||
| 93a8d12d48 | |||
| 49f21c28ea | |||
| 08202a578d | |||
| 72b9d35299 | |||
| 424eeab7d9 |
@@ -27,6 +27,12 @@ on:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
# Only run PR CI if the commit doesn't already have passing branch CI
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
(github.event.action == 'opened' ||
|
||||
github.event.action == 'synchronize' ||
|
||||
github.event.action == 'reopened')
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'doc/**'
|
||||
@@ -51,35 +57,142 @@ env:
|
||||
CI_REGISTRY: "gitea.arcodange.lab"
|
||||
|
||||
jobs:
|
||||
build-cache:
|
||||
name: Build Docker Cache
|
||||
runs-on: ubuntu-latest-ca
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]') && github.actor != 'ci-bot'"
|
||||
outputs:
|
||||
deps_hash: ${{ steps.calculate_hash.outputs.deps_hash }}
|
||||
cache_hit: ${{ steps.check_cache.outputs.cache_hit }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Calculate dependency hash
|
||||
id: calculate_hash
|
||||
run: |
|
||||
# Calculate hash of go.mod + go.sum (inline, no script needed)
|
||||
DEPS_HASH=$(sha256sum go.mod go.sum | sha256sum | cut -d' ' -f1 | head -c 12)
|
||||
echo "Dependency hash: $DEPS_HASH"
|
||||
echo "deps_hash=$DEPS_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for existing cache
|
||||
id: check_cache
|
||||
run: |
|
||||
# Check if image exists in registry
|
||||
IMAGE_NAME="${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}-build-cache:${{ steps.calculate_hash.outputs.deps_hash }}"
|
||||
|
||||
# Try to pull the image to see if it exists
|
||||
if docker pull "$IMAGE_NAME" >/dev/null 2>&1; then
|
||||
echo "✅ Cache hit - using existing build cache"
|
||||
echo "cache_hit=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "⚠️ Cache miss - will build new cache image"
|
||||
echo "cache_hit=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
if: steps.check_cache.outputs.cache_hit == 'false'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.CI_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||
|
||||
|
||||
|
||||
- name: Build and push Docker cache image
|
||||
if: steps.check_cache.outputs.cache_hit == 'false'
|
||||
run: |
|
||||
IMAGE_NAME="${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}-build-cache:${{ steps.calculate_hash.outputs.deps_hash }}"
|
||||
echo "Building cache image: $IMAGE_NAME"
|
||||
|
||||
# Build the image using traditional docker build
|
||||
docker build \
|
||||
--file docker/Dockerfile.build \
|
||||
--tag "$IMAGE_NAME" \
|
||||
.
|
||||
|
||||
# Push the image
|
||||
docker push "$IMAGE_NAME"
|
||||
|
||||
echo "✅ Build cache image pushed successfully"
|
||||
|
||||
ci-pipeline:
|
||||
name: CI Pipeline
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-cache
|
||||
runs-on: ubuntu-latest-ca
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]') && github.actor != 'ci-bot'"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
go-version: '1.26.1'
|
||||
cache: true
|
||||
registry: ${{ env.CI_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: go mod tidy
|
||||
- name: Set up build environment
|
||||
run: |
|
||||
IMAGE_NAME="${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}-build-cache:${{ needs.build-cache.outputs.deps_hash }}"
|
||||
echo "Build cache image: $IMAGE_NAME"
|
||||
|
||||
# Try to use Docker cache if available
|
||||
if docker pull "$IMAGE_NAME" >/dev/null 2>&1; then
|
||||
echo "✅ Using Docker build cache"
|
||||
echo "CACHE_AVAILABLE=true" >> $GITHUB_ENV
|
||||
echo "CACHE_IMAGE=$IMAGE_NAME" >> $GITHUB_ENV
|
||||
else
|
||||
echo "⚠️ Building without cache (first run or new dependencies)"
|
||||
echo "CACHE_AVAILABLE=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# SINGLE swag installation - reused for all steps
|
||||
- name: Install swag (once)
|
||||
run: go install github.com/swaggo/swag/cmd/swag@latest
|
||||
- name: Generate Swagger Docs using Docker cache
|
||||
run: |
|
||||
if [ "${{ env.CACHE_AVAILABLE }}" = "true" ]; then
|
||||
echo "Running in Docker cache..."
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace ${{ env.CACHE_IMAGE }} sh -c "go generate ./pkg/server/...")
|
||||
else
|
||||
echo "Running natively..."
|
||||
cd pkg/server && go generate
|
||||
fi
|
||||
|
||||
- name: Generate Swagger Docs
|
||||
run: cd pkg/server && go generate
|
||||
- name: Build all packages using Docker cache
|
||||
run: |
|
||||
if [ "${{ env.CACHE_AVAILABLE }}" = "true" ]; then
|
||||
echo "Running in Docker cache..."
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace ${{ env.CACHE_IMAGE }} sh -c "go build ./..."
|
||||
else
|
||||
echo "Running natively..."
|
||||
go build ./...
|
||||
fi
|
||||
|
||||
- name: Build all packages
|
||||
run: go build ./...
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: go test ./... -cover -v
|
||||
- name: Run tests with coverage using Docker cache
|
||||
run: |
|
||||
if [ "${{ env.CACHE_AVAILABLE }}" = "true" ]; then
|
||||
echo "Running in Docker cache..."
|
||||
docker run --rm \
|
||||
-v "$(pwd):/workspace" \
|
||||
-w /workspace \
|
||||
${{ env.CACHE_IMAGE }} \
|
||||
sh -c "go test ./... -coverprofile=coverage.out -v && go tool cover -func=coverage.out > coverage.txt"
|
||||
else
|
||||
echo "Running natively..."
|
||||
go test ./... -coverprofile=coverage.out -v
|
||||
go tool cover -func=coverage.out > coverage.txt
|
||||
fi
|
||||
|
||||
# Extract coverage percentage
|
||||
COVERAGE=$(grep "total:" coverage.txt | grep -oP '\d+\.\d+' | head -1)
|
||||
echo "Coverage: ${COVERAGE}%"
|
||||
|
||||
# Update coverage badge using script
|
||||
export PACKAGES_TOKEN="${{ secrets.PACKAGES_TOKEN }}"
|
||||
export GITHUB_REF_NAME="${{ github.ref_name }}"
|
||||
./scripts/ci-update-coverage-badge.sh "$COVERAGE"
|
||||
|
||||
- name: Run go fmt
|
||||
run: go fmt ./...
|
||||
@@ -99,45 +212,7 @@ jobs:
|
||||
# path: pkg/server/docs/swagger.json
|
||||
# retention-days: 1
|
||||
|
||||
# Version management and Docker build (main branch only)
|
||||
- name: Version management and Docker build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
# Analyze last commit message
|
||||
LAST_COMMIT=$(git log -1 --pretty=%B | head -1)
|
||||
VERSION_BUMPED="false"
|
||||
|
||||
# Automatic version bump based on commit type
|
||||
if echo "$LAST_COMMIT" | grep -q "^✨ feat:"; then
|
||||
echo "🎯 Feature commit detected - bumping MINOR version"
|
||||
./scripts/version-bump.sh minor
|
||||
VERSION_BUMPED="true"
|
||||
elif echo "$LAST_COMMIT" | grep -q "^🐛 fix:"; then
|
||||
echo "🐛 Fix commit detected - bumping PATCH version"
|
||||
./scripts/version-bump.sh patch
|
||||
VERSION_BUMPED="true"
|
||||
elif echo "$LAST_COMMIT" | grep -q "BREAKING CHANGE"; then
|
||||
echo "💥 Breaking change detected - bumping MAJOR version"
|
||||
./scripts/version-bump.sh major
|
||||
VERSION_BUMPED="true"
|
||||
else
|
||||
echo "⏭️ No automatic version bump needed"
|
||||
fi
|
||||
|
||||
# Update swagger version regardless of bump
|
||||
source VERSION
|
||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH${PRERELEASE:+-$PRERELEASE}"
|
||||
sed -i "s|// @version [0-9.]*|// @version $NEW_VERSION|" cmd/server/main.go
|
||||
|
||||
# Commit version changes if bumped
|
||||
if [ "$VERSION_BUMPED" = "true" ]; then
|
||||
git config --global user.name "CI Bot"
|
||||
git config --global user.email "ci@arcodange.fr"
|
||||
git add VERSION cmd/server/main.go README.md
|
||||
git commit -m "chore: auto version bump [skip ci]" || echo "No changes to commit"
|
||||
git push
|
||||
fi
|
||||
|
||||
# Docker build and push (main branch only)
|
||||
- name: Login to Gitea Container Registry
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
@@ -146,19 +221,60 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
source VERSION
|
||||
IMAGE_VERSION="$MAJOR.$MINOR.$PATCH${PRERELEASE:+-$PRERELEASE}"
|
||||
|
||||
# Generate Dockerfile.prod with correct dependency hash
|
||||
DEPS_HASH="${{ needs.build-cache.outputs.deps_hash }}"
|
||||
echo "Using dependency hash: $DEPS_HASH"
|
||||
|
||||
# Create Dockerfile.prod with the correct cache image tag
|
||||
cat > docker/Dockerfile.prod << EOF
|
||||
# DanceLessonsCoach Production Docker Image
|
||||
# Generated by CI/CD pipeline with dependency hash: $DEPS_HASH
|
||||
|
||||
# Use the build cache image as base
|
||||
FROM gitea.arcodange.lab/arcodange/dance-lessons-coach-build-cache:$DEPS_HASH AS builder
|
||||
|
||||
# Final minimal image
|
||||
FROM alpine:3.18
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install minimal dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /workspace/dance-lessons-coach /app/dance-lessons-coach
|
||||
|
||||
# Copy configuration
|
||||
COPY config.yaml /app/config.yaml
|
||||
|
||||
# Set permissions
|
||||
RUN chmod +x /app/dance-lessons-coach
|
||||
|
||||
# Set timezone
|
||||
ENV TZ=UTC
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s \
|
||||
CMD wget -q --spider http://localhost:8080/api/health || exit 1
|
||||
|
||||
# Entry point
|
||||
ENTRYPOINT ["/app/dance-lessons-coach"]
|
||||
EOF
|
||||
|
||||
TAGS="$IMAGE_VERSION latest ${{ github.sha }}"
|
||||
echo "Building Docker image with tags: $TAGS"
|
||||
docker build -t dance-lessons-coach .
|
||||
|
||||
# Build the production image
|
||||
docker build -t dance-lessons-coach -f docker/Dockerfile.prod .
|
||||
|
||||
for TAG in $TAGS; do
|
||||
IMAGE_NAME="${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}:$TAG"
|
||||
|
||||
@@ -12,6 +12,9 @@ The Gitea-Client skill provides comprehensive API access to Gitea repositories,
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
# List available workflows
|
||||
gitea-client list-workflows <owner> <repo>
|
||||
|
||||
# List recent workflow jobs
|
||||
gitea-client list-jobs <owner> <repo> <workflow_id> [limit]
|
||||
|
||||
@@ -26,23 +29,68 @@ gitea-client list-workflow-jobs <owner> <repo> <workflow_run_id>
|
||||
|
||||
# Wait for job completion
|
||||
gitea-client wait-job <owner> <repo> <job_id> [timeout]
|
||||
|
||||
# Monitor workflow run until completion (with automatic updates)
|
||||
gitea-client monitor-workflow <owner> <repo> <workflow_run_id> [interval_seconds]
|
||||
|
||||
# Diagnose failed job with automatic error analysis
|
||||
gitea-client diagnose-job <owner> <repo> <job_id>
|
||||
|
||||
# Get summary of recent workflow runs
|
||||
gitea-client recent-workflows <owner> <repo> [limit] [status_filter]
|
||||
```
|
||||
|
||||
**Example Workflow:**
|
||||
```bash
|
||||
# 1. Find recent failed jobs
|
||||
gitea-client list-jobs arcodange dance-lessons-coach 5 10
|
||||
# 1. Get summary of recent workflows
|
||||
gitea-client recent-workflows arcodange dance-lessons-coach 5
|
||||
|
||||
# 2. Check status of specific job
|
||||
# 2. Monitor a specific workflow run until completion
|
||||
gitea-client monitor-workflow arcodange dance-lessons-coach 415 30
|
||||
|
||||
# 3. Diagnose a failed job automatically
|
||||
gitea-client diagnose-job arcodange dance-lessons-coach 759
|
||||
|
||||
# 4. List available workflows to get workflow IDs
|
||||
gitea-client list-workflows arcodange dance-lessons-coach
|
||||
|
||||
# 5. Check status of specific job
|
||||
gitea-client job-status arcodange dance-lessons-coach 706
|
||||
|
||||
# 3. Fetch logs for debugging
|
||||
# 6. Fetch logs for debugging
|
||||
gitea-client job-logs arcodange dance-lessons-coach 706 job_706_logs.txt
|
||||
|
||||
# 4. Analyze logs
|
||||
# 7. Analyze logs manually
|
||||
grep -i "error\|fail" job_706_logs.txt
|
||||
```
|
||||
|
||||
**Advanced Monitoring Example:**
|
||||
```bash
|
||||
# Monitor workflow and automatically diagnose if it fails
|
||||
WORKFLOW_ID=415
|
||||
TIMEOUT=300
|
||||
SECONDS_ELAPSED=0
|
||||
|
||||
while [ $SECONDS_ELAPSED -lt $TIMEOUT ]; do
|
||||
STATUS=$(gitea-client job-status arcodange dance-lessons-coach $WORKFLOW_ID | jq -r '.status')
|
||||
CONCLUSION=$(gitea-client job-status arcodange dance-lessons-coach $WORKFLOW_ID | jq -r '.conclusion')
|
||||
|
||||
echo "[$(date)] Status: $STATUS, Conclusion: ${CONCLUSION:-not completed}"
|
||||
|
||||
if [[ "$CONCLUSION" == "failure" ]]; then
|
||||
echo "Job failed! Running diagnosis..."
|
||||
gitea-client diagnose-job arcodange dance-lessons-coach $WORKFLOW_ID
|
||||
break
|
||||
elif [[ "$STATUS" != "in_progress" && "$STATUS" != "waiting" ]]; then
|
||||
echo "Job completed with status: $STATUS"
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
SECONDS_ELAPSED=$((SECONDS_ELAPSED + 30))
|
||||
done
|
||||
```
|
||||
|
||||
### 2. Pull Request Management
|
||||
|
||||
**Scenario:** Monitor and comment on PRs during CI/CD
|
||||
@@ -404,4 +452,79 @@ curl -s https://gitea.arcodange.lab/swagger.v1.json | \
|
||||
- **GitHub Actions**: https://docs.github.com/en/actions
|
||||
- **JQ Tutorial**: https://stedolan.github.io/jq/manual/
|
||||
|
||||
This reference guide provides comprehensive examples for using the gitea-client skill in real-world scenarios, covering job monitoring, PR management, issue tracking, and API discovery with practical, copy-paste-ready examples.
|
||||
This reference guide provides comprehensive examples for using the gitea-client skill in real-world scenarios, covering job monitoring, PR management, issue tracking, and API discovery with practical, copy-paste-ready examples.
|
||||
|
||||
## 🎯 Real-World Use Cases from DanceLessonsCoach
|
||||
|
||||
### CI/CD Pipeline Debugging
|
||||
|
||||
**Scenario**: TLS certificate verification failures were blocking all CI/CD progress.
|
||||
|
||||
**Solution**: Replaced Docker Buildx with traditional docker build + push.
|
||||
|
||||
```bash
|
||||
# Before (Failed)
|
||||
# ERROR: failed to build: failed to solve: failed to push
|
||||
# tls: failed to verify certificate: x509: certificate signed by unknown authority
|
||||
|
||||
# After (Working)
|
||||
gitea-client diagnose-job arcodange dance-lessons-coach 766
|
||||
# Result: Building cache image: gitea.arcodange.lab/... (no TLS errors)
|
||||
|
||||
# Monitor the fix
|
||||
gitea-client monitor-workflow arcodange dance-lessons-coach 418 30
|
||||
```
|
||||
|
||||
### Automated CI Monitoring
|
||||
|
||||
```bash
|
||||
# Monitor workflow and auto-diagnose failures
|
||||
WORKFLOW_ID=418
|
||||
TIMEOUT=300
|
||||
SECONDS_ELAPSED=0
|
||||
|
||||
while [ $SECONDS_ELAPSED -lt $TIMEOUT ]; do
|
||||
STATUS=$(gitea-client job-status arcodange dance-lessons-coach $WORKFLOW_ID | jq -r '.status')
|
||||
CONCLUSION=$(gitea-client job-status arcodange dance-lessons-coach $WORKFLOW_ID | jq -r '.conclusion')
|
||||
|
||||
echo "[$(date)] Status: $STATUS, Conclusion: ${CONCLUSION:-not completed}"
|
||||
|
||||
if [[ "$CONCLUSION" == "failure" ]]; then
|
||||
echo "❌ Workflow failed! Running diagnosis..."
|
||||
gitea-client diagnose-job arcodange dance-lessons-coach $WORKFLOW_ID
|
||||
break
|
||||
elif [[ "$STATUS" != "in_progress" && "$STATUS" != "waiting" ]]; then
|
||||
echo "✅ Workflow completed: $STATUS"
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
SECONDS_ELAPSED=$((SECONDS_ELAPSED + 30))
|
||||
done
|
||||
```
|
||||
|
||||
### PR Management Automation
|
||||
|
||||
```bash
|
||||
# Automated PR triage based on CI results
|
||||
OPEN_PRS=$(gitea-client list-prs arcodange dance-lessons-coach | jq -r '.[] | select(.state == "open") | .number')
|
||||
|
||||
for pr in $OPEN_PRS; do
|
||||
PR_DETAILS=$(gitea-client pr-status arcodange dance-lessons-coach $pr)
|
||||
BRANCH=$(echo "$PR_DETAILS" | jq -r '.head.ref')
|
||||
|
||||
# Find related workflows
|
||||
WORKFLOWS=$(gitea-client recent-workflows arcodange dance-lessons-coach 5 | grep "$BRANCH" || echo "")
|
||||
|
||||
if [ -n "$WORKFLOWS" ]; then
|
||||
LATEST_WORKFLOW=$(echo "$WORKFLOWS" | head -1 | cut -d':' -f1)
|
||||
CONCLUSION=$(gitea-client job-status arcodange dance-lessons-coach $LATEST_WORKFLOW | jq -r '.conclusion')
|
||||
|
||||
if [ "$CONCLUSION" = "failure" ]; then
|
||||
gitea-client comment-pr arcodange dance-lessons-coach $pr "⚠️ CI Failed - Check workflow $LATEST_WORKFLOW"
|
||||
elif [ "$CONCLUSION" = "success" ]; then
|
||||
gitea-client comment-pr arcodange dance-lessons-coach $pr "✅ CI Passed - Ready for review!"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
```
|
||||
@@ -40,6 +40,18 @@ Create a token in Gitea:
|
||||
|
||||
## Commands
|
||||
|
||||
### List Workflows
|
||||
|
||||
```bash
|
||||
skill gitea-client list-workflows <owner> <repo>
|
||||
```
|
||||
|
||||
List available workflows for a repository.
|
||||
|
||||
**Arguments:**
|
||||
- `owner`: Repository owner
|
||||
- `repo`: Repository name
|
||||
|
||||
### List Jobs
|
||||
|
||||
```bash
|
||||
@@ -151,6 +163,80 @@ gitea-client list-workflow-jobs arcodange dance-lessons-coach 351 | jq '.jobs[]
|
||||
gitea-client list-workflow-jobs arcodange dance-lessons-coach 350
|
||||
```
|
||||
|
||||
### Monitor Workflow Run
|
||||
|
||||
```bash
|
||||
skill gitea-client monitor-workflow <owner> <repo> <workflow_run_id> [interval_seconds]
|
||||
```
|
||||
|
||||
Monitor a workflow run until completion with automatic updates.
|
||||
|
||||
**Arguments:**
|
||||
- `owner`: Repository owner
|
||||
- `repo`: Repository name
|
||||
- `workflow_run_id`: Workflow run ID
|
||||
- `interval_seconds`: Update interval in seconds (default: 30)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Monitor workflow run 415 with 30-second updates
|
||||
gitea-client monitor-workflow arcodange dance-lessons-coach 415 30
|
||||
|
||||
# Monitor with faster updates (10 seconds)
|
||||
gitea-client monitor-workflow arcodange dance-lessons-coach 415 10
|
||||
```
|
||||
|
||||
### Diagnose Failed Job
|
||||
|
||||
```bash
|
||||
skill gitea-client diagnose-job <owner> <repo> <job_id>
|
||||
```
|
||||
|
||||
Diagnose a failed job with automatic error analysis.
|
||||
|
||||
**Arguments:**
|
||||
- `owner`: Repository owner
|
||||
- `repo`: Repository name
|
||||
- `job_id`: Job ID
|
||||
|
||||
**Features:**
|
||||
- Shows job details (status, conclusion, timestamps)
|
||||
- Displays last 50 lines of logs
|
||||
- Automatically extracts and highlights error messages
|
||||
- Shows workflow run context
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Diagnose failed job 759
|
||||
gitea-client diagnose-job arcodange dance-lessons-coach 759
|
||||
```
|
||||
|
||||
### Get Recent Workflows Summary
|
||||
|
||||
```bash
|
||||
skill gitea-client recent-workflows <owner> <repo> [limit] [status_filter]
|
||||
```
|
||||
|
||||
Get a summary of recent workflow runs.
|
||||
|
||||
**Arguments:**
|
||||
- `owner`: Repository owner
|
||||
- `repo`: Repository name
|
||||
- `limit`: Maximum number of workflows to show (default: 10)
|
||||
- `status_filter`: Filter by status (optional: completed, in_progress, queued, waiting)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
# Show last 5 workflow runs
|
||||
gitea-client recent-workflows arcodange dance-lessons-coach 5
|
||||
|
||||
# Show only completed workflows
|
||||
gitea-client recent-workflows arcodange dance-lessons-coach 10 completed
|
||||
|
||||
# Show in-progress workflows
|
||||
gitea-client recent-workflows arcodange dance-lessons-coach 5 in_progress
|
||||
```
|
||||
|
||||
### Wait for Job Completion
|
||||
|
||||
```bash
|
||||
@@ -414,6 +500,70 @@ The skill handles common API errors:
|
||||
4. **Logging**: Redirect output to files for debugging
|
||||
5. **Timeouts**: Use reasonable timeouts for wait operations
|
||||
|
||||
## Enhanced Workflow Monitoring with New Commands
|
||||
|
||||
### Complete CI Debugging Workflow with New Commands
|
||||
|
||||
```bash
|
||||
# 1. Get summary of recent workflows to identify issues
|
||||
gitea-client recent-workflows arcodange dance-lessons-coach 10
|
||||
|
||||
# 2. Monitor a specific workflow run until completion
|
||||
gitea-client monitor-workflow arcodange dance-lessons-coach 415 30
|
||||
|
||||
# 3. If workflow fails, automatically diagnose all failed jobs
|
||||
WORKFLOW_ID=415
|
||||
WORKFLOW_STATUS=$(gitea-client job-status arcodange dance-lessons-coach $WORKFLOW_ID | jq -r '.status')
|
||||
WORKFLOW_CONCLUSION=$(gitea-client job-status arcodange dance-lessons-coach $WORKFLOW_ID | jq -r '.conclusion')
|
||||
|
||||
if [ "$WORKFLOW_CONCLUSION" = "failure" ]; then
|
||||
echo "Workflow failed! Diagnosing all jobs..."
|
||||
|
||||
# Get all jobs in the workflow
|
||||
JOBS=$(gitea-client list-workflow-jobs arcodange dance-lessons-coach $WORKFLOW_ID | jq -r '.jobs[] | select(.conclusion == "failure") | .id')
|
||||
|
||||
# Diagnose each failed job
|
||||
for job_id in $JOBS; do
|
||||
echo "Diagnosing job $job_id:"
|
||||
gitea-client diagnose-job arcodange dance-lessons-coach $job_id
|
||||
echo "========================================"
|
||||
done
|
||||
fi
|
||||
|
||||
# 4. Advanced monitoring with automatic diagnosis
|
||||
WORKFLOW_ID=415
|
||||
TIMEOUT=300
|
||||
SECONDS_ELAPSED=0
|
||||
|
||||
while [ $SECONDS_ELAPSED -lt $TIMEOUT ]; do
|
||||
STATUS=$(gitea-client job-status arcodange dance-lessons-coach $WORKFLOW_ID | jq -r '.status')
|
||||
CONCLUSION=$(gitea-client job-status arcodange dance-lessons-coach $WORKFLOW_ID | jq -r '.conclusion')
|
||||
|
||||
echo "[$(date)] Status: $STATUS, Conclusion: ${CONCLUSION:-not completed}"
|
||||
|
||||
if [[ "$CONCLUSION" == "failure" ]]; then
|
||||
echo "Workflow failed! Running automatic diagnosis..."
|
||||
gitea-client diagnose-job arcodange dance-lessons-coach $WORKFLOW_ID
|
||||
|
||||
# Find PR and comment
|
||||
PR_NUMBER=$(gitea-client list-prs arcodange dance-lessons-coach | \
|
||||
jq -r '.[] | select(.head.ref == "feature/user-authentication-bdd") | .number')
|
||||
|
||||
if [ -n "$PR_NUMBER" ]; then
|
||||
gitea-client comment-pr arcodange dance-lessons-coach $PR_NUMBER \
|
||||
"⚠️ CI Workflow $WORKFLOW_ID failed. See diagnosis above for details."
|
||||
fi
|
||||
break
|
||||
elif [[ "$STATUS" != "in_progress" && "$STATUS" != "waiting" ]]; then
|
||||
echo "Workflow completed with status: $STATUS"
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
SECONDS_ELAPSED=$((SECONDS_ELAPSED + 30))
|
||||
done
|
||||
```
|
||||
|
||||
## Real-World Use Case: PR Commenting Workflow
|
||||
|
||||
The Gitea client skill excels at automated PR commenting during CI/CD workflows.
|
||||
|
||||
@@ -52,6 +52,20 @@ api_request() {
|
||||
fi
|
||||
}
|
||||
|
||||
# List workflows
|
||||
cmd_list_workflows() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
|
||||
if [[ -z "$owner" || -z "$repo" ]]; then
|
||||
echo "Usage: $0 list-workflows <owner> <repo>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local endpoint="/repos/${owner}/${repo}/actions/workflows"
|
||||
api_request "GET" "$endpoint"
|
||||
}
|
||||
|
||||
# List jobs
|
||||
cmd_list_jobs() {
|
||||
local owner="$1"
|
||||
@@ -226,12 +240,16 @@ main() {
|
||||
shift || true
|
||||
|
||||
case "$command" in
|
||||
list-workflows) cmd_list_workflows "$@" ;;
|
||||
list-jobs) cmd_list_jobs "$@" ;;
|
||||
job-status) cmd_job_status "$@" ;;
|
||||
job-logs) cmd_job_logs "$@" ;;
|
||||
action-logs) cmd_action_logs "$@" ;;
|
||||
list-workflow-jobs) cmd_list_workflow_jobs "$@" ;;
|
||||
wait-job) cmd_wait_job "$@" ;;
|
||||
monitor-workflow) cmd_monitor_workflow "$@" ;;
|
||||
diagnose-job) cmd_diagnose_job "$@" ;;
|
||||
recent-workflows) cmd_recent_workflows "$@" ;;
|
||||
comment-pr) cmd_comment_pr "$@" ;;
|
||||
pr-status) cmd_pr_status "$@" ;;
|
||||
list-issues) cmd_list_issues "$@" ;;
|
||||
@@ -245,12 +263,16 @@ main() {
|
||||
echo "Usage: $0 <command> [args...]" >&2
|
||||
echo "" >&2
|
||||
echo "Commands:" >&2
|
||||
echo " list-workflows <owner> <repo>" >&2
|
||||
echo " list-jobs <owner> <repo> <workflow_id> [limit]" >&2
|
||||
echo " job-status <owner> <repo> <job_id>" >&2
|
||||
echo " job-logs <owner> <repo> <job_id> [output_file]" >&2
|
||||
echo " action-logs <owner> <repo> <action_job_id> [output_file]" >&2
|
||||
echo " list-workflow-jobs <owner> <repo> <workflow_run_id>" >&2
|
||||
echo " wait-job <owner> <repo> <job_id> [timeout]" >&2
|
||||
echo " monitor-workflow <owner> <repo> <workflow_run_id> [interval_seconds]" >&2
|
||||
echo " diagnose-job <owner> <repo> <job_id>" >&2
|
||||
echo " recent-workflows <owner> <repo> [limit] [status_filter]" >&2
|
||||
echo " comment-pr <owner> <repo> <pr_number> <comment>" >&2
|
||||
echo " pr-status <owner> <repo> <pr_number>" >&2
|
||||
echo " list-issues <owner> <repo> [state]" >&2
|
||||
@@ -389,4 +411,109 @@ cmd_get_wiki() {
|
||||
api_request "GET" "$endpoint"
|
||||
}
|
||||
|
||||
# Monitor workflow run until completion
|
||||
cmd_monitor_workflow() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local workflow_run_id="$3"
|
||||
local interval="${4:-30}"
|
||||
|
||||
if [[ -z "$owner" || -z "$repo" || -z "$workflow_run_id" ]]; then
|
||||
echo "Usage: $0 monitor-workflow <owner> <repo> <workflow_run_id> [interval_seconds]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Monitoring workflow run $workflow_run_id (interval: ${interval}s)..."
|
||||
echo "Press Ctrl+C to stop monitoring"
|
||||
|
||||
while true; do
|
||||
local endpoint="/repos/${owner}/${repo}/actions/runs/${workflow_run_id}"
|
||||
local status=$(api_request "GET" "$endpoint" | jq -r '.status')
|
||||
local conclusion=$(api_request "GET" "$endpoint" | jq -r '.conclusion')
|
||||
local updated_at=$(api_request "GET" "$endpoint" | jq -r '.updated_at')
|
||||
|
||||
echo "[$(date +'%Y-%m-%d %H:%M:%S')] Status: $status, Conclusion: ${conclusion:-not completed}, Updated: $updated_at"
|
||||
|
||||
# List jobs in this workflow
|
||||
local jobs_endpoint="/repos/${owner}/${repo}/actions/runs/${workflow_run_id}/jobs"
|
||||
local jobs=$(api_request "GET" "$jobs_endpoint")
|
||||
echo "Jobs:"
|
||||
echo "$jobs" | jq -r '.jobs[] | " \(.id): \(.name) - \(.status) \(if .conclusion then "(\(.conclusion))" else "" end)"'
|
||||
|
||||
# Check if workflow is completed
|
||||
if [[ "$status" != "queued" && "$status" != "in_progress" && "$status" != "waiting" ]]; then
|
||||
echo "Workflow run $workflow_run_id has completed with status: $status and conclusion: ${conclusion:-none}"
|
||||
break
|
||||
fi
|
||||
|
||||
sleep "$interval"
|
||||
done
|
||||
}
|
||||
|
||||
# Diagnose failed job
|
||||
cmd_diagnose_job() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local job_id="$3"
|
||||
|
||||
if [[ -z "$owner" || -z "$repo" || -z "$job_id" ]]; then
|
||||
echo "Usage: $0 diagnose-job <owner> <repo> <job_id>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Diagnosing job $job_id..."
|
||||
|
||||
# Get job details
|
||||
local job_endpoint="/repos/${owner}/${repo}/actions/jobs/${job_id}"
|
||||
local job_details=$(api_request "GET" "$job_endpoint")
|
||||
|
||||
echo "Job Details:"
|
||||
echo "$job_details" | jq '. | {id, name, status, conclusion, started_at, completed_at, runner_name}'
|
||||
|
||||
# Get job logs
|
||||
local logs_endpoint="/repos/${owner}/${repo}/actions/jobs/${job_id}/logs"
|
||||
echo -e "\nLast 50 lines of logs:"
|
||||
api_request "GET" "$logs_endpoint" | tail -50
|
||||
|
||||
# Look for errors
|
||||
echo -e "\nError analysis:"
|
||||
api_request "GET" "$logs_endpoint" | grep -i "error\|fail\|panic\|exception" | tail -10
|
||||
|
||||
# Get workflow run details
|
||||
local run_id=$(echo "$job_details" | jq -r '.run_id')
|
||||
local run_endpoint="/repos/${owner}/${repo}/actions/runs/${run_id}"
|
||||
local run_details=$(api_request "GET" "$run_endpoint")
|
||||
|
||||
echo -e "\nWorkflow Run Details:"
|
||||
echo "$run_details" | jq '. | {id, display_title, status, conclusion, head_branch, head_sha}'
|
||||
}
|
||||
|
||||
# Get recent workflow runs summary
|
||||
cmd_recent_workflows() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local limit="${3:-10}"
|
||||
local status_filter="${4:-}"
|
||||
|
||||
if [[ -z "$owner" || -z "$repo" ]]; then
|
||||
echo "Usage: $0 recent-workflows <owner> <repo> [limit] [status_filter]" >&2
|
||||
echo "Status filter options: all, completed, in_progress, queued, waiting" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local endpoint="/repos/${owner}/${repo}/actions/runs?limit=${limit}"
|
||||
if [[ -n "$status_filter" ]]; then
|
||||
endpoint="$endpoint&status=$status_filter"
|
||||
fi
|
||||
|
||||
local workflows=$(api_request "GET" "$endpoint")
|
||||
|
||||
echo "Recent Workflow Runs (showing $limit most recent):"
|
||||
echo "$workflows" | jq -r '.workflow_runs[] | "\(.id): \(.display_title) - \(.status) \(if .conclusion then "(\(.conclusion))" else "" end) - \(.updated_at)"'
|
||||
|
||||
# Show summary statistics
|
||||
echo -e "\nSummary:"
|
||||
echo "$workflows" | jq -r '.workflow_runs | group_by(.conclusion) | .[] | " \(.[0].conclusion // "in_progress"): \(length)"'
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
[](https://goreportcard.com/report/github.com/arcodange/DanceLessonsCoach)
|
||||
[](https://gitea.arcodange.fr/arcodange/DanceLessonsCoach/releases)
|
||||
[](LICENSE)
|
||||
[](https://gitea.arcodange.lab/arcodange/dance-lessons-coach)
|
||||
|
||||
A Go project demonstrating idiomatic package structure, CLI implementation, and JSON API with Chi router.
|
||||
=======
|
||||
|
||||
@@ -120,6 +120,7 @@ The user management system follows the established DanceLessonsCoach patterns:
|
||||
- 30-minute expiration for access tokens
|
||||
- Secure random signing key
|
||||
- HTTPS-only cookies
|
||||
- **Secret Rotation:** Multiple valid secrets with retention policy (see Issue #8)
|
||||
3. **Admin Access:**
|
||||
- Master password from environment variable
|
||||
- Non-persisted admin user
|
||||
@@ -464,6 +465,7 @@ The implementation maintains full backward compatibility:
|
||||
3. **User Activity Logging:** For audit trails
|
||||
4. **Password Strength Meter:** For better user experience
|
||||
5. **Account Recovery:** Email/phone-based recovery options
|
||||
6. **JWT Secret Rotation:** Implement secret persistence and rotation mechanism (Issue #8)
|
||||
|
||||
## References
|
||||
|
||||
|
||||
494
adr/0020-docker-build-strategy.md
Normal file
494
adr/0020-docker-build-strategy.md
Normal file
@@ -0,0 +1,494 @@
|
||||
# ADR 0020: Docker Build Strategy - Traditional vs Buildx
|
||||
|
||||
## Status
|
||||
**Accepted** ✅
|
||||
|
||||
## Context
|
||||
|
||||
The DanceLessonsCoach CI/CD pipeline initially used Docker Buildx (`docker buildx build --push`) for building and pushing Docker cache images. However, this approach encountered several issues:
|
||||
|
||||
### Issues with Buildx Approach
|
||||
|
||||
1. **TLS Certificate Problems**: Buildx had difficulty with self-signed certificates, requiring complex workaround steps
|
||||
2. **Performance Concerns**: Buildx setup and execution was significantly slower than expected
|
||||
3. **Complexity**: Buildx introduced additional complexity without providing immediate benefits
|
||||
4. **Reliability Issues**: Buildx builds were less reliable in the GitHub Actions environment
|
||||
|
||||
### Working Solution Analysis
|
||||
|
||||
The working webapp CI/CD pipeline uses traditional `docker build` + `docker push` approach:
|
||||
|
||||
```yaml
|
||||
# Working approach from webapp
|
||||
- name: Build and push image to Gitea Container Registry
|
||||
run: |-
|
||||
docker build -t app .
|
||||
docker tag app gitea.arcodange.lab/${{ github.repository }}:$TAG
|
||||
docker push gitea.arcodange.lab/${{ github.repository }}:$TAG
|
||||
```
|
||||
|
||||
This approach is simpler, more reliable, and works consistently with self-signed certificates.
|
||||
|
||||
## Decision
|
||||
|
||||
**Replace Docker Buildx with traditional docker build + push** for the CI/CD pipeline and implement a two-stage Docker build strategy.
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 1. Build Cache Strategy
|
||||
|
||||
```yaml
|
||||
# Build cache using traditional docker build
|
||||
- name: Build and push Docker cache image
|
||||
if: steps.check_cache.outputs.cache_hit == 'false'
|
||||
run: |
|
||||
IMAGE_NAME="${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}-build-cache:${{ steps.calculate_hash.outputs.deps_hash }}"
|
||||
echo "Building cache image: $IMAGE_NAME"
|
||||
|
||||
# Build the image using traditional docker build
|
||||
docker build \
|
||||
--file Dockerfile.build \
|
||||
--tag "$IMAGE_NAME" \
|
||||
.
|
||||
|
||||
# Push the image
|
||||
docker push "$IMAGE_NAME"
|
||||
|
||||
echo "✅ Build cache image pushed successfully"
|
||||
```
|
||||
|
||||
#### 2. Production Build Strategy
|
||||
|
||||
```yaml
|
||||
# Production build using Dockerfile.prod
|
||||
- name: Build and push Docker image
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
source VERSION
|
||||
IMAGE_VERSION="$MAJOR.$MINOR.$PATCH${PRERELEASE:+-$PRERELEASE}"
|
||||
|
||||
TAGS="$IMAGE_VERSION latest ${{ github.sha }}"
|
||||
echo "Building Docker image with tags: $TAGS"
|
||||
|
||||
# Use the production Dockerfile that leverages the build cache
|
||||
docker build -t dance-lessons-coach -f Dockerfile.prod .
|
||||
|
||||
for TAG in $TAGS; do
|
||||
IMAGE_NAME="${{ env.CI_REGISTRY }}/${{ env.GITEA_ORG }}/${{ env.GITEA_REPO }}:$TAG"
|
||||
echo "Tagging and pushing: $IMAGE_NAME"
|
||||
docker tag dance-lessons-coach "$IMAGE_NAME"
|
||||
docker push "$IMAGE_NAME"
|
||||
done
|
||||
```
|
||||
|
||||
#### 3. Dockerfile Structure
|
||||
|
||||
**Dockerfile.build** - Build environment with all dependencies:
|
||||
```dockerfile
|
||||
FROM golang:1.26.1-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git bash curl make gcc musl-dev bc grep sed jq ca-certificates
|
||||
|
||||
# Install Go tools
|
||||
RUN go install github.com/swaggo/swag/cmd/swag@latest
|
||||
|
||||
# Copy and verify dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download && go mod verify
|
||||
|
||||
WORKDIR /workspace
|
||||
```
|
||||
|
||||
**Dockerfile.prod** - Minimal production image:
|
||||
```dockerfile
|
||||
# Use the build cache image as base
|
||||
FROM gitea.arcodange.lab/arcodange/dance-lessons-coach-build-cache:latest AS builder
|
||||
|
||||
# Final minimal image
|
||||
FROM alpine:3.18
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install minimal dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /workspace/dance-lessons-coach /app/dance-lessons-coach
|
||||
|
||||
# Copy configuration
|
||||
COPY config.yaml /app/config.yaml
|
||||
|
||||
# Set permissions and entrypoint
|
||||
RUN chmod +x /app/dance-lessons-coach
|
||||
ENV TZ=UTC
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/dance-lessons-coach"]
|
||||
```
|
||||
|
||||
**docker/Dockerfile** - Development Dockerfile (kept for local development):
|
||||
```dockerfile
|
||||
# Multi-stage build for development
|
||||
FROM golang:1.26.1-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . ./
|
||||
RUN go build -o /dance-lessons-coach ./cmd/server
|
||||
|
||||
FROM alpine:3.18
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
COPY --from=builder /dance-lessons-coach /app/dance-lessons-coach
|
||||
COPY config.yaml /app/config.yaml
|
||||
RUN chmod +x /app/dance-lessons-coach
|
||||
ENV TZ=UTC
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/dance-lessons-coach"]
|
||||
```
|
||||
|
||||
### File Organization
|
||||
|
||||
All Dockerfiles are now organized in the `docker/` directory:
|
||||
- `docker/Dockerfile` - Development Dockerfile
|
||||
- `docker/Dockerfile.build` - Build cache Dockerfile
|
||||
- `docker/Dockerfile.prod` - Production Dockerfile (development only, uses latest)
|
||||
- `docker/Dockerfile.prod.template` - Template for reference
|
||||
|
||||
This organization keeps the root directory clean and makes it clear which files are for development vs production.
|
||||
|
||||
## Benefits
|
||||
|
||||
### CI/CD Pipeline Benefits
|
||||
|
||||
1. **Simplicity**: Traditional approach is easier to understand and debug
|
||||
2. **Reliability**: Consistent behavior across different environments
|
||||
3. **Certificate Handling**: Works seamlessly with self-signed certificates
|
||||
4. **Performance**: Faster execution without Buildx overhead
|
||||
5. **Compatibility**: Better compatibility with GitHub Actions environment
|
||||
|
||||
### Two-Stage Build Benefits
|
||||
|
||||
1. **Separation of Concerns**: Clear separation between build environment and production runtime
|
||||
2. **Optimized Production Image**: Minimal Alpine-based image with only necessary dependencies
|
||||
3. **Reusable Build Cache**: Build environment can be reused across multiple CI runs
|
||||
4. **Faster CI Execution**: Pre-built build cache reduces CI execution time
|
||||
5. **Consistent Builds**: All builds use the same build environment
|
||||
|
||||
### Development vs Production Clarity
|
||||
|
||||
1. **Development Dockerfile**: Full build environment for local development
|
||||
2. **Production Dockerfile**: Minimal runtime environment for deployment
|
||||
3. **Build Cache Dockerfile**: Optimized build environment for CI/CD
|
||||
4. **Clear Documentation**: Each Dockerfile has a specific purpose
|
||||
|
||||
## Trade-offs
|
||||
|
||||
### What We Lose
|
||||
|
||||
1. **Multi-platform builds**: Cannot build for multiple architectures simultaneously
|
||||
2. **BuildKit caching**: Less sophisticated caching mechanism
|
||||
3. **Advanced features**: No secret mounting, SSH agents, etc.
|
||||
4. **Parallel processing**: Slower builds without Buildx optimizations
|
||||
|
||||
### What We Gain
|
||||
|
||||
1. **Stability**: More reliable CI/CD pipeline
|
||||
2. **Simplicity**: Easier to maintain and troubleshoot
|
||||
3. **Consistency**: Matches proven patterns from working projects
|
||||
4. **Faster feedback**: Quicker build times in practice
|
||||
5. **Clear Separation**: Better distinction between development and production builds
|
||||
6. **Optimized Production**: Smaller, more secure production images
|
||||
|
||||
## Rationale
|
||||
|
||||
1. **Current Needs**: We don't need multi-platform builds or advanced BuildKit features
|
||||
2. **Simple Dockerfile**: Our `Dockerfile.build` doesn't require Buildx-specific features
|
||||
3. **Proven Pattern**: Traditional approach works reliably in production (webapp project)
|
||||
4. **CI Stability**: Reliability is more important than advanced features for CI/CD
|
||||
5. **Build Strategy**: Two-stage build provides better separation of concerns
|
||||
6. **Maintenance**: Simpler approach is easier to maintain and debug
|
||||
|
||||
## Critical Bug Fix: Dependency Hash Usage
|
||||
|
||||
### Issue Identified
|
||||
|
||||
The initial implementation had a critical bug where `Dockerfile.prod` used `latest` tag instead of the specific dependency hash:
|
||||
|
||||
```dockerfile
|
||||
# ❌ WRONG - this would never work
|
||||
FROM gitea.arcodange.lab/arcodange/dance-lessons-coach-build-cache:latest AS builder
|
||||
```
|
||||
|
||||
This approach would never work because:
|
||||
1. The build cache images are tagged with specific dependency hashes
|
||||
2. No image is ever tagged as `latest`
|
||||
3. The CI/CD workflow would fail to find the cache image
|
||||
|
||||
### Solution Implemented
|
||||
|
||||
1. **Dynamic Dockerfile Generation**: The CI/CD workflow now generates `Dockerfile.prod` dynamically with the correct dependency hash
|
||||
2. **Dependency Hash Calculation**: Added `scripts/calculate-deps-hash.sh` for consistent hash calculation
|
||||
3. **Template Approach**: Created `Dockerfile.prod.template` for reference
|
||||
|
||||
### CI/CD Workflow Fix
|
||||
|
||||
```yaml
|
||||
# ✅ CORRECT - generate Dockerfile.prod with proper hash
|
||||
- name: Build and push Docker image
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
# Generate Dockerfile.prod with correct dependency hash
|
||||
DEPS_HASH="${{ needs.build-cache.outputs.deps_hash }}"
|
||||
|
||||
# Create Dockerfile.prod with the correct cache image tag
|
||||
cat > Dockerfile.prod << EOF
|
||||
FROM gitea.arcodange.lab/arcodange/dance-lessons-coach-build-cache:$DEPS_HASH AS builder
|
||||
# ... rest of Dockerfile
|
||||
EOF
|
||||
|
||||
# Build using the generated Dockerfile
|
||||
docker build -t dance-lessons-coach -f Dockerfile.prod .
|
||||
```
|
||||
|
||||
## CI/CD Pipeline Optimization
|
||||
|
||||
### Changes Made
|
||||
|
||||
1. **Removed Buildx Setup**: Eliminated `docker/setup-buildx-action@v3` from CI/CD workflow
|
||||
2. **Removed Go Build Steps**: Removed `actions/setup-go@v4`, `go mod tidy`, and individual Go tool installations
|
||||
3. **Added Docker Cache Usage**: All build steps now use the pre-built Docker cache image
|
||||
4. **Updated Production Build**: Production Docker build now generates `Dockerfile.prod` dynamically with correct dependency hash
|
||||
|
||||
### CI/CD Workflow Structure
|
||||
|
||||
```yaml
|
||||
# CI Pipeline Job Structure
|
||||
jobs:
|
||||
build-cache:
|
||||
# Builds Docker cache image if needed
|
||||
# Note: No certificate configuration needed with traditional docker
|
||||
|
||||
ci-pipeline:
|
||||
needs: build-cache
|
||||
steps:
|
||||
- name: Set up build environment
|
||||
# Sets CACHE_IMAGE variable with proper tag
|
||||
# No Buildx setup, no Go installation, no certificate configuration
|
||||
|
||||
- name: Generate Swagger Docs using Docker cache
|
||||
# Uses: docker run ${{ env.CACHE_IMAGE }} sh -c "cd pkg/server && go generate"
|
||||
|
||||
- name: Build all packages using Docker cache
|
||||
# Uses: docker run ${{ env.CACHE_IMAGE }} sh -c "go build ./..."
|
||||
|
||||
- name: Run tests with coverage using Docker cache
|
||||
# Uses: docker run ${{ env.CACHE_IMAGE }} sh -c "go test ./..."
|
||||
|
||||
- name: Build and push Docker image
|
||||
# Uses: docker build -t dance-lessons-coach -f Dockerfile.prod .
|
||||
# No Buildx, no certificate issues
|
||||
```
|
||||
|
||||
### Key Improvements
|
||||
|
||||
1. **Faster Execution**: No need to set up Go environment for each job
|
||||
2. **Consistent Environment**: All builds use the same Docker cache image
|
||||
3. **Reduced Complexity**: Simpler workflow with fewer steps
|
||||
4. **Better Error Handling**: Docker cache handles dependency management
|
||||
5. **No Certificate Configuration**: Traditional docker works seamlessly with self-signed certificates
|
||||
6. **Improved Reliability**: Elimination of Buildx-related failures
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### When to Reconsider Buildx
|
||||
|
||||
1. **Multi-platform needs**: If we need ARM/AMD64 builds simultaneously
|
||||
2. **Complex builds**: If Dockerfile requires BuildKit-specific features
|
||||
3. **Performance optimization**: If build times become unacceptable
|
||||
4. **Certificate issues resolved**: If Docker Buildx improves self-signed certificate handling
|
||||
|
||||
### Migration Path
|
||||
|
||||
If we need to reintroduce Buildx in the future:
|
||||
|
||||
1. **Fix certificate issues properly** at the Docker daemon level
|
||||
2. **Test thoroughly** in staging environment
|
||||
3. **Monitor performance** impact
|
||||
4. **Document benefits** clearly for the specific use case
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Option 1: Keep Buildx with Certificate Workaround
|
||||
- ❌ Complex setup with questionable reliability
|
||||
- ❌ Slow performance in GitHub Actions
|
||||
- ❌ Ongoing maintenance burden
|
||||
|
||||
### Option 2: Use Insecure Registry Flag
|
||||
```yaml
|
||||
docker buildx build --allow security.insecure --push .
|
||||
```
|
||||
- ❌ Security concerns
|
||||
- ❌ Not recommended for production
|
||||
- ❌ Temporary workaround, not solution
|
||||
|
||||
### Option 3: Traditional Docker Build + Push ✅ **CHOSEN**
|
||||
- ✅ Simple and reliable
|
||||
- ✅ Proven in production
|
||||
- ✅ Better performance in practice
|
||||
- ✅ Easy to maintain
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
**Chosen Option**: Traditional docker build + push (Option 3)
|
||||
|
||||
This decision prioritizes CI/CD reliability and simplicity over advanced features we don't currently need. The traditional approach has been proven to work consistently in our environment and matches the successful pattern from the webapp project.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### CI/CD Pipeline Metrics
|
||||
|
||||
1. **CI/CD reliability**: No TLS certificate failures
|
||||
2. **Build consistency**: Predictable build times
|
||||
3. **Maintenance**: Reduced complexity and debugging time
|
||||
4. **Compatibility**: Works across all target environments
|
||||
|
||||
### Build Strategy Metrics
|
||||
|
||||
1. **Cache hit rate**: Percentage of CI runs using existing cache
|
||||
2. **Build time reduction**: Comparison of build times with vs without cache
|
||||
3. **Image size**: Production image size vs development image size
|
||||
4. **CI execution time**: Total CI pipeline duration
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
1. **Build reproducibility**: Consistent builds across different environments
|
||||
2. **Error rate**: Reduction in CI/CD failures
|
||||
3. **Recovery time**: Time to recover from cache misses
|
||||
4. **Resource utilization**: Memory and CPU usage during builds
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [x] Create `Dockerfile.prod` for production builds
|
||||
- [x] Update `Dockerfile.build` for build cache
|
||||
- [x] Keep `Dockerfile` for development use
|
||||
- [x] Remove Docker Buildx from CI/CD workflow
|
||||
- [x] Remove Go build steps from CI/CD workflow
|
||||
- [x] Remove certificate configuration step (no longer needed)
|
||||
- [x] Add Docker cache usage to all build steps
|
||||
- [x] Fix Dockerfile.prod to use proper dependency hash (not latest)
|
||||
- [x] Create dependency hash calculation script
|
||||
- [x] Create build cache environment test script
|
||||
- [x] Update CI/CD workflow to generate Dockerfile.prod dynamically
|
||||
- [x] Update ADR 0020 with comprehensive documentation
|
||||
- [x] Test changes locally
|
||||
- [x] Push changes to trigger CI/CD workflow
|
||||
- [ ] Monitor workflow execution
|
||||
- [ ] Verify successful completion
|
||||
- [ ] Document results and metrics
|
||||
|
||||
## Testing and Validation
|
||||
|
||||
### Build Cache Environment Testing
|
||||
|
||||
A comprehensive test script is provided to validate the build cache environment:
|
||||
|
||||
```bash
|
||||
# Test the build cache environment (simulates Gitea act runner)
|
||||
./scripts/test-build-cache-environment.sh
|
||||
```
|
||||
|
||||
This script tests:
|
||||
1. Dependency hash calculation
|
||||
2. Build cache image creation
|
||||
3. Go environment inside container
|
||||
4. Swagger generation
|
||||
5. Go build and test
|
||||
6. Binary build
|
||||
7. Production Dockerfile with cache
|
||||
8. Production container runtime
|
||||
|
||||
### Dependency Hash Calculation
|
||||
|
||||
```bash
|
||||
# Calculate dependency hash (used for cache image tagging)
|
||||
./scripts/calculate-deps-hash.sh
|
||||
|
||||
# Export to file for use in scripts
|
||||
./scripts/calculate-deps-hash.sh deps_hash.env
|
||||
source deps_hash.env
|
||||
echo "Hash: $DEPS_HASH"
|
||||
```
|
||||
|
||||
### Workflow Monitoring
|
||||
|
||||
```bash
|
||||
# Monitor the workflow
|
||||
./scripts/gitea-client.sh monitor-workflow arcodange dance-lessons-coach 420 30
|
||||
|
||||
# Check job status
|
||||
./scripts/gitea-client.sh job-status arcodange dance-lessons-coach 420
|
||||
|
||||
# List workflow jobs
|
||||
./scripts/gitea-client.sh list-workflow-jobs arcodange dance-lessons-coach 420
|
||||
```
|
||||
|
||||
### Validation Commands
|
||||
|
||||
```bash
|
||||
# Verify CI/CD changes
|
||||
./scripts/verify-cicd-changes.sh
|
||||
|
||||
# Test new CI/CD workflow
|
||||
./scripts/test-new-cicd.sh
|
||||
|
||||
# Check Dockerfile syntax
|
||||
docker run --rm -i hadolint/hadolint < Dockerfile.prod
|
||||
```
|
||||
|
||||
## Cleanup and Organization
|
||||
|
||||
### Files Removed
|
||||
|
||||
1. **docker-compose.cicd-test.yml**: Unused Docker Compose file
|
||||
2. **scripts/cicd/**: Old CI/CD test scripts (replaced by main test scripts)
|
||||
|
||||
### Files Organized
|
||||
|
||||
All Dockerfiles moved to `docker/` directory:
|
||||
- `docker/Dockerfile` - Development
|
||||
- `docker/Dockerfile.build` - Build cache
|
||||
- `docker/Dockerfile.prod` - Production (dev only)
|
||||
- `docker/Dockerfile.prod.template` - Template
|
||||
|
||||
### Utility Scripts
|
||||
|
||||
- `scripts/calculate-deps-hash.sh` - Consistent hash calculation
|
||||
- `scripts/test-local-ci-cd.sh` - Main local testing
|
||||
- `scripts/test-build-cache-environment.sh` - Build cache testing
|
||||
|
||||
## Expected Outcomes
|
||||
|
||||
1. **Successful workflow execution**: Workflow completes without errors
|
||||
2. **Cache image created**: Build cache image pushed to registry
|
||||
3. **Production image built**: Final Docker image built using generated `docker/Dockerfile.prod`
|
||||
4. **Faster CI execution**: Reduced build times compared to previous approach
|
||||
5. **No certificate errors**: No TLS certificate verification failures
|
||||
6. **Clean organization**: No clutter in root directory
|
||||
|
||||
## References
|
||||
|
||||
- [Docker Buildx Documentation](https://docs.docker.com/buildx/working-with-buildx/)
|
||||
- [Docker Build Documentation](https://docs.docker.com/engine/reference/commandline/build/)
|
||||
- [GitHub Actions Docker Examples](https://github.com/actions/starter-workflows/tree/main/ci-and-cd)
|
||||
- [webapp CI/CD Pipeline](https://gitea.arcodange.fr/arcodange-org/webapp/src/branch/main/.gitea/workflows/dockerimage.yaml)
|
||||
- [Docker Multi-stage Builds](https://docs.docker.com/build/building/multi-stage/)
|
||||
- [Alpine Linux Docker Images](https://hub.docker.com/_/alpine)
|
||||
|
||||
---
|
||||
|
||||
**Approved by**: @arcodange
|
||||
**Date**: 2026-04-07
|
||||
**Updated**: 2026-04-07
|
||||
**Supersedes**: None
|
||||
**Superseded by**: None
|
||||
@@ -73,7 +73,11 @@ Chosen option: "[Option 1]" because [justification]
|
||||
* [0012-git-hooks-staged-only-formatting.md](0012-git-hooks-staged-only-formatting.md) - Git hooks format only staged Go files
|
||||
* [0013-openapi-swagger-toolchain.md](0013-openapi-swagger-toolchain.md) - ✅ OpenAPI/Swagger documentation with swaggo/swag (Implemented)
|
||||
* [0014-grpc-adoption-strategy.md](0014-grpc-adoption-strategy.md) - Hybrid REST/gRPC adoption strategy
|
||||
* [0015-cli-subcommands-cobra.md](0015-cli-subcommands-cobra.md) - Cobra CLI framework adoption
|
||||
* [0016-ci-cd-pipeline-design.md](0016-ci-cd-pipeline-design.md) - CI/CD pipeline architecture
|
||||
* [0017-trunk-based-development-workflow.md](0017-trunk-based-development-workflow.md) - Trunk-based development workflow
|
||||
* [0018-user-management-auth-system.md](0018-user-management-auth-system.md) - User management and authentication system
|
||||
* [0020-docker-build-strategy.md](0020-docker-build-strategy.md) - Docker Build Strategy: Traditional vs Buildx
|
||||
|
||||
## How to Add a New ADR
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package main provides the dance-lessons-coach server entry point
|
||||
//
|
||||
// @title dance-lessons-coach API
|
||||
// @version 1.2.0
|
||||
// @version 1.4.0
|
||||
// @description API for dance-lessons-coach service providing greeting functionality
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
|
||||
@@ -12,9 +12,14 @@
|
||||
// @license.name MIT
|
||||
// @license.url https://opensource.org/licenses/MIT
|
||||
|
||||
// @host localhost:8080
|
||||
// @BasePath /api
|
||||
// @schemes http https
|
||||
// @host localhost:8080
|
||||
// @BasePath /api
|
||||
// @schemes http https
|
||||
//
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description JWT authentication using Bearer token. Format: Bearer <token>
|
||||
|
||||
package main
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
act-runner:
|
||||
image: gitea/act_runner:latest
|
||||
volumes:
|
||||
- .:/workspace
|
||||
- ./config/runner:/data/.runner
|
||||
working_dir: /workspace
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL:-https://gitea.arcodange.lab/}
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
|
||||
- GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME:-local-test-runner}
|
||||
- GITEA_RUNNER_LABELS=${GITEA_RUNNER_LABELS:-ubuntu-latest:docker://node:16-bullseye,ubuntu-22.04:docker://gitea/act_runner:latest}
|
||||
command: act -W .gitea/workflows/go-ci-cd.yaml --rm
|
||||
|
||||
yamllint:
|
||||
image: pipelinecomponents/yamllint:latest
|
||||
volumes:
|
||||
- .:/workspace
|
||||
working_dir: /workspace
|
||||
command: yamllint .gitea/workflows/
|
||||
|
||||
yq-validator:
|
||||
image: mikefarah/yq:latest
|
||||
volumes:
|
||||
- .:/workspace
|
||||
working_dir: /workspace
|
||||
command: yq eval '.' .gitea/workflows/ci-cd.yaml
|
||||
38
docker/Dockerfile.build
Normal file
38
docker/Dockerfile.build
Normal file
@@ -0,0 +1,38 @@
|
||||
# Build environment Dockerfile with pre-installed Go tools and dependencies
|
||||
# Optimized for CI/CD pipeline speed
|
||||
|
||||
FROM golang:1.26.1-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
bash \
|
||||
curl \
|
||||
make \
|
||||
gcc \
|
||||
musl-dev \
|
||||
bc \
|
||||
grep \
|
||||
sed \
|
||||
jq \
|
||||
ca-certificates
|
||||
|
||||
# Set up Go environment
|
||||
ENV GOPATH=/go
|
||||
ENV PATH=$GOPATH/bin:/usr/local/go/bin:/usr/local/bin:/usr/bin:/bin
|
||||
WORKDIR /go/src/dance-lessons-coach
|
||||
|
||||
# Install common Go tools
|
||||
RUN go install github.com/swaggo/swag/cmd/swag@latest && \
|
||||
go install golang.org/x/tools/cmd/goimports@latest && \
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
|
||||
# Copy only go.mod and go.sum first for dependency caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download && go mod verify
|
||||
|
||||
# Simple build environment - source code is mounted at runtime
|
||||
WORKDIR /workspace
|
||||
|
||||
# Pre-download common Go tools (already installed in base)
|
||||
# RUN go install github.com/swaggo/swag/cmd/swag@latest
|
||||
37
docker/Dockerfile.prod
Normal file
37
docker/Dockerfile.prod
Normal file
@@ -0,0 +1,37 @@
|
||||
# DanceLessonsCoach Production Docker Image
|
||||
# ⚠️ DEVELOPMENT ONLY - This file uses 'latest' tag for local testing
|
||||
# ⚠️ CI/CD generates the correct Dockerfile.prod with proper dependency hash
|
||||
# ⚠️ For production use, see the CI/CD workflow which generates the correct file
|
||||
|
||||
# Use the build cache image as base (latest for local dev only)
|
||||
FROM gitea.arcodange.lab/arcodange/dance-lessons-coach-build-cache:latest AS builder
|
||||
|
||||
# Final minimal image
|
||||
FROM alpine:3.18
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install minimal dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /workspace/dance-lessons-coach /app/dance-lessons-coach
|
||||
|
||||
# Copy configuration
|
||||
COPY config.yaml /app/config.yaml
|
||||
|
||||
# Set permissions
|
||||
RUN chmod +x /app/dance-lessons-coach
|
||||
|
||||
# Set timezone
|
||||
ENV TZ=UTC
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s \
|
||||
CMD wget -q --spider http://localhost:8080/api/health || exit 1
|
||||
|
||||
# Entry point
|
||||
ENTRYPOINT ["/app/dance-lessons-coach"]
|
||||
36
docker/Dockerfile.prod.template
Normal file
36
docker/Dockerfile.prod.template
Normal file
@@ -0,0 +1,36 @@
|
||||
# DanceLessonsCoach Production Docker Image
|
||||
# Minimal image using pre-built binary from CI cache
|
||||
# Template: Replace {{DEPS_HASH}} with actual dependency hash
|
||||
|
||||
# Use the build cache image as base
|
||||
FROM gitea.arcodange.lab/arcodange/dance-lessons-coach-build-cache:{{DEPS_HASH}} AS builder
|
||||
|
||||
# Final minimal image
|
||||
FROM alpine:3.18
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install minimal dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /workspace/dance-lessons-coach /app/dance-lessons-coach
|
||||
|
||||
# Copy configuration
|
||||
COPY config.yaml /app/config.yaml
|
||||
|
||||
# Set permissions
|
||||
RUN chmod +x /app/dance-lessons-coach
|
||||
|
||||
# Set timezone
|
||||
ENV TZ=UTC
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s \
|
||||
CMD wget -q --spider http://localhost:8080/api/health || exit 1
|
||||
|
||||
# Entry point
|
||||
ENTRYPOINT ["/app/dance-lessons-coach"]
|
||||
130
features/user_authentication.feature
Normal file
130
features/user_authentication.feature
Normal file
@@ -0,0 +1,130 @@
|
||||
# features/user_authentication.feature
|
||||
Feature: User Authentication
|
||||
As a user
|
||||
I want to authenticate with the system
|
||||
So I can access personalized features
|
||||
|
||||
Scenario: Successful user authentication
|
||||
Given the server is running
|
||||
And a user "testuser" exists with password "testpass123"
|
||||
When I authenticate with username "testuser" and password "testpass123"
|
||||
Then the authentication should be successful
|
||||
And I should receive a valid JWT token
|
||||
|
||||
Scenario: Failed authentication with wrong password
|
||||
Given the server is running
|
||||
And a user "testuser" exists with password "testpass123"
|
||||
When I authenticate with username "testuser" and password "wrongpassword"
|
||||
Then the authentication should fail
|
||||
And the response should contain error "invalid_credentials"
|
||||
|
||||
Scenario: Failed authentication with non-existent user
|
||||
Given the server is running
|
||||
When I authenticate with username "nonexistent" and password "somepassword"
|
||||
Then the authentication should fail
|
||||
And the response should contain error "invalid_credentials"
|
||||
|
||||
Scenario: Admin authentication with master password
|
||||
Given the server is running
|
||||
When I authenticate as admin with master password "admin123"
|
||||
Then the authentication should be successful
|
||||
And I should receive a valid JWT token
|
||||
And the token should contain admin claims
|
||||
|
||||
Scenario: User registration
|
||||
Given the server is running
|
||||
When I register a new user "newuser_" with password "newpass123"
|
||||
Then the registration should be successful
|
||||
And I should be able to authenticate with the new credentials
|
||||
|
||||
Scenario: Password reset request by admin
|
||||
Given the server is running
|
||||
And a user "resetuser" exists with password "oldpass123"
|
||||
And I am authenticated as admin
|
||||
When I request password reset for user "resetuser"
|
||||
Then the password reset should be allowed
|
||||
And the user should be flagged for password reset
|
||||
|
||||
Scenario: User completes password reset
|
||||
Given the server is running
|
||||
And a user "resetuser" exists and is flagged for password reset
|
||||
When I complete password reset for "resetuser" with new password "newpass123"
|
||||
Then the password reset should be successful
|
||||
And I should be able to authenticate with the new password
|
||||
|
||||
Scenario: Failed password reset for non-existent user
|
||||
Given the server is running
|
||||
When I request password reset for user "nonexistent"
|
||||
Then the password reset should fail
|
||||
And the response should contain error "server_error"
|
||||
|
||||
Scenario: Failed password reset completion for non-existent user
|
||||
Given the server is running
|
||||
When I complete password reset for "nonexistent" with new password "newpass123"
|
||||
Then the password reset should fail
|
||||
And the response should contain error "server_error"
|
||||
|
||||
Scenario: Failed password reset completion for user not flagged
|
||||
Given the server is running
|
||||
And a user "normaluser" exists with password "oldpass123"
|
||||
When I complete password reset for "normaluser" with new password "newpass123"
|
||||
Then the password reset should fail
|
||||
And the response should contain error "server_error"
|
||||
|
||||
Scenario: Failed registration with existing username
|
||||
Given the server is running
|
||||
And a user "existinguser" exists with password "testpass123"
|
||||
When I register a new user "existinguser" with password "newpass123"
|
||||
Then the registration should fail
|
||||
And the response should contain error "user_exists"
|
||||
And the status code should be 409
|
||||
|
||||
Scenario: Failed registration with invalid username
|
||||
Given the server is running
|
||||
When I register a new user "ab" with password "validpass123"
|
||||
Then the registration should fail
|
||||
And the status code should be 400
|
||||
|
||||
Scenario: Failed registration with invalid password
|
||||
Given the server is running
|
||||
When I register a new user "validuser" with password "short"
|
||||
Then the registration should fail
|
||||
And the status code should be 400
|
||||
|
||||
Scenario: Failed authentication with empty username
|
||||
Given the server is running
|
||||
When I authenticate with username "" and password "somepassword"
|
||||
Then the authentication should fail with validation error
|
||||
And the status code should be 400
|
||||
|
||||
Scenario: Failed authentication with empty password
|
||||
Given the server is running
|
||||
When I authenticate with username "someuser" and password ""
|
||||
Then the authentication should fail with validation error
|
||||
And the status code should be 400
|
||||
|
||||
Scenario: Failed admin authentication with wrong password
|
||||
Given the server is running
|
||||
When I authenticate as admin with master password "wrongadmin"
|
||||
Then the authentication should fail
|
||||
And the response should contain error "invalid_credentials"
|
||||
|
||||
Scenario: Multiple consecutive authentications
|
||||
Given the server is running
|
||||
And a user "multiuser" exists with password "testpass123"
|
||||
When I authenticate with username "multiuser" and password "testpass123"
|
||||
Then the authentication should be successful
|
||||
And I should receive a valid JWT token
|
||||
When I authenticate with username "multiuser" and password "testpass123" again
|
||||
Then the authentication should be successful
|
||||
And I should receive a different JWT token
|
||||
|
||||
Scenario: JWT token validation
|
||||
Given the server is running
|
||||
And a user "tokenuser" exists with password "testpass123"
|
||||
When I authenticate with username "tokenuser" and password "testpass123"
|
||||
Then the authentication should be successful
|
||||
And I should receive a valid JWT token
|
||||
When I validate the received JWT token
|
||||
Then the token should be valid
|
||||
And it should contain the correct user ID
|
||||
12
go.mod
12
go.mod
@@ -8,9 +8,11 @@ require (
|
||||
github.com/go-playground/locales v0.14.1
|
||||
github.com/go-playground/universal-translator v0.18.1
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/rs/zerolog v1.35.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0
|
||||
@@ -18,6 +20,9 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -26,6 +31,7 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect
|
||||
github.com/cucumber/messages/go/v21 v21.0.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
@@ -43,12 +49,16 @@ require (
|
||||
github.com/hashicorp/go-memdb v1.3.5 // indirect
|
||||
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
@@ -61,7 +71,6 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
@@ -73,4 +82,5 @@ require (
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
12
go.sum
12
go.sum
@@ -56,6 +56,8 @@ github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
|
||||
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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=
|
||||
@@ -79,6 +81,10 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP
|
||||
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -99,6 +105,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
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=
|
||||
@@ -212,3 +220,7 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/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/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=
|
||||
|
||||
@@ -3,6 +3,7 @@ package steps
|
||||
import (
|
||||
"dance-lessons-coach/pkg/bdd/testserver"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/cucumber/godog"
|
||||
@@ -31,6 +32,35 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client) {
|
||||
ctx.Step(`^I send a POST request to v2 greet with name "([^"]*)"$`, sc.iSendPOSTRequestToV2GreetWithName)
|
||||
ctx.Step(`^I send a POST request to v2 greet with invalid JSON "([^"]*)"$`, sc.iSendPOSTRequestToV2GreetWithInvalidJSON)
|
||||
ctx.Step(`^the response should contain error "([^"]*)"$`, sc.theResponseShouldContainError)
|
||||
|
||||
// User Authentication Steps
|
||||
ctx.Step(`^a user "([^"]*)" exists with password "([^"]*)"$`, sc.aUserExistsWithPassword)
|
||||
ctx.Step(`^I authenticate with username "([^"]*)" and password "([^"]*)"$`, sc.iAuthenticateWithUsernameAndPassword)
|
||||
ctx.Step(`^the authentication should be successful$`, sc.theAuthenticationShouldBeSuccessful)
|
||||
ctx.Step(`^I should receive a valid JWT token$`, sc.iShouldReceiveAValidJWTToken)
|
||||
ctx.Step(`^the authentication should fail$`, sc.theAuthenticationShouldFail)
|
||||
ctx.Step(`^I authenticate as admin with master password "([^"]*)"$`, sc.iAuthenticateAsAdminWithMasterPassword)
|
||||
ctx.Step(`^the token should contain admin claims$`, sc.theTokenShouldContainAdminClaims)
|
||||
ctx.Step(`^I register a new user "([^"]*)" with password "([^"]*)"$`, sc.iRegisterANewUserWithPassword)
|
||||
ctx.Step(`^the registration should be successful$`, sc.theRegistrationShouldBeSuccessful)
|
||||
ctx.Step(`^I should be able to authenticate with the new credentials$`, sc.iShouldBeAbleToAuthenticateWithTheNewCredentials)
|
||||
ctx.Step(`^I am authenticated as admin$`, sc.iAmAuthenticatedAsAdmin)
|
||||
ctx.Step(`^I request password reset for user "([^"]*)"$`, sc.iRequestPasswordResetForUser)
|
||||
ctx.Step(`^the password reset should be allowed$`, sc.thePasswordResetShouldBeAllowed)
|
||||
ctx.Step(`^the user should be flagged for password reset$`, sc.theUserShouldBeFlaggedForPasswordReset)
|
||||
ctx.Step(`^I complete password reset for "([^"]*)" with new password "([^"]*)"$`, sc.iCompletePasswordResetForWithNewPassword)
|
||||
ctx.Step(`^I should be able to authenticate with the new password$`, sc.iShouldBeAbleToAuthenticateWithTheNewPassword)
|
||||
ctx.Step(`^a user "([^"]*)" exists and is flagged for password reset$`, sc.aUserExistsAndIsFlaggedForPasswordReset)
|
||||
ctx.Step(`^the password reset should be successful$`, sc.thePasswordResetShouldBeSuccessful)
|
||||
ctx.Step(`^the password reset should fail$`, sc.thePasswordResetShouldFail)
|
||||
ctx.Step(`^the status code should be (\d+)$`, sc.theStatusCodeShouldBe)
|
||||
ctx.Step(`^I validate the received JWT token$`, sc.iValidateTheReceivedJWTToken)
|
||||
ctx.Step(`^the token should be valid$`, sc.theTokenShouldBeValid)
|
||||
ctx.Step(`^it should contain the correct user ID$`, sc.itShouldContainTheCorrectUserID)
|
||||
ctx.Step(`^I should receive a different JWT token$`, sc.iShouldReceiveADifferentJWTToken)
|
||||
ctx.Step(`^I authenticate with username "([^"]*)" and password "([^"]*)" again$`, sc.iAuthenticateWithUsernameAndPasswordAgain)
|
||||
ctx.Step(`^the registration should fail$`, sc.theRegistrationShouldFail)
|
||||
ctx.Step(`^the authentication should fail with validation error$`, sc.theAuthenticationShouldFailWithValidationError)
|
||||
}
|
||||
|
||||
func (sc *StepContext) iRequestAGreetingFor(name string) error {
|
||||
@@ -107,3 +137,275 @@ func (sc *StepContext) theResponseShouldContainError(expectedError string) error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// User Authentication Steps
|
||||
func (sc *StepContext) aUserExistsWithPassword(username, password string) error {
|
||||
// Register the user first
|
||||
req := map[string]string{"username": username, "password": password}
|
||||
if err := sc.client.Request("POST", "/api/v1/auth/register", req); err != nil {
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) iAuthenticateWithUsernameAndPassword(username, password string) error {
|
||||
req := map[string]string{"username": username, "password": password}
|
||||
return sc.client.Request("POST", "/api/v1/auth/login", req)
|
||||
}
|
||||
|
||||
func (sc *StepContext) theAuthenticationShouldBeSuccessful() error {
|
||||
// Check if we got a 200 status code
|
||||
if sc.client.GetLastStatusCode() != http.StatusOK {
|
||||
return fmt.Errorf("expected status 200, got %d", sc.client.GetLastStatusCode())
|
||||
}
|
||||
|
||||
// Check if response contains a token
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, "token") {
|
||||
return fmt.Errorf("expected response to contain token, got %s", body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) iShouldReceiveAValidJWTToken() error {
|
||||
// This is already verified in theAuthenticationShouldBeSuccessful
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) theAuthenticationShouldFail() error {
|
||||
// Check if we got a 401 status code
|
||||
if sc.client.GetLastStatusCode() != http.StatusUnauthorized {
|
||||
return fmt.Errorf("expected status 401, got %d", sc.client.GetLastStatusCode())
|
||||
}
|
||||
|
||||
// Check if response contains invalid_credentials error
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, "invalid_credentials") {
|
||||
return fmt.Errorf("expected response to contain invalid_credentials error, got %s", body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) iAuthenticateAsAdminWithMasterPassword(password string) error {
|
||||
req := map[string]string{"username": "admin", "password": password}
|
||||
return sc.client.Request("POST", "/api/v1/auth/login", req)
|
||||
}
|
||||
|
||||
func (sc *StepContext) theTokenShouldContainAdminClaims() error {
|
||||
// Check if we got a 200 status code
|
||||
if sc.client.GetLastStatusCode() != http.StatusOK {
|
||||
return fmt.Errorf("expected status 200, got %d", sc.client.GetLastStatusCode())
|
||||
}
|
||||
|
||||
// Check if response contains a token
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, "token") {
|
||||
return fmt.Errorf("expected response to contain token, got %s", body)
|
||||
}
|
||||
|
||||
// TODO: Actually decode and verify JWT claims contain admin=true
|
||||
// For now, we'll just check that authentication succeeded
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) iRegisterANewUserWithPassword(username, password string) error {
|
||||
req := map[string]string{"username": username, "password": password}
|
||||
return sc.client.Request("POST", "/api/v1/auth/register", req)
|
||||
}
|
||||
|
||||
func (sc *StepContext) theRegistrationShouldBeSuccessful() error {
|
||||
// Check if we got a 201 status code
|
||||
if sc.client.GetLastStatusCode() != http.StatusCreated {
|
||||
return fmt.Errorf("expected status 201, got %d", sc.client.GetLastStatusCode())
|
||||
}
|
||||
|
||||
// Check if response contains success message
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, "User registered successfully") {
|
||||
return fmt.Errorf("expected response to contain success message, got %s", body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) iShouldBeAbleToAuthenticateWithTheNewCredentials() error {
|
||||
// This is the same as regular authentication
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) iAmAuthenticatedAsAdmin() error {
|
||||
// For now, we'll just authenticate as admin
|
||||
return sc.iAuthenticateAsAdminWithMasterPassword("admin123")
|
||||
}
|
||||
|
||||
func (sc *StepContext) iRequestPasswordResetForUser(username string) error {
|
||||
req := map[string]string{"username": username}
|
||||
return sc.client.Request("POST", "/api/v1/auth/password-reset/request", req)
|
||||
}
|
||||
|
||||
func (sc *StepContext) thePasswordResetShouldBeAllowed() error {
|
||||
// Check if we got a 200 status code
|
||||
if sc.client.GetLastStatusCode() != http.StatusOK {
|
||||
return fmt.Errorf("expected status 200, got %d", sc.client.GetLastStatusCode())
|
||||
}
|
||||
|
||||
// Check if response contains success message
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, "Password reset allowed") {
|
||||
return fmt.Errorf("expected response to contain success message, got %s", body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) theUserShouldBeFlaggedForPasswordReset() error {
|
||||
// This is verified by the password reset request being successful
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) iCompletePasswordResetForWithNewPassword(username, password string) error {
|
||||
req := map[string]string{"username": username, "new_password": password}
|
||||
return sc.client.Request("POST", "/api/v1/auth/password-reset/complete", req)
|
||||
}
|
||||
|
||||
func (sc *StepContext) aUserExistsAndIsFlaggedForPasswordReset(username string) error {
|
||||
// First, create the user
|
||||
if err := sc.iRegisterANewUserWithPassword(username, "oldpassword123"); err != nil {
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// Then flag for password reset
|
||||
if err := sc.iRequestPasswordResetForUser(username); err != nil {
|
||||
return fmt.Errorf("failed to flag user for password reset: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) thePasswordResetShouldBeSuccessful() error {
|
||||
// Check if we got a 200 status code
|
||||
if sc.client.GetLastStatusCode() != http.StatusOK {
|
||||
return fmt.Errorf("expected status 200, got %d", sc.client.GetLastStatusCode())
|
||||
}
|
||||
|
||||
// Check if response contains success message
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, "Password reset completed successfully") {
|
||||
return fmt.Errorf("expected response to contain success message, got %s", body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) iShouldBeAbleToAuthenticateWithTheNewPassword() error {
|
||||
// This is the same as regular authentication
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) thePasswordResetShouldFail() error {
|
||||
// Check if we got a 500 status code (server error for non-existent users)
|
||||
if sc.client.GetLastStatusCode() != http.StatusInternalServerError {
|
||||
return fmt.Errorf("expected status 500, got %d", sc.client.GetLastStatusCode())
|
||||
}
|
||||
|
||||
// Check if response contains server_error
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, "server_error") {
|
||||
return fmt.Errorf("expected response to contain server_error, got %s", body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) theStatusCodeShouldBe(expectedStatus int) error {
|
||||
actualStatus := sc.client.GetLastStatusCode()
|
||||
if actualStatus != expectedStatus {
|
||||
return fmt.Errorf("expected status %d, got %d", expectedStatus, actualStatus)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) iValidateTheReceivedJWTToken() error {
|
||||
// Store the current token for comparison
|
||||
// In a real implementation, we would decode and validate the JWT
|
||||
// For now, we'll just store it
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) theTokenShouldBeValid() error {
|
||||
// Check if we got a 200 status code
|
||||
if sc.client.GetLastStatusCode() != http.StatusOK {
|
||||
return fmt.Errorf("expected status 200, got %d", sc.client.GetLastStatusCode())
|
||||
}
|
||||
|
||||
// Check if response contains a token
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, "token") {
|
||||
return fmt.Errorf("expected response to contain token, got %s", body)
|
||||
}
|
||||
|
||||
// TODO: Actually decode and verify JWT
|
||||
// For now, we'll just check that authentication succeeded
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) itShouldContainTheCorrectUserID() error {
|
||||
// TODO: Actually decode JWT and verify user ID
|
||||
// For now, we'll skip this verification
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) iShouldReceiveADifferentJWTToken() error {
|
||||
// Check if we got a 200 status code
|
||||
if sc.client.GetLastStatusCode() != http.StatusOK {
|
||||
return fmt.Errorf("expected status 200, got %d", sc.client.GetLastStatusCode())
|
||||
}
|
||||
|
||||
// Check if response contains a token
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, "token") {
|
||||
return fmt.Errorf("expected response to contain token, got %s", body)
|
||||
}
|
||||
|
||||
// TODO: Compare with previous token to ensure it's different
|
||||
// For now, we'll just check that authentication succeeded
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) iAuthenticateWithUsernameAndPasswordAgain(username, password string) error {
|
||||
// This is the same as regular authentication
|
||||
return sc.iAuthenticateWithUsernameAndPassword(username, password)
|
||||
}
|
||||
|
||||
func (sc *StepContext) theRegistrationShouldFail() error {
|
||||
// Check if we got a 400 or 409 status code
|
||||
statusCode := sc.client.GetLastStatusCode()
|
||||
if statusCode != http.StatusBadRequest && statusCode != http.StatusConflict {
|
||||
return fmt.Errorf("expected status 400 or 409, got %d", statusCode)
|
||||
}
|
||||
|
||||
// Check if response contains error
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, "error") {
|
||||
return fmt.Errorf("expected response to contain error, got %s", body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sc *StepContext) theAuthenticationShouldFailWithValidationError() error {
|
||||
// Check if we got a 400 status code
|
||||
if sc.client.GetLastStatusCode() != http.StatusBadRequest {
|
||||
return fmt.Errorf("expected status 400, got %d", sc.client.GetLastStatusCode())
|
||||
}
|
||||
|
||||
// Check if response contains validation error (new structured format)
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, "validation_failed") && !strings.Contains(body, "invalid_request") {
|
||||
return fmt.Errorf("expected response to contain validation_failed or invalid_request error, got %s", body)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -139,3 +139,10 @@ func (c *Client) GetLastResponse() *http.Response {
|
||||
func (c *Client) GetLastBody() []byte {
|
||||
return c.lastBody
|
||||
}
|
||||
|
||||
func (c *Client) GetLastStatusCode() int {
|
||||
if c.lastResp == nil {
|
||||
return 0
|
||||
}
|
||||
return c.lastResp.StatusCode
|
||||
}
|
||||
|
||||
@@ -104,5 +104,9 @@ func createTestConfig(port int) *config.Config {
|
||||
API: config.APIConfig{
|
||||
V2Enabled: true, // Enable v2 for testing
|
||||
},
|
||||
Auth: config.AuthConfig{
|
||||
JWTSecret: "default-secret-key-please-change-in-production",
|
||||
AdminMasterPassword: "admin123",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type Config struct {
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Telemetry TelemetryConfig `mapstructure:"telemetry"`
|
||||
API APIConfig `mapstructure:"api"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
}
|
||||
|
||||
// ServerConfig holds server-related configuration
|
||||
@@ -42,11 +43,17 @@ type LoggingConfig struct {
|
||||
|
||||
// TelemetryConfig holds OpenTelemetry-related configuration
|
||||
type TelemetryConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
OTLPEndpoint string `mapstructure:"otlp_endpoint"`
|
||||
ServiceName string `mapstructure:"service_name"`
|
||||
Insecure bool `mapstructure:"insecure"`
|
||||
Sampler SamplerConfig `mapstructure:"sampler"`
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
OTLPEndpoint string `mapstructure:"otlp_endpoint"`
|
||||
ServiceName string `mapstructure:"service_name"`
|
||||
Insecure bool `mapstructure:"insecure"`
|
||||
Sampler SamplerConfig `mapstructure:"sampler"`
|
||||
Persistence PersistenceTelemetryConfig `mapstructure:"persistence"`
|
||||
}
|
||||
|
||||
// PersistenceTelemetryConfig holds persistence layer telemetry configuration
|
||||
type PersistenceTelemetryConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
// APIConfig holds API version configuration
|
||||
@@ -54,6 +61,12 @@ type APIConfig struct {
|
||||
V2Enabled bool `mapstructure:"v2_enabled"`
|
||||
}
|
||||
|
||||
// AuthConfig holds authentication configuration
|
||||
type AuthConfig struct {
|
||||
JWTSecret string `mapstructure:"jwt_secret"`
|
||||
AdminMasterPassword string `mapstructure:"admin_master_password"`
|
||||
}
|
||||
|
||||
// VersionInfo holds application version information
|
||||
type VersionInfo struct {
|
||||
Version string `mapstructure:"-"` // Set via ldflags
|
||||
@@ -100,10 +113,15 @@ func LoadConfig() (*Config, error) {
|
||||
v.SetDefault("telemetry.insecure", true)
|
||||
v.SetDefault("telemetry.sampler.type", "parentbased_always_on")
|
||||
v.SetDefault("telemetry.sampler.ratio", 1.0)
|
||||
v.SetDefault("telemetry.persistence.enabled", false)
|
||||
|
||||
// API defaults
|
||||
v.SetDefault("api.v2_enabled", false)
|
||||
|
||||
// Auth defaults
|
||||
v.SetDefault("auth.jwt_secret", "default-secret-key-please-change-in-production")
|
||||
v.SetDefault("auth.admin_master_password", "admin123")
|
||||
|
||||
// Check for custom config file path via environment variable
|
||||
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
|
||||
v.SetConfigFile(configFile)
|
||||
@@ -141,6 +159,10 @@ func LoadConfig() (*Config, error) {
|
||||
v.BindEnv("telemetry.otlp_endpoint", "DLC_TELEMETRY_OTLP_ENDPOINT")
|
||||
v.BindEnv("telemetry.service_name", "DLC_TELEMETRY_SERVICE_NAME")
|
||||
v.BindEnv("telemetry.insecure", "DLC_TELEMETRY_INSECURE")
|
||||
|
||||
// Auth environment variables
|
||||
v.BindEnv("auth.jwt_secret", "DLC_AUTH_JWT_SECRET")
|
||||
v.BindEnv("auth.admin_master_password", "DLC_AUTH_ADMIN_MASTER_PASSWORD")
|
||||
v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE")
|
||||
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
|
||||
|
||||
@@ -200,6 +222,11 @@ func (c *Config) GetServiceName() string {
|
||||
return c.Telemetry.ServiceName
|
||||
}
|
||||
|
||||
// GetPersistenceTelemetryEnabled returns whether persistence layer telemetry is enabled
|
||||
func (c *Config) GetPersistenceTelemetryEnabled() bool {
|
||||
return c.Telemetry.Enabled && c.Telemetry.Persistence.Enabled
|
||||
}
|
||||
|
||||
// GetTelemetryInsecure returns whether to use insecure connection
|
||||
func (c *Config) GetTelemetryInsecure() bool {
|
||||
return c.Telemetry.Insecure
|
||||
@@ -220,6 +247,16 @@ func (c *Config) GetV2Enabled() bool {
|
||||
return c.API.V2Enabled
|
||||
}
|
||||
|
||||
// GetJWTSecret returns the JWT secret
|
||||
func (c *Config) GetJWTSecret() string {
|
||||
return c.Auth.JWTSecret
|
||||
}
|
||||
|
||||
// GetAdminMasterPassword returns the admin master password
|
||||
func (c *Config) GetAdminMasterPassword() string {
|
||||
return c.Auth.AdminMasterPassword
|
||||
}
|
||||
|
||||
// GetLogLevel returns the logging level
|
||||
func (c *Config) GetLogLevel() string {
|
||||
return c.Logging.Level
|
||||
|
||||
@@ -88,6 +88,7 @@ func (h *apiV1GreetHandler) RegisterRoutes(router chi.Router) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} GreetResponse "Successful response"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/greet [get]
|
||||
func (h *apiV1GreetHandler) handleGreetQuery(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("name")
|
||||
@@ -104,6 +105,7 @@ func (h *apiV1GreetHandler) handleGreetQuery(w http.ResponseWriter, r *http.Requ
|
||||
// @Param name path string true "Name to greet"
|
||||
// @Success 200 {object} GreetResponse "Successful response"
|
||||
// @Failure 400 {object} ErrorResponse "Invalid name parameter"
|
||||
// @Security BearerAuth
|
||||
// @Router /v1/greet/{name} [get]
|
||||
func (h *apiV1GreetHandler) handleGreetPath(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
|
||||
@@ -55,6 +55,7 @@ type greetResponse struct {
|
||||
// @Param request body GreetRequest true "Greeting request"
|
||||
// @Success 200 {object} GreetResponseV2 "Successful response"
|
||||
// @Failure 400 {object} ValidationError "Validation error"
|
||||
// @Security BearerAuth
|
||||
// @Router /v2/greet [post]
|
||||
func (h *apiV2GreetHandler) handleGreetPost(w http.ResponseWriter, r *http.Request) {
|
||||
// Read request body
|
||||
|
||||
@@ -3,21 +3,46 @@ package greet
|
||||
import (
|
||||
"context"
|
||||
|
||||
"dance-lessons-coach/pkg/user"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Context key for storing authenticated user
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
// UserContextKey is the context key for storing authenticated user
|
||||
UserContextKey contextKey = "authenticatedUser"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// GetAuthenticatedUserFromContext extracts the authenticated user from context
|
||||
func GetAuthenticatedUserFromContext(ctx context.Context) (*user.User, bool) {
|
||||
user, ok := ctx.Value(UserContextKey).(*user.User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
// Greet returns a greeting message for the given name.
|
||||
// If name is empty, it defaults to "world".
|
||||
// If name is empty, it checks for authenticated user and uses their username.
|
||||
// If no authenticated user and no name, it defaults to "world".
|
||||
// Implements the Greeter interface.
|
||||
func (s *Service) Greet(ctx context.Context, name string) string {
|
||||
log.Trace().Ctx(ctx).Str("name", name).Msg("Greet function called")
|
||||
|
||||
// If no name provided, check for authenticated user
|
||||
if name == "" {
|
||||
if authenticatedUser, ok := GetAuthenticatedUserFromContext(ctx); ok {
|
||||
name = authenticatedUser.Username
|
||||
log.Trace().Ctx(ctx).Str("authenticated_user", name).Msg("Using authenticated username for greeting")
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return "Hello world!"
|
||||
}
|
||||
|
||||
63
pkg/server/middleware.go
Normal file
63
pkg/server/middleware.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"dance-lessons-coach/pkg/greet"
|
||||
"dance-lessons-coach/pkg/user"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// AuthMiddleware handles JWT authentication and adds user to context
|
||||
type AuthMiddleware struct {
|
||||
authService user.AuthService
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates a new authentication middleware
|
||||
func NewAuthMiddleware(authService user.AuthService) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware returns the authentication middleware function
|
||||
func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Extract Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
// No authorization header, pass through with no user
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract token from "Bearer <token>" format
|
||||
const bearerPrefix = "Bearer "
|
||||
if len(authHeader) < len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix {
|
||||
log.Trace().Ctx(ctx).Str("auth_header", authHeader).Msg("Invalid authorization header format")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token := authHeader[len(bearerPrefix):]
|
||||
|
||||
// Validate JWT token
|
||||
validatedUser, err := m.authService.ValidateJWT(ctx, token)
|
||||
if err != nil {
|
||||
log.Trace().Ctx(ctx).Err(err).Msg("JWT validation failed")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Add user to context
|
||||
ctxWithUser := context.WithValue(ctx, greet.UserContextKey, validatedUser)
|
||||
r = r.WithContext(ctxWithUser)
|
||||
|
||||
// Continue to next handler
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -20,8 +20,11 @@ import (
|
||||
"dance-lessons-coach/pkg/config"
|
||||
"dance-lessons-coach/pkg/greet"
|
||||
"dance-lessons-coach/pkg/telemetry"
|
||||
"dance-lessons-coach/pkg/user"
|
||||
userapi "dance-lessons-coach/pkg/user/api"
|
||||
"dance-lessons-coach/pkg/validation"
|
||||
"dance-lessons-coach/pkg/version"
|
||||
"encoding/json"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
@@ -37,6 +40,8 @@ type Server struct {
|
||||
config *config.Config
|
||||
tracerProvider *sdktrace.TracerProvider
|
||||
validator *validation.Validator
|
||||
userRepo user.UserRepository
|
||||
userService user.UserService
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
|
||||
@@ -48,17 +53,49 @@ func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
|
||||
log.Trace().Msg("Validator created successfully")
|
||||
}
|
||||
|
||||
// Initialize user repository and services
|
||||
userRepo, userService, err := initializeUserServices(cfg)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Failed to initialize user services, user functionality will be disabled")
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
router: chi.NewRouter(),
|
||||
readyCtx: readyCtx,
|
||||
withOTEL: cfg.GetTelemetryEnabled(),
|
||||
config: cfg,
|
||||
validator: validator,
|
||||
router: chi.NewRouter(),
|
||||
readyCtx: readyCtx,
|
||||
withOTEL: cfg.GetTelemetryEnabled(),
|
||||
config: cfg,
|
||||
validator: validator,
|
||||
userRepo: userRepo,
|
||||
userService: userService,
|
||||
}
|
||||
s.setupRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
// initializeUserServices initializes the user repository and unified user service
|
||||
func initializeUserServices(cfg *config.Config) (user.UserRepository, user.UserService, error) {
|
||||
// Use in-memory SQLite database
|
||||
dbPath := "file::memory:?cache=shared"
|
||||
|
||||
// Create user repository
|
||||
repo, err := user.NewSQLiteRepository(dbPath, cfg)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create user repository: %w", err)
|
||||
}
|
||||
|
||||
// Create JWT config
|
||||
jwtConfig := user.JWTConfig{
|
||||
Secret: cfg.GetJWTSecret(),
|
||||
ExpirationTime: time.Hour * 24, // 24 hours
|
||||
Issuer: "dance-lessons-coach",
|
||||
}
|
||||
|
||||
// Create unified user service
|
||||
userService := user.NewUserService(repo, jwtConfig, cfg.GetAdminMasterPassword())
|
||||
|
||||
return repo, userService, nil
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
// Use Zerolog middleware instead of Chi's default logger
|
||||
s.router.Use(middleware.RequestLogger(&middleware.DefaultLogFormatter{
|
||||
@@ -109,9 +146,31 @@ func (s *Server) setupRoutes() {
|
||||
func (s *Server) registerApiV1Routes(r chi.Router) {
|
||||
greetService := greet.NewService()
|
||||
greetHandler := greet.NewApiV1GreetHandler(greetService)
|
||||
|
||||
// Create auth middleware if available
|
||||
var authMiddleware *AuthMiddleware
|
||||
if s.userService != nil {
|
||||
authMiddleware = NewAuthMiddleware(s.userService)
|
||||
}
|
||||
|
||||
r.Route("/greet", func(r chi.Router) {
|
||||
// Add optional authentication middleware
|
||||
if authMiddleware != nil {
|
||||
r.Use(authMiddleware.Middleware)
|
||||
}
|
||||
greetHandler.RegisterRoutes(r)
|
||||
})
|
||||
|
||||
// Register user authentication routes
|
||||
if s.userService != nil && s.userRepo != nil {
|
||||
// Use unified user service - much simpler!
|
||||
if s.userService != nil {
|
||||
handler := userapi.NewAuthHandler(s.userService, s.userService, s.validator)
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
handler.RegisterRoutes(r)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) registerApiV2Routes(r chi.Router) {
|
||||
@@ -155,24 +214,75 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
// handleReadiness godoc
|
||||
//
|
||||
// @Summary Readiness check
|
||||
// @Description Check if the service is ready to accept traffic
|
||||
// @Description Check if the service is ready to accept traffic including detailed connection status
|
||||
// @Tags System/Health
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]bool "Service is ready"
|
||||
// @Failure 503 {object} map[string]bool "Service is not ready"
|
||||
// @Success 200 {object} object "Service is ready with connection details"
|
||||
// @Failure 503 {object} object "Service is not ready with failure details"
|
||||
// @Router /ready [get]
|
||||
func (s *Server) handleReadiness(w http.ResponseWriter, r *http.Request) {
|
||||
log.Trace().Msg("Readiness check requested")
|
||||
|
||||
// Check if server is shutting down
|
||||
select {
|
||||
case <-s.readyCtx.Done():
|
||||
log.Trace().Msg("Readiness check: not ready (shutting down)")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte(`{"ready":false}`))
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ready": false,
|
||||
"reason": "server_shutting_down",
|
||||
"connections": map[string]interface{}{
|
||||
"database": "not_checked",
|
||||
},
|
||||
})
|
||||
return
|
||||
default:
|
||||
log.Trace().Msg("Readiness check: ready")
|
||||
w.Write([]byte(`{"ready":true}`))
|
||||
// Server is not shutting down, check all connections
|
||||
connectionStatus := make(map[string]interface{})
|
||||
allHealthy := true
|
||||
var failureReason string
|
||||
|
||||
// Check database if available
|
||||
if s.userRepo != nil {
|
||||
if err := s.userRepo.CheckDatabaseHealth(r.Context()); err != nil {
|
||||
log.Warn().Err(err).Msg("Database health check failed")
|
||||
connectionStatus["database"] = map[string]interface{}{
|
||||
"status": "unhealthy",
|
||||
"error": err.Error(),
|
||||
}
|
||||
allHealthy = false
|
||||
failureReason = "database_unhealthy"
|
||||
} else {
|
||||
connectionStatus["database"] = map[string]interface{}{
|
||||
"status": "healthy",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
connectionStatus["database"] = map[string]interface{}{
|
||||
"status": "not_configured",
|
||||
}
|
||||
}
|
||||
|
||||
if allHealthy {
|
||||
log.Trace().Msg("Readiness check: ready")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ready": true,
|
||||
"connections": connectionStatus,
|
||||
})
|
||||
} else {
|
||||
log.Warn().Str("reason", failureReason).Msg("Readiness check: not ready")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ready": false,
|
||||
"reason": failureReason,
|
||||
"connections": connectionStatus,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
304
pkg/user/api/auth_handler.go
Normal file
304
pkg/user/api/auth_handler.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"dance-lessons-coach/pkg/user"
|
||||
"dance-lessons-coach/pkg/validation"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication-related HTTP requests
|
||||
type AuthHandler struct {
|
||||
authService user.AuthService
|
||||
userService user.UserService
|
||||
validator *validation.Validator
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new authentication handler
|
||||
func NewAuthHandler(authService user.AuthService, userService user.UserService, validator *validation.Validator) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
userService: userService,
|
||||
validator: validator,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers authentication routes
|
||||
func (h *AuthHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Post("/login", h.handleLogin)
|
||||
router.Post("/register", h.handleRegister)
|
||||
router.Post("/password-reset/request", h.handlePasswordResetRequest)
|
||||
router.Post("/password-reset/complete", h.handlePasswordResetComplete)
|
||||
}
|
||||
|
||||
// writeValidationError writes a structured validation error response
|
||||
func (h *AuthHandler) writeValidationError(w http.ResponseWriter, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
// The validator returns a ValidationError that we can use directly
|
||||
var validationErr *validation.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "validation_failed",
|
||||
"message": "Invalid request data",
|
||||
"details": validationErr.Messages,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for other error types
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "validation_failed",
|
||||
"message": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// LoginRequest represents a login request
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
}
|
||||
|
||||
// LoginResponse represents a login response
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// handleLogin godoc
|
||||
//
|
||||
// @Summary User login
|
||||
// @Description Authenticate user or admin and return JWT token. Supports both regular users and admin authentication.
|
||||
// @Tags API/v1/User
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body LoginRequest true "Login credentials"
|
||||
// @Success 200 {object} LoginResponse "Successful authentication"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 401 {object} map[string]string "Invalid credentials"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Router /v1/auth/login [post]
|
||||
func (h *AuthHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request using validator
|
||||
if h.validator != nil {
|
||||
if err := h.validator.Validate(req); err != nil {
|
||||
h.writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Try unified authentication (regular user first, then admin fallback)
|
||||
var authenticatedUser *user.User
|
||||
var authError error
|
||||
|
||||
// Try regular user authentication first
|
||||
authenticatedUser, authError = h.authService.Authenticate(ctx, req.Username, req.Password)
|
||||
|
||||
// If regular auth fails, try admin authentication
|
||||
if authError != nil {
|
||||
authenticatedUser, authError = h.authService.AdminAuthenticate(ctx, req.Password)
|
||||
}
|
||||
|
||||
// If both authentication methods failed
|
||||
if authError != nil {
|
||||
log.Trace().Ctx(ctx).Err(authError).Str("username", req.Username).Msg("Authentication failed")
|
||||
http.Error(w, `{"error":"invalid_credentials","message":"Invalid username or password"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate JWT token using the authenticated user (regular or admin)
|
||||
token, err := h.authService.GenerateJWT(ctx, authenticatedUser)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to generate JWT token")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to generate authentication token"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return token
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(LoginResponse{Token: token})
|
||||
}
|
||||
|
||||
// RegisterRequest represents a user registration request
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
Password string `json:"password" validate:"required,min=6,max=100"`
|
||||
}
|
||||
|
||||
// handleRegister godoc
|
||||
//
|
||||
// @Summary User registration
|
||||
// @Description Register a new user account
|
||||
// @Tags API/v1/User
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RegisterRequest true "Registration details"
|
||||
// @Success 201 {object} map[string]string "User created"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 409 {object} map[string]string "Username already taken"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Router /v1/auth/register [post]
|
||||
func (h *AuthHandler) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request using validator
|
||||
if h.validator != nil {
|
||||
if err := h.validator.Validate(req); err != nil {
|
||||
h.writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
exists, err := h.userService.UserExists(ctx, req.Username)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to check if user exists")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process registration"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
http.Error(w, `{"error":"user_exists","message":"Username already taken"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := h.userService.HashPassword(ctx, req.Password)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to hash password")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process registration"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
newUser := &user.User{
|
||||
Username: req.Username,
|
||||
PasswordHash: hashedPassword,
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
if err := h.userService.CreateUser(ctx, newUser); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to create user")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to create user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "User registered successfully"})
|
||||
}
|
||||
|
||||
// PasswordResetRequest represents a password reset request
|
||||
type PasswordResetRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
}
|
||||
|
||||
// handlePasswordResetRequest godoc
|
||||
//
|
||||
// @Summary Request password reset
|
||||
// @Description Initiate password reset process for a user
|
||||
// @Tags API/v1/User
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body PasswordResetRequest true "Password reset request"
|
||||
// @Success 200 {object} map[string]string "Reset allowed"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Router /v1/auth/password-reset/request [post]
|
||||
func (h *AuthHandler) handlePasswordResetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req PasswordResetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request using validator
|
||||
if h.validator != nil {
|
||||
if err := h.validator.Validate(req); err != nil {
|
||||
h.writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Request password reset
|
||||
if err := h.userService.RequestPasswordReset(ctx, req.Username); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to request password reset")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process password reset request"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Password reset allowed, user can now reset password"})
|
||||
}
|
||||
|
||||
// PasswordResetCompleteRequest represents a password reset completion request
|
||||
type PasswordResetCompleteRequest struct {
|
||||
Username string `json:"username" validate:"required,min=3,max=50"`
|
||||
NewPassword string `json:"new_password" validate:"required,min=6,max=100"`
|
||||
}
|
||||
|
||||
// handlePasswordResetComplete godoc
|
||||
//
|
||||
// @Summary Complete password reset
|
||||
// @Description Complete password reset with new password
|
||||
// @Tags API/v1/User
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body PasswordResetCompleteRequest true "Password reset completion"
|
||||
// @Success 200 {object} map[string]string "Password updated"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 500 {object} map[string]string "Server error"
|
||||
// @Router /v1/auth/password-reset/complete [post]
|
||||
func (h *AuthHandler) handlePasswordResetComplete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req PasswordResetCompleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request using validator
|
||||
if h.validator != nil {
|
||||
if err := h.validator.Validate(req); err != nil {
|
||||
h.writeValidationError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Complete password reset
|
||||
if err := h.userService.CompletePasswordReset(ctx, req.Username, req.NewPassword); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to complete password reset")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to complete password reset"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Password reset completed successfully"})
|
||||
}
|
||||
79
pkg/user/api/password_handler.go
Normal file
79
pkg/user/api/password_handler.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"dance-lessons-coach/pkg/user"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// PasswordResetHandler handles password reset requests
|
||||
type PasswordResetHandler struct {
|
||||
passwordResetService user.PasswordResetService
|
||||
}
|
||||
|
||||
// NewPasswordResetHandler creates a new password reset handler
|
||||
func NewPasswordResetHandler(passwordResetService user.PasswordResetService) *PasswordResetHandler {
|
||||
return &PasswordResetHandler{
|
||||
passwordResetService: passwordResetService,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers password reset routes
|
||||
func (h *PasswordResetHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Post("/password-reset/request", h.handlePasswordResetRequest)
|
||||
router.Post("/password-reset/complete", h.handlePasswordResetComplete)
|
||||
}
|
||||
|
||||
// PasswordResetRequest represents a password reset request
|
||||
|
||||
// handlePasswordResetRequest handles password reset requests
|
||||
func (h *PasswordResetHandler) handlePasswordResetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req PasswordResetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Request password reset
|
||||
if err := h.passwordResetService.RequestPasswordReset(ctx, req.Username); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to request password reset")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process password reset request"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Password reset allowed, user can now reset password"})
|
||||
}
|
||||
|
||||
// PasswordResetCompleteRequest represents a password reset completion request
|
||||
|
||||
// handlePasswordResetComplete handles password reset completion requests
|
||||
func (h *PasswordResetHandler) handlePasswordResetComplete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req PasswordResetCompleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Complete password reset
|
||||
if err := h.passwordResetService.CompletePasswordReset(ctx, req.Username, req.NewPassword); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to complete password reset")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to complete password reset"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Password reset completed successfully"})
|
||||
}
|
||||
81
pkg/user/api/user_handler.go
Normal file
81
pkg/user/api/user_handler.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"dance-lessons-coach/pkg/user"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// UserHandler handles user management requests
|
||||
type UserHandler struct {
|
||||
userRepo user.UserRepository
|
||||
passwordService user.PasswordService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new user handler
|
||||
func NewUserHandler(userRepo user.UserRepository, passwordService user.PasswordService) *UserHandler {
|
||||
return &UserHandler{
|
||||
userRepo: userRepo,
|
||||
passwordService: passwordService,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers user routes
|
||||
func (h *UserHandler) RegisterRoutes(router chi.Router) {
|
||||
router.Post("/register", h.handleRegister)
|
||||
}
|
||||
|
||||
// RegisterRequest represents a user registration request
|
||||
|
||||
// handleRegister handles user registration requests
|
||||
func (h *UserHandler) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid_request","message":"Invalid JSON request body"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
exists, err := h.userRepo.UserExists(ctx, req.Username)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to check if user exists")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process registration"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
http.Error(w, `{"error":"user_exists","message":"Username already taken"}`, http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := h.passwordService.HashPassword(ctx, req.Password)
|
||||
if err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to hash password")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to process registration"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
newUser := &user.User{
|
||||
Username: req.Username,
|
||||
PasswordHash: hashedPassword,
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
if err := h.userRepo.CreateUser(ctx, newUser); err != nil {
|
||||
log.Error().Ctx(ctx).Err(err).Msg("Failed to create user")
|
||||
http.Error(w, `{"error":"server_error","message":"Failed to create user"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "User registered successfully"})
|
||||
}
|
||||
235
pkg/user/auth_service.go
Normal file
235
pkg/user/auth_service.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// JWTConfig holds JWT configuration
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
ExpirationTime time.Duration
|
||||
Issuer string
|
||||
}
|
||||
|
||||
// userServiceImpl implements the unified UserService interface
|
||||
type userServiceImpl struct {
|
||||
repo UserRepository
|
||||
jwtConfig JWTConfig
|
||||
masterPassword string
|
||||
}
|
||||
|
||||
// NewUserService creates a new user service with all functionality
|
||||
func NewUserService(repo UserRepository, jwtConfig JWTConfig, masterPassword string) *userServiceImpl {
|
||||
return &userServiceImpl{
|
||||
repo: repo,
|
||||
jwtConfig: jwtConfig,
|
||||
masterPassword: masterPassword,
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate authenticates a user with username and password
|
||||
func (s *userServiceImpl) Authenticate(ctx context.Context, username, password string) (*User, error) {
|
||||
user, err := s.repo.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Check password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
now := time.Now()
|
||||
user.LastLogin = &now
|
||||
if err := s.repo.UpdateUser(ctx, user); err != nil {
|
||||
// Don't fail authentication if we can't update last login
|
||||
// Just log it and continue
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GenerateJWT generates a JWT token for the given user
|
||||
func (s *userServiceImpl) GenerateJWT(ctx context.Context, user *User) (string, error) {
|
||||
// Create the claims
|
||||
claims := jwt.MapClaims{
|
||||
"sub": user.ID,
|
||||
"name": user.Username,
|
||||
"admin": user.IsAdmin,
|
||||
"exp": time.Now().Add(s.jwtConfig.ExpirationTime).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"iss": s.jwtConfig.Issuer,
|
||||
}
|
||||
|
||||
// Create token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Sign and get the complete encoded token as a string
|
||||
tokenString, err := token.SignedString([]byte(s.jwtConfig.Secret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// ValidateJWT validates a JWT token and returns the user
|
||||
func (s *userServiceImpl) ValidateJWT(ctx context.Context, tokenString string) (*User, error) {
|
||||
// Parse the token
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify the signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return []byte(s.jwtConfig.Secret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is valid
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid JWT token")
|
||||
}
|
||||
|
||||
// Get claims
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid JWT claims")
|
||||
}
|
||||
|
||||
// Get user ID from claims
|
||||
userIDFloat, ok := claims["sub"].(float64)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid user ID in JWT")
|
||||
}
|
||||
|
||||
userID := uint(userIDFloat)
|
||||
|
||||
// Get user from repository
|
||||
user, err := s.repo.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user from JWT: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using bcrypt (implements PasswordService interface)
|
||||
func (s *userServiceImpl) HashPassword(ctx context.Context, password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// AdminAuthenticate authenticates an admin user with master password
|
||||
func (s *userServiceImpl) AdminAuthenticate(ctx context.Context, masterPassword string) (*User, error) {
|
||||
// Check if master password matches
|
||||
if masterPassword != s.masterPassword {
|
||||
return nil, errors.New("invalid admin credentials")
|
||||
}
|
||||
|
||||
// Create a virtual admin user (not persisted)
|
||||
adminUser := &User{
|
||||
ID: 0, // Special ID for admin
|
||||
Username: "admin",
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
return adminUser, nil
|
||||
}
|
||||
|
||||
// UserExists checks if a user exists by username
|
||||
func (s *userServiceImpl) UserExists(ctx context.Context, username string) (bool, error) {
|
||||
return s.repo.UserExists(ctx, username)
|
||||
}
|
||||
|
||||
// CreateUser creates a new user in the database
|
||||
func (s *userServiceImpl) CreateUser(ctx context.Context, user *User) error {
|
||||
return s.repo.CreateUser(ctx, user)
|
||||
}
|
||||
|
||||
// RequestPasswordReset requests a password reset for a user
|
||||
func (s *userServiceImpl) RequestPasswordReset(ctx context.Context, username string) error {
|
||||
// Check if user exists
|
||||
exists, err := s.repo.UserExists(ctx, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if user exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
// Allow password reset
|
||||
return s.repo.AllowPasswordReset(ctx, username)
|
||||
}
|
||||
|
||||
// CompletePasswordReset completes the password reset process
|
||||
func (s *userServiceImpl) CompletePasswordReset(ctx context.Context, username, newPassword string) error {
|
||||
// Hash the new password
|
||||
hashedPassword, err := s.HashPassword(ctx, newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash new password: %w", err)
|
||||
}
|
||||
|
||||
// Complete the password reset
|
||||
return s.repo.CompletePasswordReset(ctx, username, hashedPassword)
|
||||
}
|
||||
|
||||
// PasswordResetServiceImpl implements the PasswordResetService interface
|
||||
type PasswordResetServiceImpl struct {
|
||||
repo UserRepository
|
||||
auth *userServiceImpl
|
||||
}
|
||||
|
||||
// NewPasswordResetService creates a new password reset service
|
||||
func NewPasswordResetService(repo UserRepository, auth *userServiceImpl) *PasswordResetServiceImpl {
|
||||
return &PasswordResetServiceImpl{
|
||||
repo: repo,
|
||||
auth: auth,
|
||||
}
|
||||
}
|
||||
|
||||
// RequestPasswordReset requests a password reset for a user
|
||||
func (s *PasswordResetServiceImpl) RequestPasswordReset(ctx context.Context, username string) error {
|
||||
// Check if user exists
|
||||
exists, err := s.repo.UserExists(ctx, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if user exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
// Allow password reset
|
||||
return s.repo.AllowPasswordReset(ctx, username)
|
||||
}
|
||||
|
||||
// CompletePasswordReset completes the password reset process
|
||||
func (s *PasswordResetServiceImpl) CompletePasswordReset(ctx context.Context, username, newPassword string) error {
|
||||
// Hash the new password
|
||||
hashedPassword, err := s.auth.HashPassword(ctx, newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash new password: %w", err)
|
||||
}
|
||||
|
||||
// Complete the password reset
|
||||
return s.repo.CompletePasswordReset(ctx, username, hashedPassword)
|
||||
}
|
||||
225
pkg/user/sqlite_repository.go
Normal file
225
pkg/user/sqlite_repository.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"dance-lessons-coach/pkg/config"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// SQLiteRepository implements UserRepository using SQLite
|
||||
type SQLiteRepository struct {
|
||||
db *gorm.DB
|
||||
dbPath string
|
||||
config *config.Config
|
||||
spanPrefix string
|
||||
}
|
||||
|
||||
// NewSQLiteRepository creates a new SQLite repository
|
||||
func NewSQLiteRepository(dbPath string, config *config.Config) (*SQLiteRepository, error) {
|
||||
repo := &SQLiteRepository{
|
||||
dbPath: dbPath,
|
||||
config: config,
|
||||
spanPrefix: "user.repo.",
|
||||
}
|
||||
|
||||
if err := repo.initializeDatabase(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// initializeDatabase sets up the SQLite database and runs migrations
|
||||
func (r *SQLiteRepository) initializeDatabase() error {
|
||||
// Create directory if it doesn't exist
|
||||
dir := filepath.Dir(r.dbPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
// Configure GORM logger to use standard log
|
||||
gormLogger := logger.New(
|
||||
log.New(os.Stdout, "\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: time.Second,
|
||||
LogLevel: logger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
Colorful: true,
|
||||
},
|
||||
)
|
||||
|
||||
var err error
|
||||
r.db, err = gorm.Open(sqlite.Open(r.dbPath), &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Auto-migrate the User model
|
||||
if err := r.db.AutoMigrate(&User{}); err != nil {
|
||||
return fmt.Errorf("failed to auto-migrate: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user in the database
|
||||
func (r *SQLiteRepository) CreateUser(ctx context.Context, user *User) error {
|
||||
// Create telemetry span
|
||||
ctx, span := r.createSpan(ctx, "create_user")
|
||||
if span != nil {
|
||||
defer span.End()
|
||||
}
|
||||
|
||||
result := r.db.WithContext(ctx).Create(user)
|
||||
if result.Error != nil {
|
||||
if span != nil {
|
||||
span.RecordError(result.Error)
|
||||
}
|
||||
return fmt.Errorf("failed to create user: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByUsername retrieves a user by username
|
||||
func (r *SQLiteRepository) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
// Create telemetry span
|
||||
ctx, span := r.createSpan(ctx, "get_user_by_username")
|
||||
if span != nil {
|
||||
defer span.End()
|
||||
span.SetAttributes(attribute.String("username", username))
|
||||
}
|
||||
|
||||
var user User
|
||||
result := r.db.WithContext(ctx).Where("username = ?", username).First(&user)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if span != nil {
|
||||
span.RecordError(result.Error)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user by username: %w", result.Error)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves a user by ID
|
||||
func (r *SQLiteRepository) GetUserByID(ctx context.Context, id uint) (*User, error) {
|
||||
var user User
|
||||
result := r.db.WithContext(ctx).First(&user, id)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user by ID: %w", result.Error)
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates a user in the database
|
||||
func (r *SQLiteRepository) UpdateUser(ctx context.Context, user *User) error {
|
||||
result := r.db.WithContext(ctx).Save(user)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to update user: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user from the database
|
||||
func (r *SQLiteRepository) DeleteUser(ctx context.Context, id uint) error {
|
||||
result := r.db.WithContext(ctx).Delete(&User{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to delete user: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllowPasswordReset flags a user for password reset
|
||||
func (r *SQLiteRepository) AllowPasswordReset(ctx context.Context, username string) error {
|
||||
user, err := r.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user for password reset: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
user.AllowPasswordReset = true
|
||||
return r.UpdateUser(ctx, user)
|
||||
}
|
||||
|
||||
// CompletePasswordReset completes the password reset process
|
||||
func (r *SQLiteRepository) CompletePasswordReset(ctx context.Context, username, newPasswordHash string) error {
|
||||
user, err := r.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get user for password reset completion: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
|
||||
if !user.AllowPasswordReset {
|
||||
return fmt.Errorf("password reset not allowed for user: %s", username)
|
||||
}
|
||||
|
||||
user.PasswordHash = newPasswordHash
|
||||
user.AllowPasswordReset = false
|
||||
return r.UpdateUser(ctx, user)
|
||||
}
|
||||
|
||||
// UserExists checks if a user exists by username
|
||||
func (r *SQLiteRepository) UserExists(ctx context.Context, username string) (bool, error) {
|
||||
var count int64
|
||||
result := r.db.WithContext(ctx).Model(&User{}).Where("username = ?", username).Count(&count)
|
||||
if result.Error != nil {
|
||||
return false, fmt.Errorf("failed to check if user exists: %w", result.Error)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (r *SQLiteRepository) Close() error {
|
||||
sqlDB, err := r.db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get database connection: %w", err)
|
||||
}
|
||||
return sqlDB.Close()
|
||||
}
|
||||
|
||||
// CheckDatabaseHealth checks if the database is healthy and responsive
|
||||
func (r *SQLiteRepository) CheckDatabaseHealth(ctx context.Context) error {
|
||||
// Simple query to test database connectivity
|
||||
var count int64
|
||||
result := r.db.WithContext(ctx).Model(&User{}).Count(&count)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("database health check failed: %w", result.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSpan creates a new telemetry span if persistence telemetry is enabled
|
||||
func (r *SQLiteRepository) createSpan(ctx context.Context, operation string) (context.Context, trace.Span) {
|
||||
if r.config == nil || !r.config.GetPersistenceTelemetryEnabled() {
|
||||
return ctx, trace.SpanFromContext(ctx)
|
||||
}
|
||||
|
||||
// Create a new span with the operation name
|
||||
spanName := r.spanPrefix + operation
|
||||
tr := otel.Tracer("user-repository")
|
||||
return tr.Start(ctx, spanName)
|
||||
}
|
||||
69
pkg/user/user.go
Normal file
69
pkg/user/user.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User represents a user in the system
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty" gorm:"index"`
|
||||
Username string `json:"username" gorm:"unique;not null" validate:"required,min=3,max=50"`
|
||||
PasswordHash string `json:"-" gorm:"not null"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CurrentGoal *string `json:"current_goal,omitempty"`
|
||||
IsAdmin bool `json:"is_admin" gorm:"default:false"`
|
||||
AllowPasswordReset bool `json:"allow_password_reset" gorm:"default:false"`
|
||||
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||
}
|
||||
|
||||
// UserRepository defines the interface for user persistence
|
||||
type UserRepository interface {
|
||||
CreateUser(ctx context.Context, user *User) error
|
||||
GetUserByUsername(ctx context.Context, username string) (*User, error)
|
||||
GetUserByID(ctx context.Context, id uint) (*User, error)
|
||||
UpdateUser(ctx context.Context, user *User) error
|
||||
DeleteUser(ctx context.Context, id uint) error
|
||||
AllowPasswordReset(ctx context.Context, username string) error
|
||||
CompletePasswordReset(ctx context.Context, username, newPassword string) error
|
||||
UserExists(ctx context.Context, username string) (bool, error)
|
||||
CheckDatabaseHealth(ctx context.Context) error
|
||||
}
|
||||
|
||||
// AuthService defines interface for authentication operations
|
||||
type AuthService interface {
|
||||
Authenticate(ctx context.Context, username, password string) (*User, error)
|
||||
GenerateJWT(ctx context.Context, user *User) (string, error)
|
||||
ValidateJWT(ctx context.Context, token string) (*User, error)
|
||||
AdminAuthenticate(ctx context.Context, masterPassword string) (*User, error)
|
||||
}
|
||||
|
||||
// UserManager defines interface for user management operations
|
||||
type UserManager interface {
|
||||
UserExists(ctx context.Context, username string) (bool, error)
|
||||
CreateUser(ctx context.Context, user *User) error
|
||||
}
|
||||
|
||||
// PasswordService defines interface for password operations
|
||||
type PasswordService interface {
|
||||
HashPassword(ctx context.Context, password string) (string, error)
|
||||
RequestPasswordReset(ctx context.Context, username string) error
|
||||
CompletePasswordReset(ctx context.Context, username, newPassword string) error
|
||||
}
|
||||
|
||||
// UserService composes all user-related interfaces using Go's interface composition
|
||||
// This is cleaner than aggregation and better for testing
|
||||
type UserService interface {
|
||||
AuthService
|
||||
UserManager
|
||||
PasswordService
|
||||
}
|
||||
|
||||
// PasswordResetService defines the interface for password reset workflow
|
||||
type PasswordResetService interface {
|
||||
RequestPasswordReset(ctx context.Context, username string) error
|
||||
CompletePasswordReset(ctx context.Context, username, newPassword string) error
|
||||
}
|
||||
237
pkg/user/user_test.go
Normal file
237
pkg/user/user_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dance-lessons-coach/pkg/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// createTestConfig creates a test configuration with telemetry disabled
|
||||
func createTestConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Telemetry: config.TelemetryConfig{
|
||||
Enabled: false,
|
||||
Persistence: config.PersistenceTelemetryConfig{
|
||||
Enabled: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRepository(t *testing.T) {
|
||||
t.Run("CRUD operations", func(t *testing.T) {
|
||||
// Create a temporary database
|
||||
dbPath := "test_db.sqlite"
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
cfg := createTestConfig()
|
||||
repo, err := NewSQLiteRepository(dbPath, cfg)
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Test CreateUser
|
||||
user := &User{
|
||||
Username: "testuser",
|
||||
PasswordHash: "hashedpassword",
|
||||
Description: ptrString("Test user"),
|
||||
CurrentGoal: ptrString("Learn to dance"),
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
err = repo.CreateUser(ctx, user)
|
||||
require.NoError(t, err)
|
||||
assert.NotZero(t, user.ID)
|
||||
|
||||
// Test GetUserByUsername
|
||||
retrievedUser, err := repo.GetUserByUsername(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, retrievedUser)
|
||||
assert.Equal(t, "testuser", retrievedUser.Username)
|
||||
|
||||
// Test UserExists
|
||||
exists, err := repo.UserExists(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Test UpdateUser
|
||||
retrievedUser.Description = ptrString("Updated description")
|
||||
err = repo.UpdateUser(ctx, retrievedUser)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify update
|
||||
updatedUser, err := repo.GetUserByUsername(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated description", *updatedUser.Description)
|
||||
|
||||
// Test AllowPasswordReset
|
||||
err = repo.AllowPasswordReset(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify password reset flag
|
||||
userWithReset, err := repo.GetUserByUsername(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, userWithReset.AllowPasswordReset)
|
||||
|
||||
// Test CompletePasswordReset
|
||||
err = repo.CompletePasswordReset(ctx, "testuser", "newhashedpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify password reset completion
|
||||
userAfterReset, err := repo.GetUserByUsername(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "newhashedpassword", userAfterReset.PasswordHash)
|
||||
assert.False(t, userAfterReset.AllowPasswordReset)
|
||||
|
||||
// Test DeleteUser
|
||||
err = repo.DeleteUser(ctx, userAfterReset.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify deletion
|
||||
deletedUser, err := repo.GetUserByUsername(ctx, "testuser")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, deletedUser)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthService(t *testing.T) {
|
||||
t.Run("Password hashing and authentication", func(t *testing.T) {
|
||||
// Create a temporary database
|
||||
dbPath := "test_auth_db.sqlite"
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
cfg := createTestConfig()
|
||||
repo, err := NewSQLiteRepository(dbPath, cfg)
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user service
|
||||
jwtConfig := JWTConfig{
|
||||
Secret: "test-secret",
|
||||
ExpirationTime: time.Hour,
|
||||
Issuer: "test-issuer",
|
||||
}
|
||||
userService := NewUserService(repo, jwtConfig, "admin123")
|
||||
|
||||
// Test password hashing
|
||||
password := "testpassword123"
|
||||
hashedPassword, err := userService.HashPassword(ctx, password)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, hashedPassword)
|
||||
|
||||
// Create a test user
|
||||
user := &User{
|
||||
Username: "testuser",
|
||||
PasswordHash: hashedPassword,
|
||||
}
|
||||
err = repo.CreateUser(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test successful authentication
|
||||
authenticatedUser, err := userService.Authenticate(ctx, "testuser", password)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, authenticatedUser)
|
||||
assert.Equal(t, "testuser", authenticatedUser.Username)
|
||||
|
||||
// Test failed authentication with wrong password
|
||||
_, err = userService.Authenticate(ctx, "testuser", "wrongpassword")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "invalid credentials", err.Error())
|
||||
|
||||
// Test JWT generation
|
||||
token, err := userService.GenerateJWT(ctx, authenticatedUser)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
// Test JWT validation
|
||||
validatedUser, err := userService.ValidateJWT(ctx, token)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, validatedUser)
|
||||
assert.Equal(t, authenticatedUser.ID, validatedUser.ID)
|
||||
|
||||
// Test admin authentication
|
||||
adminUser, err := userService.AdminAuthenticate(ctx, "admin123")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, adminUser)
|
||||
assert.True(t, adminUser.IsAdmin)
|
||||
assert.Equal(t, "admin", adminUser.Username)
|
||||
|
||||
// Test failed admin authentication
|
||||
_, err = userService.AdminAuthenticate(ctx, "wrongadminpassword")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "invalid admin credentials", err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasswordResetService(t *testing.T) {
|
||||
t.Run("Password reset workflow", func(t *testing.T) {
|
||||
// Create a temporary database
|
||||
dbPath := "test_reset_db.sqlite"
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
cfg := createTestConfig()
|
||||
repo, err := NewSQLiteRepository(dbPath, cfg)
|
||||
require.NoError(t, err)
|
||||
defer repo.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create user service
|
||||
jwtConfig := JWTConfig{
|
||||
Secret: "test-secret",
|
||||
ExpirationTime: time.Hour,
|
||||
Issuer: "test-issuer",
|
||||
}
|
||||
userService := NewUserService(repo, jwtConfig, "admin123")
|
||||
|
||||
// Create a test user
|
||||
password := "oldpassword123"
|
||||
hashedPassword, err := userService.HashPassword(ctx, password)
|
||||
require.NoError(t, err)
|
||||
|
||||
user := &User{
|
||||
Username: "resetuser",
|
||||
PasswordHash: hashedPassword,
|
||||
}
|
||||
err = repo.CreateUser(ctx, user)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test password reset request
|
||||
err = userService.RequestPasswordReset(ctx, "resetuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify user is flagged for reset
|
||||
userAfterRequest, err := repo.GetUserByUsername(ctx, "resetuser")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, userAfterRequest.AllowPasswordReset)
|
||||
|
||||
// Test password reset completion
|
||||
newPassword := "newpassword123"
|
||||
err = userService.CompletePasswordReset(ctx, "resetuser", newPassword)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify password was updated and reset flag was cleared
|
||||
userAfterReset, err := repo.GetUserByUsername(ctx, "resetuser")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, userAfterReset.AllowPasswordReset)
|
||||
|
||||
// Verify new password works by authenticating with the new password
|
||||
authenticatedUser, err := userService.Authenticate(ctx, "resetuser", newPassword)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, authenticatedUser)
|
||||
assert.Equal(t, "resetuser", authenticatedUser.Username)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to create string pointers
|
||||
func ptrString(s string) *string {
|
||||
return &s
|
||||
}
|
||||
215
scripts/LOCAL_CI_GUIDE.md
Normal file
215
scripts/LOCAL_CI_GUIDE.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Local CI/CD Testing Guide
|
||||
|
||||
This guide explains how to test the CI/CD pipeline locally using the available scripts.
|
||||
|
||||
## 📁 Available Scripts
|
||||
|
||||
### Core CI Scripts
|
||||
- `test-local-ci-cd.sh` - Complete local CI/CD simulation
|
||||
- `test-docker-cache.sh` - Test Docker build cache functionality
|
||||
- `ci-update-coverage-badge.sh` - Test coverage badge updates
|
||||
- `ci-version-bump.sh` - Test version bump logic
|
||||
|
||||
### Existing Test Scripts
|
||||
- `run-bdd-tests.sh` - Run BDD tests locally
|
||||
- `test-graceful-shutdown.sh` - Test graceful shutdown
|
||||
- `test-opentelemetry.sh` - Test OpenTelemetry integration
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Test Docker Build Cache
|
||||
```bash
|
||||
# Test the Docker cache functionality
|
||||
./scripts/test-docker-cache.sh
|
||||
|
||||
# This will:
|
||||
# 1. Calculate dependency hash (same as CI)
|
||||
# 2. Build Docker cache image
|
||||
# 3. Test commands in Docker
|
||||
# 4. Compare performance
|
||||
```
|
||||
|
||||
### 2. Full Local CI/CD Test
|
||||
```bash
|
||||
# Run complete local CI/CD simulation
|
||||
./scripts/test-local-ci-cd.sh
|
||||
|
||||
# This will:
|
||||
# 1. Install dependencies
|
||||
# 2. Generate Swagger docs
|
||||
# 3. Build and test code
|
||||
# 4. Build binaries
|
||||
# 5. Simulate version bump
|
||||
# 6. Optionally build Docker image
|
||||
```
|
||||
|
||||
### 3. Test Specific Components
|
||||
|
||||
#### Coverage Badge Updates
|
||||
```bash
|
||||
# Test coverage badge update logic
|
||||
./scripts/ci-update-coverage-badge.sh 75.5
|
||||
```
|
||||
|
||||
#### Version Bump Logic
|
||||
```bash
|
||||
# Test version bump with different commit messages
|
||||
./scripts/ci-version-bump.sh "✨ feat: add new feature"
|
||||
./scripts/ci-version-bump.sh "🐛 fix: resolve bug"
|
||||
./scripts/ci-version-bump.sh "Regular commit message"
|
||||
```
|
||||
|
||||
## 🐳 Docker Build Cache Testing
|
||||
|
||||
The Docker build cache system works by:
|
||||
|
||||
1. **Calculating dependency hash**: `sha256sum go.mod go.sum`
|
||||
2. **Building cache image**: Only when dependencies change
|
||||
3. **Using cached image**: For all subsequent CI runs
|
||||
|
||||
### Local Testing
|
||||
```bash
|
||||
# Build the cache image locally
|
||||
docker build -t dance-lessons-coach-build-cache -f Dockerfile.build .
|
||||
|
||||
# Test running commands in the cached environment
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace \
|
||||
dance-lessons-coach-build-cache \
|
||||
go test ./... -cover
|
||||
```
|
||||
|
||||
### CI Integration
|
||||
The CI workflow automatically:
|
||||
- Calculates the same hash
|
||||
- Checks if image exists in registry
|
||||
- Builds new image only when needed
|
||||
- Uses cached image for all builds
|
||||
|
||||
## 🔄 CI/CD Workflow Simulation
|
||||
|
||||
To simulate the full CI/CD workflow locally:
|
||||
|
||||
```bash
|
||||
# 1. Run local CI tests
|
||||
./scripts/test-local-ci-cd.sh
|
||||
|
||||
# 2. When prompted, build Docker image
|
||||
# 3. Test the running container
|
||||
# 4. Verify all endpoints work
|
||||
|
||||
# 5. Test BDD scenarios
|
||||
./scripts/run-bdd-tests.sh
|
||||
|
||||
# 6. Test graceful shutdown
|
||||
./scripts/test-graceful-shutdown.sh
|
||||
|
||||
# 7. Test OpenTelemetry
|
||||
./scripts/test-opentelemetry.sh
|
||||
```
|
||||
|
||||
## 📊 Performance Comparison
|
||||
|
||||
### Without Docker Cache
|
||||
```
|
||||
First run: ~90 seconds
|
||||
Subsequent: ~90 seconds (no caching)
|
||||
```
|
||||
|
||||
### With Docker Cache
|
||||
```
|
||||
First run: ~120 seconds (build cache)
|
||||
Subsequent: ~30 seconds (use cache)
|
||||
Savings: ~60 seconds per run!
|
||||
```
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
1. **Test locally first**: Always run `test-local-ci-cd.sh` before pushing
|
||||
2. **Check Docker cache**: Run `test-docker-cache.sh` after dependency changes
|
||||
3. **Verify coverage**: Test coverage badge updates with different percentages
|
||||
4. **Test version bumps**: Verify version logic with different commit types
|
||||
5. **Clean up**: Remove test containers and images when done
|
||||
|
||||
## 🧪 Advanced Testing
|
||||
|
||||
### Test Race Conditions
|
||||
```bash
|
||||
# Simulate concurrent CI runs
|
||||
./scripts/ci-update-coverage-badge.sh 75.5 &
|
||||
./scripts/ci-update-coverage-badge.sh 75.5 &
|
||||
wait
|
||||
```
|
||||
|
||||
### Test Version Bump Scenarios
|
||||
```bash
|
||||
# Test all version bump scenarios
|
||||
echo "✨ feat: new feature" > /tmp/test_commit
|
||||
./scripts/ci-version-bump.sh "$(cat /tmp/test_commit)"
|
||||
|
||||
echo "🐛 fix: bug fix" > /tmp/test_commit
|
||||
./scripts/ci-version-bump.sh "$(cat /tmp/test_commit)"
|
||||
|
||||
echo "BREAKING CHANGE: major update" > /tmp/test_commit
|
||||
./scripts/ci-version-bump.sh "$(cat /tmp/test_commit)"
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Docker Issues
|
||||
- **Permission denied**: Add user to docker group or use `sudo`
|
||||
- **Port conflicts**: Change test port or stop conflicting services
|
||||
- **Image not found**: Build the image first with `docker build`
|
||||
|
||||
### CI Script Issues
|
||||
- **Missing dependencies**: Install required tools (Go, Docker, etc.)
|
||||
- **Script permissions**: Run `chmod +x scripts/*.sh`
|
||||
- **Path issues**: Use full paths or correct working directory
|
||||
|
||||
### Performance Issues
|
||||
- **Slow Docker builds**: Use `--no-cache` for fresh builds
|
||||
- **Large images**: Check Dockerfile for unnecessary layers
|
||||
- **Memory issues**: Increase Docker resources in settings
|
||||
|
||||
## 📖 Reference
|
||||
|
||||
### Docker Commands
|
||||
```bash
|
||||
# List images
|
||||
docker images
|
||||
|
||||
# List containers
|
||||
docker ps -a
|
||||
|
||||
# Remove container
|
||||
docker rm <container_id>
|
||||
|
||||
# Remove image
|
||||
docker rmi <image_id>
|
||||
|
||||
# View logs
|
||||
docker logs <container_id>
|
||||
|
||||
# Exec into container
|
||||
docker exec -it <container_id> sh
|
||||
```
|
||||
|
||||
### CI Commands
|
||||
```bash
|
||||
# Run specific CI job
|
||||
act -j <job_name>
|
||||
|
||||
# Test workflow locally
|
||||
act
|
||||
|
||||
# Dry run (show what would run)
|
||||
act -n
|
||||
```
|
||||
|
||||
## 🎓 Learning Resources
|
||||
|
||||
- [Docker Documentation](https://docs.docker.com/)
|
||||
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
|
||||
- [Go Testing Documentation](https://pkg.go.dev/testing)
|
||||
- [CI/CD Best Practices](https://github.com/goldbergyoni/nodebestpractices)
|
||||
|
||||
This guide provides everything you need to test the CI/CD pipeline locally before pushing to the repository!
|
||||
20
scripts/calculate-deps-hash.sh
Executable file
20
scripts/calculate-deps-hash.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Calculate dependency hash for Docker cache tag
|
||||
# This script calculates the hash used for the build cache image tag
|
||||
|
||||
# Calculate hash of go.mod + go.sum
|
||||
# Use shasum on macOS, sha256sum on Linux
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
DEPS_HASH=$(sha256sum go.mod go.sum | sha256sum | cut -d' ' -f1 | head -c 12)
|
||||
else
|
||||
DEPS_HASH=$(shasum -a 256 go.mod go.sum | shasum -a 256 | cut -d' ' -f1 | head -c 12)
|
||||
fi
|
||||
|
||||
echo "Dependency hash: $DEPS_HASH"
|
||||
echo "$DEPS_HASH"
|
||||
|
||||
# Export for use in other scripts
|
||||
if [ -n "$1" ]; then
|
||||
echo "DEPS_HASH=$DEPS_HASH" > "$1"
|
||||
echo "Exported to: $1"
|
||||
fi
|
||||
69
scripts/ci-update-coverage-badge.sh
Executable file
69
scripts/ci-update-coverage-badge.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# CI script to update coverage badge in README.md
|
||||
# Usage: scripts/ci-update-coverage-badge.sh <coverage_percentage>
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Error: Coverage percentage not provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
COVERAGE=$1
|
||||
|
||||
# Determine badge color
|
||||
if (( $(echo "$COVERAGE >= 80" | bc -l) )); then
|
||||
COLOR="brightgreen"
|
||||
elif (( $(echo "$COVERAGE >= 50" | bc -l) )); then
|
||||
COLOR="yellow"
|
||||
else
|
||||
COLOR="red"
|
||||
fi
|
||||
|
||||
BADGE_URL="https://img.shields.io/badge/coverage-${COVERAGE}%-${COLOR}?style=flat-square"
|
||||
|
||||
# Only update if coverage has actually changed
|
||||
if grep -q "coverage-${COVERAGE}%" README.md; then
|
||||
echo "Coverage badge already up to date at ${COVERAGE}%"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Update README
|
||||
sed -i "s|https://img.shields.io/badge/coverage-.*-.*?style=flat-square|${BADGE_URL}|" README.md
|
||||
|
||||
# Set up git
|
||||
git config --global user.name "CI Bot"
|
||||
git config --global user.email "ci@arcodange.fr"
|
||||
|
||||
# Set up credentials using Gitea token
|
||||
if [ -n "$PACKAGES_TOKEN" ]; then
|
||||
git config --global credential.helper store
|
||||
echo "https://${PACKAGES_TOKEN}@gitea.arcodange.lab" > ~/.git-credentials
|
||||
fi
|
||||
|
||||
git add README.md
|
||||
if git commit -m "🤖 chore: update coverage badge to ${COVERAGE}% [skip ci]"; then
|
||||
# Try push with retry logic for race conditions
|
||||
for i in 1 2 3; do
|
||||
if git push; then
|
||||
echo "Successfully updated coverage badge to ${COVERAGE}%"
|
||||
# Update local repo to the new HEAD after successful push
|
||||
git fetch origin
|
||||
git reset --hard origin/${GITHUB_REF_NAME:-${CI_COMMIT_REF_NAME:-main}}
|
||||
exit 0
|
||||
else
|
||||
echo "Push attempt $i failed, retrying..."
|
||||
if [ $i -eq 3 ]; then
|
||||
echo "Final push attempt failed - another job may have updated the badge"
|
||||
git pull --rebase || true
|
||||
git push || echo "Recovery push also failed"
|
||||
# Ensure we're on the latest commit even if push failed
|
||||
git fetch origin
|
||||
git reset --hard origin/${GITHUB_REF_NAME:-${CI_COMMIT_REF_NAME:-main}}
|
||||
fi
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "No coverage change to commit"
|
||||
fi
|
||||
68
scripts/ci-version-bump.sh
Executable file
68
scripts/ci-version-bump.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
# CI script to handle automatic version bumping
|
||||
# Usage: scripts/ci-version-bump.sh <commit_message>
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Error: Commit message not provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LAST_COMMIT=$1
|
||||
VERSION_BUMPED="false"
|
||||
|
||||
# Automatic version bump based on commit type
|
||||
if echo "$LAST_COMMIT" | grep -q "^✨ feat:"; then
|
||||
echo "🎯 Feature commit detected - bumping MINOR version"
|
||||
./scripts/version-bump.sh minor
|
||||
VERSION_BUMPED="true"
|
||||
elif echo "$LAST_COMMIT" | grep -q "^🐛 fix:"; then
|
||||
echo "🐛 Fix commit detected - bumping PATCH version"
|
||||
./scripts/version-bump.sh patch
|
||||
VERSION_BUMPED="true"
|
||||
elif echo "$LAST_COMMIT" | grep -q "BREAKING CHANGE"; then
|
||||
echo "💥 Breaking change detected - bumping MAJOR version"
|
||||
./scripts/version-bump.sh major
|
||||
VERSION_BUMPED="true"
|
||||
else
|
||||
echo "⏭️ No automatic version bump needed"
|
||||
fi
|
||||
|
||||
# Update swagger version regardless of bump
|
||||
source VERSION
|
||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH${PRERELEASE:+-$PRERELEASE}"
|
||||
sed -i "s|// @version [0-9.]*|// @version $NEW_VERSION|" cmd/server/main.go
|
||||
|
||||
# Commit version changes if bumped
|
||||
if [ "$VERSION_BUMPED" = "true" ]; then
|
||||
git config --global user.name "CI Bot"
|
||||
git config --global user.email "ci@arcodange.fr"
|
||||
|
||||
# Set up credentials using Gitea token
|
||||
if [ -n "$PACKAGES_TOKEN" ]; then
|
||||
git config --global credential.helper store
|
||||
echo "https://${PACKAGES_TOKEN}@gitea.arcodange.lab" > ~/.git-credentials
|
||||
fi
|
||||
|
||||
git add VERSION cmd/server/main.go README.md
|
||||
if git commit -m "chore: auto version bump [skip ci]"; then
|
||||
# Try push with retry logic for race conditions
|
||||
for i in 1 2 3; do
|
||||
if git push; then
|
||||
echo "Successfully bumped version to $NEW_VERSION"
|
||||
exit 0
|
||||
else
|
||||
echo "Version bump push attempt $i failed, retrying..."
|
||||
if [ $i -eq 3 ]; then
|
||||
echo "Final version bump push attempt failed - another job may have bumped version"
|
||||
git pull --rebase || true
|
||||
git push || echo "Version bump recovery push also failed"
|
||||
fi
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "No version changes to commit"
|
||||
fi
|
||||
fi
|
||||
@@ -1,286 +0,0 @@
|
||||
# CI/CD Scripts for DanceLessonsCoach
|
||||
|
||||
## 🚀 Quick Start for Contributors
|
||||
|
||||
### You Only Need These Commands
|
||||
|
||||
```bash
|
||||
# 1. Run tests (this is what matters most!)
|
||||
go test ./...
|
||||
|
||||
# 2. Build binaries
|
||||
./scripts/build.sh
|
||||
|
||||
# 3. Check formatting
|
||||
go fmt ./...
|
||||
|
||||
# That's it! The CI/CD pipeline will handle the rest when you create a PR.
|
||||
```
|
||||
|
||||
## 📖 Understanding the CI/CD Pipeline
|
||||
|
||||
### What Happens Automatically
|
||||
|
||||
When you push code or create a PR, GitHub Actions runs:
|
||||
|
||||
1. **Go CI/CD Pipeline** (`.gitea/workflows/go-ci-cd.yaml`)
|
||||
- Builds all Go packages
|
||||
- Runs tests with coverage
|
||||
- Checks code formatting
|
||||
- Validates workflow structure
|
||||
|
||||
2. **Docker Image Pipeline** (`.gitea/workflows/dockerimage.yaml`)
|
||||
- Builds Docker image (on main branch only)
|
||||
- Publishes to Gitea Container Registry
|
||||
- Tags with version and commit SHA
|
||||
|
||||
### When Does It Run?
|
||||
|
||||
| Event | Go CI/CD | Docker Image |
|
||||
|-------|---------|--------------|
|
||||
| Push to `main` | ✅ Yes | ✅ Yes |
|
||||
| Push to `feature/*` | ✅ Yes | ❌ No |
|
||||
| Push to `fix/*` | ✅ Yes | ❌ No |
|
||||
| Push to `ci/*` | ✅ Yes | ❌ No |
|
||||
| Pull Request | ✅ Yes | ❌ No |
|
||||
| Manual trigger | ✅ Yes | ✅ Yes |
|
||||
|
||||
## 🧪 Local Testing Options
|
||||
|
||||
### Option 1: Simple Validation (No Docker Required)
|
||||
|
||||
```bash
|
||||
# Just run the essentials
|
||||
./scripts/cicd/contributor-quickstart.sh
|
||||
```
|
||||
|
||||
This checks:
|
||||
- ✅ Go installation
|
||||
- ✅ All tests pass
|
||||
- ✅ Code formatting
|
||||
- ✅ Go vet analysis
|
||||
- ✅ Workflow structure
|
||||
|
||||
### Option 2: Docker-Based Testing (Recommended)
|
||||
|
||||
```bash
|
||||
# Test workflow compatibility with GitHub Actions
|
||||
./scripts/cicd/test-act-local.sh
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Docker installed and running
|
||||
- Internet connection (to pull images)
|
||||
|
||||
**What it does:**
|
||||
- Validates YAML syntax
|
||||
- Checks workflow structure
|
||||
- Simulates GitHub Actions execution
|
||||
- Tests both workflow files
|
||||
|
||||
### Option 3: Full CI/CD Simulation
|
||||
|
||||
```bash
|
||||
# Complete local simulation
|
||||
./scripts/cicd/test-cicd-simple.sh
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Docker installed and running
|
||||
- More time (pulls multiple images)
|
||||
|
||||
**What it does:**
|
||||
- YAML linting
|
||||
- YAML validation
|
||||
- Workflow structure validation
|
||||
- Simulates build job
|
||||
- Runs actual Go tests in containers
|
||||
|
||||
## 🐳 Docker Setup Guide
|
||||
|
||||
### For Windows Users
|
||||
|
||||
1. **Install Docker Desktop**
|
||||
- Download: https://www.docker.com/products/docker-desktop/
|
||||
- Enable WSL 2 backend (recommended)
|
||||
- Allocate at least 4GB RAM
|
||||
|
||||
2. **Verify Installation**
|
||||
```powershell
|
||||
docker --version
|
||||
docker run hello-world
|
||||
```
|
||||
|
||||
### For macOS Users
|
||||
|
||||
1. **Install Docker Desktop**
|
||||
- Download: https://www.docker.com/products/docker-desktop/
|
||||
- Grant necessary permissions
|
||||
|
||||
2. **Verify Installation**
|
||||
```bash
|
||||
docker --version
|
||||
docker run hello-world
|
||||
```
|
||||
|
||||
### For Linux Users
|
||||
|
||||
1. **Install Docker Engine**
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get update
|
||||
sudo apt-get install docker.io docker-compose
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
|
||||
# Add user to docker group (avoid sudo)
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker # Reload group membership
|
||||
```
|
||||
|
||||
2. **Verify Installation**
|
||||
```bash
|
||||
docker --version
|
||||
docker run hello-world
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Docker Permission Issues
|
||||
|
||||
**Symptom:** `Got permission denied while trying to connect to the Docker daemon socket`
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Linux/macOS
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
|
||||
# Windows
|
||||
Right-click Docker Desktop → Settings → Resources → WSL Integration → Enable
|
||||
```
|
||||
|
||||
### Docker Not Running
|
||||
|
||||
**Symptom:** `Cannot connect to the Docker daemon`
|
||||
|
||||
**Solution:**
|
||||
- Windows/macOS: Open Docker Desktop app
|
||||
- Linux: `sudo systemctl start docker`
|
||||
|
||||
### Network Issues
|
||||
|
||||
**Symptom:** `Cannot pull Docker images`
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check internet connection
|
||||
ping google.com
|
||||
|
||||
# Try pulling manually first
|
||||
docker pull mikefarah/yq:latest
|
||||
docker pull pipelinecomponents/yamllint:latest
|
||||
```
|
||||
|
||||
### act Not Installed
|
||||
|
||||
**Symptom:** `act not found` in `test-act-local.sh`
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Install act (optional - only needed for test-act-local.sh)
|
||||
# macOS
|
||||
brew install act
|
||||
|
||||
# Linux
|
||||
curl https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash
|
||||
|
||||
# Windows (WSL)
|
||||
curl https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash
|
||||
```
|
||||
|
||||
## 📚 Script Reference
|
||||
|
||||
| Script | Purpose | Docker Required? | act Required? |
|
||||
|--------|---------|------------------|---------------|
|
||||
| `contributor-quickstart.sh` | Basic validation | ❌ No | ❌ No |
|
||||
| `validate-workflow.sh` | Workflow structure | ❌ No | ❌ No |
|
||||
| `test-act-local.sh` | GitHub Actions compatibility | ✅ Yes | ✅ Yes |
|
||||
| `test-cicd-simple.sh` | Full CI/CD simulation | ✅ Yes | ❌ No |
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### Before Submitting a PR
|
||||
|
||||
1. **Run tests locally**
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
2. **Check formatting**
|
||||
```bash
|
||||
go fmt ./...
|
||||
```
|
||||
|
||||
3. **Build binaries**
|
||||
```bash
|
||||
./scripts/build.sh
|
||||
```
|
||||
|
||||
4. **Validate workflows** (optional)
|
||||
```bash
|
||||
./scripts/cicd/validate-workflow.sh
|
||||
```
|
||||
|
||||
### Working with the CI/CD Pipeline
|
||||
|
||||
- **Don't worry about Docker images** - The pipeline builds them automatically
|
||||
- **Focus on tests** - If tests pass locally, they'll pass in CI/CD
|
||||
- **Check PR status** - GitHub will show CI/CD results automatically
|
||||
- **Fix failures** - If CI/CD fails, check the logs and fix issues
|
||||
|
||||
## 🔗 Useful Links
|
||||
|
||||
- **GitHub Actions Docs**: https://docs.github.com/en/actions
|
||||
- **Docker Docs**: https://docs.docker.com/
|
||||
- **act GitHub**: https://github.com/nektos/act
|
||||
- **DanceLessonsCoach CI/CD**: See `.gitea/workflows/` directory
|
||||
|
||||
## 💡 Pro Tips
|
||||
|
||||
### Speed Up Local Testing
|
||||
|
||||
```bash
|
||||
# Pull Docker images in advance
|
||||
docker pull mikefarah/yq:latest
|
||||
docker pull pipelinecomponents/yamllint:latest
|
||||
docker pull node:16-buster-slim
|
||||
```
|
||||
|
||||
### Test Specific Workflows
|
||||
|
||||
```bash
|
||||
# Test Go CI/CD workflow only
|
||||
act -W .gitea/workflows/go-ci-cd.yaml
|
||||
|
||||
# Test Docker workflow only
|
||||
act -W .gitea/workflows/dockerimage.yaml
|
||||
```
|
||||
|
||||
### Dry Run (No Execution)
|
||||
|
||||
```bash
|
||||
# Check workflow syntax without running
|
||||
echo 'm' | act -n -W .gitea/workflows/go-ci-cd.yaml
|
||||
```
|
||||
|
||||
## 📞 Need Help?
|
||||
|
||||
If you're stuck with CI/CD setup:
|
||||
|
||||
1. **Check this documentation** - Most issues are covered here
|
||||
2. **Run contributor-quickstart.sh** - It validates the essentials
|
||||
3. **Ask in the PR** - We'll help you resolve any issues
|
||||
4. **Check CI/CD logs** - GitHub shows detailed error messages
|
||||
|
||||
Remember: **You don't need to run CI/CD locally to contribute!** The pipeline runs automatically when you push code.
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Check CI/CD pipeline status across all platforms
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Checking CI/CD Pipeline Status"
|
||||
echo "================================"
|
||||
|
||||
# 1. Gitea (Primary) - Internal URL
|
||||
if curl -s -o /dev/null -w "%{http_code}" "https://gitea.arcodange.lab/api/v1/repos/arcodange/DanceLessonsCoach/actions/workflows" 2>/dev/null | grep -q "200"; then
|
||||
echo "✅ Gitea Internal API: Accessible"
|
||||
# Get workflow list
|
||||
WORKFLOWS=$(curl -s "https://gitea.arcodange.lab/api/v1/repos/arcodange/DanceLessonsCoach/actions/workflows" 2>/dev/null | jq -r '.[] | .name + " (" + .file_name + ")"' 2>/dev/null || echo "Unable to fetch workflow list")
|
||||
echo "📋 Gitea Workflows:"
|
||||
echo "$WORKFLOWS" | sed 's/^/ - /'
|
||||
else
|
||||
echo "❌ Gitea Internal API: Not accessible (check network/vpn)"
|
||||
fi
|
||||
|
||||
# 2. Gitea (External) - Public URL
|
||||
echo ""
|
||||
echo "🌐 Gitea External Status:"
|
||||
if curl -s -o /dev/null -w "%{http_code}" "https://gitea.arcodange.fr/arcodange/DanceLessonsCoach" 2>/dev/null | grep -q "200"; then
|
||||
echo "✅ Gitea External: Accessible"
|
||||
echo "🔗 Repository: https://gitea.arcodange.fr/arcodange/DanceLessonsCoach"
|
||||
else
|
||||
echo "❌ Gitea External: Not accessible"
|
||||
fi
|
||||
|
||||
# 3. Check badge API
|
||||
echo ""
|
||||
echo "🏷️ Badge API Status:"
|
||||
BADGE_URL="https://gitea.arcodange.fr/api/badges/arcodange/DanceLessonsCoach/status"
|
||||
if curl -s -o /dev/null -w "%{http_code}" "$BADGE_URL" 2>/dev/null | grep -q "200"; then
|
||||
echo "✅ Badge API: Accessible"
|
||||
echo "🔗 Badge URL: $BADGE_URL"
|
||||
else
|
||||
echo "❌ Badge API: Not accessible"
|
||||
fi
|
||||
|
||||
# 4. Check workflow file existence
|
||||
echo ""
|
||||
echo "📁 Workflow Files:"
|
||||
if [ -f ".gitea/workflows/ci-cd.yaml" ]; then
|
||||
echo "✅ .gitea/workflows/ci-cd.yaml: Found"
|
||||
if command -v yq >/dev/null 2>&1; then
|
||||
echo "📊 Jobs: $(yq eval '.jobs | keys | join(", ")' .gitea/workflows/ci-cd.yaml 2>/dev/null || echo 'Unable to parse')"
|
||||
else
|
||||
echo "📊 Jobs: yq not installed, cannot parse jobs"
|
||||
fi
|
||||
else
|
||||
echo "❌ .gitea/workflows/ci-cd.yaml: Not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎯 Validation Summary"
|
||||
echo "================================"
|
||||
echo "✅ Local workflow file: .gitea/workflows/ci-cd.yaml"
|
||||
if command -v yq >/dev/null 2>&1; then
|
||||
echo "✅ Syntax validation: $(yq eval '.' .gitea/workflows/ci-cd.yaml > /dev/null 2>&1 && echo 'Valid YAML' || echo 'Invalid YAML')"
|
||||
else
|
||||
echo "⚠️ Syntax validation: yq not installed"
|
||||
fi
|
||||
echo "✅ Gitea compatibility: Uses .gitea/workflows/ directory"
|
||||
echo "✅ Arcodange conventions: Matches webapp workflow style"
|
||||
|
||||
echo ""
|
||||
echo "💡 Next Steps:"
|
||||
echo " 1. Push to trigger workflow: git push origin main"
|
||||
echo " 2. Check Gitea Actions: https://gitea.arcodange.lab/arcodange/DanceLessonsCoach/actions"
|
||||
echo " 3. Monitor badges: https://gitea.arcodange.fr/arcodange/DanceLessonsCoach"
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Simple CI/CD validation for new contributors
|
||||
# Works without Docker - just validates the essentials
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 DanceLessonsCoach Contributor Quick Start"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "This script helps you validate your changes before submitting a PR."
|
||||
echo "It doesn't require Docker or complex setup."
|
||||
echo ""
|
||||
|
||||
# 1. Check Go is installed
|
||||
echo "1. Checking Go installation..."
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
echo "❌ Go is not installed. Please install Go 1.26.1+"
|
||||
echo " Download: https://go.dev/dl/"
|
||||
exit 1
|
||||
fi
|
||||
go_version=$(go version | grep -o 'go[0-9.]*')
|
||||
echo "✅ Go $go_version found"
|
||||
|
||||
# 2. Run Go tests
|
||||
echo ""
|
||||
echo "2. Running Go tests..."
|
||||
if go test ./...; then
|
||||
echo "✅ All Go tests passed"
|
||||
else
|
||||
echo "❌ Some tests failed. Please fix and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Check formatting
|
||||
echo ""
|
||||
echo "3. Checking code formatting..."
|
||||
if [ -n "$(go fmt ./...)" ]; then
|
||||
echo "❌ Code formatting issues found"
|
||||
echo " Run: go fmt ./..."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Code is properly formatted"
|
||||
|
||||
# 4. Run Go vet
|
||||
echo ""
|
||||
echo "4. Running Go vet..."
|
||||
if go vet ./...; then
|
||||
echo "✅ Go vet passed"
|
||||
else
|
||||
echo "❌ Go vet found issues"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Validate workflows (no Docker required)
|
||||
echo ""
|
||||
echo "5. Validating CI/CD workflows..."
|
||||
if [ -f "scripts/cicd/validate-workflow.sh" ]; then
|
||||
if ./scripts/cicd/validate-workflow.sh; then
|
||||
echo "✅ Workflow validation passed"
|
||||
else
|
||||
echo "⚠️ Workflow validation issues (not critical)"
|
||||
fi
|
||||
else
|
||||
echo "ℹ️ Workflow validation script not found"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 All checks passed!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Your changes are ready to submit! 🚀"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Commit your changes: git commit -m 'feat: your feature'"
|
||||
echo " 2. Push to your branch: git push origin your-branch"
|
||||
echo " 3. Create a Pull Request"
|
||||
echo ""
|
||||
echo "The CI/CD pipeline will run automatically on your PR!"
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Test Gitea workflows locally using GitHub Actions runner (act)
|
||||
# This allows local testing without requiring a Gitea instance
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧪 Testing Gitea Workflows with GitHub Actions Runner"
|
||||
echo "===================================================="
|
||||
|
||||
# Check if act is installed
|
||||
if ! command -v act >/dev/null 2>&1; then
|
||||
echo "❌ act not found. Please install with:"
|
||||
echo " brew install act # macOS"
|
||||
echo " or visit: https://github.com/nektos/act"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if workflow files exist
|
||||
WORKFLOW_FILES=(
|
||||
".gitea/workflows/go-ci-cd.yaml"
|
||||
".gitea/workflows/dockerimage.yaml"
|
||||
)
|
||||
|
||||
for file in "${WORKFLOW_FILES[@]}"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "❌ Workflow file not found: $file"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ act installed and workflow file found"
|
||||
echo ""
|
||||
|
||||
# 1. Dry run (syntax check only)
|
||||
echo "1. Running dry run (syntax validation)..."
|
||||
ALL_PASSED=true
|
||||
|
||||
for file in "${WORKFLOW_FILES[@]}"; do
|
||||
echo " Testing: $file"
|
||||
if echo 'm' | act -n -W "$file" --container-architecture linux/amd64; then
|
||||
echo " ✅ Dry run completed for $file"
|
||||
else
|
||||
echo " ❌ Dry run failed for $file"
|
||||
ALL_PASSED=false
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$ALL_PASSED" = true ]; then
|
||||
echo "✅ All dry runs completed successfully"
|
||||
else
|
||||
echo "❌ Some dry runs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
echo ""
|
||||
echo "🎉 Gitea workflows are compatible with GitHub Actions!"
|
||||
echo "=================================================="
|
||||
echo ""
|
||||
echo "📋 Summary:"
|
||||
echo " ✅ Syntax validation passed for all workflows"
|
||||
echo " ✅ All jobs parsed correctly"
|
||||
echo " ✅ Job dependencies resolved"
|
||||
echo " ✅ Conditional execution working"
|
||||
echo " ✅ Gitea/GitHub Actions compatibility confirmed"
|
||||
echo ""
|
||||
echo "🚀 You can now test locally without Gitea instance:"
|
||||
for file in "${WORKFLOW_FILES[@]}"; do
|
||||
workflow_name=$(basename "$file" .yaml)
|
||||
echo " act -n -W $file # Dry run $workflow_name"
|
||||
echo " act -W $file # Full execution $workflow_name"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "💡 Tip: Add this to your pre-commit hook to validate workflows automatically!"
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Comprehensive Docker-based CI/CD testing script
|
||||
# Tests workflows locally using Docker containers
|
||||
|
||||
set -e
|
||||
|
||||
echo "🐳 Docker-based CI/CD Testing"
|
||||
echo "================================"
|
||||
|
||||
# 1. Check Docker is available
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "❌ Docker not found. Please install Docker first."
|
||||
echo " https://docs.docker.com/get-docker/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Docker is available"
|
||||
|
||||
# 2. Pull required images
|
||||
echo ""
|
||||
echo "📦 Pulling Docker images..."
|
||||
docker pull gitea/act_runner:latest
|
||||
docker pull pipelinecomponents/yamllint:latest
|
||||
docker pull mikefarah/yq:latest
|
||||
|
||||
echo "✅ Images pulled successfully"
|
||||
|
||||
# 3. Validate YAML syntax with yq
|
||||
echo ""
|
||||
echo "🔍 Validating YAML syntax..."
|
||||
docker run --rm \
|
||||
-v $(pwd):/workspace \
|
||||
-w /workspace \
|
||||
mikefarah/yq:latest \
|
||||
yq eval .gitea/workflows/go-ci-cd.yaml > /dev/null 2>&1
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ YAML syntax is valid"
|
||||
else
|
||||
echo "❌ YAML syntax error"
|
||||
docker run --rm \
|
||||
-v $(pwd):/workspace \
|
||||
-w /workspace \
|
||||
mikefarah/yq:latest \
|
||||
yq eval .gitea/workflows/go-ci-cd.yaml || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Lint YAML with yamllint
|
||||
echo ""
|
||||
echo "🧹 Linting YAML..."
|
||||
docker run --rm \
|
||||
-v $(pwd):/workspace \
|
||||
-w /workspace \
|
||||
pipelinecomponents/yamllint:latest \
|
||||
yamllint .gitea/workflows/
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ YAML linting passed"
|
||||
else
|
||||
echo "❌ YAML linting failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Run workflow with act
|
||||
echo ""
|
||||
echo "🚀 Running CI/CD workflow..."
|
||||
docker run --rm \
|
||||
-v $(pwd):/workspace \
|
||||
-w /workspace \
|
||||
-e GITEA_INTERNAL="https://gitea.arcodange.lab/" \
|
||||
-e GITEA_EXTERNAL="https://gitea.arcodange.fr/" \
|
||||
-e GITEA_ORG="arcodange" \
|
||||
-e GITEA_REPO="DanceLessonsCoach" \
|
||||
gitea/act_runner:latest \
|
||||
act -W .gitea/workflows/go-ci-cd.yaml --rm
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Workflow executed successfully"
|
||||
else
|
||||
echo "❌ Workflow execution failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 All CI/CD tests passed!"
|
||||
echo "================================"
|
||||
echo "📁 Workflow: .gitea/workflows/ci-cd.yaml"
|
||||
echo "✅ YAML syntax validated"
|
||||
echo "✅ YAML linting passed"
|
||||
echo "✅ Workflow execution successful"
|
||||
echo "🎯 Ready for production deployment"
|
||||
|
||||
echo ""
|
||||
echo "💡 Next Steps:"
|
||||
echo " 1. Commit changes: git commit -m '🤖 ci: update workflow'"
|
||||
echo " 2. Push to trigger: git push origin main"
|
||||
echo " 3. Monitor pipeline: https://gitea.arcodange.lab/arcodange/DanceLessonsCoach/actions"
|
||||
echo " 4. Check badges: https://gitea.arcodange.fr/arcodange/DanceLessonsCoach"
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Test CI/CD setup locally without requiring Gitea instance
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧪 Testing CI/CD Local Setup"
|
||||
echo "=============================="
|
||||
|
||||
# 1. Validate YAML syntax
|
||||
echo "1. Validating YAML syntax..."
|
||||
if command -v yq >/dev/null 2>&1; then
|
||||
yq eval '.' .gitea/workflows/go-ci-cd.yaml > /dev/null
|
||||
yq eval '.' .gitea/workflows/dockerimage.yaml > /dev/null
|
||||
echo "✅ YAML syntax is valid"
|
||||
else
|
||||
echo "⚠️ yq not found, skipping YAML validation"
|
||||
fi
|
||||
|
||||
# 2. Validate workflow structure
|
||||
echo "2. Validating workflow structure..."
|
||||
./scripts/cicd/validate-workflow.sh
|
||||
|
||||
# 3. Check docker-compose configuration
|
||||
echo "3. Checking docker-compose configuration..."
|
||||
docker compose -f docker-compose.cicd-test.yml config > /dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ docker-compose configuration is valid"
|
||||
else
|
||||
echo "❌ docker-compose configuration has issues"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Check for required files
|
||||
echo "4. Checking required files..."
|
||||
REQUIRED_FILES=(
|
||||
".gitea/workflows/go-ci-cd.yaml"
|
||||
".gitea/workflows/dockerimage.yaml"
|
||||
"docker-compose.cicd-test.yml"
|
||||
"config/runner.example"
|
||||
)
|
||||
|
||||
for file in "${REQUIRED_FILES[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "✅ $file exists"
|
||||
else
|
||||
echo "❌ $file missing"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 5. Show configuration status
|
||||
echo "5. Configuration status..."
|
||||
if [ -f "config/runner" ]; then
|
||||
echo "✅ config/runner exists (gitignored)"
|
||||
echo "📝 You can connect to Gitea instance"
|
||||
else
|
||||
echo "ℹ️ config/runner not found (expected - it's gitignored)"
|
||||
echo "📝 To connect to Gitea:"
|
||||
echo " 1. Copy config/runner.example to config/runner"
|
||||
echo " 2. Fill in your Gitea runner configuration"
|
||||
echo " 3. Set environment variables:"
|
||||
echo " export GITEA_RUNNER_REGISTRATION_TOKEN=your-token"
|
||||
echo " 4. Run: docker compose -f docker-compose.cicd-test.yml up"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 CI/CD Local Setup Validation Complete!"
|
||||
echo "=============================="
|
||||
echo "📋 Summary:"
|
||||
echo " ✅ YAML syntax validated"
|
||||
echo " ✅ Workflow structure validated"
|
||||
echo " ✅ Docker-compose configuration validated"
|
||||
echo " ✅ All required files present"
|
||||
echo ""
|
||||
echo "🚀 Next steps:"
|
||||
echo " 1. Create config/runner file with your Gitea runner token"
|
||||
echo " 2. Set GITEA_RUNNER_REGISTRATION_TOKEN environment variable"
|
||||
echo " 3. Run: docker compose -f docker-compose.cicd-test.yml up"
|
||||
echo ""
|
||||
echo "💡 For local testing without Gitea:"
|
||||
echo " Use: ./scripts/test-cicd-simple.sh (if available)"
|
||||
echo " Or manually test workflow steps"
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Simple CI/CD testing without Gitea instance
|
||||
# Tests the workflow steps locally using docker containers
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧪 Simple CI/CD Testing (No Gitea Required)"
|
||||
echo "=========================================="
|
||||
|
||||
# 1. YAML Linting
|
||||
echo "1. Running YAML linting..."
|
||||
if [ -f ".yamllint.yaml" ]; then
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace pipelinecomponents/yamllint:latest \
|
||||
yamllint -c .yamllint.yaml .gitea/workflows/
|
||||
else
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace pipelinecomponents/yamllint:latest \
|
||||
yamllint .gitea/workflows/
|
||||
fi
|
||||
echo "✅ YAML linting passed"
|
||||
|
||||
# 2. YAML Validation
|
||||
echo "2. Running YAML validation..."
|
||||
WORKFLOW_FILES=(".gitea/workflows/go-ci-cd.yaml" ".gitea/workflows/dockerimage.yaml")
|
||||
for file in "${WORKFLOW_FILES[@]}"; do
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace mikefarah/yq:latest eval '.' "$file" > /dev/null
|
||||
done
|
||||
echo "✅ YAML validation passed"
|
||||
|
||||
# 3. Workflow Structure Validation
|
||||
echo "3. Running workflow structure validation..."
|
||||
./scripts/cicd/validate-workflow.sh
|
||||
|
||||
# 4. Simulate Build Job
|
||||
echo "4. Simulating build-test job..."
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace golang:1.26.1 bash -c "
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null && \
|
||||
go mod tidy && \
|
||||
go build ./... && \
|
||||
go test ./... -cover -v
|
||||
"
|
||||
echo "✅ Build and test completed"
|
||||
|
||||
# 5. Simulate Lint Job
|
||||
echo "5. Simulating lint-format job..."
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace golang:1.26.1 bash -c "
|
||||
go fmt ./... && \
|
||||
go vet ./... && \
|
||||
echo 'Formatting check passed'
|
||||
"
|
||||
echo "✅ Linting completed"
|
||||
|
||||
echo ""
|
||||
echo "🎉 Simple CI/CD Testing Complete!"
|
||||
echo "=========================================="
|
||||
echo "✅ All workflow steps validated locally"
|
||||
echo "📝 Workflow is ready for Gitea deployment"
|
||||
echo ""
|
||||
echo "🚀 To deploy to Gitea:"
|
||||
echo " 1. Create config/runner file with your Gitea runner token"
|
||||
echo " 2. Set GITEA_RUNNER_REGISTRATION_TOKEN environment variable"
|
||||
echo " 3. Run: docker compose -f docker-compose.cicd-test.yml up"
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Validate CI/CD workflow syntax and structure
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔍 Validating CI/CD Workflow"
|
||||
echo "================================"
|
||||
|
||||
# 1. Check workflow files exist
|
||||
WORKFLOW_FILES=(
|
||||
".gitea/workflows/go-ci-cd.yaml"
|
||||
".gitea/workflows/dockerimage.yaml"
|
||||
)
|
||||
|
||||
for file in "${WORKFLOW_FILES[@]}"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "❌ Workflow file not found: $file"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Workflow file found: $file"
|
||||
done
|
||||
|
||||
# 2. Validate YAML syntax for all workflows
|
||||
if command -v yq >/dev/null 2>&1; then
|
||||
for file in "${WORKFLOW_FILES[@]}"; do
|
||||
if ! yq eval '.' "$file" > /dev/null 2>&1; then
|
||||
echo "❌ Invalid YAML syntax in: $file"
|
||||
yq eval '.' "$file" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ YAML syntax valid: $file"
|
||||
done
|
||||
else
|
||||
echo "⚠️ yq not installed, skipping YAML validation"
|
||||
fi
|
||||
|
||||
# 3. YAML Linting with custom config for all workflows
|
||||
if command -v yamllint >/dev/null 2>&1; then
|
||||
for file in "${WORKFLOW_FILES[@]}"; do
|
||||
if [ -f ".yamllint.yaml" ]; then
|
||||
yamllint -c "$(pwd)/.yamllint.yaml" "$file"
|
||||
else
|
||||
yamllint "$file"
|
||||
fi
|
||||
done
|
||||
elif docker info >/dev/null 2>&1; then
|
||||
for file in "${WORKFLOW_FILES[@]}"; do
|
||||
if [ -f ".yamllint.yaml" ]; then
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace pipelinecomponents/yamllint:latest \
|
||||
yamllint -c /workspace/.yamllint.yaml "$file"
|
||||
else
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace pipelinecomponents/yamllint:latest \
|
||||
yamllint "$file"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "⚠️ Neither yamllint nor docker available, skipping linting"
|
||||
fi
|
||||
|
||||
# 3. Check required fields for all workflows
|
||||
for file in "${WORKFLOW_FILES[@]}"; do
|
||||
MISSING_FIELDS=()
|
||||
|
||||
if command -v yq >/dev/null 2>&1; then
|
||||
workflow_name=$(basename "$file" .yaml)
|
||||
|
||||
if [ -z "$(yq eval '.name' "$file" 2>/dev/null)" ]; then
|
||||
MISSING_FIELDS+=("name")
|
||||
fi
|
||||
|
||||
if [ -z "$(yq eval '.on' "$file" 2>/dev/null)" ]; then
|
||||
MISSING_FIELDS+=("on")
|
||||
fi
|
||||
|
||||
if [ -z "$(yq eval '.jobs' "$file" 2>/dev/null)" ]; then
|
||||
MISSING_FIELDS+=("jobs")
|
||||
fi
|
||||
|
||||
if [ ${#MISSING_FIELDS[@]} -gt 0 ]; then
|
||||
echo "❌ Missing required fields in $workflow_name: ${MISSING_FIELDS[*]}"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All required fields present in $workflow_name"
|
||||
else
|
||||
echo "⚠️ yq not installed, skipping field validation for $file"
|
||||
fi
|
||||
done
|
||||
|
||||
# 4. Check jobs structure
|
||||
if command -v yq >/dev/null 2>&1; then
|
||||
JOBS=$(yq eval '.jobs | keys' .gitea/workflows/ci-cd.yaml 2>/dev/null)
|
||||
echo "📋 Jobs defined: $JOBS"
|
||||
|
||||
for job in $JOBS; do
|
||||
job_str=$(echo $job | tr -d '"')
|
||||
|
||||
# Check job has steps
|
||||
if [ -z "$(yq eval ".jobs.$job_str.steps" .gitea/workflows/ci-cd.yaml 2>/dev/null)" ]; then
|
||||
echo "❌ Job $job_str has no steps"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
steps_count=$(yq eval ".jobs.$job_str.steps | length" .gitea/workflows/ci-cd.yaml 2>/dev/null)
|
||||
echo " ✅ $job_str: $steps_count steps"
|
||||
done
|
||||
else
|
||||
echo "⚠️ yq not installed, skipping job structure validation"
|
||||
fi
|
||||
|
||||
# 5. Check Arcodange-specific configurations
|
||||
if command -v yq >/dev/null 2>&1; then
|
||||
if [ -n "$(yq eval '.env.GITEA_INTERNAL' .gitea/workflows/ci-cd.yaml 2>/dev/null)" ]; then
|
||||
echo "✅ Arcodange internal URL configured"
|
||||
else
|
||||
echo "⚠️ Arcodange internal URL not found"
|
||||
fi
|
||||
|
||||
if [ -n "$(yq eval '.env.GITEA_EXTERNAL' .gitea/workflows/ci-cd.yaml 2>/dev/null)" ]; then
|
||||
echo "✅ Arcodange external URL configured"
|
||||
else
|
||||
echo "⚠️ Arcodange external URL not found"
|
||||
fi
|
||||
|
||||
# 6. Check concurrency settings
|
||||
if [ -n "$(yq eval '.concurrency' .gitea/workflows/ci-cd.yaml 2>/dev/null)" ]; then
|
||||
echo "✅ Concurrency control configured"
|
||||
else
|
||||
echo "⚠️ No concurrency control (consider adding)"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ yq not installed, skipping Arcodange-specific validations"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Workflow Validation Successful!"
|
||||
echo "================================"
|
||||
echo "📁 Workflows validated:"
|
||||
for file in "${WORKFLOW_FILES[@]}"; do
|
||||
echo " - $file"
|
||||
done
|
||||
if command -v yq >/dev/null 2>&1; then
|
||||
echo "🔧 Summary:"
|
||||
for file in "${WORKFLOW_FILES[@]}"; do
|
||||
workflow_name=$(basename "$file" .yaml)
|
||||
JOBS=$(yq eval '.jobs | keys | join(", ")' "$file" 2>/dev/null || echo 'Unable to parse')
|
||||
echo " - $workflow_name: $JOBS"
|
||||
done
|
||||
else
|
||||
echo "🔧 Jobs: yq not installed"
|
||||
fi
|
||||
echo "🎯 Ready for deployment"
|
||||
179
scripts/test-build-cache-environment.sh
Executable file
179
scripts/test-build-cache-environment.sh
Executable file
@@ -0,0 +1,179 @@
|
||||
#!/bin/bash
|
||||
# Test the build cache environment without local Go installation
|
||||
# This simulates the Gitea act runner environment
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧪 Testing Build Cache Environment"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
|
||||
# 1. Calculate dependency hash
|
||||
echo "1. Calculating dependency hash..."
|
||||
DEPS_HASH=$(./scripts/calculate-deps-hash.sh)
|
||||
echo "✅ Dependency hash: $DEPS_HASH"
|
||||
echo ""
|
||||
|
||||
# 2. Build the build cache image
|
||||
echo "2. Building build cache image..."
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
docker build -t dance-lessons-coach-build-cache:$DEPS_HASH -f docker/Dockerfile.build .
|
||||
else
|
||||
echo "❌ Docker not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Build cache image built: dance-lessons-coach-build-cache:$DEPS_HASH"
|
||||
echo ""
|
||||
|
||||
# 3. Test Go environment inside the container
|
||||
echo "3. Testing Go environment inside container..."
|
||||
docker run --rm dance-lessons-coach-build-cache:$DEPS_HASH sh -c "go version"
|
||||
docker run --rm dance-lessons-coach-build-cache:$DEPS_HASH sh -c "which swag"
|
||||
echo "✅ Go and swag available in container"
|
||||
echo ""
|
||||
|
||||
# 4. Test Swagger generation
|
||||
echo "4. Testing Swagger generation..."
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace dance-lessons-coach-build-cache:$DEPS_HASH sh -c "cd pkg/server && go generate"
|
||||
if [ -f "pkg/server/docs/swagger.json" ]; then
|
||||
echo "✅ Swagger documentation generated successfully"
|
||||
else
|
||||
echo "❌ Swagger documentation generation failed"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 5. Test Go build
|
||||
echo "5. Testing Go build..."
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace dance-lessons-coach-build-cache:$DEPS_HASH sh -c "go build ./..."
|
||||
echo "✅ Go build successful"
|
||||
echo ""
|
||||
|
||||
# 6. Test Go test
|
||||
echo "6. Testing Go test..."
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace dance-lessons-coach-build-cache:$DEPS_HASH sh -c "go test ./... -v"
|
||||
echo "✅ Go tests passed"
|
||||
echo ""
|
||||
|
||||
# 7. Test binary build
|
||||
echo "7. Testing binary build..."
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace dance-lessons-coach-build-cache:$DEPS_HASH sh -c "go build -o /workspace/dance-lessons-coach ./cmd/server"
|
||||
if [ -f "dance-lessons-coach" ]; then
|
||||
echo "✅ Binary built successfully"
|
||||
ls -la dance-lessons-coach
|
||||
rm dance-lessons-coach
|
||||
else
|
||||
echo "❌ Binary build failed"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 8. Test production Dockerfile with the cache
|
||||
echo "8. Testing production Dockerfile..."
|
||||
# First, let's create a temporary Dockerfile.prod with the correct hash
|
||||
TEMP_DOCKERFILE="Dockerfile.prod.test"
|
||||
cat > "$TEMP_DOCKERFILE" << EOF
|
||||
# DanceLessonsCoach Production Docker Image
|
||||
# Minimal image using pre-built binary from CI cache
|
||||
|
||||
# Use the build cache image as base
|
||||
FROM dance-lessons-coach-build-cache:$DEPS_HASH AS builder
|
||||
|
||||
# Final minimal image
|
||||
FROM alpine:3.18
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install minimal dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /workspace/dance-lessons-coach /app/dance-lessons-coach
|
||||
|
||||
# Copy configuration
|
||||
COPY config.yaml /app/config.yaml
|
||||
|
||||
# Set permissions
|
||||
RUN chmod +x /app/dance-lessons-coach
|
||||
|
||||
# Set timezone
|
||||
ENV TZ=UTC
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s \
|
||||
CMD wget -q --spider http://localhost:8080/api/health || exit 1
|
||||
|
||||
# Entry point
|
||||
ENTRYPOINT ["/app/dance-lessons-coach"]
|
||||
EOF
|
||||
|
||||
echo "✅ Created temporary production Dockerfile with correct hash"
|
||||
echo ""
|
||||
|
||||
# 9. Build production image
|
||||
echo "9. Building production image..."
|
||||
docker build -t dance-lessons-coach-prod:$DEPS_HASH -f "$TEMP_DOCKERFILE" .
|
||||
echo "✅ Production image built: dance-lessons-coach-prod:$DEPS_HASH"
|
||||
echo ""
|
||||
|
||||
# 10. Test production image
|
||||
echo "10. Testing production image..."
|
||||
docker run -d -p 8081:8080 --name test-prod-container dance-lessons-coach-prod:$DEPS_HASH
|
||||
sleep 5
|
||||
|
||||
# Test health endpoint
|
||||
if curl -s http://localhost:8081/api/health | grep -q "healthy"; then
|
||||
echo "✅ Production container is healthy"
|
||||
else
|
||||
echo "❌ Production container health check failed"
|
||||
docker logs test-prod-container
|
||||
docker stop test-prod-container
|
||||
docker rm test-prod-container
|
||||
rm "$TEMP_DOCKERFILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test greet endpoint
|
||||
if curl -s http://localhost:8081/api/v1/greet/ | grep -q "Hello"; then
|
||||
echo "✅ Production container greet endpoint working"
|
||||
else
|
||||
echo "❌ Production container greet endpoint failed"
|
||||
docker logs test-prod-container
|
||||
docker stop test-prod-container
|
||||
docker rm test-prod-container
|
||||
rm "$TEMP_DOCKERFILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Production container is working correctly"
|
||||
echo ""
|
||||
|
||||
# Clean up
|
||||
echo "11. Cleaning up..."
|
||||
docker stop test-prod-container > /dev/null 2>&1 || true
|
||||
docker rm test-prod-container > /dev/null 2>&1 || true
|
||||
rm "$TEMP_DOCKERFILE"
|
||||
echo "✅ Cleanup complete"
|
||||
echo ""
|
||||
|
||||
echo "🎉 All tests passed!"
|
||||
echo "==================="
|
||||
echo ""
|
||||
echo "✅ Build cache environment is working correctly"
|
||||
echo "✅ All Go tools available in container"
|
||||
echo "✅ Swagger generation works"
|
||||
echo "✅ Go build and test work"
|
||||
echo "✅ Production Dockerfile works with cache"
|
||||
echo "✅ Production container runs successfully"
|
||||
echo ""
|
||||
echo "🚀 The build cache is ready for CI/CD use!"
|
||||
echo ""
|
||||
echo "💡 To use this in CI/CD:"
|
||||
echo " 1. The build-cache job will build: dance-lessons-coach-build-cache:$DEPS_HASH"
|
||||
echo " 2. The CI pipeline will use: docker run dance-lessons-coach-build-cache:$DEPS_HASH ..."
|
||||
echo " 3. Production build will use: FROM dance-lessons-coach-build-cache:$DEPS_HASH AS builder"
|
||||
echo ""
|
||||
echo "📊 Dependency hash for this test: $DEPS_HASH"
|
||||
86
scripts/test-docker-cache.sh
Executable file
86
scripts/test-docker-cache.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# Test Docker build cache functionality locally
|
||||
# Usage: scripts/test-docker-cache.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧪 Testing Docker Build Cache"
|
||||
echo "============================"
|
||||
echo ""
|
||||
|
||||
# Check requirements
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "❌ Docker not found. Please install Docker first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
echo "❌ Go not found. Please install Go 1.26.1+."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Requirements met"
|
||||
echo ""
|
||||
|
||||
# 1. Calculate dependency hash (same as CI)
|
||||
echo "1. Calculating dependency hash..."
|
||||
# Use shasum on macOS, sha256sum on Linux
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
DEPS_HASH=$(sha256sum go.mod go.sum | sha256sum | cut -d' ' -f1 | head -c 12)
|
||||
else
|
||||
DEPS_HASH=$(shasum -a 256 go.mod go.sum | shasum -a 256 | cut -d' ' -f1 | head -c 12)
|
||||
fi
|
||||
echo " Dependency hash: $DEPS_HASH"
|
||||
echo ""
|
||||
|
||||
# 2. Build Docker cache image
|
||||
echo "2. Building Docker cache image..."
|
||||
IMAGE_NAME="dance-lessons-coach-build-cache:$DEPS_HASH"
|
||||
echo " Image name: $IMAGE_NAME"
|
||||
|
||||
docker build -t "$IMAGE_NAME" -f Dockerfile.build .
|
||||
echo "✅ Docker image built successfully"
|
||||
echo ""
|
||||
|
||||
# 3. Test running commands in Docker
|
||||
echo "3. Testing Docker execution..."
|
||||
|
||||
echo " Testing 'go version'..."
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace "$IMAGE_NAME" go version
|
||||
echo " ✅ Go version command works"
|
||||
|
||||
echo " Testing 'go build'..."
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace "$IMAGE_NAME" go build -o /tmp/test ./cmd/greet
|
||||
echo " ✅ Go build command works"
|
||||
|
||||
echo " Testing 'swag' availability..."
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace "$IMAGE_NAME" swag --version || echo " ⚠️ Swag not available"
|
||||
echo ""
|
||||
|
||||
# 4. Performance comparison
|
||||
echo "4. Performance comparison..."
|
||||
|
||||
echo " Running 'go build' natively..."
|
||||
START=$(date +%s%N)
|
||||
go build -o /tmp/native-test ./cmd/greet > /dev/null 2>&1
|
||||
NATIVE_TIME=$((($(date +%s%N) - $START)/1000000))
|
||||
echo " Native build: ${NATIVE_TIME}ms"
|
||||
|
||||
echo " Running 'go build' in Docker..."
|
||||
START=$(date +%s%N)
|
||||
docker run --rm -v "$(pwd):/workspace" -w /workspace "$IMAGE_NAME" go build -o /tmp/docker-test ./cmd/greet > /dev/null 2>&1
|
||||
DOCKER_TIME=$((($(date +%s%N) - $START)/1000000))
|
||||
echo " Docker build: ${DOCKER_TIME}ms"
|
||||
|
||||
echo " Overhead: $((DOCKER_TIME - NATIVE_TIME))ms"
|
||||
echo ""
|
||||
|
||||
# Clean up
|
||||
rm -f /tmp/native-test /tmp/docker-test
|
||||
|
||||
echo "✅ Docker cache testing complete!"
|
||||
echo ""
|
||||
echo "💡 The Docker image is ready for CI use."
|
||||
echo "💡 Push this image to your registry for CI caching:"
|
||||
echo " docker tag $IMAGE_NAME your-registry/$IMAGE_NAME"
|
||||
echo " docker push your-registry/$IMAGE_NAME"
|
||||
@@ -91,11 +91,21 @@ if [ "$HAS_DOCKER" = true ]; then
|
||||
echo "================================"
|
||||
echo ""
|
||||
|
||||
echo "1. Build Docker image locally:"
|
||||
echo "1. Build Docker image locally (development):"
|
||||
echo " docker build -t dance-lessons-coach:$CURRENT_VERSION ."
|
||||
echo ""
|
||||
|
||||
echo "2. Tag the image:"
|
||||
echo "2. Build production image using docker/Dockerfile.prod:"
|
||||
echo " # Note: Local docker/Dockerfile.prod uses 'latest' tag for testing"
|
||||
echo " docker build -t dance-lessons-coach-prod:$CURRENT_VERSION -f docker/Dockerfile.prod ."
|
||||
echo " # For CI/CD, the workflow generates correct docker/Dockerfile.prod with dependency hash"
|
||||
echo ""
|
||||
|
||||
echo "3. Compare image sizes:"
|
||||
echo " docker images | grep dance-lessons-coach"
|
||||
echo ""
|
||||
|
||||
echo "4. Tag the image:"
|
||||
echo " docker tag dance-lessons-coach:$CURRENT_VERSION dance-lessons-coach:latest"
|
||||
echo ""
|
||||
|
||||
@@ -127,9 +137,21 @@ if [ "$HAS_DOCKER" = true ]; then
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "🐳 Building Docker image..."
|
||||
docker build -t dance-lessons-coach:$CURRENT_VERSION .
|
||||
docker tag dance-lessons-coach:$CURRENT_VERSION dance-lessons-coach:latest
|
||||
echo "✅ Docker image built: dance-lessons-coach:$CURRENT_VERSION"
|
||||
read -p "📋 Build (d)development or (p)production image? [d/p]: " -n 1 -r
|
||||
echo ""
|
||||
if [[ $REPLY =~ ^[Pp]$ ]]; then
|
||||
echo "🏗️ Building production image with docker/Dockerfile.prod..."
|
||||
docker build -t dance-lessons-coach-prod:$CURRENT_VERSION -f docker/Dockerfile.prod .
|
||||
docker tag dance-lessons-coach-prod:$CURRENT_VERSION dance-lessons-coach-prod:latest
|
||||
echo "✅ Production Docker image built: dance-lessons-coach-prod:$CURRENT_VERSION"
|
||||
CONTAINER_IMAGE="dance-lessons-coach-prod:$CURRENT_VERSION"
|
||||
else
|
||||
echo "🏗️ Building development image with Dockerfile..."
|
||||
docker build -t dance-lessons-coach:$CURRENT_VERSION .
|
||||
docker tag dance-lessons-coach:$CURRENT_VERSION dance-lessons-coach:latest
|
||||
echo "✅ Development Docker image built: dance-lessons-coach:$CURRENT_VERSION"
|
||||
CONTAINER_IMAGE="dance-lessons-coach:$CURRENT_VERSION"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Check if port 8080 is available
|
||||
@@ -177,7 +199,7 @@ if [ "$HAS_DOCKER" = true ]; then
|
||||
fi
|
||||
|
||||
echo "🐳 Starting container '$CONTAINER_NAME' on port $PORT..."
|
||||
docker run -d -p $PORT:8080 --name "$CONTAINER_NAME" dance-lessons-coach:$CURRENT_VERSION
|
||||
docker run -d -p $PORT:8080 --name "$CONTAINER_NAME" "$CONTAINER_IMAGE"
|
||||
echo "✅ Container '$CONTAINER_NAME' started on port $PORT"
|
||||
echo ""
|
||||
|
||||
@@ -245,11 +267,13 @@ echo " ✅ Unit tests with coverage"
|
||||
echo " ✅ Binary build"
|
||||
echo " ✅ Version bump simulation"
|
||||
if [ "$HAS_DOCKER" = true ]; then
|
||||
echo " ✅ Docker build (if chosen)"
|
||||
echo " ✅ Docker build (development and/or production if chosen)"
|
||||
fi
|
||||
echo ""
|
||||
echo "🎯 When ready for production:"
|
||||
echo " Push to main branch to trigger full CI/CD pipeline"
|
||||
echo " Docker image will be built and pushed to Gitea Container Registry"
|
||||
echo ""
|
||||
echo "💡 Local testing complete! Your changes are ready for CI/CD."
|
||||
echo "💡 Local testing complete! Your changes are ready for CI/CD."
|
||||
# ⚠️ IMPORTANT: Local Dockerfile.prod uses 'latest' tag for testing only
|
||||
# ✅ CI/CD workflow generates correct Dockerfile.prod with dependency hash
|
||||
|
||||
Reference in New Issue
Block a user