diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..60cd483628 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,106 @@ +name: Python CI & Docker Build + +on: + push: + branches: [ main, dev, lab3 ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + +permissions: + contents: read + packages: write + +jobs: + test: + name: Test & Lint + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r app_python/requirements.txt + pip install ruff pytest + + - name: Lint with Ruff + run: ruff check . + + - name: Run tests + run: pytest app_python/tests/ --verbose -v + + - name: Format check + run: ruff format --check . + + security: + name: Snyk Security Scan + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + defaults: + run: + working-directory: ./app_python + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Snyk CLI + uses: snyk/actions/python-3.11@master + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + with: + args: --severity-threshold=critical --skip-unresolved + continue-on-error: true + + + docker: + name: Build & Push Docker + needs: [ test, security ] # Runs only if test & security passed + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' # Dont push pr to docker hub + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKER_USERNAME }}/testiks + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value={{date 'YYYY.MM'}},enable={{is_default_branch}} + type=ref,event=branch + + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./app_python/ + push: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 30d74d2584..e5e2b89573 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -test \ No newline at end of file +test +.* \ No newline at end of file diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000000..f955730d83 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,11 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +host_key_checking = False +remote_user = debil +retry_files_enabled = False + +[privilege_escalation] +become = True +become_method = sudo +become_user = root \ No newline at end of file diff --git a/ansible/app_python/Dockerfile b/ansible/app_python/Dockerfile new file mode 100644 index 0000000000..d82173a7d1 --- /dev/null +++ b/ansible/app_python/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Non-root user +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Install deps first +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + + +COPY app.py . +RUN chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +# Run app finally +CMD ["python", "app.py"] diff --git a/ansible/app_python/README.md b/ansible/app_python/README.md new file mode 100644 index 0000000000..f122dfce25 --- /dev/null +++ b/ansible/app_python/README.md @@ -0,0 +1,55 @@ +# DevOps Info Service +A lightweight demo Python web application that system information via HTTP endpoints + +### Prerequisites +Python 3.10+ +Flask 3.1.0 + +### Installation +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +``` + +### Running the Application +```bash +python3 app.py +# Or with custom config +PORT=8080 python3 app.py +``` + +### API Endpoints +There are few main endpoints: +- `GET /` - Service and system information +- `GET /health` - Health check. + +### Configuration + +| Variable | Value | Purpose | +| -------- | ------ | ------------------------------------ | +| Host | string | A host to run app on | +| Port | int | A port to assign for web application | +| Debug | bool | Should debug output be enabled | + +## Docker +This application can be run in a containerized environment with Docker + +### Build the image locally +To build the Docker image, use the Docker build command from the project directory, specifying the Dockerfile and an image name with a tag +```bash +cd app_python +docker build -t . +``` + +### Run a container +To run the application, start a container from the built image and map the container port to a port on the host machine so the application can be accessed locally +```bash +docker run -p:5000 +``` + +### Pull from Docker Hub +The pre-built image is also available on Docker Hub and can be pulled using the standard Docker pull command with the repository name and desired tag +```bash +docker pull cacucoh/testiks:1.0 +``` \ No newline at end of file diff --git a/ansible/app_python/app.py b/ansible/app_python/app.py new file mode 100644 index 0000000000..eb3510a269 --- /dev/null +++ b/ansible/app_python/app.py @@ -0,0 +1,112 @@ +import os +import socket +import platform +import logging +from datetime import datetime, timezone + +from flask import Flask, jsonify, request + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +app = Flask(__name__) + +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", 5000)) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + +START_TIME = datetime.now(timezone.utc) + + +def get_uptime(): + delta = datetime.now(timezone.utc) - START_TIME + seconds = int(delta.total_seconds()) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + return {"seconds": seconds, "human": f"{hours} hours, {minutes} minutes"} + + +def get_system_info(): + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.version(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count(), + "python_version": platform.python_version(), + } + + +@app.route("/health", methods=["GET"]) +def health(): + uptime = get_uptime() + return jsonify( + { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": uptime["seconds"], + } + ) + + +@app.route("/", methods=["GET"]) +def default_route(): + logger.info(f"Request: {request.method} {request.path}") + uptime = get_uptime() + + response = { + "service": { + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + "framework": "Flask", + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": uptime["seconds"], + "uptime_human": uptime["human"], + "current_time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC", + }, + "request": { + "client_ip": request.remote_addr, + "user_agent": request.headers.get("User-Agent"), + "method": request.method, + "path": request.path, + }, + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ], + } + + return jsonify(response) + + +@app.errorhandler(404) +def not_found(error): + return jsonify({"error": "Not Found", "message": "Endpoint does not exist"}), 404 + + +@app.errorhandler(500) +def internal_error(error): + return ( + jsonify( + { + "error": "Internal Server Error", + "message": "An unexpected error occurred", + } + ), + 500, + ) + + +if __name__ == "__main__": + logger.info("[+] Starting...") + try: + app.run(host=HOST, port=PORT, debug=DEBUG) + finally: + logger.info("[i] Shutting down") diff --git a/ansible/app_python/docs/LAB03.md b/ansible/app_python/docs/LAB03.md new file mode 100644 index 0000000000..a37f83c334 --- /dev/null +++ b/ansible/app_python/docs/LAB03.md @@ -0,0 +1,85 @@ +# LAB03 β€” Continuous Integration (CI/CD) +[![Python CI & Docker Build](https://github.com/CacucoH/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg)](https://github.com/CacucoH/DevOps-Core-Course/actions/workflows/python-ci.yml) + +## 1. Unit testing +### 1.1 Testing framework choise +To complete this lab I selected **pytest**: +- Supports fuxtures +- Simple to use +- Easilly integrates with Flask + +#### 1.2 Tests structure explanation: +- `test_root_endpoint_success`: Verifies GET / returns 200 status, checks complete JSON structure (service, system, runtime, request, endpoints fields), validates data types (str, int, list), and mocks uptime/system_info for consistent testing. +- `test_health_endpoint_success`: Tests GET /health returns 200 status, confirms health JSON structure (status, timestamp, uptime_seconds), verifies string/integer data types. +- `test_nonexistent_endpoint_404`: Ensures non-existent endpoint /nonexistent returns 404 status with correct error JSON structure ("Not Found" message). +- `test_root_wrong_method_404`: Confirms POST to root / (unsupported method) returns 404 status code. +- `test_health_wrong_method_405`: Verifies POST to /health (unsupported method) returns 404 status code. +- `test_unsupported_methods_405`: Parametrized test checking PUT, DELETE, PATCH methods on various endpoints all return 404 status. +- `test_empty_request_data`: Edge case test ensuring basic GET / works without additional request data, validates client_ip presence in response. +- `test_with_headers`: Edge case testing custom User-Agent header, confirms request parsing correctly extracts and returns header value in JSON. + +#### 1.3 Running tests locally +Execute (in main project directory) +```bash +pytest +``` +All test should pass +![all tests passing](./screenshots/lab3/tests.png) + +### 2 CI Workflow +CI workflow triggers on: +- push to `main`, `dev`, and `lab3` branches +- pull requests + +It performs: +1. Linting (ruff) +2. Testing (pytest) +3. Coverage generation +4. Docker build & push +5. Snyk security scan + + +## 2. Versioning Strategy +I have chosen Calendar Versioning (CalVer YYYY.MM): +- Format: 2026.02 (current month) + latest +- Implementation: docker/metadata-action@v5 with type=raw,value={{date 'YYYY.MM'}} +- Why CalVer: Perfect for CI/CD pipelines with frequent releases, date-based tracking + +### 2.1 Key Implementation Highlights +CI Stages: +1. Test job (matrix: Python 3.9-3.11) + - Ruff linting + formatting + - Pytest unit tests +2. Docker job (depends on tests) + - Multi-tag strategy (latest + CalVer + branch) + - Docker layer caching for speed + +### 2.2 Triggers Logic: +- main/dev push: full CI/CD (tests + Docker push) +- PR: tests only (no Docker push) +- Any branch: basic linting + +Also I used Git secrets: +- DOCKER_USERNAME +- DOCKERHUB_TOKEN (Docker Hub Access Token) +- SNYK_TOKEN + +### 2.3 Evidence + +#### - [πŸ‘‰ Link to successful CI (full lab done)](https://github.com/CacucoH/DevOps-Core-Course/actions/runs/21959626699) +#### - Tests passing locally: +![all tests passing](./screenshots/lab3/tests.png) +#### - [Docker image on Docker Hub](https://hub.docker.com/r/cacucoh/testiks) + + +## 3. Best Practices Implemented +1. Matrix Testing: Tests Python 3.9-3.11 in parallel across multiple jobs, ensuring cross-version compatibility +2. Job Dependencies: Docker build only runs after tests pass (needs: test), preventing broken images from being pushed +3. Docker Layer Caching: cache-from/to: type=gha reduces build time from 5+ minutes to ~30 seconds on repeat runs +4. Caching: Pip dependencies cached, so: 3min to 15sec speedup; Docker layers sped up from 5min to 30sec + +## 4. Key Decisions +- Versioning Strategy: CalVer (YYYY.MM) chosen over SemVer because this is a CI/CD pipeline with frequent automated releasesβ€”dates provide instant temporal context without manual version management. +- Docker Tags: Creates username/app:latest (production), username/app:2026.02 (monthly archive), username/app:main (branch tracking)β€”multiple tags enable flexible deployments and rollbacks. +- Workflow Triggers: push to main/develop β†’ full CI/CD; pull_request β†’ tests only; all branches β†’ lintingβ€”balances automation with safety (no Docker push from PRs/forks). +- Test Coverage: Unit tests via pytest + linting/formatting via ruff cover code quality; integration/E2E tests and security scanning deferred to future tasks. diff --git a/ansible/app_python/docs/screenshots/lab2/build.png b/ansible/app_python/docs/screenshots/lab2/build.png new file mode 100644 index 0000000000..2bcd5dba45 Binary files /dev/null and b/ansible/app_python/docs/screenshots/lab2/build.png differ diff --git a/ansible/app_python/docs/screenshots/lab2/curl.png b/ansible/app_python/docs/screenshots/lab2/curl.png new file mode 100644 index 0000000000..90bc56653d Binary files /dev/null and b/ansible/app_python/docs/screenshots/lab2/curl.png differ diff --git a/ansible/app_python/docs/screenshots/lab2/images.png b/ansible/app_python/docs/screenshots/lab2/images.png new file mode 100644 index 0000000000..0ceb08ec99 Binary files /dev/null and b/ansible/app_python/docs/screenshots/lab2/images.png differ diff --git a/ansible/app_python/docs/screenshots/lab2/push.png b/ansible/app_python/docs/screenshots/lab2/push.png new file mode 100644 index 0000000000..274af9a019 Binary files /dev/null and b/ansible/app_python/docs/screenshots/lab2/push.png differ diff --git a/ansible/app_python/docs/screenshots/lab2/run.png b/ansible/app_python/docs/screenshots/lab2/run.png new file mode 100644 index 0000000000..703e3f363c Binary files /dev/null and b/ansible/app_python/docs/screenshots/lab2/run.png differ diff --git a/ansible/app_python/docs/screenshots/lab3/tests.png b/ansible/app_python/docs/screenshots/lab3/tests.png new file mode 100644 index 0000000000..628dd59d64 Binary files /dev/null and b/ansible/app_python/docs/screenshots/lab3/tests.png differ diff --git a/ansible/app_python/requirements.txt b/ansible/app_python/requirements.txt new file mode 100644 index 0000000000..51c32f3429 --- /dev/null +++ b/ansible/app_python/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.2 \ No newline at end of file diff --git a/ansible/app_python/tests/__init__.py b/ansible/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ansible/app_python/tests/app_test.py b/ansible/app_python/tests/app_test.py new file mode 100644 index 0000000000..033a4a8263 --- /dev/null +++ b/ansible/app_python/tests/app_test.py @@ -0,0 +1,123 @@ +import pytest +from unittest.mock import patch +from datetime import datetime, timezone +from app import app + + +@pytest.fixture +def client(): + app.config["TESTING"] = True + with app.test_client() as client: + yield client + + +@patch("app.get_uptime") +@patch("app.get_system_info") +@patch("app.datetime") +def test_root_endpoint_success(mock_datetime, mock_system_info, mock_uptime, client): + """Test GET /, status 200, data structures & types.""" + mock_uptime.return_value = {"seconds": 3600, "human": "1 hours, 0 minutes"} + mock_system_info.return_value = { + "hostname": "test-host", + "platform": "Linux", + "platform_version": "5.15", + "architecture": "x86_64", + "cpu_count": 4, + "python_version": "3.11.0", + } + mock_datetime.now.return_value = datetime(2026, 2, 11, 22, 46, tzinfo=timezone.utc) + + response = client.get("/") + + assert response.status_code == 200 + + data = response.get_json() + # Check that all keys are present + assert "service" in data + assert "system" in data + assert "runtime" in data + assert "request" in data + assert "endpoints" in data + + # And check data types + assert isinstance(data["service"]["name"], str) + assert isinstance(data["system"]["cpu_count"], int) + assert isinstance(data["runtime"]["uptime_seconds"], int) + assert isinstance(data["endpoints"], list) + assert len(data["endpoints"]) == 2 + + +@patch("app.get_uptime") +def test_health_endpoint_success(mock_uptime, client): + """Test GET /health, status 200, data structures & types.""" + mock_uptime.return_value = {"seconds": 7200, "human": "2 hours, 0 minutes"} + + response = client.get("/health") + + assert response.status_code == 200 + + data = response.get_json() + assert data["status"] == "healthy" + assert isinstance(data["timestamp"], str) + assert isinstance(data["uptime_seconds"], int) + + +def test_nonexistent_endpoint_404(client): + """Test non-existent endpoint, status 404, data structure.""" + response = client.get("/nonexistent") + + assert response.status_code == 404 + + data = response.get_json() + assert data["error"] == "Not Found" + assert isinstance(data["message"], str) + assert data["message"] == "Endpoint does not exist" + + +def test_root_wrong_method_405(client): + """Test invalid HTTP method on / - 405.""" + response = client.post("/") + + assert response.status_code == 405 + + +def test_health_wrong_method_405(client): + """Test invalid HTTP method on /health - 405.""" + response = client.post("/health") + + assert response.status_code == 405 + + +# @patch('app.get_uptime', side_effect=Exception("Uptime calculation failed")) +# def test_internal_server_error_500(mock_uptime, client): +# """Test for internal server error response, status 500, data structure.""" +# response = client.get('/') + +# assert response.status_code == 500 + +# data = response.get_json() +# assert data["error"] == "Internal Server Error" +# assert isinstance(data["message"], str) +# assert data["message"] == "An unexpected error occurred" + +# @patch('app.socket.gethostname', side_effect=Exception("Hostname resolution failed")) +# def test_system_info_error_500(client): +# """Test for get_system_info error - 500.""" +# response = client.get('/') + +# assert response.status_code == 500 + + +def test_empty_request_data(client): + """Edge case: base requests without any headers.""" + response = client.get("/") + assert response.status_code == 200 + assert "client_ip" in response.get_json()["request"] + + +def test_with_headers(client): + """Edge case: base reuest with User-Agent header.""" + headers = {"User-Agent": "TestAgent/1.0"} + response = client.get("/", headers=headers) + data = response.get_json() + assert data["request"]["user_agent"] == "TestAgent/1.0" diff --git a/ansible/docs/LAB05.md b/ansible/docs/LAB05.md new file mode 100644 index 0000000000..e99f13bc13 --- /dev/null +++ b/ansible/docs/LAB05.md @@ -0,0 +1,237 @@ +# Lab 5 β€” Ansible Fundamentals + +### Architecture Overview +#### Ansible Version Used +Installed on Linux using apt + +```bash +$ ansible --version +ansible [core 2.20.1] + config file = None + configured module search path = ['/home/segfault/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules'] + ansible python module location = /usr/lib/python3/dist-packages/ansible + ansible collection location = /home/segfault/.ansible/collections:/usr/share/ansible/collections + executable location = /usr/bin/ansible + python version = 3.13.11 (main, Dec 8 2025, 11:43:54) [GCC 15.2.0] (/usr/bin/python3) + jinja version = 3.1.6 + pyyaml version = 6.0.3 (with libyaml v0.2.5) +``` + +### Target VM + +I used a VM that I created in previous lab: +- Debian 13 (6.12.63 amd-64) +- 4 GB RAM +- 10 GB disk space +- Network adapter in Bridged mode +- Static IP (192.168.1.145) +- SSH server is installed and configured +- Public SSH key added to `~/.ssh/authorized_keys` + +Ansible connects via SSH using key-based auth + +### Ansible Project Structure +The project follows a role-based architecture: +``` +ansible/ +β”œβ”€β”€ inventory/ +β”‚ └── hosts.ini +β”œβ”€β”€ roles/ +β”‚ β”œβ”€β”€ common/ +β”‚ β”œβ”€β”€ docker/ +β”‚ └── app_deploy/ +β”œβ”€β”€ playbooks/ +β”‚ β”œβ”€β”€ provision.yml +β”‚ └── deploy.yml +β”œβ”€β”€ group_vars/ +β”‚ └── all.yml (Vault encrypted) +β”œβ”€β”€ ansible.cfg +└── docs/LAB05.md +``` + +### Why Roles Instead of Monolithic Playbooks? +**Because roles improve modularity, reusability, and maintainability** + +Instead of putting everything in one large playbook, roles let you split infrastructure into logical components (e.g., web server, database, users). Each role has a defined structure (tasks, vars, handlers), which makes the code easier to read and manage + +### Connectivity check: + +![alt text](./img/ping.png) + +![connect](./img/rce.png) + +This confirms SSH conection working correctly for ansible + +### Roles +#### Common +##### Purpose +Provides baseline system configuration (packages, users, timezone, basic security settings, updates) + +##### Variables +- common_packages – list of packages to install (default: basic utilities) +- common_timezone – system timezone (default: UTC) +- common_create_user – whether to create a deploy user (default: true) +``` +common_packages: + - python3-pip + - curl + - git + - vim + - htop +timezone: "UTC" +``` + +##### Handlers +- Restart SSH +- Reload systemd + +##### Dependencies +- None + +#### Docker +##### Purpose +Installs and configures Docker engine and related components. + +##### Variables (key examples) +- docker_version – Docker package version (default: latest) +- docker_users – list of users added to docker group +- docker_daemon_options – custom daemon.json configuration + +##### Handlers +- Restart Docker +``` +- name: Restart Docker + service: + name: docker + state: restarted +``` + +##### Dependencies +May depend on common (for base packages and users) + +#### App_deploy +##### Purpose +Deploys and configures the application (pulls image, runs container, sets environment variables). + +#### Variables +- app_image – Docker image name +- app_tag – image tag (default: latest) +- app_env – environment variables +- app_port – exposed port +``` +restart_policy: unless-stopped +env_vars: {} +``` + +##### Handlers +- Restart application container +- Reload reverse proxy (if applicable) +``` +- name: Restart application container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: started + restart: true +``` + +##### Dependencies +- Depends on docker +- May depend on common + +### Idempotency Demonstration +#### Run playbook first time + +![alt text](./img/first.png) + +Observe: +- New packages installed +- Docker installed +- Docker started +- User added to docker group + +#### Run playbook second time + +![alt text](./img/second.png) + + +On the second run of the playbook, all tasks showed changed = 0 because the system was already in the desired state + +#### Analysis + +- First run: +Tasks that installed packages (common_packages, Docker packages), updated the apt cache, created users/groups, and set the timezone all showed changed = 1 because these actions modified the system to reach the desired state + +- Second run: +All tasks showed changed = 0 because the system was already in the desired state. Nothing needed to be updated or modified + +#### Explanation of Idempotency +The roles are idempotent because: +- Stateful modules were used (apt: state=present, service: state=started, user: state=present) rather than shell commands +- Variables define the desired state (package lists, timezone, users), so tasks only act when the system differs from that state +- Handlers (like Docker restart) only trigger when notified + + +### Ansible Vault +Sensitive data stored in `group_vars/all.yml` file + +I created it using: +```bash +ansible-vault create group_vars/all.yml +``` + +All its content are encrypted: +``` +$ANSIBLE_VAULT;1.1;AES256 +62613132333831643565386162386637626234636236356236353639353632626364363137633265 +3864393263303166333738663434653033333636643261310a373832303831613239616636393234 +36383830643236666232633936613439653836333832376330393665633134623333653662336264 +3836626638303961660a326533376539663131623337643230366238323638303562633563393062 +63663538316636643732396435643262656566666136336564373531343834326235653164643063... +``` + +#### Stored Secrets +- DH username +- DH access token +- App configuration + +#### Why Vault Is Important +- Prevents credential exposure in Git +- Secure automation + +Vault password explicitly passed during deploy process: +```bash +ansible-playbook playbooks/deploy.yml --ask-vault-pass +``` + + +### Deployment Verification + +Deploy terminal output: +![alt text](./img/deployed.png) + +Checking `docker ps` out on remote VM: +![alt text](./img/docekrps.png) + +Check if server is up: +![alt text](./img/healthcheck.png) + +### Key decisions + +Why use roles instead of plain playbooks? +- Roles structure playbooks into modular, logical units, making them easier to read, maintain, and scale + +How do roles improve reusability? +- Roles encapsulate tasks, defaults, handlers, and variables, allowing the same logic to be applied across multiple projects or environments + +What makes a task idempotent? +- A task is idempotent if running it multiple times results in the same system state, with changes applied only when necessary + +How do handlers improve efficiency? +- Handlers run only when notified by tasks, avoiding unnecessary service restarts and reducing redundant operations + +Why is Ansible Vault necessary? +- Vault secures sensitive data like passwords, tokens, and keys, keeping credentials encrypted while still usable in playbooks + +### 7. Challenges +- Docker repository on Debian 13 required using Debian 12 repo to avoid missing Release files +- Missing variables (e.g., docker_image_tag) caused container creation errors β€” fixed by defining defaults or vault variables \ No newline at end of file diff --git a/ansible/docs/LAB06.md b/ansible/docs/LAB06.md new file mode 100644 index 0000000000..25d153dea2 --- /dev/null +++ b/ansible/docs/LAB06.md @@ -0,0 +1,324 @@ +# Lab 6 β€” Advanced Ansible & CI/CD + +### Task 1: Blocks & Tags +#### Implementation Details +In this task, I refactored Ansible roles using blocks and tags to make the playbooks easier to read and manage. Blocks were used to group related tasks together and apply common settings such as become, when, and tags. Error handling was added using rescue blocks, and always blocks were used to run tasks that should execute regardless of success or failure + +#### Tag Strategy + +The following tags were used: +- common – entire common role +- packages – package installation tasks +- users – user management tasks +- docker – entire docker role +- docker_install – Docker installation tasks +- docker_config – Docker configuration tasks + +These tags allow specific tasks to be executed when running the playbook + +#### Evidence + +List all tags: +```bash +ansible-playbook playbooks/provision.yml --list-tags +``` +Example output: +```bash +play #1 (webservers): Provision web servers TAGS: [] + TASK TAGS: [common, docker, docker_config, docker_install, packages, users] +``` +Run only Docker tasks: +```bash +ansible-playbook playbooks/provision.yml --tags "docker" +``` +Run only package tasks: +```bash +ansible-playbook playbooks/provision.yml --tags "packages" +``` +Run only Docker installation: +```bash +ansible-playbook playbooks/provision.yml --tags "docker_install" +``` +Skip the common role: +```bash +ansible-playbook playbooks/provision.yml --skip-tags "common" +``` + +#### Tags listing + +![alt text](./img/lab6_oleg.png) + +#### Second run +![alt text](lab6_2ndrun.png) + +#### Docker-tasks execution + +![alt text](./img/lab6_outp.png) + +#### Research Answers + +##### What happens if the rescue block also fails? +If the rescue block fails, the playbook will fail. However, the always section will still run + +##### Can you have nested blocks? +Yes, Ansible supports nested blocks. A block can contain another block if more complex task grouping is needed + +##### How do tags inherit in blocks? +Tags applied to a block are automatically applied to all tasks inside that block. This means you do not need to add the same tag to every task + +### Task 2: Upgrade to Docker Compose +#### Implementation Details + +In this task, I upgraded app deployment from `docker run` to Docker Compose. Docker Compose allows the container configuration to be written in a file instead of long command-line commands. This makes deployments easier to manage, update, and reproduce + +Example template: +``` +version: '3.8' + +services: + {{ app_name }}: + image: {{ docker_image }}:{{ docker_tag }} + container_name: {{ app_name }} + ports: + - "{{ app_port }}:{{ app_internal_port }}" + environment: + APP_NAME: "{{ app_name }}" + APP_PORT: "{{ app_internal_port }}" + restart: unless-stopped +``` + +This allows the application configuration to be changed easily by modifying variables + +#### Role Dependency +The testiks role depends on the docker role so Docker is installed before deploying the application + +File `roles/testiks/meta/main.yml` +Example configuration: +```yml +--- +dependencies: + - role: docker +``` +This ensures Docker is always installed before attempting to deploy containers + +#### Before / After Comparison + +##### Before +```bash +docker run -d \ +-p 8000:8000 \ +--name devops-app \ +your_dockerhub_username/devops-info-service:latest +``` + +This approach requires long commands and is harder to maintain or update + +##### After (Docker Compose): +```bash +services: + devops-app: + image: your_dockerhub_username/devops-info-service:latest + ports: + - "8000:8000" + restart: unless-stopped +``` +Using Docker Compose provides a declarative configuration, meaning the desired state of the container is defined in a file + +Advantages of this approach: +- easier configuration management +- reusable templates with variables +- better support for multi-container setups +- simpler updates and redeployments + +#### Evidence +```bash +$ ansible-playbook playbooks/deploy.yml --become-password-file .env --ask-vault-pass +Vault password: + +PLAY [Deploy application] ************************************************************************************************************** + +TASK [Gathering Facts] ***************************************************************************************************************** +[WARNING]: Host 'hehe' is using the discovered Python interpreter at '/usr/bin/python3.12', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.20/reference_appendices/interpreter_discovery.html for more information. +ok: [hehe] + +TASK [docker : Install required system packages] *************************************************************************************** +ok: [hehe] + +TASK [docker : Create keyrings directory] ********************************************************************************************** +ok: [hehe] + +TASK [docker : Add Docker GPG key] ***************************************************************************************************** +ok: [hehe] + +TASK [docker : Add Docker repository] ************************************************************************************************** +ok: [hehe] + +TASK [docker : Install Docker packages] ************************************************************************************************ +ok: [hehe] + +TASK [docker : Ensure Docker service is enabled] *************************************************************************************** +ok: [hehe] + +TASK [docker : Add user to docker group] *********************************************************************************************** +ok: [hehe] + +TASK [docker : Install python docker module] ******************************************************************************************* +ok: [hehe] + +TASK [testiks : Create application directory] ****************************************************************************************** +changed: [hehe] + +TASK [testiks : Template docker-compose.yml] ******************************************************************************************* +changed: [hehe] + +TASK [testiks : Login to Docker Hub] *************************************************************************************************** +changed: [hehe] + +TASK [testiks : Start containers with Docker Compose] ********************************************************************************** +changed: [hehe] + +PLAY RECAP ***************************************************************************************************************************** +hehe : ok=15 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +#### Accessibility Verification +```bash +β”Œβ”€β”€(segfaultγ‰Ώaboltus2)-[~/Downloads] +└─$ ssh debil@192.168.0.152 +debil@192.168.0.152's password: +Linux hehe 6.12.73+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.73-1 (2026-02-17) x86_64 + +The programs included with the Debian GNU/Linux system are free software; +the exact distribution terms for each program are described in the +individual files in /usr/share/doc/*/copyright. + +Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent +permitted by applicable law. +Last login: Thu Mar 5 20:38:39 2026 from 192.168.0.145 +debil@hehe:~$ docker ps +CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES +d3ec91cbb47e cacucoh/testiks:1.0 "python app.py" 4 minutes ago Up 4 minutes 0.0.0.0:5000->5000/tcp, 8000/tcp TESTIKS +debil@hehe:~$ +debil@hehe:~$ curl -s http://localhost:5000/ | jq . +{ + "endpoints": [ + { + "description": "Service information", + "method": "GET", + "path": "/" + }, + { + "description": "Health check", + "method": "GET", + "path": "/health" + } + ], + "request": { + "client_ip": "172.17.0.1", + "method": "GET", + "path": "/", + "user_agent": "curl/8.14.1" + }, + "runtime": { + "current_time": "2026-03-05T20:46:09.269567+00:00", + "timezone": "UTC", + "uptime_human": "49 hours, 27 minutes", + "uptime_seconds": 178058 + }, + "service": { + "description": "DevOps course info service", + "framework": "Flask", + "name": "devops-info-service", + "version": "1.0.0" + }, + "system": { + "architecture": "x86_64", + "cpu_count": 1, + "hostname": "d3ec91cbb47e", + "platform": "Linux", + "platform_version": "#1 SMP PREEMPT_DYNAMIC Debian 6.12.73-1 (2026-02-17)", + "python_version": "3.12.12" + } +} + +``` + +### Task 4: CI/CD +#### GitHub Actions Workflow + +#### Secrets +These secrets are in GitHub repository settings: +- ANSIBLE_VAULT_PASSWORD +- SSH_PK +- SERVER_IP + +```yml +name: Ansible Deployment + +on: + push: + branches: [ main, master, ci-cd ] + paths: + - 'ansible/**' + - '.github/workflows/ansible-deploy.yml' + workflow_dispatch: # manual trigger + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install Ansible & dependencies + run: | + python -m pip install --upgrade pip + pip install ansible ansible-lint community.docker + ansible --version + + - name: Create Vault password file + run: echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > .vault_pass + + - name: Setup SSH key + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts + + - name: Run Ansible lint + run: | + cd ansible + ansible-lint playbooks/*.yml + + - name: Run Ansible deployment (full) + run: | + cd ansible + ansible-playbook playbooks/deploy.yml \ + -i inventory/hosts.ini \ + --vault-password-file ../.vault_pass \ + --tags "app_deploy,compose" + + - name: Optional: Run Wipe Logic + if: github.event.inputs.run_wipe == 'true' + run: | + cd ansible + ansible-playbook playbooks/deploy.yml \ + -i inventory/hosts.ini \ + --vault-password-file ../.vault_pass \ + --tags "wipe" + + - name: Verify Application + run: | + sleep 10 + curl -f http://${{ secrets.SERVER_IP }}:5000 || exit 1 + curl -f http://${{ secrets.SERVER_IP }}:5000/health || exit 1 +``` + +### Documentation \ No newline at end of file diff --git a/ansible/docs/img/deployed.png b/ansible/docs/img/deployed.png new file mode 100644 index 0000000000..52f5e3eb52 Binary files /dev/null and b/ansible/docs/img/deployed.png differ diff --git a/ansible/docs/img/docekrps.png b/ansible/docs/img/docekrps.png new file mode 100644 index 0000000000..f60802d6a3 Binary files /dev/null and b/ansible/docs/img/docekrps.png differ diff --git a/ansible/docs/img/first.png b/ansible/docs/img/first.png new file mode 100644 index 0000000000..36f2ea4550 Binary files /dev/null and b/ansible/docs/img/first.png differ diff --git a/ansible/docs/img/healthcheck.png b/ansible/docs/img/healthcheck.png new file mode 100644 index 0000000000..d75e5f4865 Binary files /dev/null and b/ansible/docs/img/healthcheck.png differ diff --git a/ansible/docs/img/lab6_2ndrun.png b/ansible/docs/img/lab6_2ndrun.png new file mode 100644 index 0000000000..380f3a1b98 Binary files /dev/null and b/ansible/docs/img/lab6_2ndrun.png differ diff --git a/ansible/docs/img/lab6_oleg.png b/ansible/docs/img/lab6_oleg.png new file mode 100644 index 0000000000..c9f5f39065 Binary files /dev/null and b/ansible/docs/img/lab6_oleg.png differ diff --git a/ansible/docs/img/lab6_outp.png b/ansible/docs/img/lab6_outp.png new file mode 100644 index 0000000000..e73c0c6b4b Binary files /dev/null and b/ansible/docs/img/lab6_outp.png differ diff --git a/ansible/docs/img/ping.png b/ansible/docs/img/ping.png new file mode 100644 index 0000000000..3ae37999b1 Binary files /dev/null and b/ansible/docs/img/ping.png differ diff --git a/ansible/docs/img/rce.png b/ansible/docs/img/rce.png new file mode 100644 index 0000000000..30352dda0a Binary files /dev/null and b/ansible/docs/img/rce.png differ diff --git a/ansible/docs/img/second.png b/ansible/docs/img/second.png new file mode 100644 index 0000000000..c60eb2f7fd Binary files /dev/null and b/ansible/docs/img/second.png differ diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000000..c9cc532b92 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,17 @@ +$ANSIBLE_VAULT;1.1;AES256 +30346539663138386333633962306237623637376138663438333761656537636430336230313165 +6430653163363662373437343666626234396333653339660a366463646631653133366536393166 +65323862666636386338396131613939383936353661343065623736313737613631643636393239 +6634636465393533390a643239313037303564623139363231323537323864353432353838666136 +34643031306365623332623438656137623365666531363334373665616238653836353730326334 +35336665663630346662393936633736393939363632643831316435633633616366373363666438 +32376537303937303366643163616566633334396234376361383637343536376331356233343134 +38386639393865346638373231323238633363353335343730333038613439643535353366313931 +63306639303037633039316336613966313034343166623163613433626539396535303138666166 +34636533616336653530343933336438316539356162616335666365323539643563393931383334 +37326563303031623839333236383262613839326462313738396635636166663139653036383866 +36616636333338393233336665363439306664333661663532303263356435333436613133346232 +62303334653165373733356162323663633466316564363438623865633036386239343038373763 +62636137303639313033616539643731303434633462613264656534393837303065386636386535 +62363038663564316234643964373162353461373962633036303536326631623533653366653765 +31313931663163656634 diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000000..35c9b8379c --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,2 @@ +[webservers] +hehe ansible_host=192.168.0.152 ansible_user=debil ansible_ssh_private_key_file=~/.ssh/temp diff --git a/ansible/playbooks/deploy.yml b/ansible/playbooks/deploy.yml new file mode 100644 index 0000000000..b77f528c7a --- /dev/null +++ b/ansible/playbooks/deploy.yml @@ -0,0 +1,7 @@ +--- +- name: Deploy application + hosts: webservers + become: yes + + roles: + - app_deploy \ No newline at end of file diff --git a/ansible/playbooks/provision.yml b/ansible/playbooks/provision.yml new file mode 100644 index 0000000000..17d437513f --- /dev/null +++ b/ansible/playbooks/provision.yml @@ -0,0 +1,8 @@ +--- +- name: Provision web servers + hosts: webservers + become: yes + + roles: + - common + - docker \ No newline at end of file diff --git a/ansible/playbooks/site.yml b/ansible/playbooks/site.yml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ansible/roles/app_deploy/defaults/main.yml b/ansible/roles/app_deploy/defaults/main.yml new file mode 100644 index 0000000000..b257cd7417 --- /dev/null +++ b/ansible/roles/app_deploy/defaults/main.yml @@ -0,0 +1,3 @@ +restart_policy: unless-stopped +env_vars: {} +docker_image: "" \ No newline at end of file diff --git a/ansible/roles/app_deploy/handlers/main.yml b/ansible/roles/app_deploy/handlers/main.yml new file mode 100644 index 0000000000..9c835acaa9 --- /dev/null +++ b/ansible/roles/app_deploy/handlers/main.yml @@ -0,0 +1,5 @@ +- name: Restart application container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: started + restart: true \ No newline at end of file diff --git a/ansible/roles/app_deploy/tasks/main.yml b/ansible/roles/app_deploy/tasks/main.yml new file mode 100644 index 0000000000..77a28b4638 --- /dev/null +++ b/ansible/roles/app_deploy/tasks/main.yml @@ -0,0 +1,59 @@ +- name: Show DockerHub credentials + debug: + msg: + - "username={{ dockerhub_username }}" + - "password={{ e6JlaH_noLll3Jl_Haxyu!!!##!#! }}" + +- name: Docker login with Vault + block: + - name: Log in to Docker Hub + community.docker.docker_login: + username: "{{ dockerhub_username }}" + password: "{{ dockerhub_password }}" + tags: + - docker + +- name: Set docker_image full name + set_fact: + docker_image_full: "{{ dockerhub_username }}/{{ app_name }}" + +- name: Pull Docker image + community.docker.docker_image: + name: "{{ docker_image_full }}" + tag: "{{ docker_image_tag }}" + source: pull + +- name: Stop existing container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: stopped + ignore_errors: yes + +- name: Remove old container + community.docker.docker_container: + name: "{{ app_container_name }}" + state: absent + ignore_errors: yes + +- name: Run new container + community.docker.docker_container: + name: "{{ app_container_name }}" + image: "{{ docker_image_full }}:{{ docker_image_tag }}" + state: started + restart_policy: "{{ restart_policy }}" + ports: + - "{{ app_port }}:{{ app_port }}" + env: "{{ env_vars }}" + notify: Restart application container + +- name: Wait for application to start + wait_for: + host: 127.0.0.1 + port: "{{ app_port }}" + delay: 5 + timeout: 30 + +- name: Check health endpoint + uri: + url: "http://127.0.0.1:{{ app_port }}/health" + status_code: 200 \ No newline at end of file diff --git a/ansible/roles/common/defaults/main.yml b/ansible/roles/common/defaults/main.yml new file mode 100644 index 0000000000..0ae0d191b6 --- /dev/null +++ b/ansible/roles/common/defaults/main.yml @@ -0,0 +1,9 @@ +--- +common_packages: + - python3-pip + - curl + - git + - vim + - htop + +timezone: "UTC" \ No newline at end of file diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000000..ff61b48805 --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,65 @@ +--- +# roles/common/tasks/main.yml + +- name: Package management tasks + block: + + - name: Update apt cache + apt: + update_cache: yes + + - name: Install common packages + apt: + name: + - curl + - git + - vim + - htop + state: present + + rescue: + + - name: Fix apt cache if update fails + command: apt-get update --fix-missing + + always: + + - name: Log package block completion + copy: + content: "Package block executed at {{ ansible_date_time.iso8601 }}" + dest: /tmp/common_packages_done.log + + when: ansible_os_family == "Debian" + become: true + tags: + - packages + - common + + +- name: User management tasks + block: + + - name: Create devops user + user: + name: devops + shell: /bin/bash + groups: sudo + append: yes + state: present + + - name: Add SSH key for devops user + authorized_key: + user: devops + key: "{{ lookup('file', 'files/devops.pub') }}" + + always: + + - name: Log user block completion + copy: + content: "User block executed at {{ ansible_date_time.iso8601 }}" + dest: /tmp/common_users_done.log + + become: true + tags: + - users + - common \ No newline at end of file diff --git a/ansible/roles/docker/defaults/main.yml b/ansible/roles/docker/defaults/main.yml new file mode 100644 index 0000000000..d3de4c96fe --- /dev/null +++ b/ansible/roles/docker/defaults/main.yml @@ -0,0 +1,8 @@ +--- +docker_packages: + - docker-ce + - docker-ce-cli + - containerd.io + +docker_users: + - "{{ ansible_user }}" \ No newline at end of file diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000000..f5700a7c2d --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,4 @@ +- name: restart docker + service: + name: docker + state: restarted \ No newline at end of file diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000000..113a27f02a --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,92 @@ +--- +- name: Docker installation + block: + + - name: Update apt cache + apt: + update_cache: yes + + - name: Install required dependencies + apt: + name: + - ca-certificates + - curl + - gnupg + state: present + update_cache: yes + + - name: Download Docker GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /tmp/docker.gpg + mode: '0644' + + - name: Install Docker GPG key + ansible.builtin.command: "gpg --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg" + args: + creates: /etc/apt/keyrings/docker.gpg + + - name: Add Docker repository + apt_repository: + repo: deb https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable + state: present + + - name: Install Docker + apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + state: present + update_cache: yes + + rescue: + + - name: Wait before retrying + pause: + seconds: 10 + + - name: Retry apt update + apt: + update_cache: yes + + always: + + - name: Ensure Docker service is running + service: + name: docker + state: started + enabled: yes + + become: true + tags: + - docker + - docker_install + + +- name: Docker configuration + block: + + - name: Add devops user to docker group + user: + name: devops + groups: docker + append: yes + + - name: Create Docker config directory + file: + path: /etc/docker + state: directory + mode: '0755' + + always: + + - name: Verify Docker service enabled + service: + name: docker + enabled: yes + + become: true + tags: + - docker + - docker_config \ No newline at end of file diff --git a/labs/docs/LAB04.md b/labs/docs/LAB04.md new file mode 100644 index 0000000000..a72b6b1220 --- /dev/null +++ b/labs/docs/LAB04.md @@ -0,0 +1,41 @@ +# LAB04 + +### 1. Provider & Infrastructure +I decided to use local VM for this lab insted of a cloud instance. I don't have access to any cloud provider instance. +And its more convinient for me to use Local VM + +Thankfully, my machine can handle VM with such hardware: +- Debian 13 (6.12.63 amd-64) +- 4 GB RAM +- 10 GB disk space +- Network adapter in Bridged mode +- Static IP (192.168.1.145) +- SSH server is installed and configured +- Public SSH key added to `~/.ssh/authorized_keys` + +### 2. Terraform Implementation +Terraform is not used, because local VM was selected. I installed `virtualbox` and set up **Debian 13** using `.iso` + +### 3. Pulumi Implementation +VM used, so no polumni implemented + +### 4. VM creation +After downloading and installing `virtualbox-7.2` (My host is `6.18.9+kali-amd64`) and Debian 13 `.iso` I set up VM: +![alt text](./screenshots/setup1.png) +![alt text](./screenshots/setup2.png) +![alt text](./screenshots/setup3.png) +![alt text](./screenshots/setup4.png) + +And intalled neccessary packages (including `openssh-server`): +![ssh](./screenshots/ssh.png) + +### 5. Exposed Ports & Firewall +These ports are accessible within bridged network: +- Port 22 (SSH) +- Port 3000 (app) + +### 6. Lab 5 Preparation & Cleanup + +**VM for Lab 5:** +- Are you keeping your VM for Lab 5? Yes [x] +- Local VM diff --git a/labs/docs/screenshots/setup1.png b/labs/docs/screenshots/setup1.png new file mode 100644 index 0000000000..65d57156dd Binary files /dev/null and b/labs/docs/screenshots/setup1.png differ diff --git a/labs/docs/screenshots/setup2.png b/labs/docs/screenshots/setup2.png new file mode 100644 index 0000000000..e7617fdfac Binary files /dev/null and b/labs/docs/screenshots/setup2.png differ diff --git a/labs/docs/screenshots/setup3.png b/labs/docs/screenshots/setup3.png new file mode 100644 index 0000000000..f584eaa885 Binary files /dev/null and b/labs/docs/screenshots/setup3.png differ diff --git a/labs/docs/screenshots/setup4.png b/labs/docs/screenshots/setup4.png new file mode 100644 index 0000000000..c7927dbdd6 Binary files /dev/null and b/labs/docs/screenshots/setup4.png differ diff --git a/labs/docs/screenshots/ssh.png b/labs/docs/screenshots/ssh.png new file mode 100644 index 0000000000..3273c39989 Binary files /dev/null and b/labs/docs/screenshots/ssh.png differ diff --git a/labs/lab04.md b/labs/lab04.md index eefa858953..36efa60723 100644 --- a/labs/lab04.md +++ b/labs/lab04.md @@ -361,7 +361,6 @@ Use `aws_ami` data source to find latest Ubuntu image dynamically ☁️ GCP Terraform Guide **GCP Setup:** - **Authentication:** - Create service account in Google Cloud Console - Download JSON key file diff --git a/labs/lab05.md b/labs/lab05.md index a76d4960aa..c5a4902a67 100644 --- a/labs/lab05.md +++ b/labs/lab05.md @@ -466,7 +466,6 @@ Save and exit. **What is Ansible Vault?** Ansible Vault encrypts sensitive data so it can be safely stored in version control. - **Vault Commands:** ```bash