diff --git a/cli/src/commands/wheels/docker/DockerCommand.cfc b/cli/src/commands/wheels/docker/DockerCommand.cfc index 18d3b5bbbf..b7ae6f68fa 100644 --- a/cli/src/commands/wheels/docker/DockerCommand.cfc +++ b/cli/src/commands/wheels/docker/DockerCommand.cfc @@ -55,7 +55,8 @@ component extends="../base" { // Extract region from image name if (!len(trim(arguments.image))) { - error("AWS ECR requires image name to determine region. Use --image=123456789.dkr.ecr.region.amazonaws.com/repo:tag"); + detailOutput.error("AWS ECR requires image name to determine region. Use --image=123456789.dkr.ecr.region.amazonaws.com/repo:tag"); + return; } var region = extractAWSRegion(arguments.image); @@ -131,7 +132,8 @@ component extends="../base" { detailOutput.output("Enter Azure ACR Registry URL (e.g. myacr.azurecr.io):"); local.registryUrl = ask(message=""); if (!len(trim(local.registryUrl))) { - error("Azure ACR requires a registry URL."); + detailOutput.error("Azure ACR requires a registry URL."); + return; } } } @@ -192,7 +194,8 @@ component extends="../base" { detailOutput.output("Enter Private Registry URL (e.g. 192.168.1.10:5000 or registry.example.com):"); local.registryUrl = ask(message=""); if (!len(trim(local.registryUrl))) { - error("Private registry URL is required."); + detailOutput.error("Private registry URL is required."); + return; } } } @@ -400,15 +403,18 @@ component extends="../base" { switch(lCase(arguments.registry)) { case "dockerhub": if (!len(trim(local.prefix))) { - error("Docker Hub requires --username or --namespace parameter"); + detailOutput.error("Docker Hub requires --username or --namespace parameter"); + return; } return local.prefix & "/" & arguments.projectName & ":" & arguments.tag; case "ecr": - error("AWS ECR requires full image path. Use --image=123456789.dkr.ecr.region.amazonaws.com/repo:tag"); + detailOutput.error("AWS ECR requires full image path. Use --image=123456789.dkr.ecr.region.amazonaws.com/repo:tag"); + return; case "gcr": - error("GCR requires full image path. Use --image=gcr.io/project-id/image:tag"); + detailOutput.error("GCR requires full image path. Use --image=gcr.io/project-id/image:tag"); + return; case "acr": local.registryUrl = ""; @@ -419,14 +425,16 @@ component extends="../base" { } else { local.registryUrl = ask("Enter Azure ACR Registry URL (e.g. myacr.azurecr.io):"); if (!len(trim(local.registryUrl))) { - error("Azure ACR requires a registry URL to determine image path."); + detailOutput.error("Azure ACR requires a registry URL to determine image path."); + return; } } return local.registryUrl & "/" & arguments.projectName & ":" & arguments.tag; case "ghcr": if (!len(trim(local.prefix))) { - error("GitHub Container Registry requires --username or --namespace parameter"); + detailOutput.error("GitHub Container Registry requires --username or --namespace parameter"); + return; } return "ghcr.io/" & lCase(local.prefix) & "/" & arguments.projectName & ":" & arguments.tag; @@ -439,7 +447,8 @@ component extends="../base" { } else { local.registryUrl = ask("Enter Private Registry URL (e.g. 192.168.1.10:5000 or registry.example.com):"); if (!len(trim(local.registryUrl))) { - error("Private registry requires a registry URL to determine image path."); + detailOutput.error("Private registry requires a registry URL to determine image path."); + return; } } @@ -451,7 +460,8 @@ component extends="../base" { return local.finalImg; default: - error("Unsupported registry type"); + detailOutput.error("Unsupported registry type"); + return; } } @@ -491,7 +501,8 @@ component extends="../base" { local.output = arrayToList(local.outputParts, chr(10)); if (local.exitCode neq 0 && arguments.showOutput) { - error("Command failed with exit code: " & local.exitCode); + detailOutput.error("Command failed with exit code: " & local.exitCode); + return; } return { exitCode: local.exitCode, output: local.output }; @@ -536,7 +547,8 @@ component extends="../base" { local.output = arrayToList(local.outputParts, chr(10)); if (local.exitCode neq 0) { - error("Command failed with exit code: " & local.exitCode); + detailOutput.error("Command failed with exit code: " & local.exitCode); + return; } return { exitCode: local.exitCode, output: local.output }; @@ -641,7 +653,8 @@ component extends="../base" { local.result = runLocalCommand(sshCmd); if (local.result.exitCode neq 0) { - error("Remote command failed: " & arguments.cmd & " (Exit code: " & local.result.exitCode & ")"); + detailOutput.error("Remote command failed: " & arguments.cmd & " (Exit code: " & local.result.exitCode & ")"); + return; } return local.result; @@ -655,7 +668,8 @@ component extends="../base" { var filePath = fileSystemUtil.resolvePath(arguments.textFile); if (!fileExists(filePath)) { - error("Text file not found: #filePath#"); + detailOutput.error("Text file not found: #filePath#"); + return; } try { @@ -692,14 +706,16 @@ component extends="../base" { } if (arrayLen(servers) == 0) { - error("No valid servers found in text file"); + detailOutput.error("No valid servers found in text file"); + return; } detailOutput.statusSuccess("Loaded #arrayLen(servers)# server(s) from text file"); return servers; } catch (any e) { - error("Error reading text file: #e.message#"); + detailOutput.error("Error reading text file: #e.message#"); + return; } } @@ -710,7 +726,8 @@ component extends="../base" { var configPath = fileSystemUtil.resolvePath(arguments.configFile); if (!fileExists(configPath)) { - error("Config file not found: #configPath#"); + detailOutput.error("Config file not found: #configPath#"); + return; } try { @@ -718,20 +735,24 @@ component extends="../base" { var config = deserializeJSON(configContent); if (!structKeyExists(config, "servers") || !isArray(config.servers)) { - error("Invalid config file format. Expected { ""servers"": [ ... ] }"); + detailOutput.error("Invalid config file format. Expected { ""servers"": [ ... ] }"); + return; } if (arrayLen(config.servers) == 0) { - error("No servers defined in config file"); + detailOutput.error("No servers defined in config file"); + return; } for (var i = 1; i <= arrayLen(config.servers); i++) { var serverConfig = config.servers[i]; if (!structKeyExists(serverConfig, "host") || !len(trim(serverConfig.host))) { - error("Server #i# is missing required 'host' field"); + detailOutput.error("Server #i# is missing required 'host' field"); + return; } if (!structKeyExists(serverConfig, "user") || !len(trim(serverConfig.user))) { - error("Server #i# is missing required 'user' field"); + detailOutput.error("Server #i# is missing required 'user' field"); + return; } var projectName = getProjectName(); @@ -750,7 +771,209 @@ component extends="../base" { return config.servers; } catch (any e) { - error("Error parsing config file: #e.message#"); + detailOutput.error("Error parsing config file: #e.message#"); + return; + } + } + + // ============================================================================= + // CENTRALIZED CONFIG RESOLUTION + // ============================================================================= + + /** + * Global Docker Defaults + */ + public struct function getDefaults() { + return { + configFile = "config/deploy.yml", + legacyConfigFile = "deploy-servers.json", + legacyTextFile = "deploy-servers.txt", + defaultTag = "latest", + defaultPort = 8090, + defaultRole = "production" + }; + } + + /** + * Determine App Name from various sources + */ + private string function resolveAppName() { + var folderName = listLast(getCWD(), "/"); + return len(folderName) ? lcase(folderName) : "wheels-app"; + } + + /** + * Resolve final configuration + * Priority: CLI args > config file > init defaults > hardcoded defaults + */ + public struct function resolveConfig(struct cliArgs = {}) { + var defaults = getDefaults(); + var config = {}; + + config.appName = resolveAppName(); + + if (fileExists(getCWD() & defaults.configFile)) { + config = readDeployConfig(defaults.configFile); + } else if (fileExists(getCWD() & defaults.legacyConfigFile)) { + config = deserializeJSON(fileRead(defaults.legacyConfigFile)); + } + + structAppend(config, cliArgs, true); + + normalizeConfig(config, defaults); + + return config; + } + + /** + * Read and parse config/deploy.yml with full YAML support + */ + private struct function readDeployConfig(string configPath) { + var config = { "name": "", "image": "", "servers": [] }; + + try { + var fullPath = fileSystemUtil.resolvePath(arguments.configPath); + if (!fileExists(fullPath)) { + return config; + } + + var content = fileRead(fullPath); + + if (isJSON(content)) { + return deserializeJSON(content); + } + + var lines = listToArray(content, chr(10)); + var currentServer = {}; + + for (var line in lines) { + var trimmedLine = trim(line); + if ( + !len(trimmedLine) + || left(trimmedLine, 2) == "##" + || asc(left(trimmedLine, 1)) == 35 + ) { + continue; + } + + if (find("name:", trimmedLine) == 1) { + config.name = trim(replace(trimmedLine, "name:", "")); + } else if (find("image:", trimmedLine) == 1) { + var imageValue = trim(replace(trimmedLine, "image:", "")); + + if (find(":", imageValue)) { + var parts = listToArray(imageValue, ":"); + config.imageName = parts[1]; + config.tag = parts[2]; + } else { + config.imageName = imageValue; + } + } else if (find("tag:", trimmedLine) == 1) { + config.tag = trim(replace(trimmedLine, "tag:", "")); + } else if (find("- host:", trimmedLine)) { + if (!structIsEmpty(currentServer)) { + arrayAppend(config.servers, currentServer); + } + currentServer = { "host": trim(replace(trimmedLine, "- host:", "")), "port": 22, "role": "production" }; + } else if (find("user:", trimmedLine) && !structIsEmpty(currentServer)) { + currentServer.user = trim(replace(trimmedLine, "user:", "")); + } else if (find("port:", trimmedLine) && !structIsEmpty(currentServer)) { + currentServer.port = val(trim(replace(trimmedLine, "port:", ""))); + } else if (find("role:", trimmedLine) && !structIsEmpty(currentServer)) { + currentServer.role = trim(replace(trimmedLine, "role:", "")); + } + } + + if (!structIsEmpty(currentServer)) { + arrayAppend(config.servers, currentServer); + } + + } catch (any e) { + // Return empty config on error + } + + return config; + } + + /** + * Normalize config with computed values + */ + private void function normalizeConfig(required struct config, required struct defaults) { + // Ensure name + if (!structKeyExists(config, "name") || !len(trim(config.name))) { + config.name = defaults.appName; + } + + // Ensure imageName + if (!structKeyExists(config, "imageName") || !len(trim(config.imageName))) { + config.imageName = lcase(config.name); + } + + // Ensure tag + if (!structKeyExists(config, "tag") || !len(trim(config.tag))) { + config.tag = defaults.defaultTag; + } + + // Ensure port + if (!structKeyExists(config, "port") || !isNumeric(config.port)) { + config.port = defaults.defaultPort; + } + + // Compose final image + config.image = config.imageName & ":" & config.tag; + + // IMPORTANT: Do NOT force "-app" if compose already defines it + config.containerName = config.name; + } + + /** + * Get Container Name + */ + public string function getContainerName(required struct config) { + return config.containerName; + } + + /** + * Get Full Image Name + */ + public string function getImageName(required struct config) { + return config.fullImageName; + } + + // ============================================================================= + // FLOW VALIDATION / STATE TRACKING + // ============================================================================= + + /** + * Check if Docker configuration exists (created by wheels docker init) + */ + public boolean function hasDockerConfig() { + var dockerfilePath = getCWD() & "/Dockerfile"; + var composePath = getCWD() & "/docker-compose.yml"; + var composePathYaml = getCWD() & "/docker-compose.yaml"; + + return fileExists(dockerfilePath) || fileExists(composePath) || fileExists(composePathYaml); + } + + /** + * Check if image has been built locally + */ + public boolean function hasLocalImage(required string imageName) { + try { + var result = runLocalCommand( + ["docker", "image", "ls", "--format", "{{.Repository}}"], + false + ); + + if (result.exitCode neq 0) { + return false; + } + + var images = listToArray(trim(result.output), chr(10)); + return arrayContains(images, arguments.imageName); + + } catch (any e) { + return false; } } diff --git a/cli/src/commands/wheels/docker/build.cfc b/cli/src/commands/wheels/docker/build.cfc index 65f70c8719..03e25f8731 100644 --- a/cli/src/commands/wheels/docker/build.cfc +++ b/cli/src/commands/wheels/docker/build.cfc @@ -3,7 +3,6 @@ * * {code:bash} * wheels docker build --local - * wheels docker build --local --tag=myapp:v1.0 * wheels docker build --local --nocache * wheels docker build --remote * wheels docker build --remote --servers=1,3 @@ -17,7 +16,6 @@ component extends="DockerCommand" { * @local Build Docker image on local machine * @remote Build Docker image on remote server(s) * @servers Comma-separated list of server numbers to build on (e.g., "1,3,5") - for remote only - * @tag Custom tag for the Docker image (default: project-name:latest) * @nocache Build without using cache * @pull Always attempt to pull a newer version of the base image */ @@ -25,13 +23,19 @@ component extends="DockerCommand" { boolean local=false, boolean remote=false, string servers="", - string tag="", boolean nocache=false, boolean pull=false ) { // Ensure we are in a Wheels app requireWheelsApp(getCWD()); + // Check if Docker config exists (created by wheels docker init) + if (!hasDockerConfig()) { + detailOutput.error("Docker configuration not found. Please run 'wheels docker init' first."); + detailOutput.output("This command creates the necessary Docker files (Dockerfile, docker-compose.yml, etc.)"); + return; + } + // Reconstruct arguments for handling --key=value style arguments=reconstructArgs(arguments); @@ -41,14 +45,15 @@ component extends="DockerCommand" { } if (arguments.local && arguments.remote) { - error("Cannot specify both --local and --remote. Please choose one."); + detailOutput.error("Cannot specify both --local and --remote. Please choose one."); + return; } // Route to appropriate build method if (arguments.local) { - buildLocal(arguments.tag, arguments.nocache, arguments.pull); + buildLocal(arguments.nocache, arguments.pull); } else { - buildRemote(arguments.servers, arguments.tag, arguments.nocache, arguments.pull); + buildRemote(arguments.servers, arguments.nocache, arguments.pull); } } @@ -56,12 +61,13 @@ component extends="DockerCommand" { // LOCAL BUILD // ============================================================================= - private function buildLocal(string customTag, boolean nocache, boolean pull) { + private function buildLocal(boolean nocache, boolean pull) { detailOutput.header("Wheels Docker Local Build"); // Check if Docker is installed locally if (!isDockerInstalled()) { - error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); + detailOutput.error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); + return; } // Check for docker-compose file @@ -81,7 +87,8 @@ component extends="DockerCommand" { arrayAppend(local.buildCmd, "--pull"); } - detailOutput.output("Building services with docker-compose..."); + detailOutput.statusInfo("Building services with docker-compose..."); + detailOutput.statusInfo("Executing: " & arrayToList(local.buildCmd, " ")); runLocalCommand(local.buildCmd); detailOutput.line(); @@ -95,30 +102,18 @@ component extends="DockerCommand" { // Check for Dockerfile local.dockerfilePath = getCWD() & "/Dockerfile"; if (!fileExists(local.dockerfilePath)) { - error("No Dockerfile or docker-compose.yml found in current directory"); + detailOutput.error("No Dockerfile or docker-compose.yml found in current directory"); + return; } detailOutput.statusSuccess("Found Dockerfile, will build using standard docker build"); - // Get project name and determine tag - local.projectName = getProjectName(); - local.deployConfig = getDeployConfig(); - local.baseImageName = (structKeyExists(local.deployConfig, "image") && len(trim(local.deployConfig.image))) ? local.deployConfig.image : local.projectName; - - if (len(trim(arguments.customTag))) { - if (find(":", arguments.customTag)) { - local.imageTag = arguments.customTag; - } else { - local.imageTag = local.baseImageName & ":" & arguments.customTag; - } - } else { - local.imageTag = local.baseImageName & ":latest"; - } + var config = resolveConfig(); - detailOutput.statusInfo("Building image: " & local.imageTag); + detailOutput.statusInfo("Building Docker image: " & config.image); // Build command array - local.buildCmd = ["docker", "build", "-t", local.imageTag]; + local.buildCmd = ["docker", "build", "-t", config.image]; if (arguments.nocache) { arrayAppend(local.buildCmd, "--no-cache"); @@ -130,14 +125,14 @@ component extends="DockerCommand" { arrayAppend(local.buildCmd, "."); - detailOutput.output("Building Docker image..."); + detailOutput.statusInfo("Executing: " & arrayToList(local.buildCmd, " ")); + runLocalCommand(local.buildCmd); detailOutput.line(); detailOutput.statusSuccess("Docker image built successfully!"); detailOutput.line(); - detailOutput.output("Image tag: " & local.imageTag); - detailOutput.output("View image with: docker images " & local.projectName); + detailOutput.output("View image with: docker images " & config.image); detailOutput.output("Run container with: wheels docker deploy --local"); detailOutput.line(); } @@ -155,63 +150,23 @@ component extends="DockerCommand" { } } - /** - * Run a local system command - */ - private function runLocalCommand(array cmd, boolean showOutput=true) { - var local = {}; - local.javaCmd = createObject("java","java.util.ArrayList").init(); - for (var c in arguments.cmd) { - local.javaCmd.add(c & ""); - } - - local.pb = createObject("java","java.lang.ProcessBuilder").init(local.javaCmd); - - // Set working directory to current directory - local.currentDir = createObject("java", "java.io.File").init(getCWD()); - local.pb.directory(local.currentDir); - - local.pb.redirectErrorStream(true); - local.proc = local.pb.start(); - - local.isr = createObject("java","java.io.InputStreamReader").init(local.proc.getInputStream(), "UTF-8"); - local.br = createObject("java","java.io.BufferedReader").init(local.isr); - local.outputParts = []; - - while (true) { - local.line = local.br.readLine(); - if (isNull(local.line)) break; - arrayAppend(local.outputParts, local.line); - if (arguments.showOutput) { - detailOutput.output(local.line); - } - } - - local.exitCode = local.proc.waitFor(); - local.output = arrayToList(local.outputParts, chr(10)); - - if (local.exitCode neq 0 && arguments.showOutput) { - error("Command failed with exit code: " & local.exitCode); - } - - return { exitCode: local.exitCode, output: local.output }; - } - // ============================================================================= // REMOTE BUILD // ============================================================================= - private function buildRemote(string serverNumbers, string customTag, boolean nocache, boolean pull) { + private function buildRemote(string serverNumbers, boolean nocache, boolean pull) { + var config = resolveConfig(); + // Check for deploy-servers file (text or json) in current directory var textConfigPath = fileSystemUtil.resolvePath("deploy-servers.txt"); var jsonConfigPath = fileSystemUtil.resolvePath("deploy-servers.json"); var ymlConfigPath = fileSystemUtil.resolvePath("config/deploy.yml"); var allServers = []; var serversToBuild = []; - var projectName = getProjectName(); + var projectName = config.name; if (len(trim(arguments.serverNumbers)) == 0 && fileExists(ymlConfigPath)) { - var deployConfig = getDeployConfig(); + var deployConfig = readDeployConfig(ymlConfigPath); if (arrayLen(deployConfig.servers)) { detailOutput.identical("Found config/deploy.yml, loading server configuration"); allServers = deployConfig.servers; @@ -224,6 +179,9 @@ component extends="DockerCommand" { if (!structKeyExists(s, "port")) { s.port = 22; } + if (!structKeyExists(s, "imageName")) { + s.imageName = config.image; + } } serversToBuild = allServers; } @@ -239,19 +197,21 @@ component extends="DockerCommand" { allServers = loadServersFromConfig("deploy-servers.json"); serversToBuild = filterServers(allServers, arguments.serverNumbers); } else { - error("No server configuration found. Use 'wheels docker init' or create deploy-servers.txt."); + detailOutput.error("No server configuration found. Use 'wheels docker init' or create deploy-servers.txt."); + return; } } if (arrayLen(serversToBuild) == 0) { - error("No servers configured for building"); + detailOutput.error("No servers configured for building"); + return; } detailOutput.line(); detailOutput.statusInfo("Building Docker images on #arrayLen(serversToBuild)# server(s)..."); // Build on all selected servers - buildOnServers(serversToBuild, arguments.customTag, arguments.nocache, arguments.pull); + buildOnServers(serversToBuild, arguments.nocache, arguments.pull); detailOutput.line(); detailOutput.success("Build operations completed on all servers!"); @@ -290,7 +250,7 @@ component extends="DockerCommand" { /** * Build on multiple servers sequentially */ - private function buildOnServers(required array servers, string customTag, boolean nocache, boolean pull) { + private function buildOnServers(required array servers, boolean nocache, boolean pull) { var successCount = 0; var failureCount = 0; var serverConfig = {}; @@ -300,7 +260,7 @@ component extends="DockerCommand" { detailOutput.header("Building on server #i# of #arrayLen(servers)#: #serverConfig.host#"); try { - buildOnServer(serverConfig, arguments.customTag, arguments.nocache, arguments.pull); + buildOnServer(serverConfig, arguments.nocache, arguments.pull); successCount++; detailOutput.statusSuccess("Build on #serverConfig.host# completed successfully"); } catch (any e) { @@ -320,17 +280,24 @@ component extends="DockerCommand" { /** * Build on a single server */ - private function buildOnServer(required struct serverConfig, string customTag, boolean nocache, boolean pull) { + private function buildOnServer(required struct serverConfig, boolean nocache, boolean pull) { var local = {}; local.host = arguments.serverConfig.host; local.user = arguments.serverConfig.user; local.port = structKeyExists(arguments.serverConfig, "port") ? arguments.serverConfig.port : 22; - local.imageName = structKeyExists(arguments.serverConfig, "imageName") ? arguments.serverConfig.imageName : "#local.user#-app"; - local.remoteDir = structKeyExists(arguments.serverConfig, "remoteDir") ? arguments.serverConfig.remoteDir : "/home/#local.user#/#local.user#-app"; + local.imageName = structKeyExists(arguments.serverConfig, "imageName") ? arguments.serverConfig.imageName : config.image; + local.remoteDir = structKeyExists(arguments.serverConfig, "remoteDir") ? arguments.serverConfig.remoteDir : "/home/#local.user#/#local.imageName#"; + + var config = resolveConfig(); + + // Use image name from config or server config + local.baseImageName = len(local.imageName) ? local.imageName : config.image; + local.imageTag = local.baseImageName & ":" & config.tag; // Check SSH connection if (!testSSHConnection(local.host, local.user, local.port)) { - error("SSH connection failed to #local.host#. Check credentials and access."); + detailOutput.error("SSH connection failed to #local.host#. Check credentials and access."); + return; } detailOutput.statusSuccess("SSH connection successful"); @@ -411,15 +378,7 @@ component extends="DockerCommand" { local.deployConfig = getDeployConfig(); local.baseImageName = (structKeyExists(local.deployConfig, "image") && len(trim(local.deployConfig.image))) ? local.deployConfig.image : local.imageName; - if (len(trim(arguments.customTag))) { - if (find(":", arguments.customTag)) { - local.imageTag = arguments.customTag; - } else { - local.imageTag = local.baseImageName & ":" & arguments.customTag; - } - } else { - local.imageTag = local.baseImageName & ":latest"; - } + local.imageTag = local.baseImageName & ":" & config.tag; detailOutput.create("Building image: " & local.imageTag); local.buildCmd = "cd " & local.remoteDir & " && "; @@ -501,7 +460,8 @@ component extends="DockerCommand" { var filePath = fileSystemUtil.resolvePath(arguments.textFile); if (!fileExists(filePath)) { - error("Text file not found: #filePath#"); + detailOutput.error("Text file not found: #filePath#"); + return; } try { @@ -549,7 +509,8 @@ component extends="DockerCommand" { var configPath = fileSystemUtil.resolvePath(arguments.configFile); if (!fileExists(configPath)) { - error("Config file not found: #configPath#"); + detailOutput.error("Config file not found: #configPath#"); + return; } try { @@ -557,7 +518,8 @@ component extends="DockerCommand" { var config = deserializeJSON(configContent); if (!structKeyExists(config, "servers") || !isArray(config.servers)) { - error("Invalid config file format. Expected { ""servers"": [ ... ] }"); + detailOutput.error("Invalid config file format. Expected { ""servers"": [ ... ] }"); + return; } var projectName = getProjectName(); @@ -618,7 +580,8 @@ component extends="DockerCommand" { ]); if (local.result.exitCode neq 0) { - error("Remote command failed: " & arguments.cmd); + detailOutput.error("Remote command failed: " & arguments.cmd); + setExitCode(1); } return local.result; @@ -652,26 +615,4 @@ component extends="DockerCommand" { return { exitCode: local.exitCode, output: local.output }; } - private function getProjectName() { - var cwd = getCWD(); - var dirName = listLast(cwd, "\/"); - dirName = lCase(dirName); - dirName = reReplace(dirName, "[^a-z0-9\-]", "-", "all"); - dirName = reReplace(dirName, "\-+", "-", "all"); - dirName = reReplace(dirName, "^\-|\-$", "", "all"); - return len(dirName) ? dirName : "wheels-app"; - } - - private function hasDockerComposeFile() { - var composeFiles = ["docker-compose.yml", "docker-compose.yaml"]; - - for (var composeFile in composeFiles) { - var composePath = getCWD() & "/" & composeFile; - if (fileExists(composePath)) { - return true; - } - } - - return false; - } } \ No newline at end of file diff --git a/cli/src/commands/wheels/docker/deploy.cfc b/cli/src/commands/wheels/docker/deploy.cfc index cfa9f2258b..38d3b92312 100644 --- a/cli/src/commands/wheels/docker/deploy.cfc +++ b/cli/src/commands/wheels/docker/deploy.cfc @@ -16,42 +16,49 @@ component extends="DockerCommand" { /** * @local Deploy to local Docker environment * @remote Deploy to remote server(s) - * @environment Deployment environment (production, staging) - for local deployment - * @db Database to use (h2, mysql, postgres, mssql) - for local deployment - * @cfengine ColdFusion engine to use (lucee, adobe) - for local deployment - * @optimize Enable production optimizations - for local deployment - * @servers Server configuration file (deploy-servers.txt or deploy-servers.json) - for remote deployment + * @serversFile Server configuration file (deploy-servers.txt or deploy-servers.json) - for remote deployment * @skipDockerCheck Skip Docker installation check on remote servers * @blueGreen Enable Blue/Green deployment strategy (zero downtime) - for remote deployment - * @image Deprecated. Use unique project name in box.json instead. - * @tag Custom tag to use (default: latest). Always treated as suffix to project name. */ function run( boolean local=false, boolean remote=false, - string environment="production", - string db="mysql", - string cfengine="lucee", - boolean optimize=true, - string servers="", + string serversFile="", boolean skipDockerCheck=false, - boolean blueGreen=false, - string image="", - string tag="" + boolean blueGreen=false ) { //ensure we are in a Wheels app requireWheelsApp(getCWD()); + // Reconstruct arguments for handling --key=value style arguments = reconstructArgs(arguments); + var config = resolveConfig({}); + + // set local as default if neither specified + if (!arguments.local && !arguments.remote) { + arguments.local=true; + } - var projectName = getProjectName(); - - // Interactive Tag Selection logic - // Only trigger if no tag is specified and we are running? - // Actually, if tag is empty, we usually default to 'latest'. - // But user requested: "check the images available with different tags and then ask the user to select" + if (arguments.local && arguments.remote) { + detailOutput.error("Cannot specify both --local and --remote. Please choose one."); + return; + } + + // Check if Docker config exists (created by wheels docker init) + if (!hasDockerConfig()) { + detailOutput.error("Docker configuration not found. Please run 'wheels docker init' first."); + detailOutput.output("This command creates the necessary Docker files (Dockerfile, docker-compose.yml, etc.)"); + return; + } - if (!len(arguments.tag)) { + // For local deploy, check if image exists (built by wheels docker build) + if (arguments.local) { + if (!hasLocalImage(config.imageName)) { + detailOutput.error("Docker image '#config.imageName#' not found."); + detailOutput.output("Please run 'wheels docker build' first to build the image."); + return; + } + try { // List images for project with a safe delimiter var imageCheck = runLocalCommand(["docker", "images", "--format", "{{.Repository}}:::{{.Tag}}"], false); @@ -64,41 +71,32 @@ component extends="DockerCommand" { // Split by our custom delimiter var parts = listToArray(img, ":::"); if (arrayLen(parts) >= 2) { - var repo = trim(parts[1]); + var imageName = trim(parts[1]); var t = trim(parts[2]); - + // Check for exact match on project name - if (repo == projectName) { + if (imageName == config.imageName) { arrayAppend(candidates, t); } } } - // Deduplicate candidates just in case - // (CFML doesn't have a native Set, so we can use a struct key trick or just leave it if docker output is unique enough) - if (arrayLen(candidates) > 1) { detailOutput.line(); - detailOutput.output("Select a tag to deploy for project '#projectName#':"); + detailOutput.output("Multiple images found for project '#config.imageName#':"); for (var i=1; i<=arrayLen(candidates); i++) { detailOutput.output(" #i#. " & candidates[i]); } detailOutput.line(); + detailOutput.output("Currently configured to use tag: " & config.tag); + detailOutput.output("Are you sure you want to continue with this tag?"); - var selection = ask("Enter number to select, or press Enter for 'latest': "); - - if (len(trim(selection)) && isNumeric(selection) && selection > 0 && selection <= arrayLen(candidates)) { - arguments.tag = candidates[selection]; - detailOutput.statusSuccess("Selected tag: " & arguments.tag); - } else if (len(trim(selection))) { - // Treat as custom tag input if they typed a string not in the list? - // Or just fallback to what they typed - arguments.tag = selection; - detailOutput.statusSuccess("Using custom tag: " & arguments.tag); - } else { - // Empty selection matches 'latest' default logic later, or we can explicit set it - detailOutput.statusInfo("No selection made, defaulting to 'latest'"); + var answer = ask("Type 'y' to continue or 'n' to cancel deployment: "); + if (lcase(trim(answer)) != "y") { + detailOutput.line(); + detailOutput.error("Deployment cancelled. To deploy a different tag, update docker-compose.yml or rebuild the image with the desired tag."); + return; } } } @@ -108,20 +106,11 @@ component extends="DockerCommand" { } } - // set local as default if neither specified - if (!arguments.local && !arguments.remote) { - arguments.local=true; - } - - if (arguments.local && arguments.remote) { - error("Cannot specify both --local and --remote. Please choose one."); - } - // Route to appropriate deployment method if (arguments.local) { - deployLocal(arguments.environment, arguments.db, arguments.cfengine, arguments.optimize, arguments.tag); + deployLocal(); } else { - deployRemote(arguments.servers, arguments.skipDockerCheck, arguments.blueGreen, arguments.tag); + deployRemote(arguments.serversFile, arguments.skipDockerCheck, arguments.blueGreen); } } @@ -129,14 +118,7 @@ component extends="DockerCommand" { // LOCAL DEPLOYMENT // ============================================================================= - private function deployLocal( - string environment, - string db, - string cfengine, - boolean optimize, - string tag="" - ) { - // Welcome message + private function deployLocal() { detailOutput.header("Wheels Docker Local Deployment"); // Check for docker-compose file @@ -144,35 +126,36 @@ component extends="DockerCommand" { if (local.useCompose) { detailOutput.statusSuccess("Found docker-compose file, will use docker-compose"); - - // Just run docker-compose up - if (len(arguments.tag)) { - detailOutput.statusInfo("Note: --tag argument is ignored when using docker-compose."); - } + local.buildCmd = ["docker-compose", "up", "-d", "--build"]; detailOutput.statusInfo("Starting services..."); - runLocalCommand(["docker-compose", "up", "-d", "--build"]); + detailOutput.statusInfo("Executing: " & arrayToList(local.buildCmd, " ")); + runLocalCommand(local.buildCmd); detailOutput.line(); detailOutput.statusSuccess("Services started successfully!"); detailOutput.line(); - detailOutput.output("View logs with: docker-compose logs -f"); + detailOutput.output("View logs with: wheels docker logs --local"); detailOutput.line(); } else { // Check for Dockerfile local.dockerfilePath = getCWD() & "/Dockerfile"; if (!fileExists(local.dockerfilePath)) { - error("No Dockerfile or docker-compose.yml found in current directory"); + detailOutput.error("No Dockerfile or docker-compose.yml found in current directory"); + return; } detailOutput.statusSuccess("Found Dockerfile, will use standard docker commands"); // Check if Docker is installed locally if (!isDockerInstalled()) { - error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); + detailOutput.error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); + return; } + var config = resolveConfig(); + // Extract port from Dockerfile local.exposedPort = getDockerExposedPort(); if (!len(local.exposedPort)) { @@ -182,27 +165,16 @@ component extends="DockerCommand" { detailOutput.statusSuccess("Found EXPOSE port: " & local.exposedPort); } - // Get project name for image/container naming - local.projectName = getProjectName(); - local.deployConfig = getDeployConfig(); - - // Strict Tag Strategy: projectName:tag - local.tag = len(arguments.tag) ? arguments.tag : "latest"; - - // Smart Tag Logic: Check if tag contains colon (full image name) - if (find(":", local.tag)) { - local.imageName = local.tag; - } else if (structKeyExists(local.deployConfig, "image") && len(trim(local.deployConfig.image))) { - local.imageName = local.deployConfig.image & ":" & local.tag; - } else { - local.imageName = local.projectName & ":" & local.tag; - } - - // Container Name: Always use project name for consistency - local.containerName = local.projectName; + // Use config from resolveConfig + local.imageName = config.image; + local.containerName = config.containerName; + local.buildCmd = ["docker", "build", "-t", local.imageName, "."]; + detailOutput.statusInfo("Building Docker image (" & local.imageName & ")..."); - runLocalCommand(["docker", "build", "-t", local.imageName, "."]); + detailOutput.statusInfo("Executing: " & arrayToList(local.buildCmd, " ")); + + runLocalCommand(local.buildCmd); detailOutput.statusInfo("Starting container..."); @@ -258,7 +230,8 @@ component extends="DockerCommand" { if (len(trim(arguments.serversFile))) { var customPath = fileSystemUtil.resolvePath(arguments.serversFile); if (!fileExists(customPath)) { - error("Server configuration file not found: #arguments.serversFile#"); + detailOutput.error("Server configuration file not found: #arguments.serversFile#"); + return; } if (right(arguments.serversFile, 5) == ".json") { @@ -271,7 +244,7 @@ component extends="DockerCommand" { else if (fileExists(ymlConfigPath)) { var deployConfig = getDeployConfig(); if (arrayLen(deployConfig.servers)) { - detailOutput.identical("Found config/deploy.yml, loading server configuration"); + detailOutput.identical("Found config/deploy.yml, loading server configuration"); servers = deployConfig.servers; // Add defaults for missing fields @@ -293,11 +266,13 @@ component extends="DockerCommand" { detailOutput.identical("Found deploy-servers.json, loading server configuration"); servers = loadServersFromConfig("deploy-servers.json"); } else { - error("No server configuration found. Use 'wheels docker init' or create deploy-servers.txt."); + detailOutput.error("No server configuration found. Use 'wheels docker init' or create deploy-servers.txt."); + return; } if (arrayLen(servers) == 0) { - error("No servers configured for deployment"); + detailOutput.error("No servers configured for deployment"); + return; } detailOutput.statusInfo("Starting remote deployment to #arrayLen(servers)# server(s)..."); @@ -376,7 +351,8 @@ component extends="DockerCommand" { // Step 1: Check SSH connection if (!testSSHConnection(local.host, local.user, local.port)) { - error("SSH connection failed to #local.host#. Check credentials and access."); + detailOutput.error("SSH connection failed to #local.host#. Check credentials and access."); + return; } detailOutput.statusSuccess("SSH connection successful"); @@ -502,7 +478,8 @@ component extends="DockerCommand" { // Step 1: Check SSH connection if (!testSSHConnection(local.host, local.user, local.port)) { - error("SSH connection failed to #local.host#. Check credentials and access."); + detailOutput.error("SSH connection failed to #local.host#. Check credentials and access."); + return; } detailOutput.statusSuccess("SSH connection successful"); @@ -689,7 +666,8 @@ component extends="DockerCommand" { if (local.sudoCheckResult.exitCode neq 0) { detailOutput.line(); detailOutput.statusFailed("ERROR: User '#arguments.user#' does not have passwordless sudo access on #arguments.host#!"); - error("Cannot install Docker: User '" & arguments.user & "' requires passwordless sudo access on " & arguments.host); + detailOutput.error("Cannot install Docker: User '" & arguments.user & "' requires passwordless sudo access on " & arguments.host); + return; } detailOutput.statusSuccess("User has sudo access"); @@ -702,7 +680,8 @@ component extends="DockerCommand" { local.osResult = runLocalCommand(osCmd); if (local.osResult.exitCode neq 0) { - error("Failed to detect OS type on remote server"); + detailOutput.error("Failed to detect OS type on remote server"); + return; } // Determine installation script based on OS @@ -715,7 +694,8 @@ component extends="DockerCommand" { local.installScript = getDockerInstallScriptRHEL(); detailOutput.identical("Detected RHEL/CentOS/Fedora system"); } else { - error("Unsupported OS. Docker installation is only automated for Ubuntu/Debian and RHEL/CentOS/Fedora systems."); + detailOutput.error("Unsupported OS. Docker installation is only automated for Ubuntu/Debian and RHEL/CentOS/Fedora systems."); + return; } // Create temp file with install script @@ -745,7 +725,8 @@ component extends="DockerCommand" { local.installResult = runLocalCommand(installCmd); if (local.installResult.exitCode neq 0) { - error("Failed to install Docker on remote server"); + detailOutput.error("Failed to install Docker on remote server"); + return; } detailOutput.statusSuccess("Docker installed successfully!"); diff --git a/cli/src/commands/wheels/docker/exec.cfc b/cli/src/commands/wheels/docker/exec.cfc index d68406c11c..cf8f18a1b2 100644 --- a/cli/src/commands/wheels/docker/exec.cfc +++ b/cli/src/commands/wheels/docker/exec.cfc @@ -14,29 +14,51 @@ component extends="DockerCommand" { /** * @command Command to execute in container + * @local Fetch logs from local Docker environment + * @remote Fetch logs from remote server(s) * @servers Specific servers to execute on (comma-separated list) * @service Service to execute in: app or db (default: app) * @interactive Run command interactively (default: false) */ function run( required string command, + boolean local=false, + boolean remote=false, string servers="", string service="app", boolean interactive=false ) { //ensure we are in a Wheels app requireWheelsApp(getCWD()); + // Reconstruct arguments for handling --key=value style arguments = reconstructArgs(arguments); + + if (arguments.local && arguments.remote) { + detailOutput.error("Cannot specify both --local and --remote. Please choose one."); + return; + } + + if (!arguments.local && !arguments.remote) { + arguments.local = true; + } + + if (arguments.remote == false) { + executeLocal(arguments.command, arguments.service, arguments.interactive); + return; + } // Load servers var serverList = []; + // Resolve config using centralized config resolution + var config = resolveConfig({}); + var projectName = config.name; + // Check for deploy-servers file (text or json) in current directory var textConfigPath = fileSystemUtil.resolvePath("deploy-servers.txt"); var jsonConfigPath = fileSystemUtil.resolvePath("deploy-servers.json"); var ymlConfigPath = fileSystemUtil.resolvePath("config/deploy.yml"); - var projectName = getProjectName(); // If specific servers argument is provided if (len(trim(arguments.servers))) { @@ -90,16 +112,19 @@ component extends="DockerCommand" { detailOutput.identical("Found deploy-servers.json, loading server configuration"); serverList = loadServersFromConfig("deploy-servers.json"); } else { - error("No server configuration found. Use 'wheels docker init' or create deploy-servers.txt."); + detailOutput.error("No server configuration found. Use 'wheels docker init' or create deploy-servers.txt."); + return; } if (arrayLen(serverList) == 0) { - error("No servers configured for execution"); + detailOutput.error("No servers configured for execution"); + return; } // Validate interactive mode with multiple servers if (arguments.interactive && arrayLen(serverList) > 1) { - error("Cannot run interactive commands on multiple servers simultaneously. Please specify a single server using 'servers=host'."); + detailOutput.error("Cannot run interactive commands on multiple servers simultaneously. Please specify a single server using 'servers=host'."); + return; } detailOutput.header("Wheels Deploy Remote Execution"); @@ -138,7 +163,8 @@ component extends="DockerCommand" { // 1. Check SSH Connection if (!testSSHConnection(local.host, local.user, local.port)) { - throw("SSH connection failed"); + detailOutput.error("SSH connection failed"); + return; } // 2. Determine Container Name @@ -188,7 +214,8 @@ component extends="DockerCommand" { } if (!len(containerName)) { - throw("Could not find running container for service: " & arguments.service); + detailOutput.error("Could not find running container for service: " & arguments.service); + return; } // 3. Construct Docker Exec Command @@ -208,7 +235,10 @@ component extends="DockerCommand" { dockerCmd &= " -it"; } - dockerCmd &= " " & containerName & " " & arguments.command; + // dockerCmd &= " " & containerName & " " & arguments.command; + + var safeCommand = replace(arguments.command, "'", "'\''", "all"); + dockerCmd &= " " & containerName & " /bin/sh -c '" & safeCommand & "'"; execCmd.addAll([local.user & "@" & local.host, dockerCmd]); @@ -223,7 +253,90 @@ component extends="DockerCommand" { var result = runInteractiveCommand(execCmd, arguments.interactive); if (result.exitCode != 0 && result.exitCode != 130) { - throw("Command failed with exit code: " & result.exitCode); + detailOutput.error("Command failed with exit code: " & result.exitCode); + return; + } + } + + private function executeLocal( + string command, + string service, + boolean interactive + ) { + // Resolve config using centralized config resolution + var config = resolveConfig({}); + var projectName = config.name; + var containerName = ""; + + // 1. Find container + if (arguments.service == "app") { + var findResult = runLocalCommand( + ["docker", "ps", "--format", "{{.Names}}", "--filter", "name=" & projectName], + false + ); + + var runningContainers = listToArray(trim(findResult.output), chr(10)); + + if (arrayLen(runningContainers)) { + containerName = runningContainers[1]; + + // Try to find exact match or blue/green + for (var container in runningContainers) { + if (container == projectName || container == projectName & "-blue" || container == projectName & "-green") { + containerName = container; + break; + } + } + } + } else { + var patterns = [ + projectName & "-" & arguments.service, + arguments.service + ]; + + for (var pattern in patterns) { + var result = runLocalCommand( + ["docker", "ps", "--format", "{{.Names}}", "--filter", "name=" & pattern], + false + ); + + if (len(trim(result.output))) { + containerName = listFirst(trim(result.output), chr(10)); + break; + } + } + } + + if (!len(containerName)) { + detailOutput.error("Could not find running container for service: " & projectName); + return; + } + + detailOutput.header("Wheels Deployment Logs (Local)"); + detailOutput.statusInfo("Executing command on local container: " & containerName); + + // 2. Build docker exec command + var execCmd = ["docker", "exec"]; + + if (arguments.interactive) { + execCmd.addAll(["-it"]); + } + + execCmd.add(containerName); + + if (arguments.interactive) { + execCmd.add("/bin/sh"); + } else { + execCmd.addAll(["/bin/sh", "-c", arguments.command]); + } + + detailOutput.statusInfo("Executing: " & arrayToList(execCmd, " ")); + detailOutput.line(); + + var result = runInteractiveCommand(execCmd, arguments.interactive); + + if (result.exitCode != 0 && result.exitCode != 130) { + detailOutput.error("Command failed with exit code: " & result.exitCode); } } diff --git a/cli/src/commands/wheels/docker/init.cfc b/cli/src/commands/wheels/docker/init.cfc index 4e21bd8504..fb3c46bc2a 100644 --- a/cli/src/commands/wheels/docker/init.cfc +++ b/cli/src/commands/wheels/docker/init.cfc @@ -28,36 +28,90 @@ component extends="DockerCommand" { string db="mysql", string dbVersion="", string cfengine="lucee", - string cfVersion="6", + string cfVersion="", string port="", boolean force=false, boolean production=false, boolean nginx=false ) { requireWheelsApp(getCWD()); - arguments = reconstructArgs( - argStruct=arguments, - allowedValues={ - db: ["h2", "sqlite", "mysql", "postgres", "mssql", "oracle"], - cfengine: ["lucee", "adobe"] - } - ); + // Allowed values + var allowedValues = { + db: ["h2", "sqlite", "mysql", "postgres", "mssql", "oracle"], + cfengine: ["lucee", "adobe"] + }; + + // Validate db and cfengine + arguments = reconstructArgs(argStruct=arguments, allowedValues=allowedValues); + + // Define CF defaults and allowed versions + var cfDefaults = { + lucee: { default: "6", versions: ["6", "7"] }, + adobe: { default: "2018", versions: ["2018", "2021", "2023", "2025"] } // default is 2018 + }; + + if (len(arguments.cfengine) && !len(arguments.cfVersion)) { + arguments.cfVersion = cfDefaults[arguments.cfengine].default; + } + + // Validate cfVersion + if (!arrayContains(cfDefaults[arguments.cfengine].versions, arguments.cfVersion)) { + detailOutput.error("Invalid cfVersion: #arguments.cfVersion# for cfengine: #arguments.cfengine#"); + detailOutput.statusInfo("Valid cfVersions for #arguments.cfengine# are: " & ArrayToList(cfDefaults[arguments.cfengine].versions, ", ")); + detailOutput.line(); + return; + } + // Welcome message detailOutput.header("Wheels Docker Configuration"); + // Docker name regex (lowercase, numbers, ., _, - allowed) + local.dockerNamePattern = "^[a-z0-9]+([._-][a-z0-9]+)*$"; + // Interactive prompts for Deployment Configuration local.appName = ask("Application Name (default: #listLast(getCWD(), '\/')#): "); if (!len(trim(local.appName))) { local.appName = listLast(getCWD(), '\/'); } + + // Validate appName + if (!reFind(local.dockerNamePattern, local.appName)) { + detailOutput.error("Invalid appName: #local.appName#"); + detailOutput.statusInfo("appName must be lowercase and may contain only letters, numbers, dots (.), underscores (_), and hyphens (-)."); + detailOutput.line(); + return; + } local.imageName = ask("Docker Image Name (default: #local.appName#): "); if (!len(trim(local.imageName))) { local.imageName = local.appName; } + + // Validate imageName + if (!reFind(local.dockerNamePattern, local.imageName)) { + detailOutput.error("Invalid imageName: #local.imageName#"); + detailOutput.statusInfo("imageName must be lowercase and may contain only letters, numbers, dots (.), underscores (_), and hyphens (-)."); + detailOutput.line(); + return; + } + + local.imageTag = ask("Docker Image Tag (default: latest): "); + if (!len(trim(local.imageTag))) { + local.imageTag = "latest"; + } + + // Simple tag validation (Docker-safe) + local.tagPattern = "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$"; + + if (!reFind(local.tagPattern, local.imageTag)) { + detailOutput.error("Invalid imageTag: #local.imageTag#"); + detailOutput.statusInfo("Tag may contain letters, numbers, dots (.), underscores (_), and hyphens (-)."); + detailOutput.line(); + return; + } detailOutput.subHeader("Production Server Configuration"); - local.serverHost = ask("Server Host/IP (e.g. 192.168.1.10): "); + local.serverHost = ask("Server Host/IP (e.g. 192.168.1.10) (default: localhost): "); local.serverUser = ""; if (len(trim(local.serverHost))) { @@ -118,12 +172,12 @@ component extends="DockerCommand" { // Create Docker configuration files detailOutput.subHeader("Creating Docker Configuration Files"); createDockerfile(arguments.cfengine, arguments.cfVersion, local.appPort, arguments.db, arguments.production); - createDockerCompose(arguments.db, arguments.dbVersion, arguments.cfengine, arguments.cfVersion, local.appPort, arguments.production, arguments.nginx); + createDockerCompose(arguments.db, arguments.dbVersion, arguments.cfengine, arguments.cfVersion, local.appPort, arguments.production, arguments.nginx, local.appName, local.imageName, local.imageTag); createDockerIgnore(arguments.production); configureDatasource(arguments.db); // Create Deployment Config - createDeployConfig(local.appName, local.imageName, local.serverHost, local.serverUser); + createDeployConfig(local.appName, local.imageName, local.imageTag, local.serverHost, local.serverUser); // Create Nginx configuration if requested if (arguments["nginx"]) { @@ -213,7 +267,18 @@ CMD ["box", "server", "start", "--console", "--force"]'; detailOutput.create("Dockerfile"); } - private function createDockerCompose(string db, string dbVersion, string cfengine, string cfVersion, numeric appPort, boolean production=false, boolean nginx=false) { + private function createDockerCompose( + string db, + string dbVersion, + string cfengine, + string cfVersion, + numeric appPort, + boolean production=false, + boolean nginx=false, + string appName, + string imageName, + string imageTag + ) { local.dbService = ''; local.dbEnvironment = ''; @@ -357,10 +422,12 @@ CMD ["box", "server", "start", "--console", "--force"]'; } // Build app service - local.composeContent = 'version: "3.8" + local.composeContent = ' services: app: + container_name: #arguments.appName# + image: #arguments.imageName#:#arguments.imageTag# build: .#local.appPorts# environment: ENVIRONMENT: #local.envMode##local.dbEnvironment##local.volumes##local.restartPolicy##local.appDependsOn#'; @@ -722,13 +789,19 @@ http { } } - private function createDeployConfig(string appName, string imageName, string serverHost, string serverUser) { + private function createDeployConfig( + string appName, + string imageName, + string imageTag, + string serverHost, + string serverUser + ) { if (!directoryExists(fileSystemUtil.resolvePath("config"))) { directoryCreate(fileSystemUtil.resolvePath("config")); } local.deployContent = "name: #arguments.appName# -image: #arguments.imageName# +image: #arguments.imageName#:#arguments.imageTag# servers: "; if (len(trim(arguments.serverHost))) { diff --git a/cli/src/commands/wheels/docker/logs.cfc b/cli/src/commands/wheels/docker/logs.cfc index 5b7e9baae8..3096e5d077 100644 --- a/cli/src/commands/wheels/docker/logs.cfc +++ b/cli/src/commands/wheels/docker/logs.cfc @@ -15,25 +15,42 @@ component extends="DockerCommand" { property name="detailOutput" inject="DetailOutputService@wheels-cli"; /** + * @local Fetch logs from local Docker environment + * @remote Fetch logs from remote server(s) * @servers Specific servers to check (comma-separated list) * @tail Number of lines to show (default: 100) * @follow Follow log output in real-time (default: false) * @service Service to show logs for: app or db (default: app) * @since Show logs since timestamp (e.g., "2023-01-01", "1h", "5m") - * @remote Fetch logs from remote Docker container instead of local */ function run( + boolean local=false, + boolean remote=false, string servers="", string tail="100", boolean follow=false, string service="app", - string since="", - boolean remote=false + string since="" ) { //ensure we are in a Wheels app requireWheelsApp(getCWD()); + // Reconstruct arguments for handling --key=value style arguments = reconstructArgs(arguments); + + if (arguments.local && arguments.remote) { + detailOutput.error("Cannot specify both --local and --remote. Please choose one."); + return; + } + + if (!arguments.local && !arguments.remote) { + arguments.local = true; + } + + if (len(arguments.tail) && !isNumeric(arguments.tail)) { + detailOutput.error("Invalid value for tail. It must be a number."); + return; + } if (arguments.remote == false) { fetchLocalLogs(arguments.tail, arguments.follow, arguments.service, arguments.since); @@ -43,11 +60,14 @@ component extends="DockerCommand" { // Load servers var serverList = []; + // Resolve config using centralized config resolution + var config = resolveConfig({}); + var projectName = config.name; + // Check for deploy-servers file (text or json) in current directory var textConfigPath = fileSystemUtil.resolvePath("deploy-servers.txt"); var jsonConfigPath = fileSystemUtil.resolvePath("deploy-servers.json"); var ymlConfigPath = fileSystemUtil.resolvePath("config/deploy.yml"); - var projectName = getProjectName(); // If specific servers argument is provided if (len(trim(arguments.servers))) { @@ -101,16 +121,19 @@ component extends="DockerCommand" { detailOutput.identical("Found deploy-servers.json, loading server configuration"); serverList = loadServersFromConfig("deploy-servers.json"); } else { - error("No server configuration found. Use 'wheels docker init' or create deploy-servers.txt."); + detailOutput.error("No server configuration found. Use 'wheels docker init' or create deploy-servers.txt."); + return; } if (arrayLen(serverList) == 0) { - error("No servers configured for logs"); + detailOutput.error("No servers configured for logs"); + return; } // Validate follow mode with multiple servers if (arguments.follow && arrayLen(serverList) > 1) { - error("Cannot follow logs from multiple servers simultaneously. Please specify a single server using 'servers=host'."); + detailOutput.error("Cannot follow logs from multiple servers simultaneously. Please specify a single server using 'servers=host'."); + return; } detailOutput.header("Wheels Deployment Logs"); @@ -151,7 +174,8 @@ component extends="DockerCommand" { // 1. Check SSH Connection (skip if following to save time/output noise?) // Better to check to avoid hanging on bad connection if (!testSSHConnection(local.host, local.user, local.port)) { - throw("SSH connection failed"); + detailOutput.error("SSH connection failed"); + return; } // 2. Determine Container Name @@ -201,7 +225,8 @@ component extends="DockerCommand" { } if (!len(containerName)) { - throw("Could not find running container for service: " & arguments.service); + detailOutput.error("Could not find running container for service: " & arguments.service); + return; } // 3. Construct Docker Logs Command @@ -228,6 +253,8 @@ component extends="DockerCommand" { dockerCmd &= " " & containerName; logsCmd.addAll([local.user & "@" & local.host, dockerCmd]); + + detailOutput.statusInfo("Executing: " & dockerCmd); // 4. Execute // If following, we want to print output as it comes. runLocalCommand does this. @@ -235,6 +262,10 @@ component extends="DockerCommand" { // This is fine for CLI usage. detailOutput.statusInfo("Fetching logs from container: " & containerName); + // Optional improvement + if (len(arguments.since)) { + detailOutput.statusInfo("Filtering logs since: " & arguments.since); + } if (arguments.follow) { detailOutput.statusInfo("Following logs... (Press Ctrl+C to stop)"); } @@ -243,7 +274,8 @@ component extends="DockerCommand" { var result = runInteractiveCommand(logsCmd); if (result.exitCode != 0 && result.exitCode != 130) { - throw("Command failed with exit code: " & result.exitCode); + detailOutput.error("Command failed with exit code: " & result.exitCode); + return; } } @@ -253,7 +285,9 @@ component extends="DockerCommand" { string service, string since ) { - var projectName = getProjectName(); + // Resolve config using centralized config resolution + var config = resolveConfig({}); + var projectName = config.name; var containerName = ""; if (arguments.service == "app") { @@ -296,12 +330,18 @@ component extends="DockerCommand" { } if (!len(containerName)) { - error("Could not find running container for service: " & arguments.service); + detailOutput.error("Could not find running container for service: " & projectName); + return; } detailOutput.header("Wheels Deployment Logs (Local)"); detailOutput.statusInfo("Fetching logs from local container: " & containerName); + // Optional improvement + if (len(arguments.since)) { + detailOutput.statusInfo("Filtering logs since: " & arguments.since); + } + if (arguments.follow) { detailOutput.statusInfo("Following logs... (Press Ctrl+C to stop)"); } @@ -323,11 +363,14 @@ component extends="DockerCommand" { } dockerCmd.add(containerName); + + detailOutput.statusInfo("Executing: " & arrayToList(dockerCmd, " ")); var result = runInteractiveCommand(dockerCmd); if (result.exitCode != 0 && result.exitCode != 130) { - error("Command failed with exit code: " & result.exitCode); + detailOutput.error("Command failed with exit code: " & result.exitCode); + return; } } diff --git a/cli/src/commands/wheels/docker/stop.cfc b/cli/src/commands/wheels/docker/stop.cfc index 8b00beb2a2..2af2a1546c 100644 --- a/cli/src/commands/wheels/docker/stop.cfc +++ b/cli/src/commands/wheels/docker/stop.cfc @@ -27,6 +27,7 @@ component extends="DockerCommand" { ) { //ensure we are in a Wheels app requireWheelsApp(getCWD()); + // Reconstruct arguments for handling --key=value style arguments = reconstructArgs(arguments); @@ -36,7 +37,8 @@ component extends="DockerCommand" { } if (arguments.local && arguments.remote) { - error("Cannot specify both --local and --remote. Please choose one."); + detailOutput.error("Cannot specify both --local and --remote. Please choose one."); + return; } // Route to appropriate stop method @@ -56,19 +58,38 @@ component extends="DockerCommand" { // Check if Docker is installed locally if (!isDockerInstalled()) { - error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); + detailOutput.error("Docker is not installed or not accessible. Please ensure Docker Desktop or Docker Engine is running."); + return; } + // Resolve config using centralized config resolution + var config = resolveConfig({}); + var containerName = config.containerName; + // Check for docker-compose file local.useCompose = hasDockerComposeFile(); if (local.useCompose) { - detailOutput.statusSuccess("Found docker-compose file, will stop docker-compose services"); - detailOutput.statusInfo("Stopping services with docker-compose..."); + detailOutput.statusSuccess("Found docker-compose file, will stop docker-compose services"); try { - runLocalCommand(["docker", "compose", "down"]); - detailOutput.success("Docker Compose services stopped successfully!"); + if (arguments.removeContainer) { + detailOutput.statusInfo("Removing Docker container '" & containerName & "'..."); + local.removeCmd = ["docker", "compose", "down"] + + detailOutput.statusInfo("Executing: " & arrayToList(removeCmd, " ")); + + runLocalCommand(local.removeCmd); + detailOutput.statusSuccess("Docker Container removed successfully"); + } else { + detailOutput.statusInfo("Stopping Docker container '" & containerName & "'..."); + local.stopCmd = ["docker", "compose", "stop"] + + detailOutput.statusInfo("Executing: " & arrayToList(stopCmd, " ")); + + runLocalCommand(local.stopCmd); + detailOutput.statusSuccess("Docker Container stopped successfully"); + } } catch (any e) { detailOutput.statusFailed("Services might not be running"); } @@ -76,19 +97,16 @@ component extends="DockerCommand" { } else { detailOutput.statusInfo("No docker-compose file found, will use standard docker commands"); - // Get project name for container naming - local.containerName = getProjectName(); - - detailOutput.statusInfo("Stopping Docker container '" & local.containerName & "'..."); + detailOutput.statusInfo("Stopping Docker container '" & containerName & "'..."); try { - runLocalCommand(["docker", "stop", local.containerName]); + runLocalCommand(["docker", "stop", containerName]); detailOutput.statusSuccess("Container stopped successfully"); if (arguments.removeContainer) { - detailOutput.statusInfo("Removing Docker container '" & local.containerName & "'..."); - runLocalCommand(["docker", "rm", local.containerName]); - detailOutput.statusSuccess("Container removed successfully").toConsole(); + detailOutput.statusInfo("Removing Docker container '" & containerName & "'..."); + runLocalCommand(["docker", "rm", containerName]); + detailOutput.statusSuccess("Container removed successfully"); } detailOutput.statusSuccess("Container operations completed!"); @@ -107,13 +125,16 @@ component extends="DockerCommand" { // ============================================================================= private function stopRemote(string serverNumbers, boolean removeContainer) { + // Resolve config using centralized config resolution + var config = resolveConfig({}); + // Check for deploy-servers file (text or json) in current directory var textConfigPath = fileSystemUtil.resolvePath("deploy-servers.txt"); var jsonConfigPath = fileSystemUtil.resolvePath("deploy-servers.json"); var ymlConfigPath = fileSystemUtil.resolvePath("config/deploy.yml"); var allServers = []; var serversToStop = []; - var projectName = getProjectName(); + var projectName = config.name; if (len(trim(arguments.serverNumbers)) == 0 && fileExists(ymlConfigPath)) { var deployConfig = getDeployConfig(); @@ -147,12 +168,14 @@ component extends="DockerCommand" { allServers = loadServersFromConfig("deploy-servers.json"); serversToStop = filterServers(allServers, arguments.serverNumbers); } else { - error("No server configuration found. Use 'wheels docker init' or create deploy-servers.txt."); + detailOutput.error("No server configuration found. Use 'wheels docker init' or create deploy-servers.txt."); + return; } } if (arrayLen(serversToStop) == 0) { - error("No servers configured to stop containers"); + detailOutput.error("No servers configured to stop containers"); + return; } detailOutput.line(); @@ -239,7 +262,8 @@ component extends="DockerCommand" { // Check SSH connection if (!testSSHConnection(local.host, local.user, local.port)) { - error("SSH connection failed to #local.host#. Check credentials and access."); + detailOutput.error("SSH connection failed to #local.host#. Check credentials and access."); + return; } detailOutput.statusSuccess("SSH connection successful"); @@ -257,7 +281,7 @@ component extends="DockerCommand" { if (local.useCompose) { // Stop using docker-compose - detailOutput.statusInfo("Stopping services with docker-compose...").toConsole(); + detailOutput.statusInfo("Stopping services with docker-compose..."); // Check if user can run docker without sudo local.stopCmd = "cd " & local.remoteDir & " && "; diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 2496690e4c..5394e71e63 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -76,11 +76,11 @@ * [wheels docker init](command-line-tools/commands/docker/docker-init.md) * [wheels docker build](command-line-tools/commands/docker/docker-build.md) * [wheels docker deploy](command-line-tools/commands/docker/docker-deploy.md) - * [wheels docker push](command-line-tools/commands/docker/docker-push.md) - * [wheels docker login](command-line-tools/commands/docker/docker-login.md) * [wheels docker logs](command-line-tools/commands/docker/docker-logs.md) * [wheels docker exec](command-line-tools/commands/docker/docker-exec.md) * [wheels docker stop](command-line-tools/commands/docker/docker-stop.md) + * [wheels docker login](command-line-tools/commands/docker/docker-login.md) + * [wheels docker push](command-line-tools/commands/docker/docker-push.md) * Get Commands * [wheels get environment](command-line-tools/commands/get/get-environment.md) * [wheels get settings](command-line-tools/commands/get/get-settings.md) diff --git a/docs/src/command-line-tools/commands/docker/docker-build.md b/docs/src/command-line-tools/commands/docker/docker-build.md index 35bf258658..c8215d6101 100644 --- a/docs/src/command-line-tools/commands/docker/docker-build.md +++ b/docs/src/command-line-tools/commands/docker/docker-build.md @@ -1,6 +1,16 @@ # wheels docker build -Unified Docker build command for Wheels apps. Builds Docker images locally or on remote servers. +Unified Docker build command for Wheels apps. Builds Docker images locally or on remote servers. This is useful for creating images that can be deployed later, testing build processes, or preparing images for remote deployment. + +## Prerequisites + +Before using this command, you must first initialize Docker configuration files: + +```bash +wheels docker init +``` + +This will create the necessary `Dockerfile` and optionally `docker-compose.yml` files in your project directory. ## Synopsis @@ -20,10 +30,9 @@ The `wheels docker build` command handles the building of Docker images for your | Option | Description | Default | |--------|-------------|---------| -| `--local` | Build Docker image on local machine | `false` | +| `--local` | Build Docker image on local machine | `true` | | `--remote` | Build Docker image on remote server(s) | `false` | | `--servers` | Comma-separated list of server numbers to build on (e.g., "1,3,5") - for remote only | `""` | -| `--tag` | Custom tag for the Docker image (default: project-name:latest) | `""` | | `--nocache` | Build without using cache | `false` | | `--pull` | Always attempt to pull a newer version of the base image | `false` | @@ -32,7 +41,7 @@ The `wheels docker build` command handles the building of Docker images for your ### Local Development Builds **Standard Build** -Builds the image using the `Dockerfile` in the current directory. Tags it with the folder name (e.g., `my-app:latest`). +Builds the image using the `Dockerfile` in the current directory. ```bash wheels docker build --local ``` @@ -43,12 +52,6 @@ If you've changed base image dependencies or want to ensure a clean build, use ` wheels docker build --local --nocache --pull ``` -**Custom Tagging** -Useful when building specific versions for release. -```bash -wheels docker build --local --tag=my-company/my-app:v2.0.0 -``` - ### Remote Server Builds **Build on All Servers** @@ -78,7 +81,6 @@ wheels docker build --remote --nocache --pull 2. **Compose Detection**: * **If `docker-compose.yml` exists**: It executes `docker compose build`. This is ideal for complex apps with multiple services (app, db, redis). * **If only `Dockerfile` exists**: It executes `docker build -t [tag] .`. -3. **Tagging**: If no custom tag is provided, it sanitizes the current directory name to create a valid Docker tag (e.g., `My Project` -> `my-project:latest`). ### Remote Build Strategy (`--remote`) @@ -93,39 +95,127 @@ wheels docker build --remote --nocache --pull ## Server Configuration - This command looks for server configurations in this order of priority: +This command looks for server configurations in this order of priority: - **Option A: `config/deploy.yml` (Recommended)** - The primary source of truth for all Wheels Docker operations. - ```yaml - name: myapp - image: myuser/myapp - servers: - - host: 192.168.1.100 - user: ubuntu - role: production - ``` - - **Option B: `deploy-servers.json` (Legacy)** - ```json - { - "servers": [ - { - "host": "192.168.1.100", - "user": "ubuntu", - "port": 22, - "remoteDir": "/var/www/myapp", - "imageName": "custom-image-name" - } - ] - } - ``` +**Option A: `config/deploy.yml` (Recommended)** +The primary source of truth for all Wheels Docker operations. +```yaml +name: myapp +image: myuser/myapp +servers: + - host: 192.168.1.100 + user: ubuntu + role: production +``` + +**Option B: `deploy-servers.json` (Legacy)** +```json +{ + "servers": [ + { + "host": "192.168.1.100", + "user": "ubuntu", + "port": 22, + "remoteDir": "/var/www/myapp", + "imageName": "custom-image-name" + } + ] +} +``` - **Option C: `deploy-servers.txt` (Legacy)** - Space-separated columns: Host, User, Port (optional). - ```text - # Host User Port - 192.168.1.100 ubuntu 22 - prod-1.example.com deploy 2202 - prod-2.example.com deploy 2202 - ``` +**Option C: `deploy-servers.txt` (Legacy)** +Space-separated columns: Host, User, Port (optional). +```text +# Host User Port +192.168.1.100 ubuntu 22 +prod-1.example.com deploy 2202 +prod-2.example.com deploy 2202 +``` + +If **port is omitted**, it defaults to: +```text +22 +``` + +**Extended Format (Recommended for Remote Builds)** + +You can optionally define: + +- remoteDir → Directory where the app lives on the server +- imageName → Custom image name override +```text +# Host User Port RemoteDir ImageName +192.168.1.100 ubuntu 22 /var/www/myapp myapp-prod +staging.example.com deploy 2222 /home/deploy/app myapp-staging +``` + +## Build vs Deploy + +Understanding when to use `build` vs `deploy`: + +### Use `wheels docker build` when: +- You want to create an image without running it +- Testing Docker configuration changes +- Preparing images for later deployment +- Building on CI/CD pipeline +- Building on remote servers for later use + +### Use `wheels docker deploy` when: +- You want to build AND run the application +- Deploying to servers with containers running +- Full deployment workflow needed +- Starting services immediately + +### Combined Workflow: +```bash +# 1. Build the image +wheels docker build --nocache + +# 2. Test locally if needed +docker run -d -p 8080:8080 myapp:v1.0.0 + +# 3. Deploy to remote servers +wheels docker deploy --remote --blueGreen +``` + +## Monitoring Build Progress + +### Local Monitoring + +Build progress is shown in real-time: +```bash +wheels docker build --local +# Output shows: +# - Layer building +# - Cache usage +# - Final image ID and size +``` + +### Remote Monitoring + +For remote builds, watch SSH output: +```bash +wheels docker build --remote +# Output shows: +# - SSH connection status +# - Source upload progress +# - Build step output from remote server +# - Success/failure summary +``` + +To monitor manually on remote server: +```bash +# In another terminal +ssh user@host "docker ps" +ssh user@host "docker images" +``` + +## Related Commands + +- [wheels docker init](docker-init.md) - Initialize Docker configuration files +- [wheels docker deploy](docker-deploy.md) - Build and deploy Docker containers +- [wheels docker logs](docker-logs.md) - View container logs +- [wheels docker exec](docker-exec.md) - Execute commands in containers +- [wheels docker stop](docker-stop.md) - Stop Docker containers + +**Note**: This command is part of the Wheels CLI tool suite for Docker management. \ No newline at end of file diff --git a/docs/src/command-line-tools/commands/docker/docker-deploy.md b/docs/src/command-line-tools/commands/docker/docker-deploy.md index 1f9492396c..5d58f2be65 100644 --- a/docs/src/command-line-tools/commands/docker/docker-deploy.md +++ b/docs/src/command-line-tools/commands/docker/docker-deploy.md @@ -20,13 +20,9 @@ The `wheels docker deploy` command manages the deployment lifecycle of your Dock | Option | Description | Default | |--------|-------------|---------| -| `--local` | Deploy to local Docker environment | `false` | +| `--local` | Deploy to local Docker environment | `true` | | `--remote` | Deploy to remote server(s) | `false` | -| `--environment` | Deployment environment (production, staging) - for local deployment | `production` | -| `--db` | Database to use (h2, mysql, postgres, mssql) - for local deployment | `mysql` | -| `--cfengine` | ColdFusion engine to use (lucee, adobe) - for local deployment | `lucee` | -| `--optimize` | Enable production optimizations - for local deployment | `true` | -| `--servers` | Server configuration file (defaults to `config/deploy.yml`) | `""` | +| `--serversFile` | Server configuration file (defaults to `config/deploy.yml`) | `""` | | `--skipDockerCheck` | Skip Docker installation check on remote servers | `false` | | `--blueGreen` | Enable Blue/Green deployment strategy (zero downtime) - for remote deployment | `false` | @@ -40,18 +36,6 @@ Starts the application locally, mimicking a production environment (optimized se wheels docker deploy --local ``` -**Staging Environment** -Deploys locally with staging environment variables. -```bash -wheels docker deploy --local --environment=staging -``` - -**Custom Stack** -Deploys locally using PostgreSQL and Adobe ColdFusion. -```bash -wheels docker deploy --local --db=postgres --cfengine=adobe -``` - ### Remote Deployment **Standard Deployment** @@ -69,7 +53,7 @@ wheels docker deploy --remote --blueGreen **Deploy to Specific Servers** Uses an override server list file for deployment. ```bash -wheels docker deploy --remote --servers=staging-servers.yml +wheels docker deploy --remote --serversFile=staging-servers.yml ``` **Skip Docker Checks** @@ -112,3 +96,30 @@ If the remote user is not part of the `docker` group, the CLI tries to use `sudo ## Server Configuration See [wheels docker build](docker-build.md#server-configuration) for details on `deploy-servers.txt` and `deploy-servers.json`. + +## Security Notes + +1. **SSH Keys**: Use SSH key authentication instead of passwords +2. **Sudo Access**: Configure minimal sudo permissions for production +3. **Firewall**: Ensure proper firewall rules are in place +4. **Docker Socket**: The deployment sets permissions on `/var/run/docker.sock` for convenience; review for production security + +## Best Practices + +1. **Test Locally First**: Always test deployments locally before remote deployment +2. **Use Blue/Green for Production**: Minimize downtime with `--blueGreen` flag +3. **Version Control**: Keep `Dockerfile` and `docker-compose.yml` in version control +4. **Environment-Specific Configs**: Use different configuration files for staging/production +5. **Monitor Resources**: Keep track of Docker resource usage on remote servers +6. **Backup Data**: Always backup databases before major deployments +7. **Rollback Plan**: Keep previous images for quick rollback if needed + +## Related Commands + +- [wheels docker init](docker-init.md) - Initialize Docker configuration files +- [wheels docker build](docker-build.md) - Build Docker images +- [wheels docker logs](docker-logs.md) - View container logs +- [wheels docker exec](docker-exec.md) - Execute commands in containers +- [wheels docker stop](docker-stop.md) - Stop Docker containers + +**Note**: This command is part of the Wheels CLI tool suite for Docker management. \ No newline at end of file diff --git a/docs/src/command-line-tools/commands/docker/docker-exec.md b/docs/src/command-line-tools/commands/docker/docker-exec.md index 00bbf00583..99ee159335 100644 --- a/docs/src/command-line-tools/commands/docker/docker-exec.md +++ b/docs/src/command-line-tools/commands/docker/docker-exec.md @@ -16,15 +16,21 @@ The `wheels docker exec` command allows you to run arbitrary commands inside you - **Source of Truth**: This command prioritizes settings from `config/deploy.yml` for server lists and project names. - **Interactive TTY**: The `--interactive` flag provides a full TTY session, allowing you to run shells, REPLs, and database clients with proper signal handling (e.g., `Ctrl+C`). +## Arguments + +| Argument | Description | Default | +|----------|-------------|---------| +| `command` | Command to execute in container | Required | + ## Options | Option | Description | Default | |--------|-------------|---------| -| `command` | **Required**. Command to execute in container | | | `--servers` | Specific servers to execute on (defaults to `config/deploy.yml`) | `""` | | `--service` | Service to execute in: `app` or `db` | `app` | | `--interactive` | Run command interactively with full TTY support | `false` | -| `--local` | Execute in local container | `false` | +| `--local` | Execute in local container | `true` | +| `--remote` | Execute in remote container | `false` | ## Detailed Examples @@ -39,7 +45,7 @@ wheels docker exec "ls -la /app" **Check Database Connectivity** Verify that your application container can reach the database service. ```bash -wheels docker exec "curl -v http://db:3306" +wheels docker exec "curl -v http://db:3306" --remote ``` **Run a CFML Script** @@ -74,23 +80,177 @@ wheels docker exec "bash" --interactive **Tail Logs on Remote Server** View the live application log file on a specific remote server. ```bash -wheels docker exec "tail -f logs/application.log" --servers=web1.example.com +wheels docker exec "tail -f logs/application.log" --servers=web1.example.com --remote ``` **Run Command on All Servers** Execute a command across all servers defined in your configuration (non-interactive only). ```bash # Clear cache on all servers -wheels docker exec "box task run clearCache" +wheels docker exec "box task run clearCache" --remote ``` **Target Specific Service** Run a command inside the database container instead of the app container. ```bash -wheels docker exec "ps aux" --service=db +wheels docker exec "ps aux" --service=db --remote ``` ## Notes * **Interactive Mode**: When using `--interactive`, you can only target a single server. Attempting to run interactive commands on multiple servers simultaneously will result in an error. * **Service Discovery**: The command attempts to find the container name automatically. It looks for containers matching your project name and service (e.g., `myproject-app`, `myproject-db`). It also correctly identifies active containers in Blue/Green deployments (e.g., `myproject-app-green`). + +## Best Practices + +### 1. Quote All Complex Commands + +Always quote commands to avoid shell interpretation issues: + +```bash +wheels docker exec "command arg1 arg2" +``` + +### 2. Test Commands Locally First + +Before running on remote servers: + +```bash +# Test locally +wheels docker exec "ls -la" --local + +# Then run remotely +wheels docker exec "ls -la" +``` + +### 3. Use Specific Server Selection + +For interactive sessions, always specify a single server: + +```bash +wheels docker exec "/bin/bash" --remote --servers=web1.example.com --interactive +``` + +### 4. Specify Service for Database Commands + +Always use `service=db` for database operations: + +```bash +wheels docker exec "mysql -u root -p" --service=db --interactive +``` + +### 5. Avoid Long-Running Commands on Multiple Servers + +Long commands on multiple servers can be difficult to monitor: + +```bash +# Better: Run on one server at a time +wheels docker exec "long-running-task" --servers=web1.example.com +wheels docker exec "long-running-task" ---servers=web2.example.com +``` + +### 6. Use Non-Interactive Mode for Scripts + +For automated tasks, avoid interactive mode: + +```bash +wheels docker exec "mysql -u root -ppass -e 'SELECT COUNT(*) FROM users;'" --service=db +``` + +### 7. Check Exit Codes + +The command returns Docker exec exit codes (130 = Ctrl+C is acceptable): + +```bash +wheels docker exec "test -f /app/config.cfm" && echo "File exists" +``` + +### 8. Be Careful with Destructive Commands + +Always double-check before running destructive operations: + +```bash +# Dangerous! Make sure you mean it +wheels docker exec "rm -rf /app/temp/*" +``` + +### 9. Use Absolute Paths + +Avoid confusion by using absolute paths: + +```bash +wheels docker exec "ls /app/logs" instead of "ls logs" +``` + +### 10. Handle Secrets Carefully + +Avoid putting passwords in commands when possible: + +```bash +# Bad: Password visible in command +wheels docker exec "mysql -u root -pMyPassword" --service=db + +# Better: Use interactive mode +wheels docker exec "mysql -u root -p" --service=db --interactive +``` + +## Troubleshooting + +### Command Hangs + +If a command hangs: +1. Press `Ctrl+C` to interrupt +2. Check if command requires input +3. Use `--interactive` if needed +4. Verify container is responsive: + ```bash + wheels docker exec "echo test" + ``` + +### Output Not Showing + +If output doesn't appear: +1. Check if command produces output: + ```bash + wheels docker exec "ls -la" + ``` +2. Redirect stderr to stdout: + ```bash + wheels docker exec "command 2>&1" + ``` + +### Interactive Mode Not Working + +If interactive mode fails: +1. Verify single server selection +2. Check TTY support: + ```bash + wheels docker exec "tty" --interactive + ``` +3. Test SSH TTY allocation: + ```bash + ssh -t user@server + ``` + +## Additional Notes + +- Commands are executed inside running containers using `docker exec` +- SSH connections use key-based authentication +- Exit code 0 indicates success, 130 indicates Ctrl+C interrupt (acceptable) +- Interactive mode requires TTY allocation on both SSH and Docker levels +- Multiple server execution is sequential, not parallel +- Commands run with the container's default user (usually root or app user) +- Working directory depends on container's WORKDIR setting +- Container must be running for exec to work +- Blue/Green deployment containers are automatically detected +- Command output is streamed in real-time + +## Related Commands + +- [wheels docker init](docker-init.md) - Initialize Docker configuration files +- [wheels docker build](docker-build.md) - Build Docker images +- [wheels docker deploy](docker-deploy.md) - Build and deploy Docker containers +- [wheels docker logs](docker-logs.md) - View container logs +- [wheels docker stop](docker-stop.md) - Stop Docker containers + +**Note**: This command is part of the Wheels CLI tool suite for Docker management. \ No newline at end of file diff --git a/docs/src/command-line-tools/commands/docker/docker-init.md b/docs/src/command-line-tools/commands/docker/docker-init.md index 471201e3d6..f063af31bf 100644 --- a/docs/src/command-line-tools/commands/docker/docker-init.md +++ b/docs/src/command-line-tools/commands/docker/docker-init.md @@ -21,7 +21,7 @@ The command follows an **interactive flow** that prompts you for project-specifi | `--db` | Database system to use | `mysql` | `h2`, `sqlite`, `mysql`, `postgres`, `mssql`, `oracle` | | `--dbVersion` | Database version to use | varies by db | Any valid version for the selected database | | `--cfengine` | CFML engine to use | `lucee` | `lucee`, `adobe` | -| `--cfVersion` | CFML engine version | `6` | Any valid version for the selected engine | +| `--cfVersion` | CFML engine version | varies by server | lucee: 5,6,7 adobe: 2018, 2021, 2023, 2025 | | `--port` | Custom application port (overrides server.json) | from server.json or `8080` | Any valid port number | | `--force` | Overwrite existing Docker files without confirmation | `false` | `true`, `false` | | `--production` | Generate production-ready configuration | `false` | `true`, `false` | @@ -796,8 +796,13 @@ docker-compose up -d --build --force-recreate ## See Also -- [wheels docker deploy](docker-deploy.md) - Deploy using Docker -- [wheels deploy](../deploy/deploy.md) - General deployment commands +- [wheels docker build](docker-build.md) - Initialize Docker configuration files +- [wheels docker deploy](docker-deploy.md) - Build and deploy Docker containers +- [wheels docker logs](docker-logs.md) - View container logs +- [wheels docker exec](docker-exec.md) - Execute commands in containers +- [wheels docker stop](docker-stop.md) - Stop Docker containers - [CommandBox Docker Images](https://hub.docker.com/r/ortussolutions/commandbox) - Official CommandBox images - [Docker Compose Documentation](https://docs.docker.com/compose/) - Docker Compose reference - [Nginx Documentation](https://nginx.org/en/docs/) - Nginx configuration reference + +**Note**: This command is part of the Wheels CLI tool suite for Docker management. \ No newline at end of file diff --git a/docs/src/command-line-tools/commands/docker/docker-logs.md b/docs/src/command-line-tools/commands/docker/docker-logs.md index 4d973932a9..9a51e02477 100644 --- a/docs/src/command-line-tools/commands/docker/docker-logs.md +++ b/docs/src/command-line-tools/commands/docker/docker-logs.md @@ -20,12 +20,13 @@ The `wheels docker logs` command fetches and displays logs from running containe | Option | Description | Default | |--------|-------------|---------| -| `--servers` | Specific servers to check (defaults to `config/deploy.yml`) | `""` | -| `--local` | Fetch logs from local Docker environment | `false` | +| `--local` | Fetch logs from local Docker environment | `true` | | `--tail` | Number of lines to show | `100` | | `--follow` | Follow log output in real-time | `false` | -| `--service` | Service to show logs for: `app` or `db` | `app` | +| `--service` | Service to show logs for: `wheels-app` or `wheels-db` | `wheels-app` | | `--since` | Show logs since timestamp | `""` | +| `--remote` | Fetch logs from remote Docker environment | `false` | +| `--servers` | Specific servers to check (defaults to `config/deploy.yml`) | `""` | ## Detailed Examples @@ -46,7 +47,7 @@ wheels docker logs --local **View Database Logs** Fetches logs from the database service container. ```bash -wheels docker logs --service=db +wheels docker logs --service=wheels-db ``` ### Real-Time Monitoring @@ -89,10 +90,20 @@ wheels docker logs --tail=50 --servers=problematic-server.com **Checking Database Errors** If the app reports database connection errors, check the DB logs for rejection messages or startup errors. ```bash -wheels docker logs --service=db --tail=100 +wheels docker logs --service=wheels-db --tail=100 ``` ## Notes * **Service Discovery**: Automatically finds the correct container, handling Blue/Green deployment naming (e.g., it knows to look at `myapp-green` if that's the active container). * **SSH**: Uses your local SSH configuration. Ensure you have access to the servers. + +## Related Commands + +- [wheels docker init](docker-init.md) - Initialize Docker configuration files +- [wheels docker build](docker-build.md) - Build Docker images +- [wheels docker deploy](docker-deploy.md) - Build and deploy Docker containers +- [wheels docker exec](docker-exec.md) - Execute commands in containers +- [wheels docker stop](docker-stop.md) - Stop Docker containers + +**Note**: This command is part of the Wheels CLI tool suite for Docker management. \ No newline at end of file diff --git a/docs/src/command-line-tools/commands/docker/docker-stop.md b/docs/src/command-line-tools/commands/docker/docker-stop.md index 768d41c9fc..29ff32cdb4 100644 --- a/docs/src/command-line-tools/commands/docker/docker-stop.md +++ b/docs/src/command-line-tools/commands/docker/docker-stop.md @@ -19,7 +19,7 @@ The `wheels docker stop` command halts running containers. It supports stopping | Option | Description | Default | |--------|-------------|---------| -| `--local` | Stop containers on local machine | `false` | +| `--local` | Stop containers on local machine | `true` | | `--remote` | Stop containers on remote server(s) | `false` | | `--servers` | Specific servers to stop (defaults to `config/deploy.yml`) | `""` | | `--removeContainer` | Also remove the container after stopping | `false` | @@ -69,3 +69,123 @@ wheels docker stop --remote --removeContainer 3. **Removal (`--removeContainer`)**: * If using standard Docker, this flag triggers `docker rm [container_name]` after stopping. * If using Docker Compose, `down` already removes containers, so this flag is redundant but harmless. + +## Best Practices + +### 1. Always Test Locally First + +Before stopping remote servers: +```bash +# Test the stop command locally +wheels docker stop --local + +# Verify it works as expected +docker ps -a +``` + +### 2. Use Server Selection for Staged Rollouts + +Don't stop all servers at once in production: +```bash +# Stop first half +wheels docker stop --remote --servers=1,2 + +# Verify, then stop second half +wheels docker stop --remote --servers=3,4 +``` + +### 3. Remove Containers During Maintenance + +For major updates or troubleshooting: +```bash +wheels docker stop --remote --removeContainer +``` + +This ensures a completely clean slate for the next deployment. + +### 4. Check Status After Stopping + +Verify containers are stopped: +```bash +wheels docker stop --local +docker ps -a +``` + +## Common Workflows + +### Development Cycle + +```bash +# 1. Stop local containers +wheels docker stop --local --removeContainer + +# 2. Make code changes +# ... edit files ... + +# 3. Rebuild and restart +wheels docker build --local +wheels docker deploy --local +``` + +### Deployment Update + +```bash +# 1. Build new image +wheels docker build --remote + +# 2. Stop old containers (staged) +wheels docker stop --remote --servers=1,3,5 + +# 3. Deploy new version +wheels docker deploy --remote --servers=1,3,5 + +# 4. Stop remaining servers +wheels docker stop --remote --servers=2,4,6 + +# 5. Deploy to remaining servers +wheels docker deploy --remote --servers=2,4,6 +``` + +### Emergency Rollback + +```bash +# 1. Stop all production servers +wheels docker stop --remote --servers=1,2,3,4 + +# 2. Deploy previous version +wheels docker deploy --remote --servers=production-servers.json + +# 3. Verify deployment +wheels docker logs --remote --servers=1 --tail=100 +``` + +### Complete Cleanup + +```bash +# Stop and remove everything +wheels docker stop --local --removeContainer +wheels docker stop --remote --removeContainer +``` + +## Additional Notes + +- Default mode is `--local` if neither `--local` nor `--remote` is specified +- Cannot specify both `--local` and `--remote` simultaneously +- SSH connections use key-based authentication (password auth not supported) +- Containers are stopped gracefully with default Docker stop timeout (10 seconds) +- Docker Compose down removes networks automatically +- The `--removeContainer` flag only removes the container, not the image +- Server selection is 1-indexed (starts at 1, not 0) +- Invalid server numbers are skipped with warnings +- The command attempts to detect sudo requirements automatically +- Exit status reflects overall operation success + +## Related Commands + +- [wheels docker init](docker-init.md) - Initialize Docker configuration files +- [wheels docker build](docker-build.md) - Build Docker images +- [wheels docker deploy](docker-deploy.md) - Build and deploy Docker containers +- [wheels docker logs](docker-logs.md) - View container logs +- [wheels docker exec](docker-exec.md) - Execute commands in containers + +**Note**: This command is part of the Wheels CLI tool suite for Docker management. \ No newline at end of file