Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 245 additions & 22 deletions cli/src/commands/wheels/docker/DockerCommand.cfc

Large diffs are not rendered by default.

177 changes: 59 additions & 118 deletions cli/src/commands/wheels/docker/build.cfc

Large diffs are not rendered by default.

179 changes: 80 additions & 99 deletions cli/src/commands/wheels/docker/deploy.cfc

Large diffs are not rendered by default.

129 changes: 121 additions & 8 deletions cli/src/commands/wheels/docker/exec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]);

Expand All @@ -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);
}
}

Expand Down
103 changes: 88 additions & 15 deletions cli/src/commands/wheels/docker/init.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down Expand Up @@ -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"]) {
Expand Down Expand Up @@ -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 = '';

Expand Down Expand Up @@ -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#';
Expand Down Expand Up @@ -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))) {
Expand Down
Loading