diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0196cce --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Ignore OpenTofu / Terraform state and backups +*.tfstate +*.tfstate.backup +.terraform/ +.terraform.lock.hcl + +# Ignore local archive dumps and backups +*.tar +*.zip +gittea/gittea-docker-backup + +# Ignore older source iterations +*.old diff --git a/argocd/main.tf b/argocd/main.tf new file mode 100644 index 0000000..9420823 --- /dev/null +++ b/argocd/main.tf @@ -0,0 +1,135 @@ +resource "kubernetes_namespace" "argocd" { + metadata { + name = "argocd" + } +} + +data "http" "argocd_manifest" { + url = "https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml" +} + +resource "kubernetes_manifest" "argocd_core" { + for_each = { for idx, doc in provider::kubernetes::manifest_decode_multi(data.http.argocd_manifest.response_body) : idx => doc } + + manifest = merge( + each.value, + contains(["ClusterRole", "ClusterRoleBinding", "CustomResourceDefinition", "Namespace"], lookup(each.value, "kind", "")) ? {} : { + metadata = merge( + try(each.value.metadata, {}), + { + namespace = kubernetes_namespace.argocd.metadata[0].name + } + ) + }, + lookup(each.value, "kind", "") == "Service" && lookup(try(each.value.metadata, {}), "name", "") == "argocd-server" ? { + spec = merge( + try(each.value.spec, {}), + { + type = "NodePort" + ports = [ + { + name = "http" + port = 80 + protocol = "TCP" + targetPort = 8080 + nodePort = 30501 + }, + { + name = "https" + port = 443 + protocol = "TCP" + targetPort = 8080 + } + ] + } + ) + } : {} + ) + + field_manager { + force_conflicts = true + } + + depends_on = [kubernetes_namespace.argocd] +} + +resource "kubernetes_secret_v1" "argocd_private_repo" { + metadata { + name = "my-homelab-repo-secret" + namespace = kubernetes_namespace.argocd.metadata[0].name + labels = { + "argocd.argoproj.io/secret-type" = "repository" + } + } + + data = { + type = "git" + url = "http://192.168.100.68:30300/jv/my-homelab-configs" + username = "jv" + password = "Summer12#$" + } + + depends_on = [kubernetes_manifest.argocd_core] +} + +resource "kubernetes_manifest" "argocd_app_registry" { + manifest = { + apiVersion = "argoproj.io/v1alpha1" + kind = "Application" + metadata = { + name = "container-registry" + namespace = "argocd" + } + spec = { + project = "default" + source = { + repoURL = "http://192.168.100.68:30300/jv/my-homelab-configs" + targetRevision = "HEAD" + path = "container-registry" # Points to the folder containing your registry YAMLs + } + destination = { + server = "https://kubernetes.default.svc" + namespace = "container-registry" # Deploys into this namespace + } + syncPolicy = { + automated = { + prune = true + selfHeal = true + } + syncOptions = ["CreateNamespace=true"] + } + } + } + depends_on = [kubernetes_manifest.argocd_core] +} + +resource "kubernetes_manifest" "argocd_app_web_app" { + manifest = { + apiVersion = "argoproj.io/v1alpha1" + kind = "Application" + metadata = { + name = "php-web-app" + namespace = "argocd" + } + spec = { + project = "default" + source = { + repoURL = "http://192.168.100.68:30300/jv/my-homelab-configs" + targetRevision = "HEAD" + path = "web-app" # ArgoCD ignores the PHP/Docker files and grabs web-app.yaml + } + destination = { + server = "https://kubernetes.default.svc" + namespace = "default" + } + syncPolicy = { + automated = { + prune = true + selfHeal = true + } + syncOptions = ["CreateNamespace=true"] + } + } + } + depends_on = [kubernetes_manifest.argocd_core] +} diff --git a/argocd/providers.tf b/argocd/providers.tf new file mode 100644 index 0000000..e3fca5f --- /dev/null +++ b/argocd/providers.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.24" + } + } +} + +provider "kubernetes" { + config_path = "~/.kube/config" + config_context = "kubernetes-admin@kubernetes" +} diff --git a/container-registry/registry-deployment.yaml b/container-registry/registry-deployment.yaml new file mode 100644 index 0000000..4bb0e3f --- /dev/null +++ b/container-registry/registry-deployment.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: local-registry + namespace: container-registry + labels: + app: local-registry +spec: + replicas: 1 + selector: + matchLabels: + app: local-registry + template: + metadata: + labels: + app: local-registry + spec: + containers: + - name: registry + image: registry:2 + ports: + - containerPort: 5000 + volumeMounts: + - name: registry-vol + mountPath: /var/lib/registry + volumes: + - name: registry-vol + persistentVolumeClaim: + claimName: registry-pvc +--- +apiVersion: v1 +kind: Service +metadata: + name: local-registry-svc + namespace: container-registry +spec: + type: NodePort + ports: + - port: 5000 + targetPort: 5000 + nodePort: 30500 + selector: + app: local-registry diff --git a/container-registry/registry-storage.yaml b/container-registry/registry-storage.yaml new file mode 100644 index 0000000..c3eee61 --- /dev/null +++ b/container-registry/registry-storage.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: PersistentVolume +metadata: + name: registry-pv + labels: + type: local +spec: + storageClassName: manual + capacity: + storage: 20Gi + accessModes: + - ReadWriteOnce + hostPath: + path: "/home/k8s-storage/registry-data" +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: registry-pvc + namespace: container-registry +spec: + storageClassName: manual + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 20Gi diff --git a/gittea/docker_gitea_backup.tf b/gittea/docker_gitea_backup.tf new file mode 100644 index 0000000..92e16c7 --- /dev/null +++ b/gittea/docker_gitea_backup.tf @@ -0,0 +1,33 @@ +# Pull down the Gitea image locally via the Pi's Docker engine +resource "docker_image" "gitea_backup" { + name = "gitea/gitea:1.21.7" + keep_locally = true +} + +# Fire up the standalone container wrapper +resource "docker_container" "gitea_backup" { + name = "gitea-backup" + image = docker_image.gitea_backup.image_id + restart = "always" + + env = [ + "USER_UID=1000", + "USER_GID=1000", + "GITEA__database__DB_TYPE=sqlite3" + ] + + ports { + internal = 3000 + external = 3000 + } + + ports { + internal = 22 + external = 2222 + } + + volumes { + host_path = "/home/pi/gitea-backup-data" + container_path = "/data" + } +} diff --git a/gittea/host_prep.tf b/gittea/host_prep.tf new file mode 100644 index 0000000..9c328d3 --- /dev/null +++ b/gittea/host_prep.tf @@ -0,0 +1,18 @@ +resource "null_resource" "node_prep" { + for_each = var.nodes + + connection { + type = "ssh" + user = each.value.user + host = each.value.ip + private_key = file(var.ssh_key_path) + } + + provisioner "remote-exec" { + inline = [ + "sudo apt-get update", + "sudo apt-get install -y open-iscsi util-linux", + "sudo systemctl enable --now iscsid" + ] + } +} diff --git a/gittea/k8s_gitea.tf b/gittea/k8s_gitea.tf new file mode 100644 index 0000000..85df5e2 --- /dev/null +++ b/gittea/k8s_gitea.tf @@ -0,0 +1,134 @@ +resource "kubernetes_namespace" "gitea" { + metadata { + name = "gitea-system" + } +} + +resource "null_resource" "install_longhorn" { + depends_on = [null_resource.node_prep] + + provisioner "local-exec" { + command = "kubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/v1.6.2/deploy/longhorn.yaml" + } +} + +resource "kubernetes_persistent_volume_claim" "gitea_pvc" { + depends_on = [null_resource.install_longhorn] + + metadata { + name = "gitea-pvc" + namespace = kubernetes_namespace.gitea.metadata[0].name + } + spec { + access_modes = ["ReadWriteOnce"] + storage_class_name = "longhorn" + resources { + requests = { + storage = "10Gi" + } + } + } +} + +resource "kubernetes_deployment" "gitea_prod" { + metadata { + name = "gitea-prod" + namespace = kubernetes_namespace.gitea.metadata[0].name + } + spec { + replicas = 1 + selector { + match_labels = { + app = "gitea" + } + } + template { + metadata { + labels = { + app = "gitea" + } + } + spec { + container { + name = "gitea" + image = "gitea/gitea:1.21.7" + + port { + container_port = 3000 + name = "http" + } + port { + container_port = 22 + name = "ssh" + } + + env { + name = "USER_UID" + value = "1000" + } + env { + name = "USER_GID" + value = "1000" + } + env { + name = "GITEA__database__DB_TYPE" + value = "sqlite3" + } + + env { + name = "GITEA__repository__ENABLE_PUSH_MIRROR" + value = "true" + } + + env { + name = "GITEA__repository__ENABLE_PUSH_MIRROR" + value = "true" + } + + # ADD THIS NEW BLOCK: + env { + name = "GITEA__migrations__ALLOW_LOCALNETWORKS" + value = "true" + } + + volume_mount { + mount_path = "/data" + name = "gitea-storage" + } + } + + volume { + name = "gitea-storage" + persistent_volume_claim { + claim_name = kubernetes_persistent_volume_claim.gitea_pvc.metadata[0].name + } + } + } + } + } +} + +resource "kubernetes_service" "gitea_service" { + metadata { + name = "gitea-service" + namespace = kubernetes_namespace.gitea.metadata[0].name + } + spec { + type = "NodePort" + selector = { + app = "gitea" + } + port { + name = "http" + port = 3000 + target_port = 3000 + node_port = 30300 + } + port { + name = "ssh" + port = 22 + target_port = 22 + node_port = 32222 + } + } +} diff --git a/gittea/providers.tf b/gittea/providers.tf new file mode 100644 index 0000000..1cf17c0 --- /dev/null +++ b/gittea/providers.tf @@ -0,0 +1,31 @@ +terraform { + required_providers { + null = { + source = "hashicorp/null" + version = "~> 3.2" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.23" + } + } +} + +# Core Cluster API access +provider "kubernetes" { + config_path = "~/.kube/config" + config_context = "kubernetes-admin@kubernetes" +} + +# Remote Docker control over the Raspberry Pi +provider "docker" { + host = "ssh://jv@192.168.100.89:22" + ssh_opts = [ + "-o", "StrictHostKeyChecking=no", + "-i", var.ssh_key_path + ] +} diff --git a/gittea/variables.tf b/gittea/variables.tf new file mode 100644 index 0000000..f1339ec --- /dev/null +++ b/gittea/variables.tf @@ -0,0 +1,26 @@ +variable "home_dir" { + type = string + default = "/home/jv" +} + +variable "ssh_key_path" { + type = string + default = "/home/jv/.ssh/id_ed25519" +} + +variable "nodes" { + type = map(object({ + ip = string + user = string + })) + default = { + raspberrypi = { + ip = "192.168.100.89" + user = "jv" + } + debian_laptop = { + ip = "192.168.100.68" + user = "jv" + } + } +} diff --git a/test b/test deleted file mode 100644 index 9daeafb..0000000 --- a/test +++ /dev/null @@ -1 +0,0 @@ -test diff --git a/web-app/Dockerfile b/web-app/Dockerfile new file mode 100644 index 0000000..6f4fc6e --- /dev/null +++ b/web-app/Dockerfile @@ -0,0 +1,36 @@ +FROM alpine:3.19 + +# Install Apache, PHP 8.2, and the SQLite extensions +RUN apk update && apk add --no-cache \ + apache2 \ + php82 \ + php82-apache2 \ + php82-pdo \ + php82-pdo_sqlite \ + php82-curl \ + curl \ + shadow + +# Symlink php82 to php so scripts run naturally if needed +RUN ln -sf /usr/bin/php82 /usr/bin/php + +# Alpine keeps Apache site configs here instead of a2enmod +RUN sed -i 's/#LoadModule rewrite_module/LoadModule rewrite_module/' /etc/apache2/httpd.conf && \ + sed -i 's/#LoadModule headers_module/LoadModule headers_module/' /etc/apache2/httpd.conf + +# Copy files directly into Alpine's default web root +COPY . /var/www/localhost/htdocs/ + +# Set up the database directory permissions +RUN mkdir -p /var/www/localhost/htdocs/db && \ + chown -R apache:apache /var/www/localhost/htdocs/db && \ + chmod -R 755 /var/www/localhost/htdocs/db + +# Match local user permissions for the runtime user (Alpine uses 'apache' instead of 'www-data') +RUN usermod -u 1000 apache && \ + groupmod -g 1000 apache + +EXPOSE 80 + +# Start Apache in the foreground +CMD ["/usr/sbin/httpd", "-D", "FOREGROUND"] diff --git a/web-app/buildkitd.toml b/web-app/buildkitd.toml new file mode 100644 index 0000000..4328ace --- /dev/null +++ b/web-app/buildkitd.toml @@ -0,0 +1,7 @@ +[registry."host.docker.internal:30500"] + http = true + insecure = true + +[registry."192.168.100.68:30500"] + http = true + insecure = true diff --git a/web-app/cv.php b/web-app/cv.php new file mode 100644 index 0000000..48fe2cd --- /dev/null +++ b/web-app/cv.php @@ -0,0 +1,134 @@ + + + + + + CV - <?php echo $text['name']; ?> + + + + + + +
+ +

+

+ +

+

+ +

+ +

+

+ +

+ +

+ +

+ +

+

+ +

+

+ +

+ +

+

+ +

+

+ +

+ +

+

+ +

+

+ +

+ +

+

+ +

+

+ +

+ +

+

+ +

+

+ +

+ +

+

+ +

+

+ +

+ +

+

+ +

+

+ +

+ +
+ + + + + diff --git a/web-app/gnu.webp b/web-app/gnu.webp new file mode 100644 index 0000000..9049060 Binary files /dev/null and b/web-app/gnu.webp differ diff --git a/web-app/images/jv.webp b/web-app/images/jv.webp new file mode 100644 index 0000000..ee3fcf2 Binary files /dev/null and b/web-app/images/jv.webp differ diff --git a/web-app/images/profile.webp b/web-app/images/profile.webp new file mode 100644 index 0000000..ee3fcf2 Binary files /dev/null and b/web-app/images/profile.webp differ diff --git a/web-app/index.php b/web-app/index.php new file mode 100644 index 0000000..69ce52d --- /dev/null +++ b/web-app/index.php @@ -0,0 +1,81 @@ + + + + + + <?php echo $text['name']; ?> + + + + + + +
+
+

+

+ +

+

+ +

+

+ +

+

+ +

+

+ + + + . +

+
+
+ profile +
+
+ + + + + + + diff --git a/web-app/lang/en.php b/web-app/lang/en.php new file mode 100644 index 0000000..c80d0d5 --- /dev/null +++ b/web-app/lang/en.php @@ -0,0 +1,53 @@ + 'Juvenal Diaz', + 'job_title' => 'Site Reliability Developer', + 'contacts' => 'Contacts: +52 449 217 6833, juvenaldiaz522@gmail.com', + + // Nav + 'nav_home' => 'Home', + 'nav_cv' => 'CV', + 'nav_blog' => 'Blog', + + // Index bio + 'bio_intro' => 'I work in infrastructure and reliability, focusing on building systems that are stable, scalable, and easy to operate.', + 'bio_story_1' => 'My interest in technology started with a simple curiosity about how systems behave — especially when they fail. Over time, that curiosity evolved into working with Linux environments, troubleshooting production systems, and improving how services run at scale.', + 'bio_story_2' => "I've spent more than a decade working across cloud platforms and distributed systems. My work has gradually shifted from reactive support to designing and maintaining platforms used by thousands of users, where reliability and clarity matter just as much as performance.", + 'bio_story_3' => 'I tend to approach problems with a strong sense of urgency, but also with a focus on long-term improvement — removing friction, simplifying systems, and preventing issues from recurring.', + 'bio_cta' => 'For a detailed breakdown of my experience, see my', + 'bio_cta_link' => 'CV', + + // CV sections + 'cv_summary_title' => 'Professional Summary', + 'cv_summary' => 'IT Professional with 12+ years of experience, specializing in Linux but also proficient in team management (local and global teams) and user satisfaction. My greatest strength is a sense of urgency which enables me to tackle issues in the most fast and efficient way, always focusing on continuous improvement and service excellence. I also enjoy learning new technologies as required.', + + 'cv_employment_title' => 'Employment History / Activities', + + 'cv_job1_period' => 'Aug 2024 → Current', + 'cv_job1_title' => 'Site Reliability Developer – Oracle | Spectra', + 'cv_job1_desc' => 'Manage a platform as a service (PaaS) that allows developers to build, run, and operate applications in a cloud environment, this service is used by 20,000+ users from internal development teams, it is based on Kubernetes / Terraform. Daily activities include planned maintenance of the platform, emergency changes, continuous improvement of internal tooling and documentation creation.', + + 'cv_job2_period' => 'June 2022 → July 2024', + 'cv_job2_title' => 'Site Reliability Developer – Oracle | Analytics', + 'cv_job2_desc' => 'Attend incidents for Oracle Analytics Cloud reported through Jira for 10,000+ external customers, related to general usage, Linux troubleshooting, SQL query tuning, and services/jobs configuration. Development of internal automation tools using Bash, Python, Ansible, and REST APIs in Bitbucket. SOP update and creation, working in a Scrum/Agile environment leading Continuous Improvement and Automation Epics. Top performer (Low TTM). Part of the onboarding team for new hires. Proposed on-call rotation improvement initiative (vNext).', + + 'cv_job3_period' => 'July 2021 → June 2022', + 'cv_job3_title' => 'Linux Support Engineer - Rackspace', + 'cv_job3_desc' => 'Attend incidents reported through phone calls and internal ticketing systems for several clients related to troubleshooting Linux, MySQL, Apache, NGINX, Varnish, PHP, VMware, DoS attacks, Storage, Backups, Firewalls, etc. Top performer (number of cases/tickets solved) of the MX and US team. Part of the onboarding team for new hires.', + + 'cv_job4_period' => 'March 2020 → July 2021', + 'cv_job4_title' => 'Linux Support Engineer - Softtek | Electronic Arts', + 'cv_job4_desc' => 'Provide infrastructure support for a PCI-compliant platform that handles 4M+ requests per minute with 30+ microservices using containers and orchestration technologies, using DevOps practices. Alerts creation and tuning.', + + 'cv_job5_period' => 'August 2017 → March 2020', + 'cv_job5_title' => 'Cross Functional Manager - Softtek | Electronic Arts', + 'cv_job5_desc' => 'Incident, Problem, Asset Management, and Automation (ITIL-based) process implementation, Continuous Improvement Assessments.', + + 'cv_job6_period' => 'September 2015 → August 2017', + 'cv_job6_title' => 'Linux Support Engineer / Tech Lead - Softtek | General Electric', + 'cv_job6_desc' => 'Incident, Change management, and monitoring for internal applications. Promoted to tech lead after one year in support position.', + + 'cv_job7_period' => 'February 2013 → August 2015', + 'cv_job7_title' => 'Customer Support Agent – Teleperformance | Comcast', + 'cv_job7_desc' => 'Provided customer support services taking calls from the US Southwest area to troubleshoot cable, phone, and internet services.', +]; diff --git a/web-app/lang/nah.php b/web-app/lang/nah.php new file mode 100644 index 0000000..a7fa5a7 --- /dev/null +++ b/web-app/lang/nah.php @@ -0,0 +1,64 @@ + 'Juvenal Diaz', + 'job_title' => 'Tlapixqui Tlahtoa Tlacuilolli', // Guardian of reliable systems + 'contacts' => 'Tlatemoliztli: +52 449 217 6833, juvenaldiaz522@gmail.com', + + // Nav + 'nav_home' => 'Nochan', // My home + 'nav_cv' => 'Notlahcuilol', // My document/record + 'nav_blog' => 'Notlahtol', // My words + + // Index bio + 'bio_intro' => 'Nitlatequitia ipan tlatecpanaliztli ihuan tlayeyecoliztli, niquitta in quenin tiquitasque tlapatlaliztli tlayecoliztli tlamantli nemiztli.', + // I work in infrastructure and reliability, seeing how we build stable, scalable systems + + 'bio_story_1' => 'Notlahtlaniliztli ipan āmantēcayōtl ōpeuh inic niquitta in quenin tlamantli mochihua — oc cequi in quenin polihui. Ic cauitl, in notlahtlaniliztli omochiuh inic nitlatequitia ipan Linux tlamantli, nitlapoa tlaneltoquiliztli, ihuan niquimati in quenin tlatequipanoa tlamantli ipan huey altepetl.', + // My curiosity about technology started by seeing how things work — especially how they fail. + + 'bio_story_2' => 'Ōnimacoc matlactli xihuitl ihuan achi ic tlatequitia ipan mixtlan tlamantilyotl ihuan nepapan tlamantli. Notequitl ōmoyolcuep in tlapalehuiloni itech inic niquitta ihuan niquimati in tlamantli mochihua ipan miec tlacame, in canin tlayeyecoliztli ihuan tlanextiliztli quinequi iuhqui in quenami tlatequipanoliztli.', + // I have spent a decade working on cloud platforms and distributed systems. + + 'bio_story_3' => 'Niquitta tlaneltoquiliztli inic niquixehua tlaneltoquiliztli — oc cequi ipan huehcauh tlapatlaliztli — niquitta in quenin ticchihua tlamantli nemiztli, tiquixehua quezqui tlamantli, ihuan ticmati in quenin ahmo mochihua occeppa.', + // I approach problems with urgency — also focusing on long-term improvement. + + 'bio_cta' => 'Inic ticita notequitl moch, xiquitta', + 'bio_cta_link' => 'Notlahcuilol', + + // CV sections + 'cv_summary_title' => 'Notequitl Tlahcuilolli', + 'cv_summary' => 'Tlapixqui āmantēcayōtl inic matlactli omome xihuitl, motemachtia Linux ihuan quimatia tlatecpanaliztli (ipan altepetl ihuan tlalpan) ihuan tlahtoa tlacame. Nohueyitequitl ic tlaneltoquiliztli niquixehua tlaneltoquiliztli inic achi ic niquichihua, moch ica tlapatlaliztli ihuan tlatequipanoliztli. Nixpampa nimati āmantēcayōtl yancuic quenin monequi.', + + 'cv_employment_title' => 'Notequitl Tlahcuilolli / Tlatequipanoliztli', + + 'cv_job1_period' => 'Ago 2024 → Axcan', + 'cv_job1_title' => 'Tlapixqui Tlahtoa Tlacuilolli – Oracle | Spectra', + 'cv_job1_desc' => 'Nitlapiya ce tlamantli inic tlatequipanoliztli (PaaS) in quimatia tlatecpanime inic quichihuasque, quimochihuiltisque ihuan quipixque tlamantli ipan mixtlan. In tlatequipanoliztli quimatia matlactli ompoalli tlamantilyotl ipan Kubernetes / Terraform. Cemilhuitl tlatequipanoliztli: tlachihualiztli, tlapatlaliztli, ihuan tlahcuilolli.', + + 'cv_job2_period' => 'Junio 2022 → Julio 2024', + 'cv_job2_title' => 'Tlapixqui Tlahtoa Tlacuilolli – Oracle | Analytics', + 'cv_job2_desc' => 'Nitlatoa tlaneltoquiliztli ipan Oracle Analytics Cloud inic matlactli tlamantilyotl tlacame, Linux, SQL, ihuan tlatequipanoliztli. Nitlachihua tlamantli inic Bash, Python, Ansible, ihuan REST APIs ipan Bitbucket. Scrum/Agile tlatequipanoliztli.', + + 'cv_job3_period' => 'Julio 2021 → Junio 2022', + 'cv_job3_title' => 'Linux Tlapalehuiani - Rackspace', + 'cv_job3_desc' => 'Nitlatoa tlaneltoquiliztli inic miec tlacame ipan Linux, MySQL, Apache, NGINX, Varnish, PHP, VMware, DoS, ihuan occequi. Huey tlapalehuiani ipan MX ihuan US.', + + 'cv_job4_period' => 'Marzo 2020 → Julio 2021', + 'cv_job4_title' => 'Linux Tlapalehuiani - Softtek | Electronic Arts', + 'cv_job4_desc' => 'Nitlapalehua tlamantilyotl inic PCI-compliant tlamantli in quichihua nauhpoalli tlamantilyotl ipan cempoallamatl inic DevOps.', + + 'cv_job5_period' => 'Agosto 2017 → Marzo 2020', + 'cv_job5_title' => 'Tlatecpanqui - Softtek | Electronic Arts', + 'cv_job5_desc' => 'ITIL tlatecpanaliztli, tlapatlaliztli, ihuan tlamantli tlatequipanoliztli.', + + 'cv_job6_period' => 'Septiembre 2015 → Agosto 2017', + 'cv_job6_title' => 'Linux Tlapalehuiani / Tech Lead - Softtek | General Electric', + 'cv_job6_desc' => 'Tlaneltoquiliztli, tlapatlaliztli, ihuan tlachihualiztli ipan tlamantli. Omotlacxitilli tech lead inic ce xihuitl.', + + 'cv_job7_period' => 'Febrero 2013 → Agosto 2015', + 'cv_job7_title' => 'Tlapalehuiani Tlacame – Teleperformance | Comcast', + 'cv_job7_desc' => 'Nitlapalehua tlacame ipan US inic cable, tepoztli, ihuan tlahtoa tlamantli.', +]; diff --git a/web-app/lang_helper.php b/web-app/lang_helper.php new file mode 100644 index 0000000..50f85e9 --- /dev/null +++ b/web-app/lang_helper.php @@ -0,0 +1,30 @@ + basename($f, '.php'), + glob(__DIR__ . '/lang/*.php') +); + +function getLang($supported) { + if (isset($_GET['lang']) && in_array($_GET['lang'], $supported)) { + return $_GET['lang']; + } + $browser = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'nah', 0, 2); + return in_array($browser, $supported) ? $browser : 'nah'; +} + +$lang = getLang($availableLangs); + +$file = __DIR__ . "/lang/$lang.php"; +if (!file_exists($file)) { + $lang = 'nah'; + $file = __DIR__ . "/lang/nah.php"; +} + +$text = include $file; + +// Always load English as translation source +$en = include __DIR__ . '/lang/en.php'; diff --git a/web-app/partials/footer.php b/web-app/partials/footer.php new file mode 100644 index 0000000..b557365 --- /dev/null +++ b/web-app/partials/footer.php @@ -0,0 +1,3 @@ + diff --git a/web-app/partials/header.php b/web-app/partials/header.php new file mode 100644 index 0000000..6ea4d7f --- /dev/null +++ b/web-app/partials/header.php @@ -0,0 +1,11 @@ + diff --git a/web-app/partials/translation_ui.php b/web-app/partials/translation_ui.php new file mode 100644 index 0000000..b3795eb --- /dev/null +++ b/web-app/partials/translation_ui.php @@ -0,0 +1,52 @@ + +// Requires: $lang, $availableLangs — provided by lang_helper.php +?> + +
+ +
+
+ + + diff --git a/web-app/save_lang.php b/web-app/save_lang.php new file mode 100644 index 0000000..208d58d --- /dev/null +++ b/web-app/save_lang.php @@ -0,0 +1,58 @@ + 'Method not allowed']); + exit; +} + +$body = json_decode(file_get_contents('php://input'), true); + +if (!isset($body['lang'], $body['translations'])) { + http_response_code(400); + echo json_encode(['error' => 'Missing lang or translations']); + exit; +} + +$lang = preg_replace('/[^a-z]/', '', strtolower($body['lang'])); +$translations = $body['translations']; + +if (strlen($lang) < 2 || strlen($lang) > 5) { + http_response_code(400); + echo json_encode(['error' => 'Invalid lang code']); + exit; +} + +// Load English as base — ensures every key exists even if not translated +$base = include __DIR__ . '/lang/en.php'; + +// Overwrite only keys that were translated +foreach ($translations as $key => $value) { + if (array_key_exists($key, $base)) { + $base[$key] = $value; + } +} + +// Build PHP file content +$lines = [" $value) { + $key = addslashes($key); + $value = addslashes($value); + $lines[] = " '$key' => '$value',"; +} +$lines[] = "];"; +$content = implode("\n", $lines) . "\n"; + +$path = __DIR__ . "/lang/$lang.php"; +if (file_put_contents($path, $content) === false) { + http_response_code(500); + echo json_encode(['error' => 'Could not write file — check permissions on lang/']); + exit; +} + +echo json_encode(['success' => true, 'lang' => $lang, 'path' => "lang/$lang.php"]); diff --git a/web-app/styles.css b/web-app/styles.css new file mode 100644 index 0000000..96823be --- /dev/null +++ b/web-app/styles.css @@ -0,0 +1,205 @@ +body { + font-family: Arial, sans-serif; + margin: 40px; + background-color: #f8f9fa; + color: #333; +} + +.container { + max-width: 900px; + margin: auto; + background: #fff; + padding: 30px; + border-radius: 12px; + box-shadow: 0 0 10px rgba(0,0,0,0.1); +} + +/* Hero Section Styles */ +.hero-section { + display: flex; + align-items: center; + gap: 40px; + margin-bottom: 30px; + padding: 20px 0; +} + +.profile-container { + flex-shrink: 0; +} + +.profile-img { + width: 200px; + height: 200px; + border-radius: 50%; + object-fit: cover; + border: 5px solid #004085; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.hero-content { + flex: 1; +} + +.hero-content h1 { + font-size: 2.5em; + margin-bottom: 10px; + color: #004085; +} + +.hero-content .title { + font-size: 1.3em; + color: #004085; + font-weight: bold; + margin: 10px 0; +} + +.hero-content .tagline { + font-size: 1.1em; + color: #666; + font-style: italic; + margin: 10px 0 20px 0; +} + +.contact-info { + background: #f8f9fa; + padding: 15px; + border-radius: 8px; + border-left: 4px solid #004085; +} + +.contact-info p { + margin: 5px 0; + color: #333; +} + +/* Welcome Section */ +.welcome { + text-align: center; + margin-bottom: 40px; + padding: 30px 0; + border-top: 1px solid #eee; + border-bottom: 1px solid #eee; +} + +.welcome h2 { + color: #004085; + margin-bottom: 20px; +} + +.welcome p { + font-size: 1.1em; + line-height: 1.6; + max-width: 800px; + margin: 0 auto 30px auto; +} + +.navigation { + margin: 30px 0; +} + +.cv-link { + display: inline-block; + background-color: #004085; + color: white; + padding: 12px 24px; + text-decoration: none; + border-radius: 6px; + font-weight: bold; + transition: all 0.3s ease; + font-size: 1.1em; +} + +.cv-link:hover { + background-color: #002752; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +/* Overview Grid */ +.overview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.overview-item { + background-color: #f8f9fa; + padding: 25px; + border-radius: 8px; + border-left: 4px solid #004085; + text-align: center; +} + +.overview-item h3 { + color: #004085; + margin-top: 0; + margin-bottom: 15px; + font-size: 1.2em; +} + +.overview-item p { + margin: 0; + color: #666; + line-height: 1.5; +} + +/* Responsive Design */ +@media (max-width: 768px) { + body { + margin: 20px; + } + + .hero-section { + flex-direction: column; + text-align: center; + gap: 20px; + } + + .profile-img { + width: 150px; + height: 150px; + } + + .hero-content h1 { + font-size: 2em; + } + + .overview-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .container { + padding: 20px; + } + + body { + margin: 10px; + } +} + +.interview-calendar, .wasm-demo { + background: #f8f9fa; + padding: 20px; + margin: 20px 0; + border-radius: 8px; +} + +.slot-item { + background: white; + padding: 15px; + margin: 10px 0; + border-radius: 6px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.hero-image img { + width: 500px; + height: 500px; + object-fit: cover; + border-radius: 50%; +} diff --git a/web-app/translation.js b/web-app/translation.js new file mode 100644 index 0000000..9c41ba9 --- /dev/null +++ b/web-app/translation.js @@ -0,0 +1,198 @@ +// translation.js +// Shared translation logic for all pages. +// Requires these vars injected by partials/translation_ui.php before this file loads: +// OLLAMA_HOST, OLLAMA_MODEL, SAVE_URL, STATIC_LANGS, CURRENT_LANG +// Optional per-page var: +// OTHER_PAGES — array of URLs to also translate in background (e.g. ['/cv.php']) + +const LANG_NAMES = { + en: 'English', es: 'Spanish', hu: 'Hungarian', + ro: 'Romanian', hi: 'Hindi', fr: 'French', + de: 'German', pt: 'Portuguese', it: 'Italian', + zh: 'Chinese', ja: 'Japanese', ko: 'Korean', + ar: 'Arabic', ru: 'Russian', pl: 'Polish', + tr: 'Turkish', sv: 'Swedish', nl: 'Dutch', + fi: 'Finnish', cs: 'Czech', sk: 'Slovak', + nah: 'Nahuatl', +}; + +const urlLang = new URLSearchParams(window.location.search).get('lang'); +const browserLang = (urlLang || navigator.language).slice(0, 2).toLowerCase(); +const langName = LANG_NAMES[browserLang] || browserLang.toUpperCase(); + +const badge = document.getElementById('translation-badge'); +const prompt = document.getElementById('translate-prompt'); +const btn = document.getElementById('translate-btn'); +const actionSpan = document.getElementById('translate-action'); +const detectedSpan = document.getElementById('detected-lang-name'); + +function showBadge(msg) { + if (badge) { + badge.textContent = msg; + badge.style.display = 'block'; + } +} + +function hidePrompt() { + if (prompt) prompt.style.display = 'none'; +} + +// Send all texts in one Ollama request, get back a JSON array +async function translateBatch(texts, targetLang) { + const name = LANG_NAMES[targetLang] || targetLang; + const prompt = `Translate each item to ${name}. +Return ONLY a valid JSON array of translated strings in the same order, no explanations, no markdown. +Example input: ["Hello", "How are you"] +Example output: ["Bonjour", "Comment allez-vous"] + +Input: ${JSON.stringify(texts)}`; + + const response = await fetch(`${OLLAMA_HOST}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: OLLAMA_MODEL, + prompt, + stream: false + }), + signal: AbortSignal.timeout(120000) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + const raw = data.response.trim().replace(/```json|```/g, '').trim(); + const result = JSON.parse(raw); + + if (!Array.isArray(result) || result.length !== texts.length) { + throw new Error(`Unexpected response: got ${result.length}, expected ${texts.length}`); + } + return result; +} + +// Save translated keys — merges with en.php base on the server +async function saveLang(lang, translations) { + try { + const res = await fetch(SAVE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lang, translations }) + }); + const data = await res.json(); + if (data.success) { + console.log(`Saved lang/${lang}.php — static next visit`); + } else { + console.warn('Save failed:', data.error); + } + } catch (e) { + console.warn('Could not save lang file:', e.message); + } +} + +// Collect translations from any document's [data-translate] elements +// Always reads data-en (English source) regardless of displayed language +async function collectPageTranslations(doc, targetLang) { + const elements = [...doc.querySelectorAll('[data-translate]')]; + if (!elements.length) return {}; + + const texts = elements.map(el => el.getAttribute('data-en') || el.textContent.trim()); + const translated = await translateBatch(texts, targetLang); + + const result = {}; + elements.forEach((el, i) => { + const key = el.getAttribute('data-key') || el.getAttribute('data-en'); + result[key] = translated[i]; + }); + return result; +} + +// Main translation flow — triggered by button click +async function doTranslation() { + hidePrompt(); + if (btn) btn.disabled = true; + + const elements = [...document.querySelectorAll('[data-translate]')]; + if (!elements.length) return; + + showBadge('Translating page...'); + elements.forEach(el => el.style.opacity = '0.4'); + + const allTranslations = {}; + + // Step 1 — batch translate current page elements live on screen + // Always uses data-en (English) as source, not whatever is displayed + try { + const texts = elements.map(el => el.getAttribute('data-en') || el.textContent.trim()); + const translated = await translateBatch(texts, browserLang); + + elements.forEach((el, i) => { + el.textContent = translated[i]; + const key = el.getAttribute('data-key') || el.getAttribute('data-en'); + allTranslations[key] = translated[i]; + }); + } catch (e) { + console.warn('Page translation failed:', e.message); + elements.forEach(el => el.style.opacity = '1'); + showBadge('Translation failed — showing original'); + return; + } + + elements.forEach(el => el.style.opacity = '1'); + + // Step 2 — fetch and batch translate OTHER_PAGES in virtual DOM + if (typeof OTHER_PAGES !== 'undefined' && OTHER_PAGES.length > 0) { + for (const url of OTHER_PAGES) { + showBadge(`Translating ${url}...`); + try { + const res = await fetch(url); + const html = await res.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const pageTranslations = await collectPageTranslations(doc, browserLang); + Object.assign(allTranslations, pageTranslations); + } catch (e) { + console.warn(`Could not translate ${url}:`, e.message); + } + } + } + + // Step 3 — save everything in one call + await saveLang(browserLang, allTranslations); + showBadge(`Translated by Ollama / ${OLLAMA_MODEL}`); +} + +// Decide what to show based on three cases: +// +// Case 1 — Page is Nahuatl AND browser is not Nahuatl +// → "Switch to English" button (redirect, no Ollama) +// +// Case 2 — Browser lang already has a static file +// → nothing shown (PHP already served it) +// +// Case 3 — Browser lang is unsupported +// → "Translate to " button (calls Ollama, saves file) + +function initTranslation() { + + // Case 1 — Nahuatl default page, non-Nahuatl visitor + if (CURRENT_LANG === 'nah' && browserLang !== 'nah') { + if (actionSpan) actionSpan.textContent = 'Switch to'; + if (detectedSpan) detectedSpan.textContent = 'English'; + if (prompt) prompt.style.display = 'block'; + if (btn) btn.addEventListener('click', () => { + window.location.href = window.location.pathname + '?lang=en'; + }); + return; + } + + // Case 2 — already supported, PHP served it + if (STATIC_LANGS.includes(browserLang)) return; + + // Case 3 — unsupported lang, offer Ollama translation + if (browserLang === 'nah') return; // actual Nahuatl browser, nothing to do + if (actionSpan) actionSpan.textContent = 'Translate to'; + if (detectedSpan) detectedSpan.textContent = langName; + if (prompt) prompt.style.display = 'block'; + if (btn) btn.addEventListener('click', doTranslation); +} + +initTranslation(); diff --git a/web-app/web-app.yaml b/web-app/web-app.yaml new file mode 100644 index 0000000..20fb5bc --- /dev/null +++ b/web-app/web-app.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: php-website-deployment + namespace: website-production + labels: + app: php-website +spec: + replicas: 2 + selector: + matchLabels: + app: php-website + template: + metadata: + labels: + app: php-website + spec: + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchExpressions: + - key: app + operator: In + values: + - php-website + topologyKey: "kubernetes.io/hostname" + containers: + - name: php-app + image: 192.168.100.68:30500/my-php-app:v1 + imagePullPolicy: Always + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: php-website-service + namespace: website-production +spec: + type: NodePort + ports: + - port: 80 + targetPort: 80 + nodePort: 30080 + selector: + app: php-website