diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0f10236 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +DB_URL=jdbc:mysql://:3306/test +DB_USERNAME=root +DB_PASSWORD=example +AWS_ACCESS_KEY_ID=your-access-key +AWS_SECRET_ACCESS_KEY=your-secret-key +AWS_REGION=us-east-1 +ECR_REGISTRY=123456789012.dkr.ecr.us-east-1.amazonaws.com + diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..6d71320 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,25 @@ +# GitHub Workflows + +This repository provides three GitHub Actions workflows: + +- **build.yml** – Builds the project and pushes a Docker image to Amazon ECR whenever changes are pushed to the `main` branch. +- **deploy.yml** – Manually triggered deployment of the Kubernetes manifests using a provided kubeconfig. +- **infra.yml** – Manually triggered workflow that creates or removes AWS infrastructure via CloudFormation. + +## Required Secrets + +Configure the following secrets in your repository settings so the workflows can access AWS and your Kubernetes cluster: + +| Secret Name | Used By | Description | +|-------------|---------|-------------| +| `AWS_ACCESS_KEY_ID` | build.yml, infra.yml | IAM user access key for AWS operations | +| `AWS_SECRET_ACCESS_KEY` | build.yml, infra.yml | Secret key associated with `AWS_ACCESS_KEY_ID` | +| `AWS_REGION` | build.yml, infra.yml | AWS region for ECR and CloudFormation | +| `ECR_REGISTRY` | build.yml | ECR registry URL (e.g. `123456789012.dkr.ecr.us-east-1.amazonaws.com`) | +| `KUBECONFIG` | deploy.yml | Base64‑encoded kubeconfig for your Kubernetes cluster | +| `DB_PASSWORD` | infra.yml | Database master password used when creating the RDS instance | +| `HOSTED_ZONE` | infra.yml (optional) | Domain name for Route53 records (defaults to `example.com.`) | + +Create these secrets in **Settings → Secrets and variables → Actions**. + +The `infra.yml` workflow reads `deploy/cloudformation/config.json` if present in the repository to determine whether to create or delete the stack and whether termination protection should be enabled. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b8ff3db --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build and Push +on: + push: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up JDK + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 17 + - name: Build jar + run: ./mvnw -B package -DskipTests + - name: Log in to ECR + uses: aws-actions/amazon-ecr-login@v1 + with: + region: ${{ secrets.AWS_REGION }} + - name: Build Docker image + run: | + docker build -t ${{ secrets.ECR_REGISTRY }}/spring-oauth-example:latest . + docker push ${{ secrets.ECR_REGISTRY }}/spring-oauth-example:latest + + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..ddf5759 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,23 @@ +name: Deploy to Kubernetes +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up kubectl + uses: azure/setup-kubectl@v3 + with: + version: 'v1.27.1' + - name: Configure kubeconfig + run: echo "${{ secrets.KUBECONFIG }}" > kubeconfig && chmod 600 kubeconfig + - name: Deploy + env: + KUBECONFIG: kubeconfig + run: | + cd deploy/kubernetes + ./deploy.sh + + diff --git a/.github/workflows/infra.yml b/.github/workflows/infra.yml new file mode 100644 index 0000000..5596094 --- /dev/null +++ b/.github/workflows/infra.yml @@ -0,0 +1,22 @@ +name: Manage Infrastructure +on: + workflow_dispatch: + +jobs: + deploy-infra: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + - name: Deploy CloudFormation + env: + DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + HOSTED_ZONE: ${{ secrets.HOSTED_ZONE }} + run: | + cd deploy/cloudformation + ./deploy.sh "$DB_PASSWORD" "${HOSTED_ZONE:-example.com.}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4547021 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM maven:3.8.6-eclipse-temurin-17 AS build +WORKDIR /workspace/app +COPY pom.xml . +COPY src src +RUN mvn package -DskipTests + +FROM eclipse-temurin:17-jre +WORKDIR /app +COPY --from=build /workspace/app/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java","-jar","app.jar"] diff --git a/README.md b/README.md index 8547695..b8fafc3 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,12 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { ## #. Run using maven wrapper/executable or via your preferred IDE - __Note__: - - To run: Change datasource properties @See application.yml -> ```spring: datasource: ``` (create database "test") + - Configure datasource using environment variables `DB_URL`, `DB_USERNAME` and `DB_PASSWORD`. Example: + ```bash + export DB_URL=jdbc:mysql://:3306/test + export DB_USERNAME=root + export DB_PASSWORD=example + ``` - To view flow: Change logging level as needed @See application.yml -> ```logging:``` ```cmd @@ -163,4 +168,63 @@ For all OAuth2 providers, On Successful OAuth2User Authentication Request proces - For Multiple properties usage from `AppProperties appProperties`, values are initialized in `@PostConstruct` to avoid code clutter - Uses Lombok `@Getter @Setter` for getter/setter generation - By Default, config classes uses Field Injection `@Autowired`, other class uses constructor injection -- Exception thrown are handled from `@ControllerAdvice` + - Exception thrown are handled from `@ControllerAdvice` + +## Deployment + +- CloudFormation template for a minimal RDS MySQL instance is available under `deploy/cloudformation/rds.yml`. +- Kubernetes manifests for running the application are located in `deploy/kubernetes` and reference secrets containing the datasource credentials and AWS access keys. + +### Preparing the environment + +1. Copy `.env.example` to `.env` and adjust credentials. The same file is used for Kubernetes secret creation and docker-compose. +2. Edit `deploy/cloudformation/config.json` to adjust the action, protection and service settings as needed. +3. If using GitHub Actions, define the secrets listed in `.github/workflows/README.md` (AWS credentials, `ECR_REGISTRY`, `KUBECONFIG`, etc.). The workflow in `.github/workflows/build.yml` builds and pushes the container image automatically. + To run locally, build the Docker image and push it to your ECR registry: + +```bash +aws ecr get-login-password --region $AWS_REGION | \ + docker login --username AWS --password-stdin $ECR_REGISTRY +docker build -t $ECR_REGISTRY/spring-oauth-example:latest . +docker push $ECR_REGISTRY/spring-oauth-example:latest +``` + +### Local development + +A docker-compose setup is available under `deploy/docker-compose` which starts MySQL, LocalStack and the application container. LocalStack emulates AWS services so the application can be tested without a real AWS account. Ensure you have the `.env` file in the project root then run: + +```bash +cd deploy/docker-compose +docker compose up --build +``` + +The application will be available on `http://localhost:8080` with MySQL exposed on port `3306` and LocalStack on `4566`. + +### Deploying to AWS + +1. Provision infrastructure using the helper script which applies `deploy/cloudformation/infra.yml`. The `config.json` file controls whether stacks are created or removed and which services are enabled: + +```bash +cd deploy/cloudformation +./deploy.sh yourdbpassword example.com. +``` + +You can also trigger the `Manage Infrastructure` workflow in GitHub Actions (`infra.yml`) which runs the same script in CI using the secrets you configured. + +Note the database endpoint from the stack outputs and update `DB_URL` in your `.env` file. + +2. Create the Kubernetes secret with the environment variables: + +```bash +cd deploy/kubernetes +./create-secret.sh +``` + +3. Deploy the application: + +```bash +cd deploy/kubernetes +./deploy.sh +``` + +Alternatively trigger the workflows defined in `.github/workflows`. Use `deploy.yml` for Kubernetes deployments and `infra.yml` for managing AWS resources. Redeploy by rebuilding the Docker image and running the workflows again as needed. diff --git a/deploy/cloudformation/config.json b/deploy/cloudformation/config.json new file mode 100644 index 0000000..e42a937 --- /dev/null +++ b/deploy/cloudformation/config.json @@ -0,0 +1,11 @@ +{ + "action": "start", + "protect": true, + "stack_name": "spring-oauth-example", + "services": { + "route53": {"enabled": true, "hosted_zone": "example.com."}, + "rds": {"enabled": true, "db_instance_class": "db.t3.micro"}, + "cache": {"enabled": true, "node_type": "cache.t2.micro"}, + "s3": {"enabled": true} + } +} diff --git a/deploy/cloudformation/deploy.sh b/deploy/cloudformation/deploy.sh new file mode 100755 index 0000000..29e21b7 --- /dev/null +++ b/deploy/cloudformation/deploy.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# Deploy or remove the CloudFormation stack that provides AWS resources. +# Behaviour is controlled via `config.json`. Requires AWS CLI credentials. +set -e + +command -v jq >/dev/null 2>&1 || { echo >&2 "jq is required but not installed."; exit 1; } + +deploy_stack() { + echo "Deploying stack $STACK_NAME" + PARAMS="DBPassword=$DB_PASSWORD HostedZoneName=$HOSTED_ZONE" + PARAMS="$PARAMS EnableS3=$S3_ENABLED EnableRDS=$RDS_ENABLED EnableCache=$CACHE_ENABLED EnableDNS=$DNS_ENABLED" + PARAMS="$PARAMS DBInstanceClass=$DB_CLASS CacheNodeType=$CACHE_NODE" + aws cloudformation deploy \ + --stack-name "$STACK_NAME" \ + --template-file "$TEMPLATE" \ + --capabilities CAPABILITY_NAMED_IAM \ + --parameter-overrides $PARAMS + if [ "$PROTECT" = "true" ]; then + aws cloudformation update-termination-protection \ + --stack-name "$STACK_NAME" --enable-termination-protection + fi + aws cloudformation describe-stacks --stack-name "$STACK_NAME" --query 'Stacks[0].Outputs' +} + +remove_stack() { + if [ "$PROTECT" = "true" ]; then + echo "Stack is protected. Set protect=false in $CONFIG_FILE to allow deletion." >&2 + return + fi + aws cloudformation update-termination-protection \ + --stack-name "$STACK_NAME" --no-enable-termination-protection || true + aws cloudformation delete-stack --stack-name "$STACK_NAME" + echo "Waiting for stack deletion..." + aws cloudformation wait stack-delete-complete --stack-name "$STACK_NAME" +} + +CONFIG_FILE="config.json" +STACK_NAME="spring-oauth-example" +TEMPLATE=infra.yml + +ACTION="start" +PROTECT=true +HOSTED_ZONE="example.com." +S3_ENABLED="true" +RDS_ENABLED="true" +CACHE_ENABLED="true" +DNS_ENABLED="true" +DB_CLASS="db.t3.micro" +CACHE_NODE="cache.t2.micro" + +# Read configuration from JSON if present +if [ -f "$CONFIG_FILE" ]; then + ACTION="$(jq -r '.action // "start"' "$CONFIG_FILE")" + PROTECT="$(jq -r '.protect // true' "$CONFIG_FILE")" + STACK_NAME="$(jq -r '.stack_name // "spring-oauth-example"' "$CONFIG_FILE")" + HOSTED_ZONE="$(jq -r '.services.route53.hosted_zone // "example.com."' "$CONFIG_FILE")" + DNS_ENABLED="$(jq -r '.services.route53.enabled // true' "$CONFIG_FILE")" + RDS_ENABLED="$(jq -r '.services.rds.enabled // true' "$CONFIG_FILE")" + S3_ENABLED="$(jq -r '.services.s3.enabled // true' "$CONFIG_FILE")" + CACHE_ENABLED="$(jq -r '.services.cache.enabled // true' "$CONFIG_FILE")" + DB_CLASS="$(jq -r '.services.rds.db_instance_class // "db.t3.micro"' "$CONFIG_FILE")" + CACHE_NODE="$(jq -r '.services.cache.node_type // "cache.t2.micro"' "$CONFIG_FILE")" +fi + +if [ -z "$1" ] && [ -z "$DB_PASSWORD" ]; then + echo "Usage: $0 [hosted-zone]" >&2 + exit 1 +fi + +DB_PASSWORD="${1:-$DB_PASSWORD}" +HOSTED_ZONE="${2:-$HOSTED_ZONE}" + +case "$ACTION" in + start) + deploy_stack + ;; + stop) + remove_stack + ;; + *) + echo "Unknown ACTION: $ACTION" >&2 + exit 1 + ;; +esac + + diff --git a/deploy/cloudformation/infra.yml b/deploy/cloudformation/infra.yml new file mode 100644 index 0000000..bed7345 --- /dev/null +++ b/deploy/cloudformation/infra.yml @@ -0,0 +1,101 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Minimal infrastructure for the Spring Security OAuth example. Each service can be toggled via parameters. + +Parameters: + DBPassword: + Type: String + NoEcho: true + Description: Database master password + HostedZoneName: + Type: String + Default: example.com. + Description: Domain for Route53 record (trailing dot required) + EnableS3: + Type: String + AllowedValues: ["true", "false"] + Default: "true" + Description: Create the S3 bucket + EnableRDS: + Type: String + AllowedValues: ["true", "false"] + Default: "true" + Description: Create the RDS instance + EnableCache: + Type: String + AllowedValues: ["true", "false"] + Default: "true" + Description: Create the ElastiCache cluster + EnableDNS: + Type: String + AllowedValues: ["true", "false"] + Default: "true" + Description: Create Route53 resources + DBInstanceClass: + Type: String + Default: db.t3.micro + Description: RDS instance class + CacheNodeType: + Type: String + Default: cache.t2.micro + Description: ElastiCache node type + +Conditions: + UseS3: !Equals [ !Ref EnableS3, "true" ] + UseRDS: !Equals [ !Ref EnableRDS, "true" ] + UseCache: !Equals [ !Ref EnableCache, "true" ] + UseDNS: !Equals [ !Ref EnableDNS, "true" ] + +Resources: + AppBucket: + Type: AWS::S3::Bucket + Condition: UseS3 + Properties: + BucketName: !Sub spring-oauth-example-${AWS::AccountId} + + DBInstance: + Type: AWS::RDS::DBInstance + Condition: UseRDS + Properties: + DBInstanceIdentifier: spring-security-db + AllocatedStorage: 20 + DBInstanceClass: !Ref DBInstanceClass + Engine: MySQL + MasterUsername: root + MasterUserPassword: !Ref DBPassword + DBName: test + PubliclyAccessible: true + StorageType: gp2 + + CacheCluster: + Type: AWS::ElastiCache::CacheCluster + Condition: UseCache + Properties: + CacheNodeType: !Ref CacheNodeType + Engine: redis + NumCacheNodes: 1 + + HostedZone: + Type: AWS::Route53::HostedZone + Condition: UseDNS + Properties: + Name: !Ref HostedZoneName + + DNSRecord: + Type: AWS::Route53::RecordSet + Condition: UseDNS + Properties: + HostedZoneName: !Ref HostedZoneName + Name: app.${HostedZoneName} + Type: CNAME + TTL: '300' + ResourceRecords: + - example.com + +Outputs: + BucketName: + Value: !Ref AppBucket + Description: S3 bucket storing application assets + DBEndpoint: + Value: !GetAtt DBInstance.Endpoint.Address + Description: RDS endpoint + diff --git a/deploy/cloudformation/rds.yml b/deploy/cloudformation/rds.yml new file mode 100644 index 0000000..a39c258 --- /dev/null +++ b/deploy/cloudformation/rds.yml @@ -0,0 +1,35 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: Simple RDS MySQL instance for the Spring Security OAuth example. + +Parameters: + DBName: + Type: String + Default: test + DBUsername: + Type: String + Default: root + DBPassword: + Type: String + NoEcho: true + DBInstanceIdentifier: + Type: String + Default: spring-security-db + +Resources: + MyDB: + Type: AWS::RDS::DBInstance + Properties: + DBInstanceIdentifier: !Ref DBInstanceIdentifier + AllocatedStorage: 20 + DBInstanceClass: db.t3.micro + Engine: MySQL + MasterUsername: !Ref DBUsername + MasterUserPassword: !Ref DBPassword + DBName: !Ref DBName + PubliclyAccessible: true + StorageType: gp2 + +Outputs: + DBEndpoint: + Description: Endpoint of the database + Value: !GetAtt MyDB.Endpoint.Address diff --git a/deploy/docker-compose/docker-compose.yml b/deploy/docker-compose/docker-compose.yml new file mode 100644 index 0000000..745f24d --- /dev/null +++ b/deploy/docker-compose/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' +services: + mysql: + image: mysql:8 + environment: + MYSQL_ROOT_PASSWORD: example + MYSQL_DATABASE: test + ports: + - "3306:3306" + + localstack: + image: localstack/localstack:1.4 + environment: + SERVICES: s3,ssm,iam,cloudformation + AWS_DEFAULT_REGION: us-east-1 + ports: + - "4566:4566" + + app: + build: ../.. + env_file: + - ../../.env + environment: + DB_URL: jdbc:mysql://mysql:3306/test + DB_USERNAME: root + DB_PASSWORD: example + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + AWS_REGION: us-east-1 + depends_on: + - mysql + - localstack + ports: + - "8080:8080" diff --git a/deploy/kubernetes/create-secret.sh b/deploy/kubernetes/create-secret.sh new file mode 100755 index 0000000..b3b8398 --- /dev/null +++ b/deploy/kubernetes/create-secret.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e +if [ ! -f ../../.env ]; then + echo "Please create a .env file based on .env.example before running." >&2 + exit 1 +fi +kubectl delete secret spring-oauth-example-secret 2>/dev/null || true +kubectl create secret generic spring-oauth-example-secret --from-env-file=../../.env diff --git a/deploy/kubernetes/deploy.sh b/deploy/kubernetes/deploy.sh new file mode 100755 index 0000000..f878098 --- /dev/null +++ b/deploy/kubernetes/deploy.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Apply Kubernetes manifests. +# Requires kubectl configured with cluster access. +set -e + +kubectl apply -f deployment.yaml +kubectl apply -f service.yaml + diff --git a/deploy/kubernetes/deployment.yaml b/deploy/kubernetes/deployment.yaml new file mode 100644 index 0000000..2794ad6 --- /dev/null +++ b/deploy/kubernetes/deployment.yaml @@ -0,0 +1,22 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: spring-oauth-example +spec: + replicas: 1 + selector: + matchLabels: + app: spring-oauth-example + template: + metadata: + labels: + app: spring-oauth-example + spec: + containers: + - name: spring-oauth-example + image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/spring-oauth-example:latest + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: spring-oauth-example-secret diff --git a/deploy/kubernetes/secret-example.yaml b/deploy/kubernetes/secret-example.yaml new file mode 100644 index 0000000..b59602f --- /dev/null +++ b/deploy/kubernetes/secret-example.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: spring-oauth-example-secret +stringData: + DB_URL: jdbc:mysql://:3306/test + DB_USERNAME: root + DB_PASSWORD: example + AWS_ACCESS_KEY_ID: your-access-key + AWS_SECRET_ACCESS_KEY: your-secret-key diff --git a/deploy/kubernetes/service.yaml b/deploy/kubernetes/service.yaml new file mode 100644 index 0000000..444c67d --- /dev/null +++ b/deploy/kubernetes/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: spring-oauth-example +spec: + type: ClusterIP + selector: + app: spring-oauth-example + ports: + - port: 80 + targetPort: 8080 diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 138b51d..925f5ba 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,9 +4,9 @@ server: # jdbc and jpa config spring: datasource: - url: jdbc:mysql://localhost:3306/test?useSSL=false&serverTimezone=UTC&useLegacyDatetimeCode=false - username: root - password: root + url: ${DB_URL:jdbc:mysql://localhost:3306/test?useSSL=false&serverTimezone=UTC&useLegacyDatetimeCode=false} + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:root} jpa: show-sql: true