Skip to content

Commit

Permalink
Merge pull request #5 from FlowSahl/feat/refactor-code-1-1-0
Browse files Browse the repository at this point in the history
Refactor code
  • Loading branch information
TariqAyman authored Sep 4, 2024
2 parents 3ecb002 + b56d33c commit b09cd69
Show file tree
Hide file tree
Showing 8 changed files with 225 additions and 92 deletions.
58 changes: 52 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,50 @@
# Laravel Zero Downtime Deployment

## Overview
This GitHub Action helps you deploy your project to a remote server with zero downtime, ensuring that your application remains available during deployments.
This GitHub Action helps you deploy your Laravel project to a remote server with zero downtime, ensuring that your application remains available during deployments. It offers flexibility, security, and ease of use, making it ideal for projects of all sizes.

## Features
- **Zero Downtime Deployment**: Ensure uninterrupted service during deployments.
- **Easy Integration**: Simple setup and integration into your existing workflow.
- **Flexible Deployment**: Suitable for projects of all sizes, from personal projects to enterprise applications.
- **Custom Scripts**: Run custom scripts before and after key deployment steps.
- **Secure**: Uses GitHub Secrets for sensitive data like server credentials and GitHub tokens.
- **Environment File Sync**: Sync environment variables with the remote server.
- **Modular and Maintainable Code**: Well-organized code structure with TypeScript, making it easy to extend and maintain.
- **Customizable Workflow**: Easily integrate custom scripts at various stages of the deployment process.
- **Environment Validation**: Robust environment configuration validation using `joi`.
- **Secure Deployment**: Uses GitHub Secrets to securely manage sensitive data like server credentials and GitHub tokens.
- **Environment File Sync**: Automatically sync environment variables with the remote server.

## How It Works

The Laravel Zero Downtime Deployment action follows a series of carefully structured steps to ensure that your application remains online throughout the deployment process:

### Steps in the Deployment Process:

1. **Preparation of Directories:**
- The action starts by preparing the necessary directories on the remote server. This includes creating a new directory for the release and ensuring that required subdirectories (e.g., storage, logs) are available.

2. **Optional Pre-Folder Script Execution:**
- If specified, a custom script is executed before the folders are checked and prepared. This can be useful for tasks like cleaning up old files or performing pre-checks.

3. **Cloning the Repository:**
- The specified branch of your GitHub repository is cloned into the newly prepared release directory on the remote server. This ensures that the latest code is deployed.

4. **Environment File Synchronization:**
- The `.env` file is synchronized between your local setup and the remote server. This ensures that your application’s environment variables are consistent across deployments.

5. **Linking the Storage Directory:**
- The storage directory is linked from the new release directory to ensure that persistent data (like uploaded files) is shared across all releases.

6. **Optional Post-Download Script Execution:**
- If specified, a custom script is executed after the repository is cloned and the environment is set up. This can be used for tasks like installing dependencies, running database migrations, or optimizing the application.

7. **Activating the New Release:**
- The symbolic link to the current release is updated to point to the new release directory. This is the step where the new version of your application goes live without any downtime.

8. **Cleaning Up Old Releases:**
- Old release directories are cleaned up, typically keeping only the last few releases to save space on the server.

9. **Optional Post-Activation Script Execution:**
- If specified, a custom script is executed after the new release is activated. This is often used to perform final optimizations or notify external services of the new deployment.

By following these steps, the action ensures that your application is deployed smoothly, with zero downtime and minimal risk.

## Inputs

Expand Down Expand Up @@ -103,6 +138,17 @@ You can provide custom scripts to run at various stages of the deployment. Below
- **Before Activating Release**: `command_script_before_activate`
- **After Activating Release**: `command_script_after_activate`

## Testing
To ensure the reliability of your deployment process, unit and feature tests have been included in the codebase using Jest. Tests cover various components such as the `DeploymentService`, `ConfigManager`, and `sshUtils`. Running these tests can help identify issues early in the development process.

To run the tests:

```bash
npm run test
```

This will execute the suite of unit and feature tests, ensuring that all parts of the deployment process function correctly.

## Troubleshooting
If you encounter issues, check the GitHub Actions logs for detailed error messages. Ensure that:
- SSH credentials are correct.
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ async function run(): Promise<void> {
const config = new ConfigManager();
const deploymentService = new DeploymentService(config);

log(`Starting deployment with configuration: ${JSON.stringify(config)}`);

await deploymentService.deploy();
} catch (error: any) {
log(`Deployment failed: ${error.message}`);
if (error.stack) {
log(error.stack);
}
process.exit(1); // Indicate failure
}
}

Expand Down
74 changes: 46 additions & 28 deletions src/services/ConfigManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,49 +11,67 @@ export class ConfigManager {
dotenv.config();
}

validateConfig(this.getInputs());
validateConnectionOptions(this.getConnectionOptions());
try {
validateConfig(this.getInputs());
validateConnectionOptions(this.getConnectionOptions());
} catch (error: any) {
core.setFailed(`Configuration validation failed: ${error.message}`);
throw error; // Re-throw if necessary for upstream handling
}
}

private getInputOrEnv(key: string, envKey: string): string {
return process.env[envKey] || core.getInput(key);
}

getInputs(): Inputs {
return {
target: process.env.TARGET || core.getInput('target'),
sha: process.env.SHA || core.getInput('sha'),
deploy_branch: process.env.GITHUB_DEPLOY_BRANCH || core.getInput('deploy_branch'),
envFile: process.env.ENV_FILE || core.getInput('env_file'),
commandScriptBeforeCheckFolders:
process.env.COMMAND_SCRIPT_BEFORE_CHECK_FOLDERS || core.getInput('command_script_before_check_folders'),
commandScriptAfterCheckFolders:
process.env.COMMAND_SCRIPT_AFTER_CHECK_FOLDERS || core.getInput('command_script_after_check_folders'),
commandScriptBeforeDownload:
process.env.COMMAND_SCRIPT_BEFORE_DOWNLOAD || core.getInput('command_script_before_download'),
commandScriptAfterDownload:
process.env.COMMAND_SCRIPT_AFTER_DOWNLOAD || core.getInput('command_script_after_download'),
commandScriptBeforeActivate:
process.env.COMMAND_SCRIPT_BEFORE_ACTIVATE || core.getInput('command_script_before_activate'),
commandScriptAfterActivate:
process.env.COMMAND_SCRIPT_AFTER_ACTIVATE || core.getInput('command_script_after_activate'),
githubRepoOwner: process.env.GITHUB_REPO_OWNER || github.context.payload.repository?.owner?.login || '',
githubRepo: process.env.GITHUB_REPO || github.context.payload.repository?.name || '',
target: this.getInputOrEnv('target', 'TARGET'),
sha: this.getInputOrEnv('sha', 'SHA'),
deploy_branch: this.getInputOrEnv('deploy_branch', 'GITHUB_DEPLOY_BRANCH'),
envFile: this.getInputOrEnv('env_file', 'ENV_FILE'),
commandScriptBeforeCheckFolders: this.getInputOrEnv(
'command_script_before_check_folders',
'COMMAND_SCRIPT_BEFORE_CHECK_FOLDERS'
),
commandScriptAfterCheckFolders: this.getInputOrEnv(
'command_script_after_check_folders',
'COMMAND_SCRIPT_AFTER_CHECK_FOLDERS'
),
commandScriptBeforeDownload: this.getInputOrEnv(
'command_script_before_download',
'COMMAND_SCRIPT_BEFORE_DOWNLOAD'
),
commandScriptAfterDownload: this.getInputOrEnv('command_script_after_download', 'COMMAND_SCRIPT_AFTER_DOWNLOAD'),
commandScriptBeforeActivate: this.getInputOrEnv(
'command_script_before_activate',
'COMMAND_SCRIPT_BEFORE_ACTIVATE'
),
commandScriptAfterActivate: this.getInputOrEnv('command_script_after_activate', 'COMMAND_SCRIPT_AFTER_ACTIVATE'),
githubRepoOwner:
this.getInputOrEnv('github_repo_owner', 'GITHUB_REPO_OWNER') ||
github.context.payload.repository?.owner?.login ||
'',
githubRepo: this.getInputOrEnv('github_repo', 'GITHUB_REPO') || github.context.payload.repository?.name || '',
};
}

getConnectionOptions(): ConnectionOptions {
return {
host: process.env.HOST || core.getInput('host'),
username: process.env.REMOTE_USERNAME || core.getInput('username'),
port: parseInt(process.env.PORT || core.getInput('port') || '22'),
password: process.env.PASSWORD || core.getInput('password'),
privateKey: (process.env.SSH_KEY || core.getInput('ssh_key')).replace(/\\n/g, '\n'),
passphrase: process.env.SSH_PASSPHRASE || core.getInput('ssh_passphrase'),
host: this.getInputOrEnv('host', 'HOST'),
username: this.getInputOrEnv('username', 'REMOTE_USERNAME'),
port: parseInt(this.getInputOrEnv('port', 'PORT')),
password: this.getInputOrEnv('password', 'PASSWORD'),
privateKey: this.getInputOrEnv('ssh_key', 'SSH_KEY').replace(/\\n/g, '\n'),
passphrase: this.getInputOrEnv('ssh_passphrase', 'SSH_PASSPHRASE'),
};
}

getTarget(): string {
return process.env.TARGET || core.getInput('target');
return this.getInputOrEnv('target', 'TARGET');
}

getSha(): string {
return process.env.SHA || core.getInput('sha');
return this.getInputOrEnv('sha', 'SHA');
}
}
18 changes: 13 additions & 5 deletions src/services/DeploymentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export class DeploymentService {

async deploy(): Promise<void> {
try {
await this.checkSponsorship(this.config.getInputs().githubRepoOwner);
await this.checkSponsorship(this.config.getInputs().githubRepoOwner ?? '');

logInputs(this.config.getInputs(), this.config.getConnectionOptions());

Expand All @@ -34,10 +34,11 @@ export class DeploymentService {
if (error instanceof Error) {
// Type guard for Error
log(`Deployment failed: ${error.message}`);
log(error.stack?.toString() ?? 'No Error Stack trace'); // Stack trace for detailed error information
} else {
log('An unknown error occurred during deployment.');
}
core.setFailed(error.message);
core.setFailed(error.message || 'An unknown error occurred');
throw error; // Re-throw the error after handling
} finally {
sshOperations.dispose();
Expand Down Expand Up @@ -67,12 +68,15 @@ export class DeploymentService {
log(`Sponsorship check failed with status ${error.response.status}: ${error.response.data}`);
throw new Error('Sponsorship check failed. Please try again later.');
}
} else if (axios.isAxiosError(error)) {
log(`Axios error: ${error.message}`);
} else {
log('Non-Axios error occurred during sponsorship check');
log('An unknown error occurred during the sponsorship check.');
// throw error;
}
}

private async prepareDeployment(): Promise<void> {
// 1. Run any user-specified script before checking folders
await this.runOptionalScript(this.config.getInputs().commandScriptBeforeCheckFolders, 'before check folders');
Expand Down Expand Up @@ -114,8 +118,10 @@ export class DeploymentService {
`${paths.target}/storage/framework/views`,
];

await sshOperations.execute(`mkdir -p ${folders.join(' ')}`, paths);
await sshOperations.execute(`rm -rf ${paths.target}/releases/${paths.sha}`, paths);
await Promise.all([
sshOperations.execute(`mkdir -p ${folders.join(' ')}`, paths),
sshOperations.execute(`rm -rf ${paths.target}/releases/${paths.sha}`, paths),
]);
}

private async cloneAndPrepareRepository(inputs: Inputs, paths: Paths): Promise<void> {
Expand Down Expand Up @@ -144,6 +150,8 @@ export class DeploymentService {
if (script && script !== 'false') {
log(`Running script ${description}: ${script}`);
await sshOperations.execute(script, this.paths);
} else {
log(`No script to run for ${description}`);
}
}

Expand Down
47 changes: 24 additions & 23 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
// src/types.ts
export interface Inputs {
target: string;
sha: string;
deploy_branch: string;
envFile?: string;
commandScriptBeforeCheckFolders?: string;
commandScriptAfterCheckFolders?: string;
commandScriptBeforeDownload?: string;
commandScriptAfterDownload?: string;
commandScriptBeforeActivate?: string;
commandScriptAfterActivate?: string;
githubRepoOwner: string;
githubRepo: string;
target: string; // The target directory on the server where the deployment will occur
sha: string; // The specific commit SHA to be deployed
deploy_branch: string; // The branch of the repository to deploy
envFile?: string; // Optional content of the environment file to be used in the deployment
commandScriptBeforeCheckFolders?: string; // Custom script to run before checking folders
commandScriptAfterCheckFolders?: string; // Custom script to run after checking folders
commandScriptBeforeDownload?: string; // Custom script to run before downloading the release
commandScriptAfterDownload?: string; // Custom script to run after downloading the release
commandScriptBeforeActivate?: string; // Custom script to run before activating the release
commandScriptAfterActivate?: string; // Custom script to run after activating the release
githubRepoOwner: string; // The owner of the GitHub repository
githubRepo: string; // The name of the GitHub repository
}

/** Represents the paths used during the deployment process */
export interface Paths {
target: string;
sha: string;
releasePath: string;
activeReleasePath: string;
target: string; // The base target directory
sha: string; // The SHA of the commit being deployed
releasePath: string; // The path to the specific release
activeReleasePath: string; // The path to the active release
}

/** Represents the SSH connection options */
export interface ConnectionOptions {
host: string;
username: string;
port?: number | 22;
password?: string;
privateKey?: string;
passphrase?: string;
host: string; // The host of the server to connect to
username: string; // The username to use for the SSH connection
port?: number | 22; // The port to use for the SSH connection (defaults to 22)
password?: string; // The password for the SSH connection
privateKey?: string; // The private key for the SSH connection
passphrase?: string; // The passphrase for the private key, if applicable
}
19 changes: 14 additions & 5 deletions src/utils/log.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { ConnectionOptions, Inputs } from '../types';

/** Logs a message with a timestamp */
export function log(message: string): void {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] ${message}`);
console.log(`[DEPLOYMENT][${timestamp}] ${message}`);
}

export function logInputs(inputs: Inputs, connectionOptions: ConnectionOptions) {
/** Logs input configurations */
export function logInputs(inputs: Inputs, connectionOptions: ConnectionOptions): void {
log(`Host: ${connectionOptions.host}`);
log(`Target: ${inputs.target}`);
log(`SHA: ${inputs.sha}`);
log(`GitHub Repo Owner: ${inputs.githubRepoOwner}`);
log(`Target Directory: ${inputs.target}`);
log(`Commit SHA: ${inputs.sha}`);
log(`GitHub Repository: ${inputs.githubRepoOwner}/${inputs.githubRepo}`);
log(`Branch: ${inputs.deploy_branch}`);
}

/** Logs an error message with a timestamp */
export function logError(message: string): void {
const timestamp = new Date().toISOString();
console.error(`[DEPLOYMENT ERROR][${timestamp}] ${message}`);
}
Loading

0 comments on commit b09cd69

Please sign in to comment.