need to get the domain name dynamically from the topology when building the app to insert into the helm chart
420 lines
13 KiB
Rust
420 lines
13 KiB
Rust
use convert_case::{Case, Casing};
|
|
use dockerfile_builder::instruction::{CMD, COPY, ENV, EXPOSE, FROM, RUN, WORKDIR};
|
|
use dockerfile_builder::{Dockerfile, instruction_builder::EnvBuilder};
|
|
use fqdn::fqdn;
|
|
use harmony_macros::ingress_path;
|
|
use harmony_types::net::Url;
|
|
use non_blank_string_rs::NonBlankString;
|
|
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::str::FromStr;
|
|
|
|
use async_trait::async_trait;
|
|
use log::{debug, info};
|
|
use serde::Serialize;
|
|
|
|
use crate::config::{REGISTRY_PROJECT, REGISTRY_URL};
|
|
use crate::modules::k8s::ingress::K8sIngressScore;
|
|
use crate::topology::HelmCommand;
|
|
use crate::{
|
|
data::Version,
|
|
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
|
inventory::Inventory,
|
|
modules::k8s::deployment::K8sDeploymentScore,
|
|
score::Score,
|
|
topology::{K8sclient, Topology},
|
|
};
|
|
use harmony_types::id::Id;
|
|
|
|
use super::helm::chart::HelmChartScore;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct LAMPScore {
|
|
pub name: String,
|
|
pub domain: Url,
|
|
pub config: LAMPConfig,
|
|
pub php_version: Version,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct LAMPConfig {
|
|
pub project_root: PathBuf,
|
|
pub ssl_enabled: bool,
|
|
pub database_size: Option<String>,
|
|
pub namespace: String,
|
|
}
|
|
|
|
impl Default for LAMPConfig {
|
|
fn default() -> Self {
|
|
LAMPConfig {
|
|
project_root: Path::new("./src").to_path_buf(),
|
|
ssl_enabled: true,
|
|
database_size: None,
|
|
namespace: "harmony-lamp".to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: Topology + K8sclient + HelmCommand> Score<T> for LAMPScore {
|
|
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
|
Box::new(LAMPInterpret {
|
|
score: self.clone(),
|
|
})
|
|
}
|
|
|
|
fn name(&self) -> String {
|
|
"LampScore".to_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct LAMPInterpret {
|
|
score: LAMPScore,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for LAMPInterpret {
|
|
async fn execute(
|
|
&self,
|
|
inventory: &Inventory,
|
|
topology: &T,
|
|
) -> Result<Outcome, InterpretError> {
|
|
let image_name = match self.build_docker_image() {
|
|
Ok(name) => name,
|
|
Err(e) => {
|
|
return Err(InterpretError::new(format!(
|
|
"Could not build LAMP docker image {e}"
|
|
)));
|
|
}
|
|
};
|
|
info!("LAMP docker image built {image_name}");
|
|
|
|
let remote_name = match self.push_docker_image(&image_name) {
|
|
Ok(remote_name) => remote_name,
|
|
Err(e) => {
|
|
return Err(InterpretError::new(format!(
|
|
"Could not push docker image {e}"
|
|
)));
|
|
}
|
|
};
|
|
info!("LAMP docker image pushed to {remote_name}");
|
|
|
|
info!("Deploying database");
|
|
self.deploy_database(inventory, topology).await?;
|
|
|
|
let base_name = self.score.name.to_case(Case::Kebab);
|
|
let secret_name = format!("{}-database-mariadb", base_name);
|
|
|
|
let deployment_score = K8sDeploymentScore {
|
|
name: <LAMPScore as Score<T>>::name(&self.score).to_case(Case::Kebab),
|
|
image: remote_name,
|
|
namespace: self.get_namespace().map(|nbs| nbs.to_string()),
|
|
env_vars: json!([
|
|
{
|
|
"name": "MYSQL_PASSWORD",
|
|
"valueFrom": {
|
|
"secretKeyRef": {
|
|
"name": secret_name,
|
|
"key": "mariadb-root-password"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"name": "MYSQL_HOST",
|
|
"value": secret_name
|
|
},
|
|
]),
|
|
};
|
|
|
|
info!("Deploying score {deployment_score:#?}");
|
|
|
|
deployment_score.interpret(inventory, topology).await?;
|
|
|
|
info!("LAMP deployment_score {deployment_score:?}");
|
|
|
|
let ingress_path = ingress_path!("/");
|
|
|
|
let lamp_ingress = K8sIngressScore {
|
|
name: fqdn!("lamp-ingress"),
|
|
host: fqdn!("test"),
|
|
backend_service: fqdn!(
|
|
<LAMPScore as Score<T>>::name(&self.score)
|
|
.to_case(Case::Kebab)
|
|
.as_str()
|
|
),
|
|
port: 8080,
|
|
path: Some(ingress_path),
|
|
path_type: None,
|
|
ingress_class_name: None,
|
|
namespace: self
|
|
.get_namespace()
|
|
.map(|nbs| fqdn!(nbs.to_string().as_str())),
|
|
};
|
|
|
|
lamp_ingress.interpret(inventory, topology).await?;
|
|
|
|
info!("LAMP lamp_ingress {lamp_ingress:?}");
|
|
|
|
Ok(Outcome::success(
|
|
"Successfully deployed LAMP Stack!".to_string(),
|
|
))
|
|
}
|
|
|
|
fn get_name(&self) -> InterpretName {
|
|
InterpretName::Lamp
|
|
}
|
|
|
|
fn get_version(&self) -> Version {
|
|
todo!()
|
|
}
|
|
|
|
fn get_status(&self) -> InterpretStatus {
|
|
todo!()
|
|
}
|
|
|
|
fn get_children(&self) -> Vec<Id> {
|
|
todo!()
|
|
}
|
|
}
|
|
|
|
impl LAMPInterpret {
|
|
async fn deploy_database<T: Topology + K8sclient + HelmCommand>(
|
|
&self,
|
|
inventory: &Inventory,
|
|
topology: &T,
|
|
) -> Result<Outcome, InterpretError> {
|
|
let mut values_overrides = HashMap::new();
|
|
if let Some(database_size) = self.score.config.database_size.clone() {
|
|
values_overrides.insert(
|
|
NonBlankString::from_str("primary.persistence.size").unwrap(),
|
|
database_size,
|
|
);
|
|
values_overrides.insert(
|
|
NonBlankString::from_str("auth.rootPassword").unwrap(),
|
|
"mariadb-changethis".to_string(),
|
|
);
|
|
}
|
|
let score = HelmChartScore {
|
|
namespace: self.get_namespace(),
|
|
release_name: NonBlankString::from_str(&format!("{}-database", self.score.name))
|
|
.unwrap(),
|
|
chart_name: NonBlankString::from_str(
|
|
"oci://registry-1.docker.io/bitnamicharts/mariadb",
|
|
)
|
|
.unwrap(),
|
|
chart_version: None,
|
|
values_overrides: Some(values_overrides),
|
|
create_namespace: true,
|
|
install_only: false,
|
|
values_yaml: None,
|
|
repository: None,
|
|
};
|
|
|
|
score.interpret(inventory, topology).await
|
|
}
|
|
fn build_dockerfile(&self, score: &LAMPScore) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
|
let mut dockerfile = Dockerfile::new();
|
|
|
|
// Use the PHP version from the score to determine the base image
|
|
let php_version = score.php_version.to_string();
|
|
let php_major_minor = php_version
|
|
.split('.')
|
|
.take(2)
|
|
.collect::<Vec<&str>>()
|
|
.join(".");
|
|
|
|
// Base image selection - using official PHP image with Apache
|
|
dockerfile.push(FROM::from(format!("php:{}-apache", php_major_minor)));
|
|
|
|
// Set environment variables for PHP configuration
|
|
dockerfile.push(ENV::from("PHP_MEMORY_LIMIT=256M"));
|
|
dockerfile.push(ENV::from("PHP_MAX_EXECUTION_TIME=30"));
|
|
dockerfile.push(
|
|
EnvBuilder::builder()
|
|
.key("PHP_ERROR_REPORTING")
|
|
.value("\"E_ERROR | E_WARNING | E_PARSE\"")
|
|
.build()
|
|
.unwrap(),
|
|
);
|
|
|
|
// Install necessary PHP extensions and dependencies
|
|
dockerfile.push(RUN::from(
|
|
"apt-get update && \
|
|
apt-get install -y --no-install-recommends \
|
|
libfreetype6-dev \
|
|
libjpeg62-turbo-dev \
|
|
libpng-dev \
|
|
libzip-dev \
|
|
unzip \
|
|
&& apt-get clean \
|
|
&& rm -rf /var/lib/apt/lists/*",
|
|
));
|
|
|
|
dockerfile.push(RUN::from(
|
|
"docker-php-ext-configure gd --with-freetype --with-jpeg && \
|
|
docker-php-ext-install -j$(nproc) \
|
|
gd \
|
|
mysqli \
|
|
pdo_mysql \
|
|
zip \
|
|
opcache",
|
|
));
|
|
|
|
dockerfile.push(RUN::from(r#"sed -i 's/VirtualHost \*:80/VirtualHost *:8080/' /etc/apache2/sites-available/000-default.conf && \
|
|
sed -i 's/^Listen 80$/Listen 8080/' /etc/apache2/ports.conf"#));
|
|
|
|
// Copy PHP configuration
|
|
dockerfile.push(RUN::from("mkdir -p /usr/local/etc/php/conf.d/"));
|
|
|
|
// Create and copy a custom PHP configuration
|
|
let php_config = r#"
|
|
memory_limit = ${PHP_MEMORY_LIMIT}
|
|
max_execution_time = ${PHP_MAX_EXECUTION_TIME}
|
|
error_reporting = ${PHP_ERROR_REPORTING}
|
|
display_errors = Off
|
|
log_errors = On
|
|
error_log = /dev/stderr
|
|
date.timezone = UTC
|
|
|
|
; Opcache configuration for production
|
|
opcache.enable=1
|
|
opcache.memory_consumption=128
|
|
opcache.interned_strings_buffer=8
|
|
opcache.max_accelerated_files=4000
|
|
opcache.revalidate_freq=2
|
|
opcache.fast_shutdown=1
|
|
"#;
|
|
|
|
// Save this configuration to a temporary file within the project root
|
|
let config_path = Path::new(&score.config.project_root).join("docker-php.ini");
|
|
fs::write(&config_path, php_config)?;
|
|
|
|
// Reference the file within the Docker context (where the build runs)
|
|
dockerfile.push(COPY::from(
|
|
"docker-php.ini /usr/local/etc/php/conf.d/docker-php.ini",
|
|
));
|
|
|
|
// Security hardening
|
|
dockerfile.push(RUN::from(
|
|
"a2enmod headers && \
|
|
a2enmod rewrite && \
|
|
sed -i 's/ServerTokens OS/ServerTokens Prod/' /etc/apache2/conf-enabled/security.conf && \
|
|
sed -i 's/ServerSignature On/ServerSignature Off/' /etc/apache2/conf-enabled/security.conf"
|
|
));
|
|
|
|
// Set env vars
|
|
dockerfile.push(RUN::from(
|
|
"echo 'PassEnv MYSQL_PASSWORD' >> /etc/apache2/sites-available/000-default.conf \
|
|
&& echo 'PassEnv MYSQL_USER' >> /etc/apache2/sites-available/000-default.conf \
|
|
&& echo 'PassEnv MYSQL_HOST' >> /etc/apache2/sites-available/000-default.conf",
|
|
));
|
|
|
|
// Create a dedicated user for running Apache
|
|
dockerfile.push(RUN::from(
|
|
"groupadd -g 1000 appuser && \
|
|
useradd -u 1000 -g appuser -m -s /bin/bash appuser && \
|
|
chown -R appuser:appuser /var/www/html",
|
|
));
|
|
|
|
// Set the working directory
|
|
dockerfile.push(WORKDIR::from("/var/www/html"));
|
|
|
|
// Copy application code from the project root to the container
|
|
// Note: In Dockerfile, the COPY context is relative to the build context
|
|
// We'll handle the actual context in the build_docker_image method
|
|
dockerfile.push(COPY::from(". /var/www/html"));
|
|
|
|
// Fix permissions
|
|
dockerfile.push(RUN::from("chown -R appuser:appuser /var/www/html"));
|
|
|
|
// Expose Apache port
|
|
dockerfile.push(EXPOSE::from("8080/tcp"));
|
|
|
|
// Set the default command
|
|
dockerfile.push(CMD::from("apache2-foreground"));
|
|
|
|
// Save the Dockerfile to disk in the project root
|
|
let dockerfile_path = Path::new(&score.config.project_root).join("Dockerfile");
|
|
fs::write(&dockerfile_path, dockerfile.to_string())?;
|
|
|
|
Ok(dockerfile_path)
|
|
}
|
|
|
|
fn check_output(
|
|
&self,
|
|
output: &std::process::Output,
|
|
msg: &str,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
if !output.status.success() {
|
|
return Err(format!("{msg}: {}", String::from_utf8_lossy(&output.stderr)).into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn push_docker_image(&self, image_name: &str) -> Result<String, Box<dyn std::error::Error>> {
|
|
let full_tag = format!("{}/{}/{}", *REGISTRY_URL, *REGISTRY_PROJECT, &image_name);
|
|
let output = std::process::Command::new("docker")
|
|
.args(["tag", image_name, &full_tag])
|
|
.output()?;
|
|
self.check_output(&output, "Tagging docker image failed")?;
|
|
|
|
debug!(
|
|
"docker tag output {} {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
let output = std::process::Command::new("docker")
|
|
.args(["push", &full_tag])
|
|
.output()?;
|
|
self.check_output(&output, "Pushing docker image failed")?;
|
|
debug!(
|
|
"docker push output {} {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
Ok(full_tag)
|
|
}
|
|
|
|
pub fn build_docker_image(&self) -> Result<String, Box<dyn std::error::Error>> {
|
|
info!("Generating Dockerfile");
|
|
let dockerfile = self.build_dockerfile(&self.score)?;
|
|
|
|
info!(
|
|
"Building Docker image with file {} from root {}",
|
|
dockerfile.to_string_lossy(),
|
|
self.score.config.project_root.to_string_lossy()
|
|
);
|
|
let image_name = format!("{}-php-apache", self.score.name);
|
|
let project_root = &self.score.config.project_root;
|
|
|
|
let output = std::process::Command::new("docker")
|
|
.args([
|
|
"build",
|
|
"--file",
|
|
dockerfile.to_str().unwrap(),
|
|
"-t",
|
|
&image_name,
|
|
project_root.to_str().unwrap(),
|
|
])
|
|
.output()?;
|
|
|
|
if !output.status.success() {
|
|
return Err(format!(
|
|
"Failed to build Docker image: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
)
|
|
.into());
|
|
}
|
|
|
|
Ok(image_name)
|
|
}
|
|
|
|
fn get_namespace(&self) -> Option<NonBlankString> {
|
|
Some(NonBlankString::from_str(&self.score.config.namespace).unwrap())
|
|
}
|
|
}
|