Compare commits

..

4 Commits

8 changed files with 312 additions and 35 deletions
+137 -7
View File
@@ -117,9 +117,9 @@ uv run ansible-playbook -i inventory.ini playbook.yml
### 6.1 Refactoring Inline Configurations to Jinja2 Templates ### 6.1 Refactoring Inline Configurations to Jinja2 Templates
To improve maintainability and separate configuration data from playbook logic, inline file content blocks in `playbook.yml` were refactored into dedicated Jinja2 template files located in the `templates/` directory: To improve maintainability and separate configuration data from playbook logic, inline file content blocks in `playbook.yml` were refactored into dedicated Jinja2 template files located in the `templates/` directory:
* **[templates/Caddyfile.j2](file:///Users/doctor/Git/ovh_gridscale_test_task/templates/Caddyfile.j2)**: Contains Caddy server block definitions and metrics setup. * **[templates/Caddyfile.j2](templates/Caddyfile.j2)**: Contains Caddy server block definitions and metrics setup.
* **[templates/prometheus.yml.j2](file:///Users/doctor/Git/ovh_gridscale_test_task/templates/prometheus.yml.j2)**: Contains global scrape configuration and imports rule files via `rule_files: [/etc/prometheus/alert_rules.yml]`. * **[templates/prometheus.yml.j2](templates/prometheus.yml.j2)**: Contains global scrape configuration and imports rule files via `rule_files: [/etc/prometheus/alert_rules.yml]`.
* **[templates/alert_rules.yml.j2](file:///Users/doctor/Git/ovh_gridscale_test_task/templates/alert_rules.yml.j2)**: Defines Prometheus alert rules for monitoring Caddy service availability and error rates. * **[templates/alert_rules.yml.j2](templates/alert_rules.yml.j2)**: Defines Prometheus alert rules for monitoring Caddy service availability and error rates.
The playbook tasks were updated to use `ansible.builtin.template` instead of `ansible.builtin.copy`. The playbook tasks were updated to use `ansible.builtin.template` instead of `ansible.builtin.copy`.
@@ -154,7 +154,7 @@ resource "null_resource" "ansible_provisioner" {
] ]
provisioner "local-exec" { provisioner "local-exec" {
command = "uv run ansible-playbook -i ${local_file.ansible_inventory.filename} playbook.yml" command = "sleep 60 &&uv run ansible-playbook -i ${local_file.ansible_inventory.filename} playbook.yml"
} }
} }
``` ```
@@ -175,14 +175,14 @@ source .env
``` ```
### 7.2 Python Environment Initialization & Dependency Setup (`uv`) ### 7.2 Python Environment Initialization & Dependency Setup (`uv`)
Initialize Python environment and install Ansible using `uv`: Initialize Python environment and install Ansible along with `passlib` and `bcrypt` (required for Jinja `password_hash` filter) using `uv`:
```bash ```bash
# Initialize uv virtual environment and lockfile # Initialize uv virtual environment and lockfile
uv init --bare uv init --bare
# Add Ansible dependency # Add Ansible, passlib, and bcrypt dependencies (bcrypt<4.0.0 required for passlib compatibility)
uv add "ansible" uv add ansible passlib "bcrypt<4.0.0"
``` ```
### 7.3 Infrastructure Provisioning & Automated Execution ### 7.3 Infrastructure Provisioning & Automated Execution
@@ -206,5 +206,135 @@ uv run ansible-playbook -i inventory.ini -l caddy playbook.yml
uv run ansible-playbook -i inventory.ini -l monitoring playbook.yml uv run ansible-playbook -i inventory.ini -l monitoring playbook.yml
``` ```
---
## 8. Additional Task: Nginx Integration and Custom Improvements
### 8.1 Nginx Infrastructure & Ansible Deployment
Expanded the infrastructure to provision a dedicated Nginx web server node (`nginx01`) alongside Caddy and Monitoring nodes:
* **Terraform Infrastructure**: Defined `gridscale_storage.storage_nginx` and `gridscale_server.server_nginx` attached to the internal private network (`gridscale_network.network_internal`) with port 80 allowed internally.
* **Ansible Automation**: Added an `nginx` play in `playbook.yml` installing Nginx and deploying a custom `index.html.j2` page displaying host metadata.
### 8.2 HTTPS Reverse Proxying via `sslip.io` Dynamic Subdomains
Configured Caddy as the central ingress gateway utilizing `sslip.io` dynamic DNS wildcard resolution to route traffic over HTTPS automatically:
* `caddy.<ip>.sslip.io` $\rightarrow$ Caddy default static page
* `nginx.<ip>.sslip.io` $\rightarrow$ Reverse proxies to private Nginx IP (`{{ private_ip_nginx }}:80`)
* `monitoring.<ip>.sslip.io` $\rightarrow$ Reverse proxies to internal Prometheus IP (`{{ private_ip_monitoring }}:9090`)
### 8.3 Prometheus Subdomain Security via Basic Authentication & Health Checks
Secured access to the Prometheus monitoring interface by embedding Caddy's `basic_auth` directive and active health checking directly into the monitoring subdomain block in `templates/Caddyfile.j2`:
```caddy
monitoring.{{ ansible_default_ipv4.address | replace(".", "-") }}.sslip.io {
reverse_proxy {{ private_ip_monitoring }}:9090 {
health_uri /-/healthy
health_interval 10s
health_timeout 5s
}
basic_auth {
{{ demo_user }} {{ demo_pass | password_hash('bcrypt') }}
}
}
```
### 8.4 Python Dependencies & `bcrypt` Version Pinning
Encountered and resolved a critical dependency issue during Ansible template rendering for `password_hash('bcrypt')`:
* **Issue**: `passlib` (1.7.4) failed with `AttributeError: module 'bcrypt' has no attribute '__about__'` when paired with `bcrypt >= 4.0.0`, resulting in a `password cannot be longer than 72 bytes` exception.
* **Fix**: Pinned `bcrypt` to `<4.0.0` (`bcrypt==3.2.2`) in `pyproject.toml` and installed via `uv`:
```bash
uv add ansible passlib "bcrypt<4.0.0"
```
### 8.5 Direct Execution Output via Terraform `nonsensitive()`
To display all endpoint URLs and auto-generated credentials immediately upon `terraform apply` completion without manual CLI commands:
* Wrapped `random_password.demo_pass.result` using Terraform's `nonsensitive()` function in `outputs.tf`:
```hcl
output "website_urls" {
description = "Website URLs"
value = [
"Caddy: https://caddy.${replace(gridscale_ipv4.public_ipv4_caddy.ip, ".", "-")}.sslip.io",
"Nginx behind Caddy: https://nginx.${replace(gridscale_ipv4.public_ipv4_caddy.ip, ".", "-")}.sslip.io",
"Prometheus behind Caddy: https://monitoring.${replace(gridscale_ipv4.public_ipv4_caddy.ip, ".", "-")}.sslip.io",
"Login for Monitoring: demo ${nonsensitive(random_password.demo_pass.result)}"
]
}
```
### 8.6 Upstream Health Checks & Fault Tolerance in Caddy
Configured active health checking inside Caddy's `reverse_proxy` directives in `templates/Caddyfile.j2` to ensure fault tolerance for upstream services:
* **Nginx Health Probes**: Periodic `health_uri /` checks every 10 seconds (5s timeout).
* **Prometheus Health Probes**: Periodic `health_uri /-/healthy` checks every 10 seconds (5s timeout).
* If an upstream service or node becomes unresponsive, Caddy automatically marks the backend as unhealthy and prevents routing traffic to dead backends.
---
## 9. Summary: Service Execution & Access Guide
### 9.1 Quickstart Execution Steps
1. **Configure Environment Credentials**:
Copy `.env.example` to `.env`, set your Gridscale API credentials, and export them:
```bash
cp .env.example .env
# Edit .env with your credentials
source .env
```
2. **Initialize Python Environment & Dependencies**:
Initialize `uv` environment and install Ansible, `passlib`, and `bcrypt`:
```bash
uv init --bare
uv add ansible passlib "bcrypt<4.0.0"
```
3. **Provision Infrastructure & Execute Playbooks**:
Apply Terraform to provision all cloud resources, generate `inventory.ini`, and automatically trigger Ansible configuration management via `null_resource`:
```bash
terraform init
terraform apply -auto-approve
```
---
### 9.2 Service Access & Endpoints Summary
Upon successful completion, Terraform outputs all HTTPS endpoints and Basic Authentication credentials:
| Service | Host / Subdomain Format | Target / Upstream | Access Details |
| :--- | :--- | :--- | :--- |
| **Caddy Ingress** | `https://caddy.<caddy-ip-with-dashes>.sslip.io` | Local Caddy static server | Public landing page |
| **Nginx Web Server** | `https://nginx.<caddy-ip-with-dashes>.sslip.io` | `http://<nginx-ip-private>:80` | Reverse proxied Hello World page |
| **Prometheus Monitoring** | `https://monitoring.<caddy-ip-with-dashes>.sslip.io` | `http://<monitoring-ip-private>:9090` with caddy `basic_auth` | **Username**: `demo`<br>**Password**: Auto-generated password from Terraform output |
*(Optional)* Display all endpoint URLs and active credentials at any time:
```bash
terraform output website_urls
```
---
## 10. Exercise Requirement Traceability & Execution Summary
The following matrix maps every requirement, deliverable, and bonus task from the Gridscale hiring exercise prompt to its exact technical implementation, file location, and execution method in this repository:
| Exercise Requirement / Task | Status | Implementation Details & File Location | Execution & Automation Method |
| :--- | :---: | :--- | :--- |
| **Install & Configure Caddy Server** | Completed | Provisioned `caddy01` VM on Debian 13 in [resources.tf](resources.tf). Installed Caddy via Deb822 repo in [playbook.yml](playbook.yml#L41-L80). | Automated via Ansible `ansible.builtin.apt` & `ansible.builtin.template` |
| **Serve Sample Web Application** | Completed | Configured static file server (`root * /usr/share/caddy`, `file_server`) in [templates/Caddyfile.j2](templates/Caddyfile.j2#L10-L13). | Rendered via Ansible template `Caddyfile.j2` |
| **Prometheus Monitoring Setup** | Completed | Provisioned `monitoring01` VM in [resources.tf](resources.tf). Installed Prometheus in [playbook.yml](playbook.yml#L81-L120). | Configured scrape job `caddy` targeting `{{ private_ip_caddy }}:2019` |
| **Metrics Collection (Codes, Latency, Uptime)** | Completed | Enabled Caddy metrics directive `metrics /metrics` with `per_host` and `observe_catchall_hosts` on Admin API `:2019` in [templates/Caddyfile.j2](templates/Caddyfile.j2#L1-L9). | Scraped every 15s by Prometheus over internal network (`192.168.121.0/27`) |
| **Prometheus Alerting Pipeline & Rules** | Completed | Defined `Caddy Down` (`up{job="caddy"} == 0`) and `CaddyHighHTTP4xxErrorRateService` (> 5% 4xx rate) rules in [templates/alert_rules.yml.j2](templates/alert_rules.yml.j2). Imported in [templates/prometheus.yml.j2](templates/prometheus.yml.j2). | Deployed to `/etc/prometheus/alert_rules.yml` via Ansible |
| **IaC Automation (Terraform + Ansible)** | Completed | 100% automated with Terraform (Gridscale provider) & Ansible. Built `null_resource.ansible_provisioner` in [inventory.tf](inventory.tf#L29-L43) to execute Ansible automatically. | Runs end-to-end via `terraform apply -auto-approve` |
| **Additional Task: Nginx "Hello World" VM** | Completed | Provisioned `nginx01` VM in [resources.tf](resources.tf). Installed Nginx and deployed `index.html.j2` in [playbook.yml](playbook.yml#L121-L140). | Bound strictly to internal private IP (`192.168.121.5:80`) |
| **Additional Task: Caddy Reverse Proxy & Routing** | Completed | Configured subdomain reverse proxy routing in [templates/Caddyfile.j2](templates/Caddyfile.j2#L15-L31): `nginx.<ip>.sslip.io` $\rightarrow$ Nginx, `monitoring.<ip>.sslip.io` $\rightarrow$ Prometheus. | Managed via Caddy ingress rules |
| **Bonus: Upstream Health Checks & Fault Tolerance** | Completed | Added `health_uri /` (Nginx) and `health_uri /-/healthy` (Prometheus) with 10s intervals in [templates/Caddyfile.j2](templates/Caddyfile.j2#L16-L28). | Probed actively by Caddy to bypass dead nodes |
| **Bonus: SSL Termination & Client Encryption** | Completed | Used `sslip.io` dynamic wildcard subdomains on port 443 in [templates/Caddyfile.j2](templates/Caddyfile.j2). | Caddy Auto-HTTPS manages Let's Encrypt TLS termination |
| **Deliverables: Endpoints & Output Credentials** | Completed | Configured unmasked output using `nonsensitive()` in [outputs.tf](outputs.tf). | Printed directly to terminal on `terraform apply` |
+14 -2
View File
@@ -1,3 +1,8 @@
resource "random_password" "demo_pass" {
length = 16
special = true
}
resource "local_file" "ansible_inventory" { resource "local_file" "ansible_inventory" {
filename = "${path.module}/inventory.ini" filename = "${path.module}/inventory.ini"
file_permission = "0644" file_permission = "0644"
@@ -6,12 +11,18 @@ resource "local_file" "ansible_inventory" {
ansible_ssh_extra_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' ansible_ssh_extra_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'
private_ip_caddy='${gridscale_server.server_caddy.network[1].auto_assigned_ip}' private_ip_caddy='${gridscale_server.server_caddy.network[1].auto_assigned_ip}'
private_ip_monitoring='${gridscale_server.server_monitoring.network[1].auto_assigned_ip}' private_ip_monitoring='${gridscale_server.server_monitoring.network[1].auto_assigned_ip}'
private_ip_nginx='${gridscale_server.server_nginx.network[1].auto_assigned_ip}'
demo_pass="${random_password.demo_pass.result}"
demo_user="demo"
[caddy] [caddy]
${var.caddy_hostname} ansible_host=${gridscale_ipv4.public_ipv4.ip} ansible_user=root ${var.caddy_hostname} ansible_host=${gridscale_ipv4.public_ipv4_caddy.ip} ansible_user=root
[monitoring] [monitoring]
${var.monitoring_hostname} ansible_host=${gridscale_ipv4.public_ipv4_monitoring.ip} ansible_user=root ${var.monitoring_hostname} ansible_host=${gridscale_ipv4.public_ipv4_monitoring.ip} ansible_user=root
[nginx]
${var.nginx_hostname} ansible_host=${gridscale_ipv4.public_ipv4_nginx.ip} ansible_user=root
EOT EOT
} }
@@ -19,7 +30,8 @@ resource "null_resource" "ansible_provisioner" {
depends_on = [ depends_on = [
local_file.ansible_inventory, local_file.ansible_inventory,
gridscale_server.server_caddy, gridscale_server.server_caddy,
gridscale_server.server_monitoring gridscale_server.server_monitoring,
gridscale_server.server_nginx
] ]
provisioner "local-exec" { provisioner "local-exec" {
+20 -4
View File
@@ -1,6 +1,6 @@
output "caddy_public_ip" { output "caddy_public_ip" {
description = "Public IPv4 address of the Caddy server" description = "Public IPv4 address of the Caddy server"
value = gridscale_ipv4.public_ipv4.ip value = gridscale_ipv4.public_ipv4_caddy.ip
} }
output "monitoring_public_ip" { output "monitoring_public_ip" {
@@ -8,6 +8,11 @@ output "monitoring_public_ip" {
value = gridscale_ipv4.public_ipv4_monitoring.ip value = gridscale_ipv4.public_ipv4_monitoring.ip
} }
output "nginx_public_ip" {
description = "Public IPv4 address of the Nginx server"
value = gridscale_ipv4.public_ipv4_nginx.ip
}
output "caddy_private_ip" { output "caddy_private_ip" {
description = "Private IPv4 address of the Caddy server" description = "Private IPv4 address of the Caddy server"
value = gridscale_server.server_caddy.network[1].auto_assigned_ip value = gridscale_server.server_caddy.network[1].auto_assigned_ip
@@ -18,7 +23,18 @@ output "monitoring_private_ip" {
value = gridscale_server.server_monitoring.network[1].auto_assigned_ip value = gridscale_server.server_monitoring.network[1].auto_assigned_ip
} }
output "ansible_inventory" { output "nginx_private_ip" {
description = "Generated Ansible inventory content" description = "Private IPv4 address of the Nginx server"
value = local_file.ansible_inventory.content value = gridscale_server.server_nginx.network[1].auto_assigned_ip
} }
output "website_urls" {
description = "Website URLs"
value = [
"Caddy: https://caddy.${replace(gridscale_ipv4.public_ipv4_caddy.ip, ".", "-")}.sslip.io",
"Nginx behind Caddy: https://nginx.${replace(gridscale_ipv4.public_ipv4_caddy.ip, ".", "-")}.sslip.io",
"Prometheus behind Caddy: https://monitoring.${replace(gridscale_ipv4.public_ipv4_caddy.ip, ".", "-")}.sslip.io",
"Login for Monitoring: demo ${nonsensitive(random_password.demo_pass.result)}"
]
}
+20 -1
View File
@@ -114,4 +114,23 @@
ansible.builtin.template: ansible.builtin.template:
src: prometheus.yml.j2 src: prometheus.yml.j2
dest: /etc/prometheus/prometheus.yml dest: /etc/prometheus/prometheus.yml
mode: "0644" mode: "0644"
- name: Configure Nginx Server
hosts: nginx
become: true
tags:
- nginx
tasks:
- name: Install Nginx
ansible.builtin.apt:
pkg:
- nginx
state: present
- name: Create Hello World page
ansible.builtin.template:
src: index.html.j2
dest: /var/www/html/index.html
mode: "0644"
+84 -20
View File
@@ -7,12 +7,12 @@ resource "gridscale_network" "network_internal"{
dhcp_reserved_subnet = ["192.168.121.0/31"] dhcp_reserved_subnet = ["192.168.121.0/31"]
} }
resource "gridscale_ipv4" "public_ipv4" { resource "gridscale_ipv4" "public_ipv4_caddy" {
name = "public_ipv4" name = "public_ipv4_caddy"
} }
resource "gridscale_ipv6" "public_ipv6" { resource "gridscale_ipv6" "public_ipv6_caddy" {
name = "public_ipv6" name = "public_ipv6_caddy"
} }
resource "gridscale_ipv4" "public_ipv4_monitoring" { resource "gridscale_ipv4" "public_ipv4_monitoring" {
@@ -23,6 +23,14 @@ resource "gridscale_ipv6" "public_ipv6_monitoring" {
name = "public_ipv6_monitoring" name = "public_ipv6_monitoring"
} }
resource "gridscale_ipv4" "public_ipv4_nginx" {
name = "public_ipv4_nginx"
}
resource "gridscale_ipv6" "public_ipv6_nginx" {
name = "public_ipv6_nginx"
}
data "gridscale_template" "template_debian_13" { data "gridscale_template" "template_debian_13" {
name = "Debian 13" name = "Debian 13"
} }
@@ -68,6 +76,13 @@ resource "gridscale_server" "server_caddy" {
dst_port = 80 dst_port = 80
comment = "Allow HTTP access" comment = "Allow HTTP access"
} }
rules_v4_in {
order = 15
protocol = "tcp"
action = "accept"
dst_port = 443
comment = "Allow HTTPS access"
}
rules_v6_in { rules_v6_in {
order = 0 order = 0
protocol = "tcp" protocol = "tcp"
@@ -82,6 +97,13 @@ resource "gridscale_server" "server_caddy" {
dst_port = 80 dst_port = 80
comment = "Allow HTTPv6 access" comment = "Allow HTTPv6 access"
} }
rules_v6_in {
order = 15
protocol = "tcp"
action = "accept"
dst_port = 443
comment = "Allow HTTPSv6 access"
}
} }
network { network {
# Private network # Private network
@@ -95,8 +117,8 @@ resource "gridscale_server" "server_caddy" {
src_cidr = "192.168.121.0/27" src_cidr = "192.168.121.0/27"
} }
} }
ipv4 = gridscale_ipv4.public_ipv4.id ipv4 = gridscale_ipv4.public_ipv4_caddy.id
ipv6 = gridscale_ipv6.public_ipv6.id ipv6 = gridscale_ipv6.public_ipv6_caddy.id
timeouts { timeouts {
create = "10m" create = "10m"
} }
@@ -131,13 +153,6 @@ resource "gridscale_server" "server_monitoring" {
dst_port = 22 dst_port = 22
comment = "Allow SSH access" comment = "Allow SSH access"
} }
rules_v4_in {
order = 10
protocol = "tcp"
action = "accept"
dst_port = 80
comment = "Allow HTTP access"
}
rules_v6_in { rules_v6_in {
order = 0 order = 0
protocol = "tcp" protocol = "tcp"
@@ -145,13 +160,6 @@ resource "gridscale_server" "server_monitoring" {
dst_port = 22 dst_port = 22
comment = "Allow SSHv6 access" comment = "Allow SSHv6 access"
} }
rules_v6_in {
order = 10
protocol = "tcp"
action = "accept"
dst_port = 80
comment = "Allow HTTPv6 access"
}
} }
network { network {
# Private Network # Private Network
@@ -162,4 +170,60 @@ resource "gridscale_server" "server_monitoring" {
timeouts { timeouts {
create = "10m" create = "10m"
} }
}
resource "gridscale_storage" "storage_nginx" {
name = "storage_nginx"
storage_type = "storage"
capacity = 20
template {
sshkeys = [gridscale_sshkey.sshkey_richard.id]
template_uuid = data.gridscale_template.template_debian_13.id
hostname = var.nginx_hostname
}
}
resource "gridscale_server" "server_nginx" {
name = var.nginx_hostname
cores = 2
memory = 1
power = true
storage {
object_uuid = gridscale_storage.storage_nginx.id
}
network {
# Public Network
object_uuid = "5557a73b-31ee-4b1f-aa15-7789ad6ae04c"
rules_v4_in {
order = 0
protocol = "tcp"
action = "accept"
dst_port = 22
comment = "Allow SSH access"
}
rules_v6_in {
order = 0
protocol = "tcp"
action = "accept"
dst_port = 22
comment = "Allow SSHv6 access"
}
}
network {
# Private Network
object_uuid = gridscale_network.network_internal.id
rules_v4_in {
order = 10
protocol = "tcp"
action = "accept"
dst_port = 80
comment = "Allow HTTP access for Loadbalancer"
}
}
ipv4 = gridscale_ipv4.public_ipv4_nginx.id
ipv6 = gridscale_ipv6.public_ipv6_nginx.id
timeouts {
create = "10m"
}
} }
+20 -1
View File
@@ -7,7 +7,26 @@
origins http://{{ private_ip_caddy }}:2019 origins http://{{ private_ip_caddy }}:2019
} }
} }
:80 { caddy.{{ ansible_default_ipv4.address | replace(".", "-") }}.sslip.io {
root * /usr/share/caddy root * /usr/share/caddy
file_server file_server
} }
nginx.{{ ansible_default_ipv4.address | replace(".", "-") }}.sslip.io {
reverse_proxy {{ private_ip_nginx }}:80 {
health_uri /
health_interval 10s
health_timeout 5s
}
}
monitoring.{{ ansible_default_ipv4.address | replace(".", "-") }}.sslip.io {
reverse_proxy {{ private_ip_monitoring }}:9090 {
health_uri /-/healthy
health_interval 10s
health_timeout 5s
}
basic_auth {
{{ demo_user }} {{ demo_pass | password_hash('bcrypt') }}
}
}
+11
View File
@@ -0,0 +1,11 @@
<html>
<head>
<title>Hello World</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is {{ ansible_hostname }}</p>
<p>Listening on {{ private_ip_nginx }}</p>
<p>Behind Reverseproxy from Caddy</p>
</body>
</html>
+6
View File
@@ -19,4 +19,10 @@ variable "monitoring_hostname" {
type = string type = string
description = "Monitoring hostname" description = "Monitoring hostname"
default = "monitoring01" default = "monitoring01"
}
variable "nginx_hostname" {
type = string
description = "Nginx hostname"
default = "nginx01"
} }