From 230aa79a6407d0e7e3e39b6c15f3d412ba743f9c Mon Sep 17 00:00:00 2001 From: Jesse Date: Thu, 6 Aug 2026 08:11:12 +0000 Subject: [PATCH] Add one-click deployment scripts --- deploy-company-linux/scripts/deploy.ps1 | 168 +++++++++++++ deploy-company-linux/scripts/remote-deploy.sh | 236 ++++++++++++++++++ .../scripts/setup-ssh-key.ps1 | 68 +++++ 3 files changed, 472 insertions(+) create mode 100644 deploy-company-linux/scripts/deploy.ps1 create mode 100644 deploy-company-linux/scripts/remote-deploy.sh create mode 100644 deploy-company-linux/scripts/setup-ssh-key.ps1 diff --git a/deploy-company-linux/scripts/deploy.ps1 b/deploy-company-linux/scripts/deploy.ps1 new file mode 100644 index 0000000..272fe6e --- /dev/null +++ b/deploy-company-linux/scripts/deploy.ps1 @@ -0,0 +1,168 @@ +[CmdletBinding()] +param( + [ValidateSet("deploy", "preflight", "status", "rollback")] + [string]$Action = "deploy", + + [string]$ProjectPath, + + [Parameter(Mandatory = $true)] + [ValidatePattern("^[A-Za-z0-9][A-Za-z0-9_-]*$")] + [string]$AppName, + + [ValidateSet("auto", "compose", "dockerfile")] + [string]$Mode = "auto", + + [string]$ServerHost = "192.168.31.51", + [string]$ServerUser = "jsxq", + [int]$SshPort = 22, + [string]$DeployRoot = "/home/jsxq/apps", + [string]$HealthUrl = "", + [int]$PublishPort = 0, + [int]$ContainerPort = 0, + [switch]$SkipHealthCheck, + [switch]$KeepArchive +) + +$ErrorActionPreference = "Stop" +$script:ArchivePath = $null +$script:KnownHostsFile = Join-Path ([IO.Path]::GetTempPath()) "codex-company-linux-known-hosts" +$AppName = $AppName.ToLowerInvariant() + +function Require-Command([string]$Name) { + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required command not found: $Name" + } +} + +function Quote-Sh([string]$Value) { + return "'" + $Value.Replace("'", "'`"'`"'") + "'" +} + +function Invoke-Ssh([string]$RemoteCommand, [switch]$BatchMode) { + $arguments = @("-T", "-p", "$SshPort", "-o", "StrictHostKeyChecking=accept-new", "-o", "UserKnownHostsFile=$script:KnownHostsFile", "-o", "ConnectTimeout=10") + if ($BatchMode) { + $arguments += @("-o", "BatchMode=yes") + } else { + $arguments += @("-o", "NumberOfPasswordPrompts=1") + } + $arguments += @("$ServerUser@$ServerHost", $RemoteCommand) + & ssh.exe @arguments + if ($LASTEXITCODE -ne 0) { + throw "SSH command failed with exit code $LASTEXITCODE" + } +} + +function Invoke-Scp([string]$LocalPath, [string]$RemotePath) { + & scp.exe -P $SshPort -o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$script:KnownHostsFile" -o ConnectTimeout=10 -o NumberOfPasswordPrompts=1 $LocalPath "$ServerUser@$ServerHost`:$RemotePath" + if ($LASTEXITCODE -ne 0) { + throw "SCP upload failed with exit code $LASTEXITCODE" + } +} + +function Enable-AskPass { + if ([string]::IsNullOrWhiteSpace($env:COMPANY_LINUX_PASSWORD)) { + return $false + } + $env:SSH_ASKPASS = Join-Path $PSScriptRoot "ssh-askpass.cmd" + $env:SSH_ASKPASS_REQUIRE = "force" + $env:DISPLAY = "codex" + return $true +} + +Require-Command "ssh.exe" + +$oldAskPass = $env:SSH_ASKPASS +$oldAskPassRequire = $env:SSH_ASKPASS_REQUIRE +$oldDisplay = $env:DISPLAY +$usingPassword = Enable-AskPass + +try { + if (-not $usingPassword) { + & ssh.exe -T -p $SshPort -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$script:KnownHostsFile" -o ConnectTimeout=8 "$ServerUser@$ServerHost" "true" + if ($LASTEXITCODE -ne 0) { + throw "SSH key authentication failed. Run setup-ssh-key.ps1 or set COMPANY_LINUX_PASSWORD only for this process." + } + } + + if ($Action -eq "preflight") { + $preflight = "set -eu; echo '=== identity ==='; id; echo '=== runtime ==='; docker --version; docker compose version; echo '=== disk ==='; df -h /; echo '=== used ports ==='; docker ps --format '{{.Names}}|{{.Ports}}'; echo '=== target ==='; if [ -e " + (Quote-Sh "$DeployRoot/$AppName") + " ]; then ls -ld " + (Quote-Sh "$DeployRoot/$AppName") + "; else echo 'new application'; fi" + Invoke-Ssh $preflight -BatchMode:(-not $usingPassword) + return + } + + if ($Action -eq "status") { + $projectName = "codex-$($AppName.ToLowerInvariant())" + $status = "set -eu; app_root=" + (Quote-Sh "$DeployRoot/$AppName") + "; echo '=== release ==='; if [ -L `"`$app_root/current`" ]; then readlink -f `"`$app_root/current`"; else echo 'not deployed'; fi; echo '=== containers ==='; docker ps -a --filter " + (Quote-Sh "label=com.docker.compose.project=$projectName") + " --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'; docker ps -a --filter " + (Quote-Sh "name=^/${projectName}-app$") + " --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'" + Invoke-Ssh $status -BatchMode:(-not $usingPassword) + return + } + + Require-Command "scp.exe" + $remoteScript = "/tmp/codex-remote-deploy-$AppName-$([Guid]::NewGuid().ToString('N')).sh" + Invoke-Scp (Join-Path $PSScriptRoot "remote-deploy.sh") $remoteScript + + if ($Action -eq "rollback") { + $command = "bash " + (Quote-Sh $remoteScript) + " rollback " + (Quote-Sh $AppName) + " '' auto " + (Quote-Sh $DeployRoot) + " '' 1 0 0; rc=`$?; rm -f " + (Quote-Sh $remoteScript) + "; exit `$rc" + Invoke-Ssh $command -BatchMode:(-not $usingPassword) + return + } + + if ([string]::IsNullOrWhiteSpace($ProjectPath)) { + throw "ProjectPath is required for deploy." + } + + Require-Command "tar.exe" + $resolvedProject = (Resolve-Path -LiteralPath $ProjectPath).Path + if (-not (Test-Path -LiteralPath $resolvedProject -PathType Container)) { + throw "Project path is not a directory: $resolvedProject" + } + + $composeNames = @("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml") + $composeFile = $composeNames | Where-Object { Test-Path -LiteralPath (Join-Path $resolvedProject $_) } | Select-Object -First 1 + $effectiveMode = $Mode + if ($Mode -eq "auto") { + if ($composeFile) { + $effectiveMode = "compose" + } elseif (Test-Path -LiteralPath (Join-Path $resolvedProject "Dockerfile")) { + $effectiveMode = "dockerfile" + } else { + throw "No Compose file or Dockerfile found in $resolvedProject" + } + } + if ($effectiveMode -eq "compose" -and -not $composeFile) { + throw "Compose mode selected, but no Compose file was found." + } + if ($effectiveMode -eq "dockerfile" -and -not (Test-Path -LiteralPath (Join-Path $resolvedProject "Dockerfile"))) { + throw "Dockerfile mode selected, but Dockerfile was not found." + } + if ($effectiveMode -eq "dockerfile" -and (($PublishPort -eq 0) -xor ($ContainerPort -eq 0))) { + throw "Provide both PublishPort and ContainerPort, or neither." + } + + $tempRoot = Join-Path ([IO.Path]::GetTempPath()) "codex-company-deploy" + New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null + $script:ArchivePath = Join-Path $tempRoot "$AppName-$([DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ'))-$([Guid]::NewGuid().ToString('N')).tgz" + & tar.exe -czf $script:ArchivePath --exclude=.git --exclude=node_modules --exclude=.venv --exclude=__pycache__ --exclude=.idea --exclude=.vscode --exclude=.codex -C $resolvedProject . + if ($LASTEXITCODE -ne 0) { + throw "Unable to create deployment archive." + } + + $remoteArchive = "/tmp/$([IO.Path]::GetFileName($script:ArchivePath))" + Invoke-Scp $script:ArchivePath $remoteArchive + + $skipFlag = if ($SkipHealthCheck) { "1" } else { "0" } + $commandParts = @( + "bash", (Quote-Sh $remoteScript), "deploy", (Quote-Sh $AppName), + (Quote-Sh $remoteArchive), (Quote-Sh $effectiveMode), (Quote-Sh $DeployRoot), + (Quote-Sh $HealthUrl), $skipFlag, "$PublishPort", "$ContainerPort" + ) + $command = ($commandParts -join " ") + "; rc=`$?; rm -f " + (Quote-Sh $remoteScript) + " " + (Quote-Sh $remoteArchive) + "; exit `$rc" + Invoke-Ssh $command -BatchMode:(-not $usingPassword) +} finally { + $env:SSH_ASKPASS = $oldAskPass + $env:SSH_ASKPASS_REQUIRE = $oldAskPassRequire + $env:DISPLAY = $oldDisplay + if ($script:ArchivePath -and (Test-Path -LiteralPath $script:ArchivePath) -and -not $KeepArchive) { + Remove-Item -LiteralPath $script:ArchivePath -Force + } +} diff --git a/deploy-company-linux/scripts/remote-deploy.sh b/deploy-company-linux/scripts/remote-deploy.sh new file mode 100644 index 0000000..b7a0dc9 --- /dev/null +++ b/deploy-company-linux/scripts/remote-deploy.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +set -euo pipefail + +action="${1:-}" +app_name="${2:-}" +archive="${3:-}" +mode="${4:-auto}" +deploy_root="${5:-/home/jsxq/apps}" +health_url="${6:-}" +skip_health="${7:-0}" +publish_port="${8:-0}" +container_port="${9:-0}" + +if [[ ! "$app_name" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then + echo "Invalid application name" >&2 + exit 2 +fi + +expected_prefix="/home/${USER}/apps" +if [[ "$deploy_root" != "$expected_prefix" ]]; then + echo "Refusing deployment outside $expected_prefix" >&2 + exit 2 +fi + +app_name="${app_name,,}" +project_name="codex-${app_name}" +app_root="${deploy_root}/${app_name}" +releases_dir="${app_root}/releases" +current_link="${app_root}/current" +previous_link="${app_root}/previous" + +find_compose_file() { + local directory="$1" + local candidate + for candidate in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do + if [[ -f "${directory}/${candidate}" ]]; then + printf '%s\n' "${directory}/${candidate}" + return 0 + fi + done + return 1 +} + +compose_up() { + local directory="$1" + local compose_file + compose_file="$(find_compose_file "$directory")" + (cd "$directory" && docker compose -p "$project_name" -f "$compose_file" up -d --build --remove-orphans) +} + +compose_down() { + local directory="$1" + local compose_file + compose_file="$(find_compose_file "$directory" || true)" + if [[ -z "$compose_file" ]]; then + return 0 + fi + (cd "$directory" && docker compose -p "$project_name" -f "$compose_file" down --remove-orphans) || true +} + +swap_release_links() { + local from="$1" + local to="$2" + ln -sfn "$to" "$current_link" + ln -sfn "$from" "$previous_link" +} + +rollback_release() { + if [[ ! -L "$previous_link" ]]; then + echo "No previous release is available" >&2 + return 1 + fi + + local current_release previous_release current_mode previous_mode + current_release="$(readlink -f "$current_link" 2>/dev/null || true)" + previous_release="$(readlink -f "$previous_link")" + current_mode="" + if [[ -n "$current_release" && -f "${current_release}/.codex-deploy-mode" ]]; then + current_mode="$(cat "${current_release}/.codex-deploy-mode")" + fi + previous_mode="$(cat "${previous_release}/.codex-deploy-mode")" + + if [[ "$current_mode" == "compose" && -n "$current_release" ]]; then + compose_down "$current_release" + elif [[ "$current_mode" == "dockerfile" ]]; then + docker rm -f "${project_name}-app" >/dev/null 2>&1 || true + fi + + if [[ "$previous_mode" == "compose" ]]; then + compose_up "$previous_release" + elif [[ "$previous_mode" == "dockerfile" ]]; then + if docker container inspect "${project_name}-previous" >/dev/null 2>&1; then + docker rename "${project_name}-previous" "${project_name}-app" + docker start "${project_name}-app" >/dev/null + else + echo "Previous Dockerfile container is unavailable" >&2 + return 1 + fi + else + echo "Unknown previous deployment mode: $previous_mode" >&2 + return 1 + fi + + swap_release_links "$current_release" "$previous_release" + echo "ROLLBACK_STATUS=success" + echo "CURRENT_RELEASE=$previous_release" +} + +if [[ "$action" == "rollback" ]]; then + rollback_release + exit 0 +fi + +if [[ "$action" != "deploy" ]]; then + echo "Expected deploy or rollback action" >&2 + exit 2 +fi + +if [[ ! -f "$archive" ]]; then + echo "Deployment archive not found: $archive" >&2 + exit 2 +fi + +command -v docker >/dev/null +docker compose version >/dev/null + +mkdir -p "$releases_dir" +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +release_dir="${releases_dir}/${timestamp}" +if [[ -e "$release_dir" ]]; then + release_dir="${release_dir}-$RANDOM" +fi +mkdir -p "$release_dir" +tar -xzf "$archive" -C "$release_dir" +printf '%s\n' "$mode" > "${release_dir}/.codex-deploy-mode" + +old_release="$(readlink -f "$current_link" 2>/dev/null || true)" +old_mode="" +if [[ -n "$old_release" ]]; then + if [[ -f "${old_release}/.codex-deploy-mode" ]]; then + old_mode="$(cat "${old_release}/.codex-deploy-mode")" + fi + ln -sfn "$old_release" "$previous_link" +fi +ln -sfn "$release_dir" "$current_link" + +deploy_failed=0 +if [[ "$mode" == "compose" ]]; then + if [[ "$old_mode" == "dockerfile" ]] && docker container inspect "${project_name}-app" >/dev/null 2>&1; then + docker rm -f "${project_name}-previous" >/dev/null 2>&1 || true + docker stop "${project_name}-app" >/dev/null || true + docker rename "${project_name}-app" "${project_name}-previous" + fi + if ! find_compose_file "$release_dir" >/dev/null; then + echo "Compose file not found in release" >&2 + deploy_failed=1 + elif ! compose_up "$release_dir"; then + deploy_failed=1 + fi +elif [[ "$mode" == "dockerfile" ]]; then + if [[ "$old_mode" == "compose" && -n "$old_release" ]]; then + compose_down "$old_release" + fi + if [[ ! -f "${release_dir}/Dockerfile" ]]; then + echo "Dockerfile not found in release" >&2 + deploy_failed=1 + else + image_name="${project_name}:${timestamp}" + if ! (cd "$release_dir" && docker build -t "$image_name" .); then + deploy_failed=1 + else + docker rm -f "${project_name}-previous" >/dev/null 2>&1 || true + if docker container inspect "${project_name}-app" >/dev/null 2>&1; then + docker stop "${project_name}-app" >/dev/null || true + docker rename "${project_name}-app" "${project_name}-previous" + fi + run_args=(--detach --name "${project_name}-app" --restart unless-stopped) + if [[ "$publish_port" != "0" && "$container_port" != "0" ]]; then + run_args+=(--publish "${publish_port}:${container_port}") + fi + if [[ -f "${release_dir}/.env" ]]; then + run_args+=(--env-file "${release_dir}/.env") + fi + if ! docker run "${run_args[@]}" "$image_name"; then + deploy_failed=1 + fi + fi + fi +else + echo "Unsupported deployment mode: $mode" >&2 + deploy_failed=1 +fi + +if [[ "$deploy_failed" == "0" && "$skip_health" != "1" ]]; then + sleep 3 + if [[ -n "$health_url" ]]; then + if command -v curl >/dev/null 2>&1; then + curl --fail --silent --show-error --max-time 15 "$health_url" >/dev/null || deploy_failed=1 + elif command -v wget >/dev/null 2>&1; then + wget --quiet --timeout=15 --spider "$health_url" || deploy_failed=1 + else + echo "No curl or wget is available for health checks" >&2 + deploy_failed=1 + fi + elif [[ "$mode" == "compose" ]]; then + compose_file="$(find_compose_file "$release_dir")" + (cd "$release_dir" && docker compose -p "$project_name" -f "$compose_file" ps --status running -q | grep -q .) || deploy_failed=1 + else + [[ "$(docker inspect -f '{{.State.Running}}' "${project_name}-app" 2>/dev/null || true)" == "true" ]] || deploy_failed=1 + fi +fi + +if [[ "$deploy_failed" != "0" ]]; then + echo "Deployment or health check failed" >&2 + if [[ -n "$old_release" && -L "$previous_link" ]]; then + rollback_release || true + echo "AUTO_ROLLBACK=attempted" >&2 + else + if [[ "$mode" == "compose" ]]; then + compose_down "$release_dir" + else + docker rm -f "${project_name}-app" >/dev/null 2>&1 || true + fi + rm -f "$current_link" + echo "AUTO_ROLLBACK=unavailable" >&2 + fi + exit 1 +fi + +echo "DEPLOY_STATUS=success" +echo "APP_NAME=$app_name" +echo "MODE=$mode" +echo "CURRENT_RELEASE=$release_dir" +if [[ -n "$health_url" ]]; then + echo "HEALTH_URL=$health_url" +fi diff --git a/deploy-company-linux/scripts/setup-ssh-key.ps1 b/deploy-company-linux/scripts/setup-ssh-key.ps1 new file mode 100644 index 0000000..7f4216a --- /dev/null +++ b/deploy-company-linux/scripts/setup-ssh-key.ps1 @@ -0,0 +1,68 @@ +[CmdletBinding()] +param( + [string]$ServerHost = "192.168.31.51", + [string]$ServerUser = "jsxq", + [int]$SshPort = 22 +) + +$ErrorActionPreference = "Stop" +$knownHostsFile = Join-Path ([IO.Path]::GetTempPath()) "codex-company-linux-known-hosts" + +function Require-Command([string]$Name) { + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + throw "Required command not found: $Name" + } +} + +Require-Command "ssh.exe" +Require-Command "ssh-keygen.exe" + +if ([string]::IsNullOrWhiteSpace($env:COMPANY_LINUX_PASSWORD)) { + throw "Set COMPANY_LINUX_PASSWORD in the current process before running first-time key setup." +} + +$sshDirectory = Join-Path $env:USERPROFILE ".ssh" +$privateKey = Join-Path $sshDirectory "id_ed25519" +$publicKey = "$privateKey.pub" + +if (-not (Test-Path -LiteralPath $publicKey)) { + New-Item -ItemType Directory -Path $sshDirectory -Force | Out-Null + & ssh-keygen.exe -q -t ed25519 -f $privateKey -N "" -C "$env:USERNAME@codex-company-linux" + if ($LASTEXITCODE -ne 0) { + throw "ssh-keygen failed with exit code $LASTEXITCODE" + } +} + +$keyText = (Get-Content -LiteralPath $publicKey -Raw).Trim() +if ([string]::IsNullOrWhiteSpace($keyText)) { + throw "Public key is empty: $publicKey" +} + +$keyBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($keyText)) +$askPass = Join-Path $PSScriptRoot "ssh-askpass.cmd" +$oldAskPass = $env:SSH_ASKPASS +$oldAskPassRequire = $env:SSH_ASKPASS_REQUIRE +$oldDisplay = $env:DISPLAY + +try { + $env:SSH_ASKPASS = $askPass + $env:SSH_ASKPASS_REQUIRE = "force" + $env:DISPLAY = "codex" + + $remote = "set -eu; umask 077; mkdir -p ~/.ssh; touch ~/.ssh/authorized_keys; key=`$(printf '%s' '$keyBase64' | base64 -d); grep -qxF -- `"`$key`" ~/.ssh/authorized_keys || printf '%s\n' `"`$key`" >> ~/.ssh/authorized_keys; chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys" + & ssh.exe -T -p $SshPort -o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$knownHostsFile" -o ConnectTimeout=10 -o NumberOfPasswordPrompts=1 "$ServerUser@$ServerHost" $remote + if ($LASTEXITCODE -ne 0) { + throw "Unable to install the SSH public key." + } +} finally { + $env:SSH_ASKPASS = $oldAskPass + $env:SSH_ASKPASS_REQUIRE = $oldAskPassRequire + $env:DISPLAY = $oldDisplay +} + +& ssh.exe -T -p $SshPort -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o "UserKnownHostsFile=$knownHostsFile" -o ConnectTimeout=10 "$ServerUser@$ServerHost" "echo SSH_KEY_READY" +if ($LASTEXITCODE -ne 0) { + throw "The key was installed, but key authentication verification failed." +} + +Write-Output "SSH key authentication is ready for $ServerUser@$ServerHost."