diff --git a/deployment.lua b/deployment.lua new file mode 100644 index 0000000..c7330e9 --- /dev/null +++ b/deployment.lua @@ -0,0 +1,32 @@ +local app_name = "Express Blog" +local repo = "ssh://git@git.jasonpoage.vpn:29418/jason/express-blog.git" +local config_dir = "/srv/jasonpoage.com/env/" +-- 1. Static Lookups +local base = "/srv/jasonpoage.com" +local deployments = base .. "/deployments" + +function get_config(env_key) + -- Specific folder name for this environment + local instance_name = "blog-" .. env_key + local deploy_link = deployments .. "/" .. instance_name + + return { + deploy_link = deploy_link, + config_file = config_dir .. env_key .. ".toml", + service_name = "expressjs-blog@" .. env_key .. ".service", + -- Tracking which deployments were successful + get_release_dir = function(timestamp) + return deploy_link .. "-" .. timestamp + end, + count = (env_key == "release") and 5 or 1, + } +end + +return { + app_name = app_name, + timestamp_format = "%Y%m%d-%H%M%S", + repo = repo, + base = base, + release = get_config("release"), + testing = get_config("testing"), +} diff --git a/deployment/README b/deployment/README new file mode 100644 index 0000000..32a7bf0 --- /dev/null +++ b/deployment/README @@ -0,0 +1,2 @@ +#Entry point: +python . --config ../deployment.lua --dry-run --branch refs/heads/dev diff --git a/deployment/core/task_runner.py b/deployment/core/task_runner.py index 09a77ce..e7f2a6c 100755 --- a/deployment/core/task_runner.py +++ b/deployment/core/task_runner.py @@ -11,7 +11,7 @@ from core.tasks import ( GetDeploymentConfig, - VerifyConfigExists, + LoadServerConfig, YarnBuild, AtomicDeploy, HealthCheck, @@ -48,7 +48,7 @@ CheckNix, VerifySystemDependencies, GetDeploymentConfig, - VerifyConfigExists, + LoadServerConfig, EnsureBuildPaths, YarnBuild, TestRunner, diff --git a/deployment/core/tasks.py b/deployment/core/tasks.py index 2b5c963..ba93992 100644 --- a/deployment/core/tasks.py +++ b/deployment/core/tasks.py @@ -21,38 +21,40 @@ def _run(self): # 1. Load Lua lua = LuaRuntime(unpack_returned_tuples=True) - with open(self.get_arg("config"), "r") as f: + config_path = self.get_arg("config") + + with open(config_path, "r") as f: module = lua.execute(f.read()) - # 2. Call the factory - target_env = self.get_arg("branch").split("/")[-1] # e.g., 'main' or 'testing' - print(target_env) + # 2. Determine environment key from branch + # Mapping 'main' to 'release' as per lua schema + branch = self.get_arg("branch").split("/")[-1] + target_env = "release" if branch == "main" else branch - # 3. Hydrate self.env - config = module.get_config(target_env) + if target_env not in module: + self.fail(f"Environment '{target_env}' not defined in {config_path}") - self.env.build_dir = Path(config.paths.build) - self.env.release_dir = Path(config.paths.release_dir) - self.env.deploy_path = Path(config.paths.deploy_link) - self.env.service = config.systemd.service_name - self.env.config_file_source = config.paths.config_file - self.env.meta = config.meta + # 3. Extract environment specific sub-table + cfg = module[target_env] - self.print(f"✅ Context hydrated for {config.meta.app_name}:{target_env}") + # 4. Hydrate self.env + self.env.lua_cfg = cfg # Store the lua object for functional calls later + self.env.app_name = module.app_name + self.env.repo = module.repo + self.env.timestamp_format = module.timestamp_format + + self.env.deploy_path = Path(cfg.deploy_link) + self.env.service_name = cfg.service_name + self.env.config_file_source = Path(cfg.config_file) + self.env.retention_count = cfg.count + self.env.deploy_branch = branch + + self.print(f"✅ Context hydrated for {self.env.app_name}:{target_env}") + # self.env.build_dir = Path(config.paths.build) return True class LoadServerConfig(SuiteTask): - """Fails the pipeline if the required TOML config is missing from the host""" - - _stage = Stage.BOOTSTRAP - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.name = "Verify the server's toml configuration exists" - - -class VerifyConfigExists(SuiteTask): """Verifies TOML existence and hydrates the environment with health check URI components""" _stage = Stage.BOOTSTRAP @@ -106,7 +108,7 @@ """Executes dependency installation and asset compilation""" _stage = Stage.BUILD - _deps = [GetDeploymentConfig, VerifyConfigExists] + _deps = [GetDeploymentConfig, LoadServerConfig] skip: bool = False def __init__(self, *args, **kwargs): @@ -114,13 +116,23 @@ self.name = "Running Yarn build process" def _run(self): - build_dir = self.env.build_dir - self.sh( - f"git clone --branch {self.env.deploy_branch} {self.get_arg('repo')} {build_dir}" + # Use a temporary build directory with -BUILDING suffix + # This is finalized in AtomicDeploy + timestamp = time.strftime(self.env.timestamp_format) + self.env.release_dir = Path(self.env.lua_cfg.get_release_dir(timestamp)) + self.env.build_dir = self.env.release_dir.with_name( + self.env.release_dir.name + "-BUILDING" ) - self.sh("git submodule update --init --recursive", cwd=build_dir) - self.sh("yarn install", cwd=build_dir) - self.sh("yarn combine:css", cwd=build_dir) + + self.print(f" [BUILD] Target: {self.env.build_dir}") + + self.sh( + f"git clone --branch {self.env.deploy_branch} {self.env.repo} {self.env.build_dir}" + ) + self.sh("git submodule update --init --recursive", cwd=self.env.build_dir) + self.sh("yarn install", cwd=self.env.build_dir) + self.sh("yarn combine:css", cwd=self.env.build_dir) + return True class AtomicDeploy(SuiteTask): @@ -135,11 +147,19 @@ self.name = "Executing atomic symlink swap" def _run(self): - env = self.env - self.sh(f"mkdir -p {env.release_dir}") - self.sh(f"rsync -a --delete {env.build_dir}/ {env.release_dir}/") - self.sh(f"ln -sfn {env.release_dir} {env.deploy_path}") - self.sh(f"sudo systemctl restart {env.service_name}") + # 1. Finalize the directory name (remove -BUILDING) + self.sh(f"mv {self.env.build_dir} {self.env.release_dir}") + + # 2. Atomic Symlink Swap + temp_link = self.env.deploy_path.with_name(self.env.deploy_path.name + "_tmp") + self.sh(f"ln -sfn {self.env.release_dir} {temp_link}") + self.sh(f"mv -Tf {temp_link} {self.env.deploy_path}") + + # 3. Restart Service + self.sh(f"sudo systemctl restart {self.env.service_name}") + + self.print(f"🚀 Deployed to {self.env.deploy_path} -> {self.env.release_dir}") + return True class HealthCheck(SuiteTask): diff --git a/deployment/core/tests.py b/deployment/core/tests.py index 547b855..65aba72 100644 --- a/deployment/core/tests.py +++ b/deployment/core/tests.py @@ -16,9 +16,7 @@ super().__init__(*args, **kwargs) def _run(self): - if self._owner.args.get("skip_tests") and not self.env.meta.get( - "enforce_testing" - ): + if self._owner.args.get("skip_tests") and not self.get_arg("enforce_testing"): self.print(" [SKIP] Skipping per user request.") return True @@ -27,7 +25,7 @@ self.sh(f"sudo systemctl stop {self.env.service_name} || true") # Start background process and record PID - cmd = f"nohup yarn run prod >> '{self.env.meta.server_log_file}' 2>&1 & echo $! > '{self.env.pidfile}'" + cmd = f"nohup yarn run prod >> '{self.env.test_log}' 2>&1 & echo $! > '{self.env.pidfile}'" self.sh(cmd, cwd=self.env.build_dir) return True @@ -43,9 +41,7 @@ self.name = "Wait for Service Readiness" def _run(self): - if self._owner.args.get("skip_tests") and not self.env.meta.get( - "enforce_testing" - ): + if self._owner.args.get("skip_tests") and not self.get_arg("enforce_testing"): return True uri = self.env.test_endpoint_uri @@ -63,7 +59,7 @@ except Exception: time.sleep(2) - self.sh(f"cat '{self.env.meta.server_log_file}'") + self.sh(f"cat '{self.env.test_log}'") self.fail(f"Service at {uri} failed to start within 30s.") @@ -78,9 +74,7 @@ super().__init__(*args, **kwargs) def _run(self): - if self._owner.args.get("skip_tests") and not self.env.meta.get( - "enforce_testing" - ): + if self._owner.args.get("skip_tests") and not self.get_arg("enforce_testing"): return True self.print(" [RUN] npm run test:postreceive") @@ -124,7 +118,7 @@ def _run(self): # 1. Check if we should even be here skip_param = self.args.get("skip_tests", False) - enforced = (self.env.meta.enforce_testing,) + enforced = self.get_arg("enforce_testing") if skip_param and not enforced: self.print(" [SKIP] Integration tests bypassed by user flag.") diff --git a/deployment/lib/printer.py b/deployment/lib/printer.py index 1ca254e..87b294f 100755 --- a/deployment/lib/printer.py +++ b/deployment/lib/printer.py @@ -57,6 +57,7 @@ f.write(json.dumps(line)) except Exception as e: print(e.with_traceback) + raise e def _msg_prefix(self): # Format: [ID] for main tasks, [ID.Sub] for subtasks diff --git a/deployment/lib/task_types.py b/deployment/lib/task_types.py index ba8128e..def6c45 100755 --- a/deployment/lib/task_types.py +++ b/deployment/lib/task_types.py @@ -59,10 +59,7 @@ except: pass if cwd is None: - try: - cwd = os.getcwd() - except: - pass + cwd = os.getcwd() self._cwd = cwd self._owner = owner diff --git a/deployment/lib/types.py b/deployment/lib/types.py index 1fe95b9..ac6fe74 100644 --- a/deployment/lib/types.py +++ b/deployment/lib/types.py @@ -1,5 +1,4 @@ import os -import time from enum import Enum from pathlib import Path @@ -22,8 +21,8 @@ service_name: str release_dir: Path test_endpoint_uri: str - meta: dict = dict() pidfile: Path = Path() + test_log: Path = Path() def __init__(self, timestamp_format: str | None = None): self.workspace: Path = Path() @@ -31,14 +30,15 @@ self.deploy_branch: str = "" self.deploy_path: Path = Path() self.build_dir: Path = Path() + self.test_log: Path = self.build_dir / "test_log.log" self.service_name: str = "" self.release_dir: Path = Path() - self.server_schema: str = "http" - self.server_domain: str = "localhost" + self.server_schema = "http" + self.server_address = "localhost" + self.pidfile = Path("/tmp/hexascript_test.pid") self.root_dir = os.getcwd() if timestamp_format is not None: self.timestamp_format = timestamp_format - self.timestamp = time.strftime(self.timestamp_format) self.workspace = Path(os.getenv("WORKSPACE", self.root_dir)) self.build_dir = self.workspace / "build" diff --git a/deployment/prototype.py b/deployment/prototype.py deleted file mode 100755 index b591d4a..0000000 --- a/deployment/prototype.py +++ /dev/null @@ -1,177 +0,0 @@ -GIT_REPO = 'ssh://git@git.jasonpoage.vpn:29418/jason/express-blog.git' -DEPLOY_BASE = '/srv/jasonpoage.com' -YARN_ENABLE_GLOBAL_CACHE = 'false' -YARN_CACHE_FOLDER = '/var/cache/jenkins/yarn' -CREDENTIALS_ID = '08a57452-477d-4aa6-86c6-242553660b3f' - - - -options { - timestamps() -} - - -parameters { - string(name: 'branch', defaultValue: 'refs/heads/main', description: 'Branch ref from webhook') - string(name: 'oldrev', defaultValue: '', description: 'old rev') - string(name: 'newrev', defaultValue: '', description: 'new rev') - - booleanParam(name: 'SKIP_TESTS', defaultValue: true, description: 'Skip all testing') -} -class test_runner: - def init(): - print('Init') - if params.branch?.startsWith("refs/heads/"): - DEPLOY_BRANCH = params.branch.replaceFirst(/^refs\/heads\//, '') - else: - print(f "Invalid branch ref: '{params.branch}'") - - - print( "==== DEBUG: Branch Param ====") - print ("params.branch: '{params.branch}'") - print ("DEPLOY_BRANCH: '{DEPLOY_BRANCH}'") - - TIMESTAMP = sh(script: "date +%Y%m%d-%H%M%S", returnStdout: true).trim() - LOG_DIR = f"{DEPLOY_BASE}/deployments/logs" - SERVER_LOG_FILE = f"{LOG_DIR}/server/server-{TIMESTAMP}.log" - TEST_LOGS_FILE = f"{LOG_DIR}/test-results/test-" - BUILD_DIR = f"{WORKSPACE}/build" - PIDFILE = f"{BUILD_DIR}/test.pid" - ENV_FILE = f"{DEPLOY_BASE}/env/{DEPLOY_BRANCH}.env" - SERVICE_NAME = f"express-blog@{DEPLOY_BRANCH}.service" - DEPLOY_PATH = f"{DEPLOY_BASE}/deployments/blog-{DEPLOY_BRANCH}" - - - if params.oldrev?.trim() && params.newrev?.trim(): - OLD_REV = params.oldrev - NEW_REV = params.newrev - else : - OLD_REV = sh(script: 'git rev-parse HEAD~1', returnStdout: true).trim() - NEW_REV = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() - - print (f"==== DEBUG: Revisions ====") - print (f"params.oldrev: '{params.oldrev}'") - print (f"params.newrev: '{params.newrev}'") - print (f"Old revision: {OLD_REV}") - print (f"New revision: {NEW_REV}") - sh (f"mkdir -p '{LOG_DIR}/server' '{LOG_DIR}/test-results'") - def checkout () : - print('Checkout') - checkout([$class: 'GitSCM', - branches: [[name: "*/{DEPLOY_BRANCH}"]], - userRemoteConfigs: [[ - url: GIT_REPO, - credentialsId: CREDENTIALS_ID - ]] - ]) - - def validate_branch(): - print('Validate Branch') - steps { - script { - def allowed = ['testing', 'staging', 'main', 'production'] - if (!allowed.contains(DEPLOY_BRANCH)) { - fail "Branch '{DEPLOY_BRANCH}' is not allowed for deployment." - } - } - } - - def clone_build_dir(): - print('Clone to Build Dir') - sh (f"git clone --branch '{DEPLOY_BRANCH}' '{GIT_REPO}' '{BUILD_DIR}'") - - def build(): - print('Build') - dir(f"{BUILD_DIR}") { - sh """ - git submodule update --init --recursive - yarn - yarn combine:css - """ - - def start_app(): - print('Start Application for Test') { - if not params.SKIP_TESTS : - dir(BUILD_DIR) { - sh """ - sudo systemctl stop {SERVICE_NAME} || true - corepack enable - nohup yarn run prod >> '{SERVER_LOG_FILE}' 2>&1 & - echo \$! > '{PIDFILE}' - """ - - def wait_for_service(): - print('Wait for Service Readiness') - if not params.SKIP_TESTS : - def timeout = 30 - def elapsed = 0 - def success = false - while elapsed < timeout : - def result = sh(script: f"curl --max-time 2 --silent --fail '\{SERVER_SCHEMA}://\{SERVER_DOMAIN}/health -I' > /dev/null || true", returnStatus: true) - if result == 0: - success = true - break - sleep 1 - elapsed += 1 - if not success : - sh f"cat '{SERVER_LOG_FILE}'" - fail f"Service did not become available within {timeout}s." - } - } - } - - def run_tests(): - print('Run Tests') - if not params.SKIP_TESTS : - def testStatus = sh(script: f"cd '{BUILD_DIR}' && npm run test:postreceive", returnStatus: true) - archiveArtifacts artifacts: f"{TEST_LOGS_FILE}*", onlyIfSuccessful: false - if testStatus != 0: - sh f""" - kill \$(cat '{PIDFILE}') || true - cat '{SERVER_LOG_FILE}' - """ - fail( "Tests failed for branch {DEPLOY_BRANCH}") - } - } - - def kill_test_server(): - """systemctl stop test server""" - print('Stop Test App') - if not params.SKIP_TESTS : - sh ("kill \$(cat '{PIDFILE}') || true") - def deploy(): - print('Deploy') - # 1. Create the new release directory - releaseDir = f"{DEPLOY_BASE}/releases/blog-{DEPLOY_BRANCH}-{TIMESTAMP}" - sh( f"mkdir -p {releaseDir}") - - # 2. Sync the finished build to the release directory - print(f"Deploying build to {releaseDir}") - sh( f"rsync -a --delete '{BUILD_DIR}/' '{releaseDir}/'") - - # 3. Atomically flip the symlink - # We use 'ln -sfn' to overwrite the existing link to the new path - sh (f"ln -sfn '{releaseDir}' '{DEPLOY_PATH}'") - - # 4. Cleanup old releases (Keep only last 5) - dir(f"{DEPLOY_BASE}/releases") { - sh f"ls -1t | grep 'blog-{DEPLOY_BRANCH}' | tail -n +6 | xargs rm -rf || true" - } - def restart_production_server(): - print('Restart Service') - sh f"sudo systemctl restart {SERVICE_NAME}" - def verify_service(): - print("VerifyService") - timeout = 30 - elapsed = 0 - success = false - while (elapsed < timeout): - result = sh(script: f"curl --max-time 2 --silent --fail '{SERVER_SCHEMA}://{SERVER_DOMAIN}/health -I' > /dev/null || true", returnStatus: true) - if result == 0: - success = true - break - sleep 1 - elapsed += 1 - if not success: - print(SERVER_LOG_FILE) - self.fail( f"Service did not become available within {timeout}s.")