Files
harmony/harmony/src/modules/lamp.rs
Willem 288129b0c1 wip: added ingress scores for install grafana and install prometheusadded ingress capability to k8s anywhere topology
need to get the domain name dynamically from the topology when building the app to insert into the helm chart
2025-09-08 16:16:01 -04:00

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())
}
}