diff --git a/.env b/.env new file mode 100644 index 0000000..e4a5fa6 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +SA_PASSWORD=P@ssword1! +MSSQL_PID=Developer +INSERT_SIMULATED_DATA=true \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fd246e9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +.bacpac filter=lfs diff=lfs merge=lfs -text +.bak filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile index 98b20e7..ee27a1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,30 +8,47 @@ ENV SQLCMDPASSWORD=${SA_PASSWORD} ENV MSSQL_PID=${MSSQL_PID:-Developer} ENV INSERT_SIMULATED_DATA=${INSERT_SIMULATED_DATA:-false} +# Install dependencies for sqlpackage +RUN apt-get update && \ + apt-get install -y \ + wget \ + unzip \ + libicu-dev \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + # Copy in scripts COPY docker-entrypoint.sh / COPY healthcheck.sh / COPY scripts /scripts - COPY sqlcmd /sqlcmd -# If the architecture is arm copy in the correct sqlcmd -RUN if [ "$(uname -m)" = "aarch64" ]; then \ - cp sqlcmd/linux-arm64 /usr/bin/sqlcmd; \ - elif [ "$(uname -m)" = "x86_64" ]; then \ - cp sqlcmd/linux-x64 /usr/bin/sqlcmd; \ - fi +# make sure the scripts directory is executable +RUN chmod -R +x /scripts + +# Install sqlcmd +RUN cp sqlcmd/linux-x64 /usr/bin/sqlcmd; + +# Install sqlpackage +RUN echo "Downloading sqlpackage for Linux..." && \ + wget -q "https://aka.ms/sqlpackage-linux" -O /tmp/sqlpackage.zip && \ + unzip -q /tmp/sqlpackage.zip -d /opt/sqlpackage && \ + chmod +x /opt/sqlpackage/sqlpackage && \ + echo "Verifying sqlpackage installation:" && \ + /opt/sqlpackage/sqlpackage /version && \ + rm /tmp/sqlpackage.zip && \ + echo "sqlpackage installation complete" # Set a Simple Health Check HEALTHCHECK \ --interval=30s \ --retries=3 \ --start-period=10s \ - --timeout=30s \ + --timeout=60s \ CMD /healthcheck.sh -# Put CLI tools on the PATH -ENV PATH /opt/mssql-tools/bin:$PATH +# Put CLI tools on the PATH (including sqlpackage) +ENV PATH=/opt/mssql-tools/bin:/opt/sqlpackage:$PATH # Create some base paths and place our provisioning script RUN mkdir /docker-entrypoint-initdb.d && \ diff --git a/docker-bake.hcl b/docker-bake.hcl index 054c2fa..2bf6881 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -10,8 +10,7 @@ target "default" { } dockerfile = "Dockerfile" platforms = [ - "linux/amd64", - "linux/arm64" + "linux/amd64" ] tags = [ "ghcr.io/design-group/${BASE_IMAGE_NAME}:${version}" diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 0000000..71e7797 --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,27 @@ + +services: + database: + build: + context: . + platforms: + - linux/amd64 # Force x86-64 platform + platform: linux/amd64 # Force x86-64 platform + environment: + - MSSQL_STARTUP_DELAY=30 # Faster startup for development + - INSERT_SIMULATED_DATA=true # Enable simulated data insertion + volumes: + # Mount source code for development + - ./scripts:/scripts + - ./test/fixtures/init-sql:/docker-entrypoint-initdb.d + - ./healthcheck.sh:/healthcheck.sh + # Enable debug logging + command: ["/docker-entrypoint.sh", "/opt/mssql/bin/sqlservr", "--accept-eula"] + networks: + - default + - proxy + +networks: + default: + proxy: + external: true + name: proxy \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..44f85c8 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,35 @@ + +services: + database: + build: . + # image: ghcr.io/design-group/mssql-docker:latest + hostname: azure-sql-db + volumes: + - ./test/fixtures:/backups + - ./test/fixtures/simulated-data:/simulated-data + - ./test/integration:/test-scripts + environment: + SA_PASSWORD: ${SA_PASSWORD:-} + healthcheck: + test: ["CMD", "/healthcheck.sh"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + labels: + traefik.enable: true + traefik.hostname: azure-sql-db + traefik.tcp.routers.azure-sql-db.entrypoints: "azure-sql" + traefik.tcp.routers.azure-sql-db.tls: false + traefik.tcp.routers.azure-sql-db.rule: "HostSNI(`*`)" + traefik.tcp.routers.azure-sql-db.service: "azure-sql-db-svc" + traefik.tcp.services.azure-sql-db-svc.loadbalancer.server.port: 1433 + networks: + - default + - proxy + +networks: + default: + proxy: + external: true + name: proxy diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 207df72..c1150f6 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,69 +1,425 @@ #!/bin/bash # shellcheck source=/dev/null - ################################################################################ -# Execute any startup .sql scripts +# FUNCTION DEFINITIONS - Must be defined before use ################################################################################ + +# Execute any startup .sql scripts execute_startup_scripts() { - # Execute any files in the /docker-entrypoint-initdb.d directory with sqlcmd - for f in /docker-entrypoint-initdb.d/*; do - case "$f" in - *.sh) echo "$0: running $f"; . "$f" ;; - *.sql) echo "$0: running $f"; sqlcmd -S localhost -U sa -i "$f"; echo ;; - *) echo "$0: ignoring $f" ;; - esac - echo - done + for f in /docker-entrypoint-initdb.d/*; do + case "$f" in + *.sh) echo "$0: running $f"; . "$f" ;; + *.sql) echo "$0: running $f"; sqlcmd -S localhost -U sa -i "$f"; echo ;; + *) echo "$0: ignoring $f" ;; + esac + echo + done } -################################################################################ -# Check for the `INSERT_SIMULATED_DATA` environment variable, and if so, insert the csvs from the `/simulated-data` directory into the database. -# -# This image will automatically insert simulated data into the database if the `INSERT_SIMULATED_DATA` environment variable is set to `true`. This is useful for testing purposes, but should not be used in production. To make these files available to the image, you can mount a volume to `/simulated-data`. The files should be in the format `table_name.csv` and should be comma separated. The first line of the file should be the column names. The files should be mounted in the `/simulated-data` directory. For example, if you have a file named `users.csv` that you want to insert into the `users` table, you would mount the file to `/simulated-data/users.csv`. -################################################################################ +# Check for the `INSERT_SIMULATED_DATA` environment variable copy_simulation_scripts() { - if [ "$INSERT_SIMULATED_DATA" = "true" ]; then - # Iterate through any CSV files in the /simulated-data directory and insert them into the database - for f in /simulated-data/*; do - case "$f" in - *.sh) echo "$0: running $f"; . "$f" ;; - *.sql) echo "$0: running $f"; sqlcmd -S localhost -U sa -i "$f"; echo ;; - *) echo "$0: ignoring $f" ;; - esac - echo - done - fi + if [ "$INSERT_SIMULATED_DATA" = "true" ]; then + for f in /simulated-data/*; do + case "$f" in + *.sh) echo "$0: running $f"; . "$f" ;; + *.sql) echo "$0: running $f"; sqlcmd -S localhost -U sa -i "$f"; echo ;; + *) echo "$0: ignoring $f" ;; + esac + echo + done + fi } -################################################################################ -# Restore and pre-prepared database backups -################################################################################ -restore_database_backups() { - # Restore any database backups located in the /backups directory - for f in /backups/*; do - case "$f" in - *.bak) echo "$0: restoring $f"; sqlcmd -S localhost -U sa -i /scripts/restore-database.sql -v databaseName="$(basename "$f" .bak)" -v databaseBackup="$f"; echo ;; - *) echo "$0: ignoring $f" ;; - esac - echo - done +# Restore .bak files (SQL Server native backups) +restore_bak_files() { + local bak_count=0 + echo "=== Restoring .bak files ===" + + if [ ! -f /scripts/restore-database.sql ]; then + mkdir -p /scripts + cat > /scripts/restore-database.sql << 'EOF' +-- Restore database from .bak file +DECLARE @DatabaseName NVARCHAR(128) = '$(databaseName)'; +DECLARE @BackupFile NVARCHAR(500) = '$(databaseBackup)'; + +-- Drop database if it exists +IF EXISTS (SELECT 1 FROM sys.databases WHERE name = @DatabaseName) +BEGIN + PRINT 'Dropping existing database [' + @DatabaseName + ']...'; + EXEC('ALTER DATABASE [' + @DatabaseName + '] SET SINGLE_USER WITH ROLLBACK IMMEDIATE'); + EXEC('DROP DATABASE [' + @DatabaseName + ']'); +END + +-- Restore the database +PRINT 'Restoring database [' + @DatabaseName + '] from ' + @BackupFile + '...'; + +-- Get file list from backup +CREATE TABLE #FileList ( + LogicalName NVARCHAR(128), + PhysicalName NVARCHAR(260), + Type CHAR(1), + FileGroupName NVARCHAR(128), + Size NUMERIC(20,0), + MaxSize NUMERIC(20,0), + FileId BIGINT, + CreateLSN NUMERIC(25,0), + DropLSN NUMERIC(25,0), + UniqueId UNIQUEIDENTIFIER, + ReadOnlyLSN NUMERIC(25,0), + ReadWriteLSN NUMERIC(25,0), + BackupSizeInBytes BIGINT, + SourceBlockSize INT, + FileGroupId INT, + LogGroupGUID UNIQUEIDENTIFIER, + DifferentialBaseLSN NUMERIC(25,0), + DifferentialBaseGUID UNIQUEIDENTIFIER, + IsReadOnly BIT, + IsPresent BIT, + TDEThumbprint VARBINARY(32), + SnapshotUrl NVARCHAR(360) +); + +INSERT INTO #FileList +EXEC('RESTORE FILELISTONLY FROM DISK = ''' + @BackupFile + ''''); +-- Build restore command with file mapping +DECLARE @DataFile NVARCHAR(128); +DECLARE @LogFile NVARCHAR(128); +DECLARE @RestoreCmd NVARCHAR(MAX); + +SELECT @DataFile = LogicalName FROM #FileList WHERE Type = 'D' AND FileId = 1; +SELECT @LogFile = LogicalName FROM #FileList WHERE Type = 'L'; + +SET @RestoreCmd = 'RESTORE DATABASE [' + @DatabaseName + '] FROM DISK = ''' + @BackupFile + ''' WITH FILE = 1, ' + + 'MOVE ''' + @DataFile + ''' TO ''/var/opt/mssql/data/' + @DatabaseName + '.mdf'', ' + + 'MOVE ''' + @LogFile + ''' TO ''/var/opt/mssql/data/' + @DatabaseName + '_log.ldf'', ' + + 'NOUNLOAD, REPLACE, STATS = 10'; + +EXEC(@RestoreCmd); + +DROP TABLE #FileList; + +PRINT 'Database [' + @DatabaseName + '] restored successfully.'; +GO +EOF + fi + + for f in /backups/*.bak; do + if [ -f "$f" ]; then + ((bak_count++)) + echo "$0: restoring $f" + if sqlcmd -S localhost -U sa -i /scripts/restore-database.sql -v databaseName="$(basename "$f" .bak)" -v databaseBackup="$f"; then + echo "$0: successfully restored $f" + else + echo "$0: failed to restore $f" + fi + echo + fi + done + + if [ $bak_count -eq 0 ]; then + echo "$0: No .bak files found in /backups" + else + echo "$0: Restored $bak_count .bak file(s)" + fi + echo +} + +# Extract and create logins from .bacpac files +extract_and_create_logins() { + local login_file="/tmp/all_logins.txt" + local extract_counter=0 + rm -f "$login_file" 2>/dev/null + touch "$login_file" + + echo "$0: Extracting logins from .bacpac files..." >> /tmp/login_creation.log + + for f in /backups/*.bacpac; do + if [ -f "$f" ]; then + local temp_dir="/tmp/bacpac_extract_$_$((extract_counter++))" + mkdir -p "$temp_dir" + + if unzip -q "$f" model.xml -d "$temp_dir" >>/tmp/login_creation.log; then + if [ -f "$temp_dir/model.xml" ]; then + # Extract usernames and add to the login file + grep -oP '> "$login_file" + else + echo "$0: No model.xml found in $f" >> /tmp/login_creation.log + fi + else + echo "$0: Failed to extract model.xml from $f" >> /tmp/login_creation.log + fi + + rm -rf "$temp_dir" + fi + done + + # Create logins from extracted usernames + if [ -s "$login_file" ]; then + echo "$0: Creating logins from .bacpac files..." >> /tmp/login_creation.log + + # Use sqlcmd with individual commands instead of building a complex SQL file + while IFS= read -r login; do + # Skip empty lines and validate login name + if [ -n "$login" ] && [ ${#login} -le 128 ]; then + echo "$0: Processing login: '$login'" >> /tmp/login_creation.log + + # Use sqlcmd with properly escaped parameters + if sqlcmd -S localhost -U sa -Q " + IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = N'$(printf '%s' "$login" | sed "s/'/''/g")') + BEGIN + BEGIN TRY + CREATE LOGIN [$(printf '%s' "$login" | sed "s/\]/\]\]/g")] WITH PASSWORD = N'${SA_PASSWORD}', CHECK_POLICY = OFF, CHECK_EXPIRATION = OFF; + PRINT 'Successfully created login: $(printf '%s' "$login" | sed "s/'/''/g")'; + END TRY + BEGIN CATCH + PRINT 'Failed to create login $(printf '%s' "$login" | sed "s/'/''/g"): ' + ERROR_MESSAGE(); + END CATCH + END + ELSE + BEGIN + PRINT 'Login already exists: $(printf '%s' "$login" | sed "s/'/''/g")'; + END" >> /tmp/login_creation.log 2>&1; then + echo "$0: Successfully processed login: '$login'" >> /tmp/login_creation.log + else + echo "$0: Failed to process login: '$login'" >> /tmp/login_creation.log + fi + else + echo "$0: Skipping invalid login name: '$login' (length: ${#login})" >> /tmp/login_creation.log + fi + done < "$login_file" + + # Cleanup + rm -f "$login_file" + else + echo "$0: No SQL logins found in .bacpac files" >> /tmp/login_creation.log + fi } +# Restore .bacpac files +restore_bacpac_files() { + local bacpac_count=0 + + echo "=== Restoring .bacpac files - Dynamic Login Handling ===" + + # Clear the login creation log to prevent continuous growth + # Fix for SC2129: Use grouped commands instead of individual redirects + { + echo "$(date '+%Y-%m-%d %H:%M:%S') - Starting .bacpac restore process" + echo "$(date '+%Y-%m-%d %H:%M:%S') - Creating generic app_user login" + } > /tmp/login_creation.log + + sqlcmd -S localhost -U sa -Q "IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = 'app_user') CREATE LOGIN [app_user] WITH PASSWORD = N'${SA_PASSWORD}', CHECK_POLICY = OFF, CHECK_EXPIRATION = OFF;" >> /tmp/login_creation.log 2>&1 + + # Extract and create logins from all .bacpac files + echo "$(date '+%Y-%m-%d %H:%M:%S') - Starting login extraction and creation" >> /tmp/login_creation.log + extract_and_create_logins + + # Reset counter for the main restoration loop + bacpac_count=0 + for f in /backups/*.bacpac; do + if [ -f "$f" ]; then + ((bacpac_count++)) + local database_name log_file + database_name="$(basename "$f" .bacpac)" + log_file="/tmp/restore_${database_name}.log" + + echo "$0: Starting restore of $f to database [$database_name]" | tee -a "$log_file" + echo "$(date '+%Y-%m-%d %H:%M:%S') - Starting restore of $f to database [$database_name]" >> /tmp/login_creation.log + + # Get file size for progress estimation + file_size=$(du -h "$f" | cut -f1) + echo "$0: File size: $file_size" | tee -a "$log_file" + + # Drop existing database if it exists + sqlcmd -S localhost -U sa -Q " + IF EXISTS (SELECT 1 FROM sys.databases WHERE name = '$database_name') + BEGIN + ALTER DATABASE [$database_name] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [$database_name]; + END" >> "$log_file" 2>&1 + + echo "$0: Attempting sqlpackage restore for [$database_name]..." | tee -a "$log_file" + + # Start progress monitoring in background + monitor_import_progress "$database_name" & + local monitor_pid=$! + + # Start the import with CORRECT sqlpackage arguments + for attempt in {1..3}; do + echo "$0: Import attempt $attempt for [$database_name]..." | tee -a "$log_file" + + if sqlpackage /Action:Import \ + /SourceFile:"$f" \ + /TargetServerName:localhost \ + /TargetDatabaseName:"$database_name" \ + /TargetUser:sa \ + /TargetPassword:"${SA_PASSWORD}" \ + /TargetTrustServerCertificate:True \ + /Diagnostics:True \ + /DiagnosticsFile:"/tmp/sqlpackage_diagnostics_${database_name}.log" \ + /p:CommandTimeout=300 \ + /p:LongRunningCommandTimeout=0 \ + >> "$log_file" 2>&1; then + echo "$0: sqlpackage completed for [$database_name]" | tee -a "$log_file" + break + else + echo "$0: Retry $attempt failed for [$database_name]" | tee -a "$log_file" + sleep 5 + fi + done + + # Stop progress monitoring + kill $monitor_pid 2>/dev/null || true + wait $monitor_pid 2>/dev/null || true + + sleep 2 + if sqlcmd -S localhost -U sa -Q "SELECT name FROM sys.databases WHERE name = '$database_name'" 2>>"$log_file" | grep -q "$database_name"; then + echo "$0: ✓ Successfully restored $f - database [$database_name] exists and is accessible" | tee -a "$log_file" + + echo "$0: Mapping orphaned users to app_user in [$database_name]..." | tee -a "$log_file" + echo "$(date '+%Y-%m-%d %H:%M:%S') - Mapping orphaned users to app_user in [$database_name]..." >> /tmp/login_creation.log + + # Create a SQL script for user mapping + local user_mapping_sql="/tmp/map_users_${database_name}.sql" + cat > "$user_mapping_sql" << EOF +USE [$database_name]; +GO + +DECLARE @sql NVARCHAR(MAX) = ''; +DECLARE @username NVARCHAR(128); + +DECLARE user_cursor CURSOR FOR +SELECT name FROM sys.database_principals +WHERE type IN ('S', 'U') + AND principal_id > 4 + AND name NOT IN ('guest', 'INFORMATION_SCHEMA', 'sys') + AND sid NOT IN (SELECT sid FROM master.sys.server_principals); + +OPEN user_cursor; +FETCH NEXT FROM user_cursor INTO @username; + +WHILE @@FETCH_STATUS = 0 +BEGIN + BEGIN TRY + SET @sql = 'ALTER USER [' + @username + '] WITH LOGIN = [app_user]'; + EXEC sp_executesql @sql; + PRINT 'Mapped user: ' + @username + ' to app_user'; + END TRY + BEGIN CATCH + PRINT 'Could not map user: ' + @username + ' - ' + ERROR_MESSAGE(); + BEGIN TRY + SET @sql = 'DROP USER [' + @username + ']'; + EXEC sp_executesql @sql; + PRINT 'Dropped problematic user: ' + @username; + END TRY + BEGIN CATCH + PRINT 'Could not drop user: ' + @username + ' - ' + ERROR_MESSAGE(); + END CATCH + END CATCH + FETCH NEXT FROM user_cursor INTO @username; +END + +CLOSE user_cursor; +DEALLOCATE user_cursor; +GO +EOF + + # Execute the user mapping script + sqlcmd -S localhost -U sa -i "$user_mapping_sql" >> "$log_file" 2>&1 + rm -f "$user_mapping_sql" + + # Clean up log files after successful restore + rm -f "$log_file" 2>/dev/null + else + echo "$0: ✗ Failed to restore $f - database [$database_name] was not created" | tee -a "$log_file" + echo "$0: sqlpackage error details:" | tee -a "$log_file" + if [ -f "$log_file" ]; then + tail -30 "$log_file" | tee -a "$log_file" + echo "--- Full log saved at: $log_file ---" | tee -a "$log_file" + else + echo "No log file found" | tee -a "$log_file" + fi + fi + echo + fi + done + + if [ $bacpac_count -eq 0 ]; then + echo "$0: No .bacpac files found in /backups" + else + echo "$0: Processed $bacpac_count .bacpac file(s)" + echo "$0: Final database list:" + sqlcmd -S localhost -U sa -Q "SELECT name, create_date, state_desc FROM sys.databases WHERE database_id > 4 ORDER BY name" >> /tmp/database_list.log 2>&1 || true + cat /tmp/database_list.log 2>/dev/null || echo "Could not retrieve database list" + fi + echo +} + +# Simplified progress monitoring function +monitor_import_progress() { + local database_name="$1" + # Fix for SC2155: Declare and assign separately + local start_time + start_time=$(date +%s) + + while true; do + sleep 15 + + # Fix for SC2155: Declare and assign separately + local current_time + current_time=$(date +%s) + local elapsed=$((current_time - start_time)) + local elapsed_min=$((elapsed / 60)) + local elapsed_sec=$((elapsed % 60)) + + # Check if database exists + # Fix for SC2155: Declare and assign separately + local db_status + db_status=$(sqlcmd -S localhost -U sa -h -1 -Q "SELECT ISNULL((SELECT state_desc FROM sys.databases WHERE name = '$database_name'), 'NOT_FOUND')" 2>/dev/null | tr -d ' \r\n' | tail -1) + + echo "$(date '+%H:%M:%S') - [${elapsed_min}m${elapsed_sec}s] Database: $database_name | Status: $db_status" + + # Break if database is online + if [ "$db_status" = "ONLINE" ]; then + echo "$(date '+%H:%M:%S') - Database $database_name is now ONLINE!" + break + fi + + # Safety break after 15 minutes + if [ $elapsed -gt 900 ]; then + echo "$(date '+%H:%M:%S') - Progress monitor timeout for $database_name" + break + fi + done +} + +# Main restore function that handles both .bak and .bacpac files +restore_database_backups() { + echo "=== Starting Database Restore Process ===" + echo "Backup directory: /backups" + echo + + restore_bak_files + restore_bacpac_files + + echo "=== Database Restore Process Complete ===" +} + +################################################################################ +# MAIN SCRIPT EXECUTION +################################################################################ + MSSQL_BASE=${MSSQL_BASE:-/var/opt/mssql} -# Check for Init Complete if [ ! -f "${MSSQL_BASE}/.docker-init-complete" ]; then - # Mark Initialization Complete mkdir -p "${MSSQL_BASE}" - touch "${MSSQL_BASE}"/.docker-init-complete + touch "${MSSQL_BASE}/.docker-init-complete" - # Initialize MSSQL before attempting database creation "$@" & pid="$!" - # Wait up to 60 seconds for database initialization to complete echo "Database Startup In Progress..." for ((i=${MSSQL_STARTUP_DELAY:=60};i>0;i--)); do if sqlcmd -S localhost -U sa -l 1 -V 16 -Q "SELECT 1" &> /dev/null; then @@ -77,16 +433,11 @@ if [ ! -f "${MSSQL_BASE}/.docker-init-complete" ]; then exit 1 fi - restore_database_backups - - execute_startup_scripts - - copy_simulation_scripts - + restore_database_backups + execute_startup_scripts + copy_simulation_scripts echo "Startup Complete." - - # Attach and wait for exit wait "$pid" else exec "$@" diff --git a/readme.md b/readme.md index 0f74432..d53058b 100644 --- a/readme.md +++ b/readme.md @@ -10,7 +10,7 @@ If using a windows device, you will want to [Set up WSL](https://github.com/desi ___ -## Getting the Docker Imgage +## Getting the Docker Image 1. The user must have a local personal access token to authenticate to the Github Repository. For details on how to authenticate to the Github Repository, see the [Github Documentation](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-with-a-personal-access-token-classic). @@ -26,6 +26,101 @@ ___ This is a derived image of the microsoft `azure-sql-edge` image. Please see the [Azure SQL Edge Docker Hub](https://hub.docker.com/_/microsoft-azure-sql-edge?tab=description) for more information on the base image. This image should be able to take all arguments provided by the base image, but has not been tested. +### Backup and Restore Functionality + +This image includes built-in backup and restore capabilities for both `.bak` and `.bacpac` files: + +#### Creating Backups + +Use the backup script to create database backups: + +```bash +# Backup all databases to .bak files +docker exec your-container-name bash /scripts/backup-databases.sh + +# Backup specific databases +docker exec -e EXPORT_DATABASES=SAP,My_Site_Data,MyData your-container-name bash /scripts/backup-databases.sh + +# Save backups to host directory +docker exec -e BACKUP_EXPORT_DIR=/backups -v ./backups:/backups your-container-name bash /scripts/backup-databases.sh +``` + +#### Restoring .bak Files + +**Automatic restore during startup:** + +Any `.bak` files placed in the `/backups` directory of the container will be automatically restored during container startup. The database will be created with the same name as the backup file (without the `.bak` extension). + +```yaml +services: + mssql: + image: ghcr.io/design-group/mssql-docker:latest + volumes: + - ./backups/my-database.bak:/backups/my-database.bak + - ./backups/SAP.bak:/backups/SAP.bak + environment: + - RESTORE_DATABASES=my-database,SAP # Optional: specify which to restore +``` + +**Example:** A file named `MyData.bak` will be restored as database `MyData`. + +**Manual restore:** + +```bash +# Restore a .bak file +docker exec your-container-name sqlcmd -S localhost -U sa -Q " +RESTORE DATABASE [NewDatabaseName] +FROM DISK = '/backups/my-database.bak' +WITH MOVE 'LogicalDataName' TO '/var/opt/mssql/data/NewDatabaseName.mdf', + MOVE 'LogicalLogName' TO '/var/opt/mssql/data/NewDatabaseName.ldf', + REPLACE" +``` + +#### Restoring .bacpac Files + +**Automatic restore during startup:** + +Any `.bacpac` files placed in the `/backups` directory will be automatically imported during container startup. The database will be created with the same name as the backup file (without the `.bacpac` extension). + +**Example:** A file named `CustomerDB.bacpac` will be imported as database `CustomerDB`. + +**Manual import using installed sqlpackage:** + +```bash +# Import a .bacpac file +docker exec your-container-name sqlpackage /Action:Import \ + /SourceFile:"/backups/database.bacpac" \ + /TargetServerName:localhost \ + /TargetDatabaseName:RestoredDatabase \ + /TargetUser:sa \ + /TargetPassword:"${SA_PASSWORD}" +``` + +**Note:** + +`.bak` files are recommended for local development as they: + +- Restore faster and more reliably +- Preserve all permissions, users, and database settings +- Support incremental backups (differential, transaction log) +- Have better compression and smaller file sizes + +`.bacpac` files are useful for: + +- Cross-platform database migrations +- Importing to Azure SQL Database +- Sharing database schema and data without SQL Server dependencies + +### Backup Script + +The image includes a backup script at `/scripts/backup-databases.sh` with the following environment variables: + +| Environment Variable | Default | Description | +| --- | --- | --- | +| `BACKUP_EXPORT_DIR` | `/backups` | Directory to save backup files | +| `EXPORT_DATABASES` | *(all user databases)* | Comma-separated list of databases to backup | +| `SA_PASSWORD` | `P@ssword1!` | SA password for database connection | + ### Simulated Data Insertion This image will automatically insert simulated data into the database if the `INSERT_SIMULATED_DATA` environment variable is set to `true`. This is useful for testing purposes, but should not be used in production. To make these files available to the image, you can mount a volume to `/simulated-data`. The files should be in the `.sql` format and contain any necessary `INSERT` statements. The files will be executed in alphabetical order. @@ -33,6 +128,7 @@ This image will automatically insert simulated data into the database if the `IN ### Environment Variables This image also preloads the following environment variables by default: + | Environment Variable | Value | | --- | --- | | `ACCEPT_EULA` | `Y` | @@ -52,10 +148,51 @@ services: - "1433:1433" environment: INSERT_SIMULATED_DATA: "true" + SA_PASSWORD: "YourStrong!Passw0rd" volumes: - ./simulated-data:/simulated-data - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql - - ./backups/my-database.bak:/backups/my-database.bak + - ./backups:/backups # Mount directory for backup files +``` + +### Complete Backup and Restore Workflow + +```bash +# 1. Start your container +docker-compose up -d + +# 2. Create backups of all databases +docker exec your-mssql-container bash /scripts/backup-databases.sh + +# 3. Backup files will be available in ./backups/ +ls -la ./backups/ +# Enterprise_20250606_123456.bak +# SAP_20250606_123456.bak +# My_Site_Data_20250606_123456.bak +# MyData_20250606_123456.bak + +# 4. To restore in a new container, rename files if needed and mount them +# Files are automatically restored with the filename as the database name +mv Enterprise_20250606_123456.bak Enterprise.bak +mv SAP_20250606_123456.bak SAP.bak + +# 5. Mount backup files - they'll be automatically restored on startup +# Database names will match the filenames (without extension) +``` + +### Advanced Backup Options + +The backup script supports additional customization: + +```bash +# Backup with custom export directory +docker exec -e BACKUP_EXPORT_DIR=/custom/path your-container bash /scripts/backup-databases.sh + +# Backup only specific databases +docker exec -e EXPORT_DATABASES=Database1,Database2 your-container bash /scripts/backup-databases.sh + +# Use custom SA password +docker exec -e SA_PASSWORD=CustomPassword your-container bash /scripts/backup-databases.sh ``` ___ @@ -71,4 +208,3 @@ If you have any requests for additional features, please feel free to [open an i ### Shoutout A big shoutout to [Kevin Collins](https://github.com/thirdgen88) for the original inspiration and support for building this image. - diff --git a/scripts/backup-databases.sh b/scripts/backup-databases.sh new file mode 100755 index 0000000..00a996f --- /dev/null +++ b/scripts/backup-databases.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# Simple backup script that works reliably + +# Configuration +EXPORT_DIR="${BACKUP_EXPORT_DIR:-/backups}" +DATABASES="${EXPORT_DATABASES:-}" +SA_PASSWORD="${SA_PASSWORD:-YourStrong!Passw0rd}" + +echo "Starting backup process..." +echo "Export directory: $EXPORT_DIR" +echo "Databases: $DATABASES" + +# Create export directory +mkdir -p "$EXPORT_DIR" + +# Wait for SQL Server +echo "Waiting for SQL Server..." +for i in {1..60}; do + if sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -l 1 -Q "SELECT 1" &> /dev/null; then + echo "SQL Server is ready" + break + fi + sleep 1 + if [ "$i" -eq 60 ]; then + echo "ERROR: SQL Server not ready after 60 seconds" + exit 1 + fi +done + +# Get databases to backup +if [ -n "$DATABASES" ]; then + echo "Using specified databases" + # Convert comma-separated list to array + IFS=',' read -ra DB_ARRAY <<< "$DATABASES" +else + echo "Getting all user databases" + # Get all user databases - use a more reliable method + DB_ARRAY=() + + # Get raw database list + raw_output=$(sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -h -1 -W -Q "SET NOCOUNT ON; SELECT name FROM sys.databases WHERE database_id > 4 AND state = 0 ORDER BY name" 2>/dev/null) + + # Parse each line + while IFS= read -r line; do + # Clean the line and check if it's a valid database name + clean_line=$(echo "$line" | sed 's/^[ \t]*//;s/[ \t]*$//') + if [ -n "$clean_line" ] && [ "$clean_line" != "name" ] && [[ ! "$clean_line" =~ "affected" ]] && [[ ! "$clean_line" =~ "---" ]]; then + DB_ARRAY+=("$clean_line") + fi + done <<< "$raw_output" +fi + +echo "Found ${#DB_ARRAY[@]} database(s) to backup: ${DB_ARRAY[*]}" + +if [ ${#DB_ARRAY[@]} -eq 0 ]; then + echo "No databases to backup" + exit 0 +fi + +# Backup each database +success_count=0 +failure_count=0 + +for db_name in "${DB_ARRAY[@]}"; do + if [ -z "$db_name" ]; then + continue + fi + + echo + echo "Backing up database: [$db_name]" + + backup_file="$EXPORT_DIR/${db_name}_$(date +%Y%m%d_%H%M%S).bak" + + # Create backup SQL + timestamp=$(date +"%Y-%m-%d %H:%M:%S") + sql="BACKUP DATABASE [$db_name] TO DISK = '$backup_file' WITH FORMAT, INIT, NAME = '${db_name} Full Backup $timestamp', SKIP, NOREWIND, NOUNLOAD, COMPRESSION, CHECKSUM, STATS = 10" + + echo " Executing backup..." + echo " File: $backup_file" + + # Execute backup and capture result + if sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -Q "$sql" >/dev/null 2>&1; then + if [ -f "$backup_file" ]; then + file_size=$(du -h "$backup_file" 2>/dev/null | cut -f1 || echo "unknown") + echo " ✓ SUCCESS: $backup_file ($file_size)" + ((success_count++)) + else + echo " ✗ FAILED: $db_name (backup file not created)" + ((failure_count++)) + fi + else + echo " ✗ FAILED: $db_name (SQL command failed)" + ((failure_count++)) + fi + + # Small delay between backups + sleep 1 +done + +echo +echo "=== BACKUP SUMMARY ===" +echo "Success: $success_count" +echo "Failed: $failure_count" +echo +echo "Backup files:" +ls -lh "$EXPORT_DIR"/*.bak 2>/dev/null || echo "No backup files found" + +total_size=$(du -sh "$EXPORT_DIR" 2>/dev/null | cut -f1 || echo "unknown") +echo "Total backup size: $total_size" \ No newline at end of file diff --git a/scripts/download-backup.sql b/scripts/download-backup.sql old mode 100644 new mode 100755 diff --git a/scripts/download-test-data.sh b/scripts/download-test-data.sh new file mode 100755 index 0000000..558a208 --- /dev/null +++ b/scripts/download-test-data.sh @@ -0,0 +1,79 @@ +#!/bin/bash +set -e + +echo "Downloading sample databases for testing..." + +# Create test fixtures directory +mkdir -p test/fixtures + +# Download Northwind .bacpac (small, classic example) +echo "Downloading Northwind.bacpac..." +curl -L "https://github.com/urfnet/URF.Core.Sample/raw/master/Northwind.Data/Sql/northwind.bacpac" \ + -o test/fixtures/Northwind.bacpac + +# Download WideWorldImporters Standard .bacpac (modern example) +echo "Downloading WideWorldImporters-Standard.bacpac..." +curl -L "https://github.com/Microsoft/sql-server-samples/releases/download/wide-world-importers-v1.0/WideWorldImporters-Standard.bacpac" \ + -o test/fixtures/WideWorldImporters-Standard.bacpac + +# Alternative: Create a small custom test database +echo "Creating custom test database script..." +cat > test/fixtures/create-testdb.sql << 'EOF' +CREATE DATABASE TestDB; +GO + +USE TestDB; +GO + +-- Create sample tables +CREATE TABLE Customers ( + CustomerID INT IDENTITY(1,1) PRIMARY KEY, + CustomerName NVARCHAR(100) NOT NULL, + Email NVARCHAR(100), + City NVARCHAR(50), + CreatedDate DATETIME2 DEFAULT GETUTCDATE() +); + +CREATE TABLE Products ( + ProductID INT IDENTITY(1,1) PRIMARY KEY, + ProductName NVARCHAR(100) NOT NULL, + Price DECIMAL(10,2), + CategoryID INT, + CreatedDate DATETIME2 DEFAULT GETUTCDATE() +); + +CREATE TABLE Orders ( + OrderID INT IDENTITY(1,1) PRIMARY KEY, + CustomerID INT FOREIGN KEY REFERENCES Customers(CustomerID), + OrderDate DATETIME2 DEFAULT GETUTCDATE(), + TotalAmount DECIMAL(10,2) +); + +-- Insert sample data +INSERT INTO Customers (CustomerName, Email, City) VALUES +('Acme Corp', 'contact@acme.com', 'New York'), +('TechStart Inc', 'hello@techstart.com', 'San Francisco'), +('Global Solutions', 'info@global.com', 'Chicago'), +('Innovation Labs', 'team@innovation.com', 'Austin'), +('Future Systems', 'sales@future.com', 'Seattle'); + +INSERT INTO Products (ProductName, Price, CategoryID) VALUES +('Widget Pro', 29.99, 1), +('Super Widget', 49.99, 1), +('Mega Widget', 99.99, 1), +('Basic Tool', 19.99, 2), +('Advanced Tool', 39.99, 2); + +INSERT INTO Orders (CustomerID, TotalAmount) VALUES +(1, 129.97), +(2, 49.99), +(3, 199.98), +(4, 79.98), +(5, 29.99); + +PRINT 'TestDB created and populated successfully'; +GO +EOF + +echo "Sample database files ready in test/fixtures/" +ls -la test/fixtures/ diff --git a/scripts/insert-simulated-data.sql b/scripts/insert-simulated-data.sql old mode 100644 new mode 100755 diff --git a/scripts/restore-database.sql b/scripts/restore-database.sql old mode 100644 new mode 100755 diff --git a/test/fixtures/Northwind.bacpac b/test/fixtures/Northwind.bacpac new file mode 100644 index 0000000..67cf6bf Binary files /dev/null and b/test/fixtures/Northwind.bacpac differ diff --git a/test/fixtures/TestDB.bak b/test/fixtures/TestDB.bak new file mode 100644 index 0000000..8d79d27 Binary files /dev/null and b/test/fixtures/TestDB.bak differ diff --git a/test/fixtures/init-sql/create-testdb.sql b/test/fixtures/init-sql/create-testdb.sql new file mode 100644 index 0000000..d698b53 --- /dev/null +++ b/test/fixtures/init-sql/create-testdb.sql @@ -0,0 +1,54 @@ +CREATE DATABASE TestDB_init; +GO + +USE TestDB_init; +GO + +-- Create sample tables +CREATE TABLE Customers ( + CustomerID INT IDENTITY(1,1) PRIMARY KEY, + CustomerName NVARCHAR(100) NOT NULL, + Email NVARCHAR(100), + City NVARCHAR(50), + CreatedDate DATETIME2 DEFAULT GETUTCDATE() +); + +CREATE TABLE Products ( + ProductID INT IDENTITY(1,1) PRIMARY KEY, + ProductName NVARCHAR(100) NOT NULL, + Price DECIMAL(10,2), + CategoryID INT, + CreatedDate DATETIME2 DEFAULT GETUTCDATE() +); + +CREATE TABLE Orders ( + OrderID INT IDENTITY(1,1) PRIMARY KEY, + CustomerID INT FOREIGN KEY REFERENCES Customers(CustomerID), + OrderDate DATETIME2 DEFAULT GETUTCDATE(), + TotalAmount DECIMAL(10,2) +); + +-- Insert sample data +INSERT INTO Customers (CustomerName, Email, City) VALUES +('Acme Corp', 'contact@acme.com', 'New York'), +('TechStart Inc', 'hello@techstart.com', 'San Francisco'), +('Global Solutions', 'info@global.com', 'Chicago'), +('Innovation Labs', 'team@innovation.com', 'Austin'), +('Future Systems', 'sales@future.com', 'Seattle'); + +INSERT INTO Products (ProductName, Price, CategoryID) VALUES +('Widget Pro', 29.99, 1), +('Super Widget', 49.99, 1), +('Mega Widget', 99.99, 1), +('Basic Tool', 19.99, 2), +('Advanced Tool', 39.99, 2); + +INSERT INTO Orders (CustomerID, TotalAmount) VALUES +(1, 129.97), +(2, 49.99), +(3, 199.98), +(4, 79.98), +(5, 29.99); + +PRINT 'TestDB_init created and populated successfully'; +GO diff --git a/test/fixtures/simulated-data/northwind-sim.sql b/test/fixtures/simulated-data/northwind-sim.sql new file mode 100644 index 0000000..dc91b3d --- /dev/null +++ b/test/fixtures/simulated-data/northwind-sim.sql @@ -0,0 +1,184 @@ +-- northwind-sim.sql +-- Fixed version - This script adds permanent test tables to Northwind database with 'z' prefix +-- Place this file in your /simulated-data directory + +-- Check if Northwind database exists first +IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'Northwind') +BEGIN + PRINT 'Northwind database not found - skipping simulated data'; + RETURN; +END + +USE [Northwind]; +GO + +PRINT 'Adding simulated data tables to Northwind database...'; +GO + +-- Create our test customer table (similar to Customers but clearly ours) +IF OBJECT_ID('dbo.zTestCustomers') IS NOT NULL + DROP TABLE dbo.zTestCustomers; + +CREATE TABLE dbo.zTestCustomers ( + TestCustomerID NCHAR(5) NOT NULL PRIMARY KEY, + CompanyName NVARCHAR(40) NOT NULL, + ContactName NVARCHAR(30), + ContactTitle NVARCHAR(30), + Address NVARCHAR(60), + City NVARCHAR(15), + Region NVARCHAR(15), + PostalCode NVARCHAR(10), + Country NVARCHAR(15), + Phone NVARCHAR(24), + Fax NVARCHAR(24), + CreatedBy NVARCHAR(50) DEFAULT 'SimulatedDataScript', + CreatedDate DATETIME2 DEFAULT GETUTCDATE() +); + +-- Create test orders table +IF OBJECT_ID('dbo.zTestOrders') IS NOT NULL + DROP TABLE dbo.zTestOrders; + +CREATE TABLE dbo.zTestOrders ( + TestOrderID INT IDENTITY(1,1) PRIMARY KEY, + TestCustomerID NCHAR(5), + OrderDate DATETIME, + RequiredDate DATETIME, + ShippedDate DATETIME, + Freight MONEY DEFAULT 0, + ShipName NVARCHAR(40), + ShipAddress NVARCHAR(60), + ShipCity NVARCHAR(15), + ShipRegion NVARCHAR(15), + ShipPostalCode NVARCHAR(10), + ShipCountry NVARCHAR(15), + CreatedDate DATETIME2 DEFAULT GETUTCDATE(), + FOREIGN KEY (TestCustomerID) REFERENCES dbo.zTestCustomers(TestCustomerID) +); + +-- Create test products table +IF OBJECT_ID('dbo.zTestProducts') IS NOT NULL + DROP TABLE dbo.zTestProducts; + +CREATE TABLE dbo.zTestProducts ( + TestProductID INT IDENTITY(1,1) PRIMARY KEY, + ProductName NVARCHAR(40) NOT NULL, + CategoryName NVARCHAR(50), + UnitPrice MONEY DEFAULT 0, + UnitsInStock SMALLINT DEFAULT 0, + Discontinued BIT DEFAULT 0, + CreatedDate DATETIME2 DEFAULT GETUTCDATE() +); + +-- Insert test customers +INSERT INTO dbo.zTestCustomers (TestCustomerID, CompanyName, ContactName, ContactTitle, Address, City, Region, PostalCode, Country, Phone, Fax) +VALUES +('TEST1', 'Docker Test Company', 'John Container', 'CEO', '123 Container Street', 'Docker City', 'CA', '12345', 'USA', '(555) 123-4567', '(555) 123-4568'), +('TEST2', 'Simulated Data Corp', 'Jane Database', 'CTO', '456 Database Avenue', 'SQL Town', 'TX', '67890', 'USA', '(555) 234-5678', '(555) 234-5679'), +('TEST3', 'Development LLC', 'Bob Developer', 'Lead Dev', '789 Code Boulevard', 'Dev City', 'NY', '11111', 'USA', '(555) 345-6789', '(555) 345-6790'), +('TEST4', 'Testing Solutions Inc', 'Alice Tester', 'QA Manager', '321 Test Lane', 'Bug City', 'FL', '22222', 'USA', '(555) 456-7890', '(555) 456-7891'), +('TEST5', 'Cloud Native Co', 'Charlie Kubernetes', 'DevOps', '654 Cloud Drive', 'Container Town', 'WA', '33333', 'USA', '(555) 567-8901', '(555) 567-8902'); + +-- Insert test products +INSERT INTO dbo.zTestProducts (ProductName, CategoryName, UnitPrice, UnitsInStock) +VALUES +('Docker Container License', 'Software', 299.99, 100), +('SQL Server Instance', 'Database', 1499.99, 50), +('Development Environment', 'Tools', 99.99, 200), +('Testing Framework', 'Tools', 149.99, 75), +('Cloud Storage Package', 'Services', 49.99, 500), +('Backup Solution', 'Services', 199.99, 25), +('Monitoring Dashboard', 'Software', 79.99, 150), +('Security Scanner', 'Software', 349.99, 30); + +-- Insert test orders +INSERT INTO dbo.zTestOrders (TestCustomerID, OrderDate, RequiredDate, Freight, ShipName, ShipAddress, ShipCity, ShipRegion, ShipPostalCode, ShipCountry) +VALUES +('TEST1', GETDATE(), DATEADD(day, 7, GETDATE()), 25.50, 'Docker Test Company', '123 Container Street', 'Docker City', 'CA', '12345', 'USA'), +('TEST2', GETDATE(), DATEADD(day, 10, GETDATE()), 45.75, 'Simulated Data Corp', '456 Database Avenue', 'SQL Town', 'TX', '67890', 'USA'), +('TEST3', DATEADD(day, -1, GETDATE()), DATEADD(day, 5, GETDATE()), 15.25, 'Development LLC', '789 Code Boulevard', 'Dev City', 'NY', '11111', 'USA'), +('TEST4', DATEADD(day, -2, GETDATE()), DATEADD(day, 8, GETDATE()), 35.00, 'Testing Solutions Inc', '321 Test Lane', 'Bug City', 'FL', '22222', 'USA'), +('TEST5', GETDATE(), DATEADD(day, 14, GETDATE()), 55.50, 'Cloud Native Co', '654 Cloud Drive', 'Container Town', 'WA', '33333', 'USA'); + +-- Create execution tracking table +IF OBJECT_ID('dbo.zSimulatedDataLog') IS NOT NULL + DROP TABLE dbo.zSimulatedDataLog; + +CREATE TABLE dbo.zSimulatedDataLog ( + LogID INT IDENTITY(1,1) PRIMARY KEY, + ScriptName NVARCHAR(100), + ExecutionTime DATETIME2 DEFAULT GETUTCDATE(), + TableName NVARCHAR(100), + RecordsAdded INT, + Notes NVARCHAR(500), + SessionID INT DEFAULT @@SPID +); + +-- Log this execution (FIXED VERSION - No subqueries in VALUES) +DECLARE @CustomerCount INT; +DECLARE @ProductCount INT; +DECLARE @OrderCount INT; + +SELECT @CustomerCount = COUNT(*) FROM dbo.zTestCustomers; +SELECT @ProductCount = COUNT(*) FROM dbo.zTestProducts; +SELECT @OrderCount = COUNT(*) FROM dbo.zTestOrders; + +INSERT INTO dbo.zSimulatedDataLog (ScriptName, TableName, RecordsAdded, Notes) +VALUES +('northwind-sim.sql', 'zTestCustomers', @CustomerCount, 'Created test customers table with sample data'), +('northwind-sim.sql', 'zTestProducts', @ProductCount, 'Created test products table with sample data'), +('northwind-sim.sql', 'zTestOrders', @OrderCount, 'Created test orders table with sample data'); + +-- Show summary of what we created +SELECT + 'Test Tables Created' as Summary, + 'zTestCustomers' as TableName, + COUNT(*) as RecordCount +FROM dbo.zTestCustomers +UNION ALL +SELECT + 'Test Tables Created' as Summary, + 'zTestProducts' as TableName, + COUNT(*) as RecordCount +FROM dbo.zTestProducts +UNION ALL +SELECT + 'Test Tables Created' as Summary, + 'zTestOrders' as TableName, + COUNT(*) as RecordCount +FROM dbo.zTestOrders; + +-- Show sample data +SELECT 'Sample Test Customers' as DataType, TestCustomerID, CompanyName, ContactName, City, Country +FROM dbo.zTestCustomers +ORDER BY TestCustomerID; + +-- Show the execution log +SELECT + LogID, + ScriptName, + ExecutionTime, + TableName, + RecordsAdded, + Notes +FROM dbo.zSimulatedDataLog +ORDER BY ExecutionTime DESC; + +-- List all our test tables +SELECT + 'Our Test Tables in Northwind' as Info, + name as TableName, + create_date as CreatedAt +FROM sys.tables +WHERE name LIKE 'z%' +ORDER BY name; + +PRINT 'Northwind simulated data script completed successfully!'; +PRINT 'Created tables: zTestCustomers, zTestProducts, zTestOrders, zSimulatedDataLog'; + +-- Show final counts +DECLARE @TotalRecords INT; +SELECT @TotalRecords = @CustomerCount + @ProductCount + @OrderCount; +PRINT 'Total test records: ' + CAST(@TotalRecords AS NVARCHAR(10)); + +GO \ No newline at end of file