diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index f1747133..00000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,2 +0,0 @@ -If being asked to set up Azure infrastructure for this app, use the tool `run_command_in_terminal` to run script `azure-setup.sh` in the project root in WSL environment. -If being asked to deploy this app to Azure, use the tool `run_command_in_terminal` to run script `deploy-to-azure.sh` in the project root in WSL environment. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8cfce585..9e18db1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -# Use Maven with OpenJDK 8 for building -FROM maven:3.9.6-eclipse-temurin-8 AS build +# Use Maven with OpenJDK 21 for building +FROM maven:3.9.9-eclipse-temurin-21 AS build WORKDIR /app @@ -13,8 +13,8 @@ COPY src ./src # Build the application RUN mvn clean package -DskipTests -# Use OpenJDK 8 runtime for the final image -FROM eclipse-temurin:8-jre +# Use OpenJDK 21 runtime for the final image +FROM eclipse-temurin:21-jre WORKDIR /app diff --git a/README.md b/README.md index 1e4200d9..a0a1367f 100644 --- a/README.md +++ b/README.md @@ -1,245 +1,220 @@ -# Photo Album Application - Java Spring Boot with Oracle DB +# Photo Album Application - Migration Workshop -A photo gallery application built with Spring Boot and Oracle Database, featuring drag-and-drop upload, responsive gallery view, and full-size photo details with navigation. +This document serves as a comprehensive workshop guide that will walk you through the process of migrating a Java application to Azure using GitHub Copilot app modernization. The workshop covers assessment, database migration from Oracle to PostgreSQL, and deployment to Azure. -## Features +**What the Migration Process Will Do:** +The migration will transform your application from using Oracle Database to a modern Azure-native solution. This includes migrating from Oracle Database to Azure Database for PostgreSQL Flexible Server with managed identity authentication, and deploying it to Azure with proper monitoring and health checks. -- 📤 **Photo Upload**: Drag-and-drop or click to upload multiple photos -- 🖼️ **Gallery View**: Responsive grid layout for browsing uploaded photos -- 🔍 **Photo Detail View**: Click any photo to view full-size with metadata and navigation -- 📊 **Metadata Display**: View file size, dimensions, aspect ratio, and upload timestamp -- ⬅️➡️ **Photo Navigation**: Previous/Next buttons to browse through photos -- ✅ **Validation**: File type and size validation (JPEG, PNG, GIF, WebP; max 10MB) -- 🗄️ **Database Storage**: Photo data stored as BLOBs in Oracle Database -- 🗑️ **Delete Photos**: Remove photos from both gallery and detail views -- 🎨 **Modern UI**: Clean, responsive design with Bootstrap 5 +## Table of Contents -## Technology Stack +- [Overview](#overview) +- [Running Locally (Pre-Migration)](#running-locally-pre-migration) +- [Prerequisites](#prerequisites) +- [Workshop Steps](#workshop-steps) + - [Step 1: Assess Your Java Application](#step-1-assess-your-java-application) + - [Step 2: Migrate from Oracle to PostgreSQL](#step-2-migrate-from-oracle-to-postgresql) + - [Step 3: Deploy to Azure](#step-3-deploy-to-azure) -- **Framework**: Spring Boot 2.7.18 (Java 8) -- **Database**: Oracle Database 21c Express Edition -- **Templating**: Thymeleaf -- **Build Tool**: Maven -- **Frontend**: Bootstrap 5.3.0, Vanilla JavaScript -- **Containerization**: Docker & Docker Compose +## Overview -## Prerequisites +The Photo Album application is a Spring Boot web application that allows users to: +- Upload photos via drag-and-drop or file selection +- View photos in a responsive gallery +- View photo details with metadata +- Navigate between photos +- Delete photos + +**Original State (Before Migration):** +* Oracle Database 21c Express Edition for photo storage +* Photos stored as BLOBs in Oracle Database +* Password-based authentication +* Running in Docker containers locally + +**After Migration:** +* Azure Database for PostgreSQL Flexible Server +* Managed Identity passwordless authentication +* Deployed to Azure Container Apps + +**Time Estimates:** +The complete workshop takes approximately **40 minutes** to complete. Here's the breakdown for each major step: +- **Assess Your Java Application**: ~5 minutes +- **Migrate from Oracle to PostgreSQL**: ~15 minutes +- **Deploy to Azure**: ~20 minutes -- Docker Desktop installed and running -- Docker Compose (included with Docker Desktop) -- Minimum 4GB RAM available for Oracle DB container +## Running Locally (Pre-Migration) -## Quick Start +Before starting the migration, you can run the original Oracle-based application locally to understand how it works. -1. **Clone the repository**: +### Quick Start with Docker Compose + +1. **Clone the repository** (if not already done): ```bash - git clone https://github.com/Azure-Samples/PhotoAlbum-Java.git - cd PhotoAlbum-Java + git clone https://github.com/Azure-Samples/PhotoAlbum-Java-Lite.git + cd PhotoAlbum-Java-Lite ``` 2. **Start the application**: ```bash - # Use docker-compose directly docker-compose up --build -d ``` This will: - Start Oracle Database 21c Express Edition container - - Build the Java Spring Boot application - - Start the Photo Album application container - - Automatically create the database schema using JPA/Hibernate + - Build and start the Photo Album application container + - Automatically create the database schema -3. **Wait for services to start**: - - Oracle DB takes 2-3 minutes to initialize on first run - - Application will start once Oracle is healthy +3. **Wait for services to start** (~2-3 minutes for Oracle DB initialization on first run) 4. **Access the application**: - - Open your browser and navigate to: **http://localhost:8080** - - The application should be running and ready to use - -## Services - -## Oracle Database -- **Image**: `container-registry.oracle.com/database/express:21.3.0-xe` -- **Ports**: - - `1521` (database) - mapped to host port 1521 - - `5500` (Enterprise Manager) - mapped to host port 5500 -- **Database**: `XE` (Express Edition) -- **Schema**: `photoalbum` -- **Username/Password**: `photoalbum/photoalbum` - -## Photo Album Java Application -- **Port**: `8080` (mapped to host port 8080) -- **Framework**: Spring Boot 2.7.18 -- **Java Version**: 8 -- **Database**: Connects to Oracle container -- **Photo Storage**: All photos stored as BLOBs in database (no file system storage) -- **UUID System**: Each photo gets a globally unique identifier for cache-busting - -## Database Setup - -The application uses Spring Data JPA with Hibernate for automatic schema management: - -1. **Automatic Schema Creation**: Hibernate automatically creates tables and indexes -2. **User Creation**: Oracle init scripts create the `photoalbum` user -3. **No Manual Setup Required**: Everything is handled automatically - -### Database Schema - -The application creates the following table structure in Oracle: - -#### PHOTOS Table -- `ID` (VARCHAR2(36), Primary Key, UUID Generated) -- `ORIGINAL_FILE_NAME` (VARCHAR2(255), Not Null) -- `STORED_FILE_NAME` (VARCHAR2(255), Not Null) -- `FILE_PATH` (VARCHAR2(500), Nullable) -- `FILE_SIZE` (NUMBER, Not Null) -- `MIME_TYPE` (VARCHAR2(50), Not Null) -- `UPLOADED_AT` (TIMESTAMP, Not Null, Default SYSTIMESTAMP) -- `WIDTH` (NUMBER, Nullable) -- `HEIGHT` (NUMBER, Nullable) -- `PHOTO_DATA` (BLOB, Not Null) - -#### Indexes -- `IDX_PHOTOS_UPLOADED_AT` (Index on UPLOADED_AT for chronological queries) - -#### UUID Generation -- **Java**: `UUID.randomUUID().toString()` generates unique identifiers -- **Benefits**: Eliminates browser caching issues, globally unique across databases -- **Format**: Standard UUID format (36 characters with hyphens) - -## Storage Architecture - -### Database BLOB Storage (Current Implementation) -- **Photos**: Stored as BLOB data directly in the database -- **Benefits**: - - No file system dependencies - - ACID compliance for photo operations - - Simplified backup and migration - - Perfect for containerized deployments -- **Trade-offs**: Database size increases, but suitable for moderate photo volumes - -## Development - -### Running Locally (without Docker) - -1. **Install Oracle Database** (or use Oracle XE) -2. **Create database user**: - ```sql - CREATE USER photoalbum IDENTIFIED BY photoalbum; - GRANT CONNECT, RESOURCE, DBA TO photoalbum; - ``` -3. **Update application.properties**: - ```properties - spring.datasource.url=jdbc:oracle:thin:@localhost:1521:XE - spring.datasource.username=photoalbum - spring.datasource.password=photoalbum - spring.jpa.hibernate.ddl-auto=create + - Open your browser to **http://localhost:8080** + - Upload, view, and manage photos to explore the features + +5. **Stop the application**: + ```bash + docker-compose down ``` -4. **Run the application**: + + To remove data volumes as well: ```bash - mvn spring-boot:run + docker-compose down -v ``` -### Building from Source +## Prerequisites -```bash -# Build the JAR file -mvn clean package +Before starting this workshop, ensure you have: -# Run the JAR file -java -jar target/photo-album-1.0.0.jar -``` +### Required Software -## Troubleshooting +- **Operating System**: Windows, macOS, or Linux +- **Java Development Kit (JDK)**: JDK 21 or higher + - Download from [Microsoft OpenJDK](https://learn.microsoft.com/java/openjdk/download) +- **Maven**: 3.8.0 or higher + - Download from [Apache Maven](https://maven.apache.org/download.cgi) +- **Docker Desktop**: Latest version, required if you want to run locally + - Download from [Docker](https://docs.docker.com/desktop/) +- **Git**: For version control + - Download from [Git](https://git-scm.com/) -### Oracle Database Issues +### IDE and Extensions -1. **Oracle container won't start**: - ```bash - # Check container logs - docker-compose logs oracle-db - - # Increase Docker memory allocation to at least 4GB - ``` +- **Visual Studio Code**: Version 1.101 or later + - Download from [Visual Studio Code](https://code.visualstudio.com/) +- **GitHub Copilot**: Must be enabled in your GitHub account + - [GitHub Copilot subscription](https://github.com/features/copilot) (Pro, Pro+, Business, or Enterprise) +- **VS Code Extensions** (Required): + 1. **GitHub Copilot** extension + - Install from [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) + - Sign in to your GitHub account within VS Code + 2. **GitHub Copilot app modernization** extension + - Install from [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=vscjava.migrate-java-to-azure) + - Restart VS Code after installation -2. **Database connection errors**: - ```bash - # Verify Oracle is ready - docker exec -it photoalbum-oracle sqlplus photoalbum/photoalbum@//localhost:1521/XE - ``` +### Azure Requirements -3. **Permission errors**: - ```bash - # Check Oracle init scripts ran - docker-compose logs oracle-db | grep "setup" - ``` +- **Azure Account**: Active Azure subscription + - [Create a free account](https://azure.microsoft.com/free/) if you don't have one +- **Azure CLI**: Latest version + - Download from [Azure CLI](https://docs.microsoft.com/cli/azure/install-azure-cli) -### Application Issues +### Configuration -1. **View application logs**: - ```bash - docker-compose logs photoalbum-java-app - ``` +- Ensure `chat.extensionTools.enabled` is set to `true` in VS Code settings +- In the Visual Studio Code settings, make sure this setting is enabled (it might be controlled by your organization) +- Access to public Maven Central repository for Maven-based projects +- Git-managed Java project using Maven -2. **Rebuild application**: - ```bash - docker-compose up --build - ``` +## Workshop Steps -3. **Reset database (nuclear option)**: - ```bash - docker-compose down -v - docker-compose up --build - ``` +### Step 1: Assess Your Java Application -## Stopping the Application +The first step is to assess the Photo Album application to identify migration opportunities and potential issues. -```bash -# Stop services -docker-compose down +#### 1.1 Open the Project + +1. Clone or open the Photo Album project in Visual Studio Code: -# Stop and remove all data (including database) -docker-compose down -v +```bash +git clone https://github.com/Azure-Samples/PhotoAlbum-Java-Lite.git +cd PhotoAlbum-Java-Lite +code . ``` -## Enterprise Manager (Optional) +#### 1.2 Install GitHub Copilot App Modernization Extension -Oracle Enterprise Manager is available at `http://localhost:5500/em` for database administration: -- **Username**: `system` -- **Password**: `photoalbum` -- **Container**: `XE` +In VS Code, open the Extensions view from the Activity Bar, search for the `GitHub Copilot app modernization` extension in the marketplace. Click the Install button for the extension. After installation completes, you should see a notification in the bottom-right corner of VS Code confirming success. -## Performance Notes +#### 1.3 Run Assessment -- Oracle XE has limitations (max 2 CPU threads, 2GB RAM, 12GB storage) -- BLOB storage in database impacts performance at scale -- Suitable for development and small-scale deployments +1. In the Activity sidebar, open the **GitHub Copilot app modernization** extension pane. +1. In the **QUICKSTART** section, click **Start Assessment** to trigger the app assessment. -## Project Structure + ![Trigger Assessment](doc-media/trigger-assessment.png) -``` -PhotoAlbum/ -├── src/ # Java source code -├── oracle-init/ # Oracle initialization scripts -├── docker-compose.yml # Oracle + Application services -├── Dockerfile # Application container build -├── pom.xml # Maven dependencies and build config -└── README.md # Project documentation -``` +1. Wait for the assessment to be completed. This step could take several minutes. +1. Upon completion, an **Assessment Report** tab opens. This report provides a categorized view of cloud readiness issues and recommended solutions. Select the **Issues** tab to view proposed solutions and proceed with migration steps. + +#### 1.4 Review Assessment Report + +The Assessment Report provides: + +- **Application Information**: Summary of detected technologies and frameworks +- **Issues**: Categorized list of migration opportunities + - **Database Migration**: Oracle Database → Azure Database for PostgreSQL + - **Security**: Current password-based authentication +- **Recommended Solutions**: Predefined migration tasks for each issue + +Look for the following in your report: + +1. **Database Migration (Oracle Database)** + - Detected: Oracle Database 21c + - Recommendation: Migrate to Azure Database for PostgreSQL Flexible Server + - Action: **Run Task** button available + + ![Assessment Report](doc-media/assessment-report.png) + +### Step 2: Migrate from Oracle to PostgreSQL + +Before running the migration task in Copilot Chat, make sure the chat is configured to use your preferred LLM model, to choose the model: + +1. Open Copilot Chat in **Agent** mode. +1. Select the custom agent **modernize-azure-java** from the agent picker. +1. Select the a model from the model picker, e.g., **GPT-5.4**. + + ![Custom Agent](doc-media/custom-agent.png) + +Now that you've assessed the application, let's begin the database migration from Oracle to Azure Database for PostgreSQL. + +1. In the **Assessment Report**, locate the **Database Migration (Oracle)** issue +1. Click the **Run Task** button next to **Migrate to Azure Database for PostgreSQL (Spring)** + +1. The Copilot Chat panel opens in **Agent Mode** with a pre-populated migration prompt + + ![Run Task Prompt](doc-media/run-task-prompt.png) + +1. The Copilot Agent will analyze the project, generate and open **plan.md** and **progress.md**, then automatically proceed with the migration process. +1. The agent checks the version control system status and checks out a new branch for migration, then performs the code changes. Click **Allow** for any tool call requests from the agent. +1. When the code migration is complete, the agent will automatically run a **validation and fix iteration loop** which includes: + - **CVE Validation**: Detects Common Vulnerabilities and Exposures in current dependencies and fixes them. + - **Build Validation**: Attempts to resolve any build errors. + - **Consistency Validation**: Analyzes the code for functional consistency. + - **Test Validation**: Runs unit tests and automatically fixes any failures. + - **Completeness Validation**: Catches migration items missed in the initial code migration and fixes them. +1. After all validations complete, the agent generates a **summary.md** as the final step. +1. Review the proposed code changes and click **Keep** to apply them. + +### Step 3: Deploy to Azure + +At this point, you have successfully migrated the application to PostgreSQL. Now, you can deploy it to Azure. + +1. In the Activity sidebar, open the **GitHub Copilot app modernization** extension pane. In the **TASKS** section, expand **Common Tasks** > **Deployment Tasks**. Click the run button for **Provision Infrastructure and Deploy to Azure**. -## Contributing + ![Run Deployment task](doc-media/deployment-run-task.png) +1. A predefined prompt will be populated in the Copilot Chat panel with Agent Mode. -When contributing to this project: +1. Click ****Continue**/**Allow** if pop-up notifications to let Copilot Agent analyze the project and create a deployment plan in **plan.copilotmd** with Azure resources architecture, recommended Azure resources for project and security configurations, and execution steps for deployment. -- Follow Spring Boot best practices -- Maintain database compatibility -- Ensure UI/UX consistency -- Test both local Docker and Azure deployment scenarios -- Update documentation for any architectural changes -- Preserve UUID system integrity -- Add appropriate tests for new features +1. View the architecture diagram, resource configurations, and execution steps in the plan. Click **Keep** to save the plan and type in **Execute the plan** to start the deployment. -## License +1. When prompted, click **Continue**/**Allow** in chat notifications or type **y**/**yes** in terminal as Copilot Agent follows the plan and leverages agent tools to create and run provisioning and deployment scripts, fix potential errors, and finish the deployment. You can also check the deployment status in **progress.copilotmd**. **DO NOT interrupt** when provisioning or deployment scripts are running. -This project is provided as-is for educational and demonstration purposes. \ No newline at end of file + ![Deployment progress](doc-media/deployment-progress.png) diff --git a/azure-setup.sh b/azure-setup.sh deleted file mode 100644 index b469db5b..00000000 --- a/azure-setup.sh +++ /dev/null @@ -1,199 +0,0 @@ -#!/bin/bash - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -echo_info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -echo_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -echo_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -echo_info "=== Azure Photo Album Resources Setup ===" - -# Variables -RANDOM_SUFFIX=$(openssl rand -hex 3) -RESOURCE_GROUP="photo-album-resources-${RANDOM_SUFFIX}" -LOCATION="Central US" -ACR_NAME="photoalbumacr$RANDOM" -AKS_NODE_VM_SIZE="Standard_D8ds_v5" -POSTGRES_SERVER_NAME="photoalbum-postgres-$(date +%s)" -POSTGRES_ADMIN_USER="photoalbum_admin" -POSTGRES_ADMIN_PASSWORD="P@ssw0rd123!" -POSTGRES_DATABASE_NAME="photoalbum" -POSTGRES_APP_USER="photoalbum" -POSTGRES_APP_PASSWORD="photoalbum" - -echo_info "Using default subscription..." -az account show --query "{Name:name, SubscriptionId:id}" -o table - -# Create Resource Group -echo_info "Creating resource group: $RESOURCE_GROUP" -az group create \ - --name $RESOURCE_GROUP \ - --location $LOCATION - -# Create PostgreSQL Flexible Server -echo_info "Creating PostgreSQL server: $POSTGRES_SERVER_NAME" -az postgres flexible-server create \ - --resource-group "$RESOURCE_GROUP" \ - --name "$POSTGRES_SERVER_NAME" \ - --location "$LOCATION" \ - --admin-user "$POSTGRES_ADMIN_USER" \ - --admin-password "$POSTGRES_ADMIN_PASSWORD" \ - --version "15" \ - --sku-name "Standard_D2ds_v4" \ - --storage-size "32" \ - --backup-retention "7" \ - --public-access "0.0.0.0" \ - --output none - -echo_info "PostgreSQL server created successfully!" - -# Create application database -echo_info "Creating database: $POSTGRES_DATABASE_NAME" -az postgres flexible-server db create \ - --resource-group "$RESOURCE_GROUP" \ - --server-name "$POSTGRES_SERVER_NAME" \ - --database-name "$POSTGRES_DATABASE_NAME" \ - --output none - -# Configure firewall for Azure services -echo_info "Configuring firewall rules..." -az postgres flexible-server firewall-rule create \ - --resource-group "$RESOURCE_GROUP" \ - --name "$POSTGRES_SERVER_NAME" \ - --rule-name "AllowAzureServices" \ - --start-ip-address "0.0.0.0" \ - --end-ip-address "0.0.0.0" \ - --output none - -# Add current IP to firewall -CURRENT_IP=$(curl -s https://api.ipify.org) -if [ -n "$CURRENT_IP" ]; then - echo_info "Adding your current IP ($CURRENT_IP) to firewall..." - az postgres flexible-server firewall-rule create \ - --resource-group "$RESOURCE_GROUP" \ - --name "$POSTGRES_SERVER_NAME" \ - --rule-name "AllowCurrentIP" \ - --start-ip-address "$CURRENT_IP" \ - --end-ip-address "$CURRENT_IP" \ - --output none -fi - -# Get server FQDN -echo_info "Getting server connection details..." -SERVER_FQDN=$(az postgres flexible-server show \ - --resource-group "$RESOURCE_GROUP" \ - --name "$POSTGRES_SERVER_NAME" \ - --query "fullyQualifiedDomainName" \ - --output tsv) - -# Wait a moment for server to be fully ready -echo_info "Waiting for server to be fully ready..." -sleep 30 - -# Setup application user and tables -echo_info "Setting up database user and tables..." - -# Create application user using the more reliable execute command -echo_info "Creating application user..." -az postgres flexible-server execute \ - --name "$POSTGRES_SERVER_NAME" \ - --admin-user "$POSTGRES_ADMIN_USER" \ - --admin-password "$POSTGRES_ADMIN_PASSWORD" \ - --database-name "postgres" \ - --querytext "CREATE USER photoalbum WITH PASSWORD 'photoalbum';" || echo_warning "User may already exist, continuing..." - -# Grant database connection privileges -echo_info "Granting database connection privileges..." -az postgres flexible-server execute \ - --name "$POSTGRES_SERVER_NAME" \ - --admin-user "$POSTGRES_ADMIN_USER" \ - --admin-password "$POSTGRES_ADMIN_PASSWORD" \ - --database-name "postgres" \ - --querytext "GRANT CONNECT ON DATABASE photoalbum TO photoalbum;" || echo_warning "Grant may have failed, continuing..." - -# Grant schema privileges on the photoalbum database -echo_info "Granting schema privileges..." -az postgres flexible-server execute \ - --name "$POSTGRES_SERVER_NAME" \ - --admin-user "$POSTGRES_ADMIN_USER" \ - --admin-password "$POSTGRES_ADMIN_PASSWORD" \ - --database-name "photoalbum" \ - --querytext "GRANT ALL PRIVILEGES ON SCHEMA public TO photoalbum;" || echo_warning "Schema privileges may have failed, continuing..." - -# Grant privileges on future objects (so Hibernate can create and manage tables) -echo_info "Setting up future object privileges for Hibernate..." -az postgres flexible-server execute \ - --name "$POSTGRES_SERVER_NAME" \ - --admin-user "$POSTGRES_ADMIN_USER" \ - --admin-password "$POSTGRES_ADMIN_PASSWORD" \ - --database-name "photoalbum" \ - --querytext "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO photoalbum;" || echo_warning "Default privileges may have failed, continuing..." - -echo_info "Database user and schema setup completed! Hibernate will create and manage tables." - -# Store the datasource URL for later use -DATASOURCE_URL="jdbc:postgresql://$SERVER_FQDN:5432/$POSTGRES_DATABASE_NAME" -echo_info "Datasource URL: $DATASOURCE_URL" - -# Create Azure Container Registry -echo_info "Creating Azure Container Registry: $ACR_NAME" -az acr create \ - --name $ACR_NAME \ - --resource-group $RESOURCE_GROUP \ - --location $LOCATION \ - --sku Basic \ - --admin-enabled true - -# Create Azure Kubernetes Service (AKS) Cluster -echo_info "Creating AKS Cluster: ${RESOURCE_GROUP}-aks" -az aks create \ - --resource-group $RESOURCE_GROUP \ - --name "$RESOURCE_GROUP-aks" \ - --node-count 2 \ - --generate-ssh-keys \ - --location $LOCATION \ - --node-vm-size $AKS_NODE_VM_SIZE - -# Output connection information -echo "" -echo "================================================================" -echo "Setup Complete!" -echo "================================================================" -echo "Server FQDN: $SERVER_FQDN" -echo "Database: $POSTGRES_DATABASE_NAME" -echo "Application User: $POSTGRES_APP_USER" -echo "Resource Group: $RESOURCE_GROUP_NAME" -echo "" -echo "To connect your application to Azure PostgreSQL:" -echo "" -echo "1. Update your application.properties file:" -echo " spring.datasource.url=jdbc:postgresql://$SERVER_FQDN:5432/$POSTGRES_DATABASE_NAME" -echo "" -echo "2. Keep your existing username and password:" -echo " spring.datasource.username=$POSTGRES_APP_USER" -echo " spring.datasource.password=$POSTGRES_APP_PASSWORD" -echo "" -echo "3. Run your application normally:" -echo " mvn spring-boot:run" -echo " or" -echo " java -jar target/photo-album-*.jar" -echo "" -echo "Your existing application.properties configuration will work with Azure!" -echo "================================================================" - -echo_warning "Please save these credentials securely!" -echo_warning "Consider using Azure Key Vault for production deployments." \ No newline at end of file diff --git a/doc-media/assessment-report.png b/doc-media/assessment-report.png new file mode 100644 index 00000000..be0dfbcb Binary files /dev/null and b/doc-media/assessment-report.png differ diff --git a/doc-media/containerization-plan.png b/doc-media/containerization-plan.png new file mode 100644 index 00000000..03d72026 Binary files /dev/null and b/doc-media/containerization-plan.png differ diff --git a/doc-media/containerization-run-task.png b/doc-media/containerization-run-task.png new file mode 100644 index 00000000..2d041088 Binary files /dev/null and b/doc-media/containerization-run-task.png differ diff --git a/doc-media/custom-agent.png b/doc-media/custom-agent.png new file mode 100644 index 00000000..ff2ef523 Binary files /dev/null and b/doc-media/custom-agent.png differ diff --git a/doc-media/deployment-progress.png b/doc-media/deployment-progress.png new file mode 100644 index 00000000..3e30d0cd Binary files /dev/null and b/doc-media/deployment-progress.png differ diff --git a/doc-media/deployment-run-task.png b/doc-media/deployment-run-task.png new file mode 100644 index 00000000..cd530978 Binary files /dev/null and b/doc-media/deployment-run-task.png differ diff --git a/doc-media/run-task-prompt.png b/doc-media/run-task-prompt.png new file mode 100644 index 00000000..74c27a2f Binary files /dev/null and b/doc-media/run-task-prompt.png differ diff --git a/doc-media/trigger-assessment.png b/doc-media/trigger-assessment.png new file mode 100644 index 00000000..d899dcf9 Binary files /dev/null and b/doc-media/trigger-assessment.png differ diff --git a/pom.xml b/pom.xml index 216b79d5..7290531d 100644 --- a/pom.xml +++ b/pom.xml @@ -8,7 +8,7 @@ org.springframework.boot spring-boot-starter-parent - 2.7.18 + 3.5.0 @@ -21,9 +21,9 @@ A simple photo storage and gallery application built with Spring Boot and Oracle DB - 1.8 - 8 - 8 + 21 + 21 + 21 UTF-8 @@ -63,7 +63,7 @@ commons-io commons-io - 2.11.0 + 2.14.0 diff --git a/src/main/java/com/photoalbum/controller/DetailController.java b/src/main/java/com/photoalbum/controller/DetailController.java index d138ef95..1c091add 100644 --- a/src/main/java/com/photoalbum/controller/DetailController.java +++ b/src/main/java/com/photoalbum/controller/DetailController.java @@ -37,7 +37,7 @@ public String detail(@PathVariable String id, Model model) { try { Optional photoOpt = photoService.getPhotoById(id); - if (!photoOpt.isPresent()) { + if (photoOpt.isEmpty()) { return "redirect:/"; } diff --git a/src/main/java/com/photoalbum/controller/HomeController.java b/src/main/java/com/photoalbum/controller/HomeController.java index d9905d97..6b1559d2 100644 --- a/src/main/java/com/photoalbum/controller/HomeController.java +++ b/src/main/java/com/photoalbum/controller/HomeController.java @@ -54,7 +54,7 @@ public String index(Model model) { */ @PostMapping("/upload") @ResponseBody - public ResponseEntity> uploadPhotos(@RequestParam("files") List files) { + public ResponseEntity> uploadPhotos(@RequestParam List files) { Map response = new HashMap(); List> uploadedPhotos = new ArrayList>(); List> failedUploads = new ArrayList>(); @@ -75,7 +75,6 @@ public ResponseEntity> uploadPhotos(@RequestParam("files") L Map uploadedPhoto = new HashMap(); uploadedPhoto.put("id", photo.getId()); uploadedPhoto.put("originalFileName", photo.getOriginalFileName()); - uploadedPhoto.put("filePath", photo.getFilePath()); uploadedPhoto.put("uploadedAt", photo.getUploadedAt()); uploadedPhoto.put("fileSize", photo.getFileSize()); uploadedPhoto.put("width", photo.getWidth()); diff --git a/src/main/java/com/photoalbum/controller/PhotoFileController.java b/src/main/java/com/photoalbum/controller/PhotoFileController.java index 2f314081..b86723ff 100644 --- a/src/main/java/com/photoalbum/controller/PhotoFileController.java +++ b/src/main/java/com/photoalbum/controller/PhotoFileController.java @@ -17,7 +17,7 @@ import java.util.Optional; /** - * Controller for serving photo files from Oracle database BLOB storage + * Controller for serving photo files from database storage */ @Controller @RequestMapping("/photo") @@ -32,7 +32,7 @@ public PhotoFileController(PhotoService photoService) { } /** - * Serves a photo file by ID from Oracle database BLOB storage + * Serves a photo file by ID from database storage */ @GetMapping("/{id}") public ResponseEntity servePhoto(@PathVariable String id) { @@ -45,7 +45,7 @@ public ResponseEntity servePhoto(@PathVariable String id) { logger.info("=== DEBUGGING: Serving photo request for ID {} ===", id); Optional photoOpt = photoService.getPhotoById(id); - if (!photoOpt.isPresent()) { + if (photoOpt.isEmpty()) { logger.warn("Photo with ID {} not found", id); return ResponseEntity.notFound().build(); } @@ -54,7 +54,7 @@ public ResponseEntity servePhoto(@PathVariable String id) { logger.info("Found photo: originalFileName={}, mimeType={}", photo.getOriginalFileName(), photo.getMimeType()); - // Get photo data from Oracle database BLOB + // Get photo data from database byte[] photoData = photo.getPhotoData(); if (photoData == null || photoData.length == 0) { logger.error("No photo data found for photo ID {}", id); @@ -68,7 +68,7 @@ public ResponseEntity servePhoto(@PathVariable String id) { // Create resource from byte array Resource resource = new ByteArrayResource(photoData); - logger.info("Serving photo ID {} ({}, {} bytes) from Oracle database", + logger.info("Serving photo ID {} ({}, {} bytes) from database", id, photo.getOriginalFileName(), photoData.length); // Return the photo data with appropriate content type and aggressive no-cache headers @@ -82,7 +82,7 @@ public ResponseEntity servePhoto(@PathVariable String id) { .header("X-Photo-Size", String.valueOf(photoData.length)) .body(resource); } catch (Exception ex) { - logger.error("Error serving photo with ID {} from Oracle database", id, ex); + logger.error("Error serving photo with ID {} from database", id, ex); return ResponseEntity.status(500).build(); } } diff --git a/src/main/java/com/photoalbum/model/Photo.java b/src/main/java/com/photoalbum/model/Photo.java index d1cea3ec..f4eb34a4 100644 --- a/src/main/java/com/photoalbum/model/Photo.java +++ b/src/main/java/com/photoalbum/model/Photo.java @@ -1,10 +1,10 @@ package com.photoalbum.model; -import javax.persistence.*; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Positive; -import javax.validation.constraints.Size; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.UUID; @@ -34,33 +34,18 @@ public class Photo { private String originalFileName; /** - * Binary photo data stored directly in Oracle database + * Binary photo data stored directly in database */ @Lob @Column(name = "photo_data", nullable = true) private byte[] photoData; - /** - * GUID-based filename with extension (for compatibility) - */ - @NotBlank - @Size(max = 255) - @Column(name = "stored_file_name", nullable = false, length = 255) - private String storedFileName; - - /** - * Relative path from static resources (for compatibility - not used for DB storage) - */ - @Size(max = 500) - @Column(name = "file_path", length = 500) - private String filePath; - /** * File size in bytes */ @NotNull @Positive - @Column(name = "file_size", nullable = false, columnDefinition = "NUMBER(19,0)") + @Column(name = "file_size", nullable = false) private Long fileSize; /** @@ -75,7 +60,7 @@ public class Photo { * Timestamp of upload */ @NotNull - @Column(name = "uploaded_at", nullable = false, columnDefinition = "TIMESTAMP DEFAULT SYSTIMESTAMP") + @Column(name = "uploaded_at", nullable = false) private LocalDateTime uploadedAt; /** @@ -97,25 +82,14 @@ public Photo() { } // Constructor with required fields - public Photo(String originalFileName, byte[] photoData, String storedFileName, String filePath, Long fileSize, String mimeType) { + public Photo(String originalFileName, byte[] photoData, Long fileSize, String mimeType) { this(); this.originalFileName = originalFileName; this.photoData = photoData; - this.storedFileName = storedFileName; - this.filePath = filePath; this.fileSize = fileSize; this.mimeType = mimeType; } - // Constructor with required fields (file system compatibility) - public Photo(String originalFileName, String storedFileName, String filePath, Long fileSize, String mimeType) { - this(); - this.originalFileName = originalFileName; - this.storedFileName = storedFileName; - this.filePath = filePath; - this.fileSize = fileSize; - this.mimeType = mimeType; - } // Getters and Setters public String getId() { @@ -142,22 +116,6 @@ public void setPhotoData(byte[] photoData) { this.photoData = photoData; } - public String getStoredFileName() { - return storedFileName; - } - - public void setStoredFileName(String storedFileName) { - this.storedFileName = storedFileName; - } - - public String getFilePath() { - return filePath; - } - - public void setFilePath(String filePath) { - this.filePath = filePath; - } - public Long getFileSize() { return fileSize; } @@ -203,8 +161,6 @@ public String toString() { return "Photo{" + "id=" + id + ", originalFileName='" + originalFileName + '\'' + - ", storedFileName='" + storedFileName + '\'' + - ", filePath='" + filePath + '\'' + ", fileSize=" + fileSize + ", mimeType='" + mimeType + '\'' + ", uploadedAt=" + uploadedAt + diff --git a/src/main/java/com/photoalbum/model/UploadResult.java b/src/main/java/com/photoalbum/model/UploadResult.java index a008a72f..8b8279cd 100644 --- a/src/main/java/com/photoalbum/model/UploadResult.java +++ b/src/main/java/com/photoalbum/model/UploadResult.java @@ -13,21 +13,6 @@ public class UploadResult { public UploadResult() { } - // Constructor for successful upload with photo ID - public UploadResult(boolean success, String fileName, String photoId) { - this.success = success; - this.fileName = fileName; - this.photoId = photoId; - } - - // Static factory method for failed upload - public static UploadResult failure(String fileName, String errorMessage) { - UploadResult result = new UploadResult(); - result.success = false; - result.fileName = fileName; - result.errorMessage = errorMessage; - return result; - } // Getters and Setters public boolean isSuccess() { diff --git a/src/main/java/com/photoalbum/repository/PhotoRepository.java b/src/main/java/com/photoalbum/repository/PhotoRepository.java index 135799a0..b53a1e7d 100644 --- a/src/main/java/com/photoalbum/repository/PhotoRepository.java +++ b/src/main/java/com/photoalbum/repository/PhotoRepository.java @@ -19,10 +19,10 @@ public interface PhotoRepository extends JpaRepository { * Find all photos ordered by upload date (newest first) * @return List of photos ordered by upload date descending */ - @Query(value = "SELECT ID, ORIGINAL_FILE_NAME, PHOTO_DATA, STORED_FILE_NAME, FILE_PATH, FILE_SIZE, " + - "MIME_TYPE, UPLOADED_AT, WIDTH, HEIGHT " + - "FROM PHOTOS " + - "ORDER BY UPLOADED_AT DESC", + @Query(value = "SELECT id, original_file_name, photo_data, file_size, " + + "mime_type, uploaded_at, width, height " + + "FROM photos " + + "ORDER BY uploaded_at DESC", nativeQuery = true) List findAllOrderByUploadedAtDesc(); @@ -32,11 +32,11 @@ public interface PhotoRepository extends JpaRepository { * @return List of photos uploaded before the given timestamp */ @Query(value = "SELECT * FROM (" + - "SELECT ID, ORIGINAL_FILE_NAME, PHOTO_DATA, STORED_FILE_NAME, FILE_PATH, FILE_SIZE, " + - "MIME_TYPE, UPLOADED_AT, WIDTH, HEIGHT, ROWNUM as RN " + - "FROM PHOTOS " + - "WHERE UPLOADED_AT < :uploadedAt " + - "ORDER BY UPLOADED_AT DESC" + + "SELECT id, original_file_name, photo_data, file_size, " + + "mime_type, uploaded_at, width, height, ROWNUM as rn " + + "FROM photos " + + "WHERE uploaded_at < :uploadedAt " + + "ORDER BY uploaded_at DESC" + ") WHERE ROWNUM <= 10", nativeQuery = true) List findPhotosUploadedBefore(@Param("uploadedAt") LocalDateTime uploadedAt); @@ -46,56 +46,11 @@ public interface PhotoRepository extends JpaRepository { * @param uploadedAt The upload timestamp to compare against * @return List of photos uploaded after the given timestamp */ - @Query(value = "SELECT ID, ORIGINAL_FILE_NAME, PHOTO_DATA, STORED_FILE_NAME, " + - "NVL(FILE_PATH, 'default_path') as FILE_PATH, FILE_SIZE, " + - "MIME_TYPE, UPLOADED_AT, WIDTH, HEIGHT " + - "FROM PHOTOS " + - "WHERE UPLOADED_AT > :uploadedAt " + - "ORDER BY UPLOADED_AT ASC", + @Query(value = "SELECT id, original_file_name, photo_data, file_size, " + + "mime_type, uploaded_at, width, height " + + "FROM photos " + + "WHERE uploaded_at > :uploadedAt " + + "ORDER BY uploaded_at ASC", nativeQuery = true) List findPhotosUploadedAfter(@Param("uploadedAt") LocalDateTime uploadedAt); - - /** - * Find photos by upload month using Oracle TO_CHAR function - Oracle specific - * @param year The year to search for - * @param month The month to search for - * @return List of photos uploaded in the specified month - */ - @Query(value = "SELECT ID, ORIGINAL_FILE_NAME, PHOTO_DATA, STORED_FILE_NAME, FILE_PATH, FILE_SIZE, " + - "MIME_TYPE, UPLOADED_AT, WIDTH, HEIGHT " + - "FROM PHOTOS " + - "WHERE TO_CHAR(UPLOADED_AT, 'YYYY') = :year " + - "AND TO_CHAR(UPLOADED_AT, 'MM') = :month " + - "ORDER BY UPLOADED_AT DESC", - nativeQuery = true) - List findPhotosByUploadMonth(@Param("year") String year, @Param("month") String month); - - /** - * Get paginated photos using Oracle ROWNUM - Oracle specific pagination - * @param startRow Starting row number (1-based) - * @param endRow Ending row number - * @return List of photos within the specified row range - */ - @Query(value = "SELECT * FROM (" + - "SELECT P.*, ROWNUM as RN FROM (" + - "SELECT ID, ORIGINAL_FILE_NAME, PHOTO_DATA, STORED_FILE_NAME, FILE_PATH, FILE_SIZE, " + - "MIME_TYPE, UPLOADED_AT, WIDTH, HEIGHT " + - "FROM PHOTOS ORDER BY UPLOADED_AT DESC" + - ") P WHERE ROWNUM <= :endRow" + - ") WHERE RN >= :startRow", - nativeQuery = true) - List findPhotosWithPagination(@Param("startRow") int startRow, @Param("endRow") int endRow); - - /** - * Find photos with file size statistics using Oracle analytical functions - Oracle specific - * @return List of photos with running totals and rankings - */ - @Query(value = "SELECT ID, ORIGINAL_FILE_NAME, PHOTO_DATA, STORED_FILE_NAME, FILE_PATH, FILE_SIZE, " + - "MIME_TYPE, UPLOADED_AT, WIDTH, HEIGHT, " + - "RANK() OVER (ORDER BY FILE_SIZE DESC) as SIZE_RANK, " + - "SUM(FILE_SIZE) OVER (ORDER BY UPLOADED_AT ROWS UNBOUNDED PRECEDING) as RUNNING_TOTAL " + - "FROM PHOTOS " + - "ORDER BY UPLOADED_AT DESC", - nativeQuery = true) - List findPhotosWithStatistics(); } \ No newline at end of file diff --git a/src/main/java/com/photoalbum/service/impl/PhotoServiceImpl.java b/src/main/java/com/photoalbum/service/impl/PhotoServiceImpl.java index fa379a51..ce2473a5 100644 --- a/src/main/java/com/photoalbum/service/impl/PhotoServiceImpl.java +++ b/src/main/java/com/photoalbum/service/impl/PhotoServiceImpl.java @@ -18,7 +18,6 @@ import java.util.Arrays; import java.util.List; import java.util.Optional; -import java.util.UUID; /** * Service implementation for photo operations including upload, retrieval, and deletion @@ -91,7 +90,7 @@ public UploadResult uploadPhoto(MultipartFile file) { // Validate file size if (file.getSize() > maxFileSizeBytes) { result.setSuccess(false); - result.setErrorMessage(String.format("File size exceeds %dMB limit.", maxFileSizeBytes / 1024 / 1024)); + result.setErrorMessage("File size exceeds %dMB limit.".formatted(maxFileSizeBytes / 1024 / 1024)); logger.warn("Upload rejected: File size {} exceeds limit for {}", file.getSize(), file.getOriginalFilename()); return result; @@ -104,11 +103,6 @@ public UploadResult uploadPhoto(MultipartFile file) { return result; } - // Generate unique filename for compatibility (stored in database, not on disk) - String extension = getFileExtension(file.getOriginalFilename()); - String storedFileName = UUID.randomUUID().toString() + extension; - String relativePath = "/uploads/" + storedFileName; // For compatibility only - // Extract image dimensions and read file data Integer width = null; Integer height = null; @@ -136,29 +130,27 @@ public UploadResult uploadPhoto(MultipartFile file) { // Continue without dimensions - not critical } - // Create photo entity with database BLOB storage + // Create photo entity with database storage Photo photo = new Photo( file.getOriginalFilename(), - photoData, // Store actual photo data in Oracle database - storedFileName, - relativePath, // Keep for compatibility, not used for serving + photoData, file.getSize(), file.getContentType() ); photo.setWidth(width); photo.setHeight(height); - // Save to database (with BLOB photo data) + // Save to database (with photo data) try { photo = photoRepository.save(photo); result.setSuccess(true); result.setPhotoId(photo.getId()); - logger.info("Successfully uploaded photo {} with ID {} to Oracle database", + logger.info("Successfully uploaded photo {} with ID {} to database", file.getOriginalFilename(), photo.getId()); } catch (Exception ex) { - logger.error("Error saving photo to Oracle database for {}", file.getOriginalFilename(), ex); + logger.error("Error saving photo to database for {}", file.getOriginalFilename(), ex); result.setSuccess(false); result.setErrorMessage("Error saving photo to database. Please try again."); } @@ -178,20 +170,20 @@ public UploadResult uploadPhoto(MultipartFile file) { public boolean deletePhoto(String id) { try { Optional photoOpt = photoRepository.findById(id); - if (!photoOpt.isPresent()) { + if (photoOpt.isEmpty()) { logger.warn("Photo with ID {} not found for deletion", id); return false; } Photo photo = photoOpt.get(); - // Delete from Oracle database (photos stored as BLOB) + // Delete from database photoRepository.delete(photo); logger.info("Successfully deleted photo ID {} from Oracle database", id); return true; } catch (Exception ex) { - logger.error("Error deleting photo with ID {} from Oracle database", id, ex); + logger.error("Error deleting photo with ID {} from database", id, ex); throw new RuntimeException("Error deleting photo", ex); } } @@ -203,7 +195,7 @@ public boolean deletePhoto(String id) { @Transactional(readOnly = true) public Optional getPreviousPhoto(Photo currentPhoto) { List olderPhotos = photoRepository.findPhotosUploadedBefore(currentPhoto.getUploadedAt()); - return olderPhotos.isEmpty() ? Optional.empty() : Optional.of(olderPhotos.get(0)); + return olderPhotos.isEmpty() ? Optional.empty() : Optional.of(olderPhotos.getFirst()); } /** @@ -213,17 +205,6 @@ public Optional getPreviousPhoto(Photo currentPhoto) { @Transactional(readOnly = true) public Optional getNextPhoto(Photo currentPhoto) { List newerPhotos = photoRepository.findPhotosUploadedAfter(currentPhoto.getUploadedAt()); - return newerPhotos.isEmpty() ? Optional.empty() : Optional.of(newerPhotos.get(0)); - } - - /** - * Extract file extension from filename - */ - private String getFileExtension(String filename) { - if (filename == null || filename.isEmpty()) { - return ""; - } - int lastDotIndex = filename.lastIndexOf('.'); - return lastDotIndex > 0 ? filename.substring(lastDotIndex) : ""; + return newerPhotos.isEmpty() ? Optional.empty() : Optional.of(newerPhotos.getFirst()); } } \ No newline at end of file diff --git a/src/main/java/com/photoalbum/util/MathUtil.java b/src/main/java/com/photoalbum/util/MathUtil.java deleted file mode 100644 index 81378d23..00000000 --- a/src/main/java/com/photoalbum/util/MathUtil.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.photoalbum.util; - -/** - * Mathematical utility functions - */ -public class MathUtil { - - /** - * Calculate the Greatest Common Divisor (GCD) of two integers - * @param a First integer - * @param b Second integer - * @return The GCD of a and b - */ - public static int gcd(int a, int b) { - while (b != 0) { - int temp = b; - b = a % b; - a = temp; - } - return a; - } -} \ No newline at end of file diff --git a/src/main/resources/application-docker.properties b/src/main/resources/application-docker.properties index 8dff3063..f8266846 100644 --- a/src/main/resources/application-docker.properties +++ b/src/main/resources/application-docker.properties @@ -1,4 +1,4 @@ -# Docker-specific configuration for Oracle DB +# Docker-specific database configuration spring.datasource.url=jdbc:oracle:thin:@oracle-db:1521:XE spring.datasource.username=photoalbum spring.datasource.password=photoalbum @@ -15,7 +15,7 @@ spring.jpa.hibernate.ddl-auto=create spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true -# File Upload Configuration - Validation only (photos stored in Oracle database) +# File Upload Configuration - Validation only (photos stored in database) app.file-upload.max-file-size-bytes=10485760 app.file-upload.allowed-mime-types=image/jpeg,image/png,image/gif,image/webp app.file-upload.max-files-per-upload=10 @@ -24,9 +24,6 @@ app.file-upload.max-files-per-upload=10 server.port=8080 spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=50MB -app.file-upload.max-file-size-bytes=10485760 -app.file-upload.allowed-mime-types=image/jpeg,image/png,image/gif,image/webp -app.file-upload.max-files-per-upload=10 # Logging for Docker logging.level.com.photoalbum=INFO