{
  "id": "100-days",
  "title": "Hands-on Labs",
  "subtitle": "100 Days of DevOps",
  "disclaimer": "Yêu cầu viết lại từ tiêu đề công khai của series 100 Days of DevOps. Không phải đề KodeKloud gốc. Thông số (user, ngày, path, port) do đề luyện tự đặt. Làm trên VM, Docker hoặc cluster của bạn.",
  "sourceTitle": "KodeKloud Engineer — practice",
  "sourceUrl": "https://engineer.kodekloud.com/practice",
  "blocks": [
    {
      "id": "linux",
      "title": "Linux",
      "range": "Ngày 1–20 · lab thêm"
    },
    {
      "id": "git",
      "title": "Git",
      "range": "Ngày 21–34"
    },
    {
      "id": "docker",
      "title": "Docker",
      "range": "Ngày 35–47"
    },
    {
      "id": "kubernetes",
      "title": "Kubernetes",
      "range": "Ngày 48–67"
    },
    {
      "id": "cicd",
      "title": "CI/CD",
      "range": "Jenkins · Ngày 68–81"
    },
    {
      "id": "ansible",
      "title": "Ansible",
      "range": "Ngày 82–93"
    }
  ],
  "labs": [
    {
      "id": "day-01",
      "day": 1,
      "block": "linux",
      "title": "Linux User Setup with Non-Interactive Shell",
      "goal": "Máy lab cần một account chạy service: tồn tại trong hệ thống nhưng không ai đăng nhập tương tác được bằng account đó. Khi xong, user `batch01` có home riêng và trường shell trỏ tới `nologin`.",
      "do": [
        "Tạo user `batch01` với home `/home/batch01` và login shell là đường dẫn `nologin` (không phải `/bin/bash`).",
        "Trong `/etc/passwd` (hoặc `getent passwd batch01`), cột shell phải đúng `/usr/sbin/nologin`.",
        "Thử đăng nhập login shell của user này phải bị từ chối (thông báo kiểu This account is currently not available)."
      ],
      "hints": {
        "commands": [
          "useradd -m -s /usr/sbin/nologin batch01",
          "getent passwd batch01",
          "su - batch01"
        ],
        "questions": [
          "Cờ `-s` đặt gì?",
          "`/usr/sbin/nologin` khác `/bin/bash` chỗ nào?"
        ],
        "explain": [
          {
            "term": "-m",
            "text": "Tạo home directory của user (ở đây /home/batch01) nếu chưa có."
          },
          {
            "term": "-s",
            "text": "Đặt login shell. Giá trị là đường dẫn file shell, không phải tên kiểu bash."
          },
          {
            "term": "/usr/sbin/nologin",
            "text": "Shell không tương tác: process in thông báo rồi thoát, không mở prompt. /bin/bash mở shell đăng nhập được."
          },
          {
            "term": "getent passwd",
            "text": "Đọc một dòng user từ NSS (passwd/LDAP). Cột cuối là shell."
          },
          {
            "term": "su -",
            "text": "Đăng nhập login shell của user đó. Với nologin sẽ bị từ chối."
          }
        ]
      },
      "given": [
        {
          "label": "Tên user",
          "value": "batch01"
        },
        {
          "label": "Login shell",
          "value": "/usr/sbin/nologin"
        },
        {
          "label": "Thư mục home",
          "value": "/home/batch01"
        }
      ],
      "task": "Tạo user không mở được login shell"
    },
    {
      "id": "day-02",
      "day": 2,
      "block": "linux",
      "title": "Temporary User Setup with Expiry",
      "goal": "Cần account dùng trong thời hạn rồi tự tắt. Khi xong, user `temp02` còn login shell bình thường, nhưng tài khoản hết hạn đúng `2026-12-31`. Đó là ngày tắt account, không phải hạn mật khẩu.",
      "do": [
        "Tạo user `temp02`, shell `/bin/bash`, và đặt ngày tài khoản hết hạn là `2026-12-31`.",
        "Đọc hạn của user: dòng Account expires phải là 31 Dec 2026 (hoặc `2026-12-31`).",
        "Dòng Password expires có thể vẫn never — đừng nhầm hai trường này."
      ],
      "hints": {
        "commands": [
          "useradd -m -s /bin/bash -e 2026-12-31 temp02",
          "chage -l temp02",
          "chage -E 2026-12-31 temp02"
        ],
        "questions": [
          "`useradd -e` khác `chage -M` chỗ nào?",
          "Ngày ghi theo định dạng nào?"
        ],
        "explain": [
          {
            "term": "-e",
            "text": "Ngày hết hạn tài khoản (YYYY-MM-DD). Hết ngày đó thì không login được."
          },
          {
            "term": "chage -M",
            "text": "Số ngày mật khẩu còn hạn. Khác -e: tài khoản vẫn sống nhưng bắt đổi password."
          },
          {
            "term": "chage -l",
            "text": "In hạn tài khoản và hạn mật khẩu. Xem dòng Account expires."
          },
          {
            "term": "chage -E",
            "text": "Đổi expire account sau khi user đã tồn tại. Cùng nghĩa với useradd -e."
          }
        ]
      },
      "given": [
        {
          "label": "Tên user",
          "value": "temp02"
        },
        {
          "label": "Ngày tắt tài khoản",
          "value": "2026-12-31"
        },
        {
          "label": "Login shell",
          "value": "/bin/bash"
        }
      ],
      "task": "Tạo user tạm hết hạn đúng một ngày"
    },
    {
      "id": "day-03",
      "day": 3,
      "block": "linux",
      "title": "Secure Root SSH Access",
      "goal": "Root không được đăng nhập SSH. User thường `deploy` vẫn SSH được. Cấu hình nằm trong `sshd_config` và phải có hiệu lực trên tiến trình sshd đang chạy.",
      "do": [
        "Trong `/etc/ssh/sshd_config`, đặt `PermitRootLogin no`. Nếu dòng đang bị comment (`#`) thì bỏ comment.",
        "Kiểm tra cấu hình sshd rồi nạp lại service (không restart mù nếu `-t` báo lỗi).",
        "SSH `root@127.0.0.1` phải bị từ chối. SSH `deploy@127.0.0.1` vẫn vào được (tạo user `deploy` nếu máy chưa có)."
      ],
      "hints": {
        "commands": [
          "PermitRootLogin no",
          "sshd -t",
          "systemctl reload sshd",
          "grep -n PermitRootLogin /etc/ssh/sshd_config"
        ],
        "questions": [
          "`no` khác `prohibit-password` chỗ nào?",
          "Sửa file mà quên reload thì sao?"
        ],
        "explain": [
          {
            "term": "PermitRootLogin no",
            "text": "sshd từ chối mọi đăng nhập user root (password và khóa)."
          },
          {
            "term": "prohibit-password",
            "text": "Root vẫn SSH được bằng khóa, chỉ cấm password. Lab này cần no."
          },
          {
            "term": "sshd -t",
            "text": "Kiểm tra cú pháp sshd_config, chưa đổi process đang chạy."
          },
          {
            "term": "reload sshd",
            "text": "Nạp lại config. Sửa file mà không reload thì phiên mới vẫn theo bản cũ."
          }
        ]
      },
      "given": [
        {
          "label": "File cấu hình",
          "value": "/etc/ssh/sshd_config"
        },
        {
          "label": "Chỉ thị cần có",
          "value": "PermitRootLogin no"
        },
        {
          "label": "User vẫn SSH được",
          "value": "deploy"
        }
      ],
      "task": "Cấm SSH bằng tài khoản root"
    },
    {
      "id": "day-04",
      "day": 4,
      "block": "linux",
      "title": "Script Execution Permissions",
      "goal": "Script backup chỉ owner `deploy` và group `ops` được chạy. User ngoài group không execute. Mode đúng `750`, không `777`.",
      "do": [
        "Tạo thư mục `/opt/scripts` và file `backup.sh`: dòng đầu shebang bash, khi chạy in `backup-ok`.",
        "User `deploy` và group `ops` phải tồn tại. Gán owner:group `deploy:ops` và mode `750`.",
        "`ls -l` phải ra `-rwxr-x---`. Chạy script bằng user `deploy` thành công. User ngoài group `ops` không execute được."
      ],
      "hints": {
        "commands": [
          "mkdir -p /opt/scripts",
          "chown deploy:ops /opt/scripts/backup.sh",
          "chmod 750 /opt/scripts/backup.sh",
          "ls -l /opt/scripts/backup.sh"
        ],
        "questions": [
          "Bit `x` thiếu thì bash báo gì?",
          "`chmod +x` khác `chmod 750` chỗ nào?"
        ],
        "explain": [
          {
            "term": "mkdir -p",
            "text": "Tạo thư mục, kể cả cha. Đã tồn tại thì không lỗi."
          },
          {
            "term": "chown deploy:ops",
            "text": "Owner là user deploy, group là ops."
          },
          {
            "term": "chmod 750",
            "text": "rwxr-x--- : owner rwx, group rx, others không gì. Bit x của owner/group mới chạy script."
          },
          {
            "term": "chmod +x",
            "text": "Chỉ bật bit execute, không đụng r/w. 750 chốt đủ ba bộ quyền."
          }
        ]
      },
      "given": [
        {
          "label": "File script",
          "value": "/opt/scripts/backup.sh"
        },
        {
          "label": "Owner:group",
          "value": "deploy:ops"
        },
        {
          "label": "Mode",
          "value": "750"
        }
      ],
      "task": "Đặt quyền chạy script cho đúng user và group"
    },
    {
      "id": "lab-linux-special-perms",
      "block": "linux",
      "title": "Special Permissions: SUID, SGID, Sticky Bit",
      "goal": "Ba bit đặc biệt không phải rwx thường. `passwd` chạy với quyền owner. Thư mục nhóm tạo file đúng group. Thư mục scratch không cho xóa file người khác. Không gắn setuid lên script.",
      "given": [
        {
          "label": "File có SUID (quan sát)",
          "value": "/usr/bin/passwd — owner root, kỳ vọng 4755 / -rwsr-xr-x"
        },
        {
          "label": "Thư mục sticky (quan sát)",
          "value": "/tmp — kỳ vọng 1777 / drwxrwxrwt"
        },
        {
          "label": "Thư mục nhóm (SGID)",
          "value": "/opt/lab/shared — 2775, group ops"
        },
        {
          "label": "File trong thư mục nhóm",
          "value": "/opt/lab/shared/note.txt — group phải là ops"
        },
        {
          "label": "Thư mục scratch (sticky)",
          "value": "/opt/lab/scratch — 1777"
        }
      ],
      "do": [
        "Đọc `/usr/bin/passwd`: chữ `s` chỗ owner, owner `root`, octal `4xxx` (thường `4755`). Đó là setuid.",
        "Đọc `/tmp`: `1777` và chữ `t` chỗ other. Đó là sticky / restricted deletion.",
        "Tạo `/opt/lab/shared` group `ops` mode `2775`. User thuộc `ops` tạo `note.txt` trong đó — group của file phải là `ops`, không phải group chính của user nếu khác `ops`.",
        "Tạo `/opt/lab/scratch` mode `1777`. User A tạo file; user B (không owner, không root) xóa file đó phải bị từ chối.",
        "Đừng `chmod u+s` một file `.sh` rồi coi là xong: Linux bỏ setuid trên script shebang. Binary mới có hiệu lực."
      ],
      "hints": {
        "commands": [
          "stat -c '%a %A %U %G' /usr/bin/passwd",
          "stat -c '%a %A' /tmp",
          "chmod 2775 /opt/lab/shared",
          "chmod g+s /opt/lab/shared",
          "chmod 1777 /opt/lab/scratch",
          "chmod +t /opt/lab/scratch",
          "stat -c '%a %A %G' /opt/lab/shared /opt/lab/shared/note.txt /opt/lab/scratch"
        ],
        "explain": [
          {
            "term": "4000 / u+s",
            "text": "setuid. File chạy với euid của owner. passwd thuộc root nên user thường ghi được đúng dòng của mình trong /etc/shadow."
          },
          {
            "term": "2000 / g+s",
            "text": "setgid. Trên file: process lấy egid của group file. Trên directory: file mới lấy group của thư mục cha."
          },
          {
            "term": "1000 / +t",
            "text": "sticky / restricted deletion. Trong directory, user thường chỉ xóa/đổi tên file mình sở hữu. /tmp là 1777."
          },
          {
            "term": "s vs S",
            "text": "s = bit đặc biệt + bit x. S (hoa) = bit đặc biệt, lớp đó không có x — file setuid kiểu 4644 không chạy được như 4755."
          },
          {
            "term": "t vs T",
            "text": "t = sticky + other có x. T = sticky, other không x."
          },
          {
            "term": "shebang",
            "text": "Kernel Linux bỏ setuid/setgid trên script. ls vẫn hiện s. Process vẫn là user đang gọi."
          },
          {
            "term": "chmod 755 dir",
            "text": "Trên directory đang 2775, muốn gỡ setgid thì g-s, 00755, hoặc =755. Đừng đoán 755 luôn xóa bit đặc biệt."
          }
        ],
        "questions": [
          "`-rwsr-xr-x` trên /usr/bin/passwd nghĩa là user thường làm được gì, và không làm được gì?",
          "Thư mục 775 không g+s khác 2775 chỗ file mới?",
          "777 không sticky khác 1777 chỗ nào?"
        ]
      },
      "task": "Đọc và đặt SUID, SGID, sticky bit"
    },
    {
      "id": "lab-linux-links",
      "block": "linux",
      "title": "Hard Link và Soft Link (Symlink)",
      "goal": "Hard link là tên thứ hai cùng inode: xóa một tên thì dữ liệu còn. Symlink là file mới chỉ chứa path: đích mất thì đứt. Hard không tạo cho thư mục và không cắt partition.",
      "given": [
        {
          "label": "Thư mục làm việc",
          "value": "/opt/lab/links"
        },
        {
          "label": "File nguồn",
          "value": "/opt/lab/links/report.txt — một dòng: link-lab-2026"
        },
        {
          "label": "Hard link",
          "value": "/opt/lab/links/report-hard"
        },
        {
          "label": "Soft link",
          "value": "/opt/lab/links/report-soft → report.txt"
        },
        {
          "label": "Symlink thư mục",
          "value": "/opt/lab/links/notes-soft → notes"
        }
      ],
      "do": [
        "Trong `/opt/lab/links` tạo `report.txt` đúng một dòng `link-lab-2026`, và thư mục `notes`.",
        "Tạo hard link `report-hard` và soft link `report-soft` trỏ `report.txt`. Tạo soft link thư mục `notes-soft` trỏ `notes`.",
        "`report.txt` và `report-hard` cùng số inode, số link ≥ 2. `report-soft` inode khác, kiểu symbolic link, `readlink` ra `report.txt`.",
        "Xóa `report.txt`. Đọc `report-hard` vẫn ra `link-lab-2026`. Đọc `report-soft` phải lỗi (broken link).",
        "Tạo hard link cho thư mục `notes` phải thất bại. `notes-soft` chữ đầu `l` và mũi tên `notes`. Đừng dùng `cp` thay `ln`."
      ],
      "hints": {
        "commands": [
          "ln report.txt report-hard",
          "ln -s report.txt report-soft",
          "ln -s notes notes-soft",
          "stat -c '%n inode=%i nlink=%h device=%d type=%F' report.txt report-hard report-soft",
          "readlink report-soft",
          "rm report.txt",
          "cat report-hard",
          "cat report-soft"
        ],
        "explain": [
          {
            "term": "ln TARGET LINK",
            "text": "Hard link. TARGET phải đã tồn tại. Thêm một tên cùng inode. Hai tên bình đẳng; không nhân bản nội dung."
          },
          {
            "term": "ln -s TARGET LINK",
            "text": "Soft link / symlink. File mới, inode riêng. Nội dung chỉ là chuỗi đường dẫn. TARGET lúc tạo không bắt buộc tồn tại."
          },
          {
            "term": "inode / %i",
            "text": "Chỉ số inode trên filesystem. Hard link cùng %i. Symlink %i khác file đích."
          },
          {
            "term": "nlink / %h",
            "text": "Số tên đang trỏ inode đó. Hard link tăng số này. Dữ liệu mất khi nlink = 0 và không process nào còn mở file."
          },
          {
            "term": "device / %d",
            "text": "Hard link phải cùng filesystem (cùng %d). Cắt sang partition khác thì ln không -s lỗi EXDEV."
          },
          {
            "term": "directory",
            "text": "Hard link không tạo cho thư mục (trừ trường hợp siêu hiếm superuser, đừng dùng). Shortcut thư mục: ln -s."
          },
          {
            "term": "path tương đối",
            "text": "Trong symlink, path tương đối giải theo thư mục chứa link, không theo pwd lúc bạn gõ ln."
          }
        ],
        "questions": [
          "`rm report.txt` rồi `cat report-hard` và `cat report-soft` mỗi cái ra sao, vì sao khác nhau?",
          "Khi nào bắt buộc `ln -s` thay vì `ln`?",
          "`stat` hai tên hard khác nhau chỗ nào, giống chỗ nào?"
        ]
      },
      "task": "Phân biệt hard link và symlink"
    },
    {
      "id": "day-05",
      "day": 5,
      "block": "linux",
      "title": "SELinux Installation and Configuration",
      "goal": "SELinux phải đang Enforcing ngay bây giờ, và vẫn Enforcing sau khi reboot. Mode tạm (`setenforce`) khác mode ghi trong file cấu hình.",
      "do": [
        "Nếu máy họ RHEL/Rocky mà `getenforce` báo Disabled vì thiếu gói thì cài SELinux trước.",
        "Bật enforcing ngay trên kernel đang chạy. `getenforce` phải in `Enforcing`.",
        "Sửa `/etc/selinux/config`: `SELINUX=enforcing` (không `permissive`, không `disabled`). Đây là giá trị sau reboot."
      ],
      "hints": {
        "commands": [
          "getenforce",
          "sestatus",
          "setenforce 1",
          "grep ^SELINUX= /etc/selinux/config"
        ],
        "questions": [
          "`setenforce 1` còn sau reboot không?",
          "enforcing khác permissive chỗ nào?"
        ],
        "explain": [
          {
            "term": "getenforce",
            "text": "In mode hiện tại: Enforcing, Permissive, hoặc Disabled."
          },
          {
            "term": "setenforce 1",
            "text": "Bật enforcing ngay, mất sau reboot. 0 = permissive tạm."
          },
          {
            "term": "SELINUX=",
            "text": "File /etc/selinux/config giữ mode sau reboot. enforcing / permissive / disabled."
          },
          {
            "term": "enforcing",
            "text": "Vi phạm policy bị chặn. permissive chỉ log, không chặn."
          }
        ]
      },
      "given": [
        {
          "label": "Mode đích",
          "value": "enforcing"
        },
        {
          "label": "File giữ sau reboot",
          "value": "/etc/selinux/config"
        },
        {
          "label": "Dòng cần có",
          "value": "SELINUX=enforcing"
        }
      ],
      "task": "Bật SELinux enforcing và giữ sau reboot"
    },
    {
      "id": "day-06",
      "day": 6,
      "block": "linux",
      "title": "Create a Cron Job",
      "goal": "Root có một dòng cron chạy `/usr/local/bin/daily-backup.sh` lúc 02:30 mỗi ngày. Không xóa crontab sẵn có. Lịch là năm trường phút-giờ-ngày-tháng-thứ.",
      "do": [
        "Tạo script `/usr/local/bin/daily-backup.sh` (shebang, in `backup`), mode `755`.",
        "Trong crontab của root, thêm đúng một dòng: `30 2 * * * /usr/local/bin/daily-backup.sh`.",
        "Liệt kê crontab và thấy dòng đó. Đừng gõ lệnh xóa toàn bộ crontab."
      ],
      "hints": {
        "commands": [
          "crontab -e",
          "crontab -l",
          "30 2 * * * /usr/local/bin/daily-backup.sh",
          "systemctl status cron || systemctl status crond"
        ],
        "questions": [
          "Năm trường cron theo thứ tự nào?",
          "`crontab -r` làm gì?"
        ],
        "explain": [
          {
            "term": "crontab -e",
            "text": "Sửa crontab của user hiện tại trong editor."
          },
          {
            "term": "crontab -l",
            "text": "In crontab, không sửa."
          },
          {
            "term": "30 2 * * *",
            "text": "Năm trường: phút giờ ngày-tháng tháng thứ. 30 2 * * * = 02:30 mỗi ngày."
          },
          {
            "term": "crontab -r",
            "text": "Xóa hết crontab của user. Đừng gõ nhầm khi đang xem."
          }
        ]
      },
      "given": [
        {
          "label": "User sở hữu cron",
          "value": "root"
        },
        {
          "label": "Lịch (phút giờ …)",
          "value": "30 2 * * *"
        },
        {
          "label": "Lệnh chạy",
          "value": "/usr/local/bin/daily-backup.sh"
        }
      ],
      "task": "Thêm cron của root chạy backup lúc 02:30"
    },
    {
      "id": "day-07",
      "day": 7,
      "block": "linux",
      "title": "Linux SSH Authentication",
      "goal": "Từ máy hiện tại SSH tới `deploy@127.0.0.1` bằng khóa ed25519. Không dùng password. Quyền thư mục `.ssh` trên đích phải đúng, không thì sshd bỏ qua key.",
      "do": [
        "User `deploy` tồn tại. Tạo cặp khóa ed25519 không passphrase nếu máy chưa có.",
        "Đưa public key vào `/home/deploy/.ssh/authorized_keys`. Thư mục `.ssh` mode `700`, file key mode `600`, owner `deploy`.",
        "SSH tắt password (`PasswordAuthentication=no`) tới `deploy@127.0.0.1` phải thoát mã 0."
      ],
      "hints": {
        "commands": [
          "ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519",
          "ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@127.0.0.1",
          "chmod 700 /home/deploy/.ssh",
          "chmod 600 /home/deploy/.ssh/authorized_keys"
        ],
        "questions": [
          "File nào trên server nhận public key?",
          "authorized_keys 644 thì sshd hay từ chối?"
        ],
        "explain": [
          {
            "term": "-t ed25519",
            "text": "Thuật toán khóa. ed25519 ngắn, phổ biến hơn rsa cho lab mới."
          },
          {
            "term": "-N ''",
            "text": "Passphrase rỗng — lab tự động, máy thật nên đặt passphrase."
          },
          {
            "term": "-f",
            "text": "Đường dẫn file khóa private. Public là cùng tên + .pub."
          },
          {
            "term": "ssh-copy-id -i",
            "text": "Append public key vào ~/.ssh/authorized_keys trên máy đích."
          },
          {
            "term": "700 / 600",
            "text": "sshd đòi .ssh = 700, authorized_keys = 600. 644 thường bị từ chối."
          }
        ]
      },
      "given": [
        {
          "label": "User đích",
          "value": "deploy"
        },
        {
          "label": "Host",
          "value": "127.0.0.1"
        },
        {
          "label": "Loại khóa",
          "value": "ed25519"
        },
        {
          "label": "File trên đích",
          "value": "/home/deploy/.ssh/authorized_keys"
        }
      ],
      "task": "SSH bằng khóa ed25519, không hỏi mật khẩu"
    },
    {
      "id": "day-08",
      "day": 8,
      "block": "linux",
      "title": "Install Ansible",
      "goal": "Chỉ cần Ansible có trên PATH. Bài này không viết playbook. Cài bằng package manager của distro.",
      "do": [
        "Cài gói `ansible` bằng apt hoặc dnf. Không bắt buộc pip.",
        "`ansible` phải nằm trên PATH. `ansible --version` in được dòng version, không `command not found`.",
        "Dừng ở bước cài. Playbook thuộc các ngày Ansible sau."
      ],
      "hints": {
        "commands": [
          "sudo apt-get install -y ansible",
          "sudo dnf install -y ansible",
          "ansible --version"
        ],
        "questions": [
          "Lệnh nào chứng minh đã cài xong?",
          "ansible-playbook khác ansible ad-hoc chỗ nào?"
        ],
        "explain": [
          {
            "term": "apt-get / dnf",
            "text": "Cài gói ansible theo distro. -y khỏi hỏi yes."
          },
          {
            "term": "ansible --version",
            "text": "Có lệnh và in được version là đã cài xong."
          },
          {
            "term": "ansible",
            "text": "Ad-hoc: một module, một group. ansible-playbook chạy file YAML nhiều task."
          }
        ]
      },
      "given": [
        {
          "label": "Gói cần có",
          "value": "ansible"
        },
        {
          "label": "Lệnh kiểm",
          "value": "ansible --version"
        },
        {
          "label": "Kỳ vọng",
          "value": "ansible [core 2.x] hoặc ansible 2.x"
        }
      ],
      "task": "Cài gói Ansible và kiểm version"
    },
    {
      "id": "day-09",
      "day": 9,
      "block": "linux",
      "title": "MariaDB Troubleshooting",
      "goal": "MariaDB (hoặc mysqld) đang active, lắng nghe `127.0.0.1:3306`, và `mysqladmin ping` trả lời alive. Đọc journal trước khi đoán.",
      "do": [
        "Xem status unit `mariadb` (hoặc `mysqld`). Nếu failed thì đọc journal để biết lý do.",
        "Sửa cấu hình hoặc quyền file rồi start lại. `bind-address` phải là `127.0.0.1`.",
        "Cổng `3306` đang listen. `mysqladmin ping` báo alive."
      ],
      "hints": {
        "commands": [
          "systemctl status mariadb",
          "journalctl -u mariadb -n 50",
          "ss -lnt | grep 3306",
          "mysqladmin ping"
        ],
        "questions": [
          "status inactive khác failed chỗ nào?",
          "Không có listener 3306 thì client thấy gì?"
        ],
        "explain": [
          {
            "term": "systemctl status",
            "text": "active = đang chạy. inactive = dừng sạch. failed = chết lỗi."
          },
          {
            "term": "journalctl -u -n 50",
            "text": "-u chọn unit, -n 50 lấy 50 dòng log mới."
          },
          {
            "term": "ss -lnt",
            "text": "Socket TCP đang listen. Không thấy 3306 thì client bị refuse."
          },
          {
            "term": "mysqladmin ping",
            "text": "Hỏi daemon còn sống. alive = nhận kết nối local."
          }
        ]
      },
      "given": [
        {
          "label": "Unit systemd",
          "value": "mariadb (một số máy: mysqld)"
        },
        {
          "label": "Listen",
          "value": "127.0.0.1:3306"
        },
        {
          "label": "Kiểm sống",
          "value": "mysqladmin ping"
        }
      ],
      "task": "Sửa MariaDB để service lên và nghe 3306"
    },
    {
      "id": "day-10",
      "day": 10,
      "block": "linux",
      "title": "Linux Bash Scripts",
      "goal": "Chạy `/home/lab/bin/welcome.sh` thì file `/home/lab/welcome.txt` xuất hiện, đúng một dòng `Welcome to Linux labs`. Script phải có bit thực thi.",
      "do": [
        "Tạo thư mục `/home/lab/bin` và script `welcome.sh`: shebang bash, ghi đúng chuỗi trên vào `/home/lab/welcome.txt`.",
        "Script mode `755` (có bit `x`).",
        "Chạy script bằng đường dẫn đầy đủ. `cat` file output khớp từng ký tự, không thừa dòng trống nếu đề không yêu cầu."
      ],
      "hints": {
        "commands": [
          "#!/bin/bash",
          "chmod 755 /home/lab/bin/welcome.sh",
          "/home/lab/bin/welcome.sh",
          "bash -n /home/lab/bin/welcome.sh"
        ],
        "questions": [
          "Thiếu shebang thì ai chạy file?",
          "`bash -n` kiểm tra gì?"
        ],
        "explain": [
          {
            "term": "#!/bin/bash",
            "text": "Shebang: kernel gọi bash để chạy file. Thiếu thì phụ thuộc ai exec (sh, lỗi)."
          },
          {
            "term": "chmod 755",
            "text": "Owner rwx, group/other rx. Script chạy được bằng đường dẫn."
          },
          {
            "term": "bash -n",
            "text": "Chỉ parse, không chạy. Bắt lỗi cú pháp."
          }
        ]
      },
      "given": [
        {
          "label": "File script",
          "value": "/home/lab/bin/welcome.sh"
        },
        {
          "label": "File output",
          "value": "/home/lab/welcome.txt"
        },
        {
          "label": "Nội dung ghi",
          "value": "Welcome to Linux labs"
        }
      ],
      "task": "Viết script ghi một dòng ra file"
    },
    {
      "id": "day-11",
      "day": 11,
      "block": "linux",
      "title": "Install and Configure Tomcat Server",
      "goal": "Tomcat chạy như service, lắng nghe `8080`. Trình duyệt hoặc `curl` tới `http://127.0.0.1:8080/` phải ra HTTP 200, không Connection refused.",
      "do": [
        "Cài Tomcat (gói distro hoặc tarball). Connector HTTP để port `8080`.",
        "Bật và start service để còn sau reboot.",
        "Cổng `8080` đang listen. `curl` trang mặc định trả 200."
      ],
      "hints": {
        "commands": [
          "systemctl status tomcat || systemctl status tomcat9",
          "ss -lnt | grep 8080",
          "curl -sS -D - http://127.0.0.1:8080 -o /dev/null"
        ],
        "questions": [
          "Tomcat mặc định hay nghe port nào?",
          "Service active mà curl refused — xem gì?"
        ],
        "explain": [
          {
            "term": "8080",
            "text": "Connector HTTP mặc định của Tomcat."
          },
          {
            "term": "ss -lnt",
            "text": "Có :8080 listen thì process đang mở cổng, không chỉ unit xanh."
          },
          {
            "term": "curl -sS -D -",
            "text": "-D - in header ra stdout. -o /dev/null bỏ body. Xem HTTP status."
          }
        ]
      },
      "given": [
        {
          "label": "Service",
          "value": "tomcat (hoặc tomcat9)"
        },
        {
          "label": "Listen",
          "value": "0.0.0.0:8080"
        },
        {
          "label": "Kiểm HTTP",
          "value": "http://127.0.0.1:8080/ → 200"
        }
      ],
      "task": "Cài Tomcat nghe cổng 8080"
    },
    {
      "id": "day-12",
      "day": 12,
      "block": "linux",
      "title": "Linux Network Services",
      "goal": "Unit nginx vừa active vừa enabled. Socket `:80` thực sự đang listen — unit xanh mà chưa bind port thì chưa xong.",
      "do": [
        "Cài nginx nếu thiếu, rồi enable và start.",
        "`is-active` = `active`, `is-enabled` = `enabled`.",
        "Có process listen `:80`. Phân biệt service đang chạy với socket chưa mở."
      ],
      "hints": {
        "commands": [
          "systemctl enable --now nginx",
          "systemctl is-active nginx",
          "ss -tuln | grep ':80 '"
        ],
        "questions": [
          "status xanh có nghĩa port đã mở?",
          "`ss -tuln` khác `ss -tulnp` chỗ nào?"
        ],
        "explain": [
          {
            "term": "enable --now",
            "text": "enable = lên cùng boot. --now = start luôn."
          },
          {
            "term": "is-active",
            "text": "Chỉ hỏi unit. Port mở phải xem ss."
          },
          {
            "term": "ss -tuln",
            "text": "t=tcp u=udp l=listen n=số, không resolve tên. Thêm p thì hiện process."
          }
        ]
      },
      "given": [
        {
          "label": "Service",
          "value": "nginx"
        },
        {
          "label": "Cổng",
          "value": "80/tcp"
        },
        {
          "label": "Khi boot",
          "value": "enabled"
        }
      ],
      "task": "Bật nginx lúc boot và nghe cổng 80"
    },
    {
      "id": "day-13",
      "day": 13,
      "block": "linux",
      "title": "IPtables Installation And Configuration",
      "goal": "Chain INPUT của bảng filter có rule ACCEPT TCP dport 8080 ở dòng đầu. Không xóa rule cũ (`-F`).",
      "do": [
        "Cài `iptables` nếu thiếu. Đọc chain INPUT kèm số dòng trước khi sửa.",
        "Chèn rule ACCEPT TCP cổng `8080` vào đầu INPUT.",
        "Dòng 1 phải là rule đó. Đừng flush toàn bộ chain."
      ],
      "hints": {
        "commands": [
          "iptables -L INPUT -n -v --line-numbers",
          "iptables -I INPUT 1 -p tcp --dport 8080 -j ACCEPT",
          "iptables -C INPUT -p tcp --dport 8080 -j ACCEPT"
        ],
        "questions": [
          "`-A` chèn vào đâu trong chain?",
          "Policy `-P` chỉ nhận ACCEPT hay DROP?"
        ],
        "explain": [
          {
            "term": "-L -n -v",
            "text": "Liệt kê rule. -n không DNS. -v đếm packet. --line-numbers để thấy vị trí."
          },
          {
            "term": "-I INPUT 1",
            "text": "Chèn rule vào đầu chain (vị trí 1). Gặp trước các rule phía dưới."
          },
          {
            "term": "-A",
            "text": "Append cuối chain. Policy DROP mà ACCEPT nằm sau DROP thì không bao giờ tới."
          },
          {
            "term": "-C",
            "text": "Kiểm tra rule đã có chưa, không thêm mới."
          },
          {
            "term": "-P",
            "text": "Policy mặc định của chain khi không rule nào khớp: ACCEPT hoặc DROP."
          }
        ]
      },
      "given": [
        {
          "label": "Bảng / chain",
          "value": "filter / INPUT"
        },
        {
          "label": "Rule",
          "value": "tcp dport 8080 → ACCEPT"
        },
        {
          "label": "Vị trí",
          "value": "đầu chain (insert dòng 1)"
        }
      ],
      "task": "Chèn rule iptables ACCEPT cổng 8080"
    },
    {
      "id": "day-14",
      "day": 14,
      "block": "linux",
      "title": "Linux Process Troubleshooting",
      "goal": "Có script `/opt/loop.sh` đang chạy vòng. Tìm PID rồi gửi SIGTERM trước. Chỉ SIGKILL khi process không chịu chết. Khi xong không còn PID của script đó.",
      "do": [
        "Nếu máy chưa có process này: tạo script vòng lặp rồi chạy nền — đó là tình huống lab.",
        "Tìm PID theo đường dẫn `/opt/loop.sh`. Gửi SIGTERM (không `-9` ngay).",
        "Đợi khoảng 2 giây. Còn sống mới SIGKILL. Kiểm lại: không còn process đó."
      ],
      "hints": {
        "commands": [
          "pgrep -af /opt/loop.sh",
          "ps aux | grep loop.sh",
          "kill PID",
          "kill -9 PID"
        ],
        "questions": [
          "`kill` không số gửi signal gì?",
          "Khi nào mới dùng SIGKILL?"
        ],
        "explain": [
          {
            "term": "pgrep -af",
            "text": "-a in đủ command line, -f khớp cả args, không chỉ tên process."
          },
          {
            "term": "kill PID",
            "text": "Không ghi số = SIGTERM (15). Process kịp dọn rồi thoát."
          },
          {
            "term": "kill -9",
            "text": "SIGKILL. Kernel cắt ngay, không handler. Chỉ khi TERM không chết."
          }
        ]
      },
      "given": [
        {
          "label": "Process cần dừng",
          "value": "/opt/loop.sh"
        },
        {
          "label": "Tín hiệu thử trước",
          "value": "SIGTERM (kill thường, không -9)"
        },
        {
          "label": "Khi xong",
          "value": "không còn PID của /opt/loop.sh"
        }
      ],
      "task": "Tìm process chiếm CPU và dừng bằng SIGTERM"
    },
    {
      "id": "day-15",
      "day": 15,
      "block": "linux",
      "title": "Setup SSL for Nginx",
      "goal": "Nginx nghe 443, dùng cert/key tự ký cho `lab.local`. `curl` HTTPS (bỏ qua verify) tới 127.0.0.1 trả 200.",
      "do": [
        "Tạo cặp cert/key tự ký RSA 2048, CN `lab.local`, ghi đúng hai path trên.",
        "Server block: `listen 443 ssl`, `server_name lab.local`, trỏ `ssl_certificate` và `ssl_certificate_key`.",
        "Kiểm cấu hình nginx rồi reload. `curl` HTTPS (không verify cert) ra 200."
      ],
      "hints": {
        "commands": [
          "openssl req -x509 -newkey rsa:2048 -noenc -days 365 -subj /CN=lab.local -keyout /etc/nginx/ssl/lab.key -out /etc/nginx/ssl/lab.crt",
          "listen 443 ssl;",
          "nginx -t",
          "curl -kI https://127.0.0.1/"
        ],
        "questions": [
          "`ssl on` còn dùng không?",
          "`curl` fail verify trên self-signed có nghĩa Nginx sai?"
        ],
        "explain": [
          {
            "term": "-x509",
            "text": "Ra certificate tự ký, không cần CSR/CA."
          },
          {
            "term": "-newkey rsa:2048",
            "text": "Tạo luôn cặp khóa RSA 2048."
          },
          {
            "term": "-noenc",
            "text": "Private key không passphrase — Nginx start không hỏi mật khẩu."
          },
          {
            "term": "-days 365",
            "text": "Certificate hết hạn sau 365 ngày."
          },
          {
            "term": "listen 443 ssl",
            "text": "Nginx 1.x: ssl trên listen. Chỉ thị ssl on đã bỏ."
          },
          {
            "term": "curl -k",
            "text": "-k bỏ verify CA. Self-signed fail verify là bình thường, không nghĩa Nginx sai."
          }
        ]
      },
      "given": [
        {
          "label": "CN / server_name",
          "value": "lab.local"
        },
        {
          "label": "Certificate",
          "value": "/etc/nginx/ssl/lab.crt"
        },
        {
          "label": "Private key",
          "value": "/etc/nginx/ssl/lab.key"
        },
        {
          "label": "Listen",
          "value": "443 ssl"
        }
      ],
      "task": "Cấu hình Nginx HTTPS với cert tự ký"
    },
    {
      "id": "day-16",
      "day": 16,
      "block": "linux",
      "title": "Install and Configure Nginx as an LBR",
      "goal": "Nginx listen 80, `upstream app_pool` gồm hai server local 9001 và 9002, `location /` chuyển sang pool. Mặc định round-robin: vài request lần lượt vào hai backend.",
      "do": [
        "Chạy hai process giả backend trên `9001` và `9002` (http.server hoặc tương đương) để còn gì mà cân bằng.",
        "Khai `upstream app_pool` đúng hai server, `location /` `proxy_pass` sang `http://app_pool`.",
        "Kiểm nginx rồi reload. Gọi `http://127.0.0.1/` vài lần — request phải xoay hai backend."
      ],
      "hints": {
        "commands": [
          "upstream app_pool { server 127.0.0.1:9001; server 127.0.0.1:9002; }",
          "proxy_pass http://app_pool;",
          "nginx -t",
          "systemctl reload nginx"
        ],
        "questions": [
          "Không ghi `weight` thì chia thế nào?",
          "Sai tên upstream thì Nginx báo gì?"
        ],
        "explain": [
          {
            "term": "upstream",
            "text": "Đặt tên nhóm backend. proxy_pass phải ghi đúng tên đó."
          },
          {
            "term": "server 127.0.0.1:9001",
            "text": "Một backend. Không weight thì round-robin đều."
          },
          {
            "term": "proxy_pass http://app_pool",
            "text": "Chuyển request tới nhóm. Sai tên thì nginx -t hoặc reload lỗi."
          }
        ]
      },
      "given": [
        {
          "label": "Tên upstream",
          "value": "app_pool"
        },
        {
          "label": "Backend 1",
          "value": "127.0.0.1:9001"
        },
        {
          "label": "Backend 2",
          "value": "127.0.0.1:9002"
        },
        {
          "label": "proxy_pass",
          "value": "http://app_pool;"
        }
      ],
      "task": "Nginx làm load balancer cho hai backend"
    },
    {
      "id": "day-17",
      "day": 17,
      "block": "linux",
      "title": "Install and Configure PostgreSQL",
      "goal": "PostgreSQL đang chạy, chỉ listen localhost. Role `shop` sở hữu database `shopdb`. Kết nối bằng user/password lab được `SELECT 1`.",
      "do": [
        "Cài PostgreSQL, enable và start service.",
        "Tạo role `shop`, database `shopdb` thuộc `shop`, mật khẩu `ShopLab17`.",
        "`listen_addresses` = `localhost`. Kết nối `shop` / `shopdb` trên `127.0.0.1` chạy được `SELECT 1`."
      ],
      "hints": {
        "commands": [
          "systemctl status postgresql",
          "sudo -u postgres createuser shop",
          "sudo -u postgres createdb -O shop shopdb",
          "sudo -u postgres psql -c \"ALTER USER shop PASSWORD 'ShopLab17';\""
        ],
        "questions": [
          "User OS `postgres` khác role trong SQL chỗ nào?",
          "listen_addresses mặc định thường là gì?"
        ],
        "explain": [
          {
            "term": "createuser / createdb",
            "text": "Tạo role và database trên cluster PostgreSQL, chạy bằng OS user postgres."
          },
          {
            "term": "-O shop",
            "text": "Owner của database shopdb là role shop."
          },
          {
            "term": "OS postgres",
            "text": "Tài khoản Linux chạy process. Role SQL là user trong database, khác nhau."
          },
          {
            "term": "listen_addresses",
            "text": "Mặc định thường localhost — chỉ nhận 127.0.0.1."
          }
        ]
      },
      "given": [
        {
          "label": "Role SQL",
          "value": "shop"
        },
        {
          "label": "Database",
          "value": "shopdb"
        },
        {
          "label": "Mật khẩu role",
          "value": "ShopLab17"
        },
        {
          "label": "listen_addresses",
          "value": "localhost"
        }
      ],
      "task": "Cài PostgreSQL, tạo role shop và database shopdb"
    },
    {
      "id": "day-18",
      "day": 18,
      "block": "linux",
      "title": "Configure LAMP server",
      "goal": "Apache, MariaDB và PHP cùng máy. Mở `/info.php` ra HTML phpinfo, không in nguyên source `<?php`. Đó là dấu PHP đã được module xử lý.",
      "do": [
        "Cài Apache (apache2 hoặc httpd), MariaDB, PHP và module PHP của Apache. Enable + start web và DB.",
        "Đặt `/var/www/html/info.php` đúng một câu `phpinfo()`.",
        "Gọi URL: body có `PHP Version`, không phải chữ `<?php` trần."
      ],
      "hints": {
        "commands": [
          "systemctl enable --now apache2",
          "systemctl enable --now mariadb",
          "echo '<?php phpinfo();' > /var/www/html/info.php",
          "curl -sS http://127.0.0.1/info.php"
        ],
        "questions": [
          "curl ra source PHP nghĩa là thiếu gì?",
          "Apache và MariaDB là hai unit riêng?"
        ],
        "explain": [
          {
            "term": "enable --now",
            "text": "Bật unit và start ngay. Apache và MariaDB là hai service riêng."
          },
          {
            "term": "phpinfo()",
            "text": "In thông tin PHP. curl ra HTML có PHP Version là module PHP đã gắn."
          },
          {
            "term": "source <?php",
            "text": "curl thấy nguyên mã = Apache không đưa file cho PHP (thiếu module hoặc handler)."
          }
        ]
      },
      "given": [
        {
          "label": "Stack",
          "value": "Apache + MariaDB + PHP"
        },
        {
          "label": "File thử",
          "value": "/var/www/html/info.php"
        },
        {
          "label": "Nội dung file",
          "value": "<?php phpinfo();"
        },
        {
          "label": "URL",
          "value": "http://127.0.0.1/info.php"
        }
      ],
      "task": "Dựng LAMP và chạy được file PHP"
    },
    {
      "id": "day-19",
      "day": 19,
      "block": "linux",
      "title": "Install and Configure Web Application",
      "goal": "`http://127.0.0.1/catalog/` trả 200, thân trang chứa `Catalog OK`. Không 404 vì thiếu `index.html` hoặc sai docroot.",
      "do": [
        "Tạo thư mục `catalog` dưới docroot và file `index.html` nội dung `Catalog OK`.",
        "Gán owner cho user chạy Apache (`www-data` trên Debian, `apache` trên RHEL).",
        "Gọi URL: status 200, body có `Catalog OK`."
      ],
      "hints": {
        "commands": [
          "mkdir -p /var/www/html/catalog",
          "echo -n 'Catalog OK' > /var/www/html/catalog/index.html",
          "chown -R www-data:www-data /var/www/html/catalog",
          "curl -sS -D - http://127.0.0.1/catalog/"
        ],
        "questions": [
          "404 là thiếu file hay sai root?",
          "403 thường do quyền hay thiếu index?"
        ],
        "explain": [
          {
            "term": "mkdir -p",
            "text": "Tạo docroot catalog, kể cả cha."
          },
          {
            "term": "chown -R",
            "text": "Worker Apache (www-data) phải đọc được cây file."
          },
          {
            "term": "404",
            "text": "URL không khớp file/root. 403 = có path nhưng bị cấm (quyền hoặc thiếu index)."
          }
        ]
      },
      "given": [
        {
          "label": "Docroot app",
          "value": "/var/www/html/catalog"
        },
        {
          "label": "File index",
          "value": "/var/www/html/catalog/index.html"
        },
        {
          "label": "Nội dung",
          "value": "Catalog OK"
        },
        {
          "label": "URL",
          "value": "http://127.0.0.1/catalog/"
        },
        {
          "label": "Owner",
          "value": "www-data:www-data (httpd: apache:apache)"
        }
      ],
      "task": "Xuất bản thư mục web catalog"
    },
    {
      "id": "day-20",
      "day": 20,
      "block": "linux",
      "title": "Configure Nginx + PHP-FPM Using Unix Sock",
      "goal": "Request `.php` đi Unix socket của PHP-FPM, không CGI cổ. `ping.php` in `pong`, không 502 Bad Gateway.",
      "do": [
        "Pool PHP-FPM listen đúng `/run/php/php-fpm.sock`. File socket tồn tại khi php-fpm active.",
        "Trong nginx, `location ~ \\.php$` chuyển FastCGI tới socket đó và set `SCRIPT_FILENAME`.",
        "Tạo `ping.php` in `pong`. Kiểm nginx, reload. `curl` khớp `pong`, không 502."
      ],
      "hints": {
        "commands": [
          "fastcgi_pass unix:/run/php/php-fpm.sock;",
          "include fastcgi_params;",
          "echo '<?php echo \"pong\";' > /var/www/html/ping.php",
          "curl -sS http://127.0.0.1/ping.php"
        ],
        "questions": [
          "502 khác 404 chỗ nào?",
          "Vì sao không `proxy_pass` vào FPM?"
        ],
        "explain": [
          {
            "term": "fastcgi_pass unix:",
            "text": "Đưa request PHP tới FPM qua socket Unix. Path phải trùng listen của pool."
          },
          {
            "term": "fastcgi_params",
            "text": "Bộ biến FastCGI chuẩn. SCRIPT_FILENAME thường phải set thêm."
          },
          {
            "term": "502",
            "text": "Nginx tới được nhưng FPM chết hoặc sai socket. 404 là không thấy file .php."
          },
          {
            "term": "proxy_pass",
            "text": "Dùng cho HTTP upstream. FPM nói FastCGI, không phải HTTP."
          }
        ]
      },
      "given": [
        {
          "label": "Unix socket",
          "value": "/run/php/php-fpm.sock"
        },
        {
          "label": "fastcgi_pass",
          "value": "unix:/run/php/php-fpm.sock;"
        },
        {
          "label": "File PHP",
          "value": "/var/www/html/ping.php"
        },
        {
          "label": "Output",
          "value": "pong"
        }
      ],
      "task": "Nginx chuyển PHP qua PHP-FPM Unix socket"
    },
    {
      "id": "day-21",
      "day": 21,
      "block": "git",
      "title": "Set Up Git Repository on Storage Server",
      "goal": "`/srv/git/demo.git` là repo bare: không working tree, dùng làm remote. `HEAD` trỏ `refs/heads/main`.",
      "do": [
        "Tạo repo bare tại `/srv/git/demo.git`, branch mặc định `main`.",
        "File `HEAD` tồn tại và chứa `ref: refs/heads/main`.",
        "Không clone hay checkout vào chính thư mục bare đó."
      ],
      "hints": {
        "commands": [
          "git init --bare -b main /srv/git/demo.git",
          "cat /srv/git/demo.git/HEAD",
          "ls /srv/git/demo.git"
        ],
        "questions": [
          "`--bare` khác repo thường chỗ nào?",
          "`-b` đặt gì?"
        ],
        "explain": [
          {
            "term": "--bare",
            "text": "Repo không working tree. Chỉ objects + refs — chỗ nhận push."
          },
          {
            "term": "-b main",
            "text": "Nhánh mặc định tên main (HEAD → refs/heads/main)."
          },
          {
            "term": "HEAD",
            "text": "Trong bare, file HEAD là ref: refs/heads/main, không phải checkout."
          }
        ]
      },
      "given": [
        {
          "label": "Đường dẫn repo",
          "value": "/srv/git/demo.git"
        },
        {
          "label": "Loại",
          "value": "bare (không working tree)"
        },
        {
          "label": "Branch mặc định",
          "value": "main"
        }
      ],
      "task": "Tạo Git repo bare để nhận push"
    },
    {
      "id": "day-22",
      "day": 22,
      "block": "git",
      "title": "Clone Git Repository on Storage Server",
      "goal": "`/home/lab/demo` là bản clone của `/srv/git/demo.git`. Branch đang đứng là `main`, remote `origin` trỏ đúng bare.",
      "do": [
        "Bare phải có ít nhất một commit trên `main`. Nếu trống: clone tạm, commit, push, rồi xóa bản tạm.",
        "Clone bare vào `/home/lab/demo`, đứng trên `main`.",
        "Trong clone: remote `origin` URL đúng `/srv/git/demo.git`, working tree sạch."
      ],
      "hints": {
        "commands": [
          "git clone -b main /srv/git/demo.git /home/lab/demo",
          "git -C /home/lab/demo status",
          "git -C /home/lab/demo remote -v"
        ],
        "questions": [
          "Clone bare repo rồi làm việc — thiếu bước nào?",
          "`-b` lúc clone chọn gì?"
        ],
        "explain": [
          {
            "term": "git clone -b",
            "text": "Checkout đúng branch sau khi clone. -b main = đứng trên main."
          },
          {
            "term": "git -C DIR",
            "text": "Chạy git như đang đứng trong DIR, không cần cd."
          },
          {
            "term": "bare → clone",
            "text": "Bare không có file để sửa. Phải clone ra working copy rồi mới commit."
          }
        ]
      },
      "given": [
        {
          "label": "Repo nguồn (bare)",
          "value": "/srv/git/demo.git"
        },
        {
          "label": "Thư mục clone",
          "value": "/home/lab/demo"
        },
        {
          "label": "Branch",
          "value": "main"
        }
      ],
      "task": "Clone repo bare ra working copy"
    },
    {
      "id": "day-23",
      "day": 23,
      "block": "git",
      "title": "Fork a Git Repository",
      "goal": "Fork không phải clone thường. Working copy `/home/lab/demo-fork` đẩy lên fork (`origin`) và theo dõi repo gốc (`upstream`).",
      "do": [
        "Tạo bản bare `/srv/git/demo-fork.git` từ `demo.git` — đó là fork local.",
        "Clone fork thành `/home/lab/demo-fork` (`origin` = fork).",
        "Thêm remote `upstream` trỏ repo gốc. `git remote -v` có đủ cả hai."
      ],
      "hints": {
        "commands": [
          "git clone --bare /srv/git/demo.git /srv/git/demo-fork.git",
          "git remote add upstream /srv/git/demo.git",
          "git remote -v"
        ],
        "questions": [
          "`remote add origin` khi origin đã có thì sao?",
          "Fetch từ đâu, push lên đâu trên fork?"
        ],
        "explain": [
          {
            "term": "clone --bare",
            "text": "Copy repo thành bare thứ hai — fork local."
          },
          {
            "term": "remote add upstream",
            "text": "Thêm remote tên mới. origin đã có từ lúc clone fork."
          },
          {
            "term": "origin / upstream",
            "text": "Push lên origin (fork). Fetch từ upstream (gốc) rồi rebase/merge."
          }
        ]
      },
      "given": [
        {
          "label": "Repo gốc (upstream)",
          "value": "/srv/git/demo.git"
        },
        {
          "label": "Repo fork (origin)",
          "value": "/srv/git/demo-fork.git"
        },
        {
          "label": "Working copy",
          "value": "/home/lab/demo-fork"
        }
      ],
      "task": "Tạo fork local với origin và upstream"
    },
    {
      "id": "day-24",
      "day": 24,
      "block": "git",
      "title": "Git Create Branches",
      "goal": "Trong `/home/lab/demo` có branch `feature/auth` cắt từ `main`. HEAD đang ở `feature/auth`, `main` vẫn còn.",
      "do": [
        "Đứng trên `main` trước khi cắt nhánh.",
        "Tạo và chuyển sang `feature/auth`.",
        "Branch hiện tại là `feature/auth`. Danh sách branch thấy cả `main` và `feature/auth`."
      ],
      "hints": {
        "commands": [
          "git switch main",
          "git switch -c feature/auth",
          "git branch --show-current"
        ],
        "questions": [
          "`git branch feature/auth` có chuyển HEAD không?",
          "`switch -c` khác `branch` chỗ nào?"
        ],
        "explain": [
          {
            "term": "git branch TÊN",
            "text": "Tạo branch, HEAD vẫn đứng chỗ cũ."
          },
          {
            "term": "switch -c",
            "text": "Tạo và chuyển HEAD sang branch mới. checkout -b cùng nghĩa."
          },
          {
            "term": "--show-current",
            "text": "In tên branch đang đứng."
          }
        ]
      },
      "given": [
        {
          "label": "Repo",
          "value": "/home/lab/demo"
        },
        {
          "label": "Branch mới",
          "value": "feature/auth"
        },
        {
          "label": "Cắt từ",
          "value": "main"
        }
      ],
      "task": "Tạo branch feature/auth từ main"
    },
    {
      "id": "day-25",
      "day": 25,
      "block": "git",
      "title": "Git Merge Branches",
      "goal": "Đứng trên `main` rồi merge `feature/auth`. Mọi commit của feature đã nằm trên `main`. Không để conflict treo.",
      "do": [
        "`feature/auth` phải có ít nhất một commit chưa có trên `main` (nếu chưa có thì commit trên feature trước).",
        "Chuyển `main` rồi merge `feature/auth`.",
        "Không còn conflict. `feature/auth` là tổ tiên của `main` (mọi commit feature đã vào main)."
      ],
      "hints": {
        "commands": [
          "git switch main",
          "git merge feature/auth",
          "git log --oneline --graph"
        ],
        "questions": [
          "Merge khi đang đứng nhầm branch thì commit vào đâu?",
          "Fast-forward khác merge commit chỗ nào?"
        ],
        "explain": [
          {
            "term": "git merge NGUỒN",
            "text": "Gộp NGUỒN vào branch hiện tại. Đứng nhầm thì commit merge nằm nhầm chỗ."
          },
          {
            "term": "fast-forward",
            "text": "main chỉ tiến pointer, không tạo merge commit."
          },
          {
            "term": "merge commit",
            "text": "Hai nhánh lệch: Git tạo commit hai parent."
          }
        ]
      },
      "given": [
        {
          "label": "Repo",
          "value": "/home/lab/demo"
        },
        {
          "label": "Nhánh đích",
          "value": "main"
        },
        {
          "label": "Nhánh nguồn",
          "value": "feature/auth"
        }
      ],
      "task": "Merge feature/auth vào main"
    },
    {
      "id": "day-26",
      "day": 26,
      "block": "git",
      "title": "Git Manage Remotes",
      "goal": "Repo `/home/lab/demo` có thêm remote `mirror` trỏ `/srv/git/demo-mirror.git`. `origin` không bị ghi đè.",
      "do": [
        "Tạo repo bare `/srv/git/demo-mirror.git` nếu chưa có.",
        "Thêm remote tên `mirror` với URL đó.",
        "Nếu URL sai thì sửa URL, đừng `remote add origin` lần hai."
      ],
      "hints": {
        "commands": [
          "git remote -v",
          "git remote add mirror /srv/git/demo-mirror.git",
          "git remote set-url mirror /srv/git/demo-mirror.git"
        ],
        "questions": [
          "`add origin` lần hai thì sao?",
          "set-url khác add chỗ nào?"
        ],
        "explain": [
          {
            "term": "remote add",
            "text": "Thêm remote tên mới. Trùng tên thì lỗi."
          },
          {
            "term": "remote set-url",
            "text": "Đổi URL remote đã có, giữ nguyên tên."
          },
          {
            "term": "remote -v",
            "text": "In fetch/push URL của mọi remote."
          }
        ]
      },
      "given": [
        {
          "label": "Repo",
          "value": "/home/lab/demo"
        },
        {
          "label": "Remote thêm",
          "value": "mirror"
        },
        {
          "label": "URL remote",
          "value": "/srv/git/demo-mirror.git"
        }
      ],
      "task": "Thêm remote mirror, giữ nguyên origin"
    },
    {
      "id": "day-27",
      "day": 27,
      "block": "git",
      "title": "Git Revert Some Changes",
      "goal": "Commit đang HEAD bị hoàn tác bằng một commit mới. Lịch sử còn commit cũ. Không xóa lịch sử bằng reset.",
      "do": [
        "Xem vài commit gần nhất và ghi hash của HEAD.",
        "Revert HEAD (không mở editor nếu lab cho phép `--no-edit`).",
        "Log có thêm commit message Revert. Commit cũ vẫn còn. Đừng `reset --hard`."
      ],
      "hints": {
        "commands": [
          "git log --oneline -5",
          "git revert HEAD --no-edit",
          "git show HEAD"
        ],
        "questions": [
          "revert khác reset --hard chỗ nào?",
          "Revert merge commit cần thêm flag nào hay gặp?"
        ],
        "explain": [
          {
            "term": "git revert HEAD",
            "text": "Tạo commit mới đảo diff của HEAD. Lịch sử cũ còn."
          },
          {
            "term": "--no-edit",
            "text": "Giữ message Revert mặc định, không mở editor."
          },
          {
            "term": "reset --hard",
            "text": "Dời branch, xóa commit khỏi tip. Đã share thì phá history người khác."
          },
          {
            "term": "revert -m",
            "text": "Revert merge commit phải chọn parent chính, hay gặp -m 1."
          }
        ]
      },
      "given": [
        {
          "label": "Repo",
          "value": "/home/lab/demo"
        },
        {
          "label": "Commit cần hoàn tác",
          "value": "HEAD (commit mới nhất trên branch hiện tại)"
        },
        {
          "label": "Cách làm",
          "value": "git revert — tạo commit mới"
        }
      ],
      "task": "Hoàn tác commit bằng revert, giữ lịch sử"
    },
    {
      "id": "day-28",
      "day": 28,
      "block": "git",
      "title": "Git Cherry Pick",
      "goal": "Đứng `main`, lấy đúng commit tip của `feature/auth`. `main` có thay đổi đó, không merge cả branch.",
      "do": [
        "Lấy hash tip của `feature/auth`.",
        "Chuyển `main` rồi cherry-pick đúng hash đó.",
        "Commit mới nhất trên `main` mang thay đổi đó. Nếu conflict: sửa, add, rồi `--continue`."
      ],
      "hints": {
        "commands": [
          "git rev-parse feature/auth",
          "git switch main",
          "git cherry-pick HASH"
        ],
        "questions": [
          "Cherry-pick copy diff hay copy hash cũ?",
          "Đang đứng sai branch thì commit mới nằm đâu?"
        ],
        "explain": [
          {
            "term": "rev-parse",
            "text": "In hash đầy đủ của ref (tip feature/auth)."
          },
          {
            "term": "cherry-pick",
            "text": "Áp diff của commit đó lên HEAD hiện tại. Hash mới, không giữ hash cũ."
          },
          {
            "term": "sai branch",
            "text": "Commit cherry-pick nằm trên branch đang đứng."
          }
        ]
      },
      "given": [
        {
          "label": "Repo",
          "value": "/home/lab/demo"
        },
        {
          "label": "Lấy commit từ",
          "value": "feature/auth (tip)"
        },
        {
          "label": "Đặt lên",
          "value": "main"
        }
      ],
      "task": "Cherry-pick một commit sang main"
    },
    {
      "id": "day-29",
      "day": 29,
      "block": "git",
      "title": "Manage Git Pull Requests",
      "goal": "Thay đổi vào `main` qua pull request, không merge thẳng local rồi đẩy. PR còn mở trên remote.",
      "do": [
        "Push `feature/auth` lên `origin` và set upstream.",
        "Mở PR: base `main`, head `feature/auth`, title và body rõ (lab: Add auth / lab-29) — CLI `gh` hoặc UI host.",
        "Danh sách PR thấy một PR đang mở."
      ],
      "hints": {
        "commands": [
          "git push -u origin feature/auth",
          "gh pr create --base main --head feature/auth --title 'Add auth'",
          "gh pr list"
        ],
        "questions": [
          "`-u` làm gì?",
          "PR merge khác merge local chỗ nào?"
        ],
        "explain": [
          {
            "term": "-u / --set-upstream",
            "text": "Gắn branch local với origin/feature/auth. Lần sau git push đủ."
          },
          {
            "term": "--base / --head",
            "text": "PR vào main, từ feature/auth."
          },
          {
            "term": "PR merge",
            "text": "Merge trên remote (có thể squash). merge local chỉ trên máy bạn."
          }
        ]
      },
      "given": [
        {
          "label": "Branch đẩy",
          "value": "feature/auth"
        },
        {
          "label": "Remote",
          "value": "origin"
        },
        {
          "label": "PR vào",
          "value": "main"
        }
      ],
      "task": "Đẩy branch và mở pull request vào main"
    },
    {
      "id": "day-30",
      "day": 30,
      "block": "git",
      "title": "Git hard reset",
      "goal": "Branch hiện tại về đúng `HEAD~1`, working tree sạch. Chỉ làm trên repo lab — `--hard` bỏ thay đổi chưa commit.",
      "do": [
        "Ghi hash của HEAD và của `HEAD~1` trước khi reset.",
        "Reset `--hard` về `HEAD~1`.",
        "HEAD mới khớp hash cũ của `HEAD~1`. `git status` sạch."
      ],
      "hints": {
        "commands": [
          "git log --oneline -3",
          "git reset --hard HEAD~1",
          "git reflog"
        ],
        "questions": [
          "`--hard` giữ file đã sửa không?",
          "Sau reset, reflog còn hash cũ không?"
        ],
        "explain": [
          {
            "term": "HEAD~1",
            "text": "Parent thứ nhất của HEAD — commit ngay trước."
          },
          {
            "term": "--hard",
            "text": "Index và working tree về đúng commit đó. File đã sửa mất."
          },
          {
            "term": "reflog",
            "text": "Còn hash cũ một thời gian. Có thể checkout lại nếu chưa gc."
          }
        ]
      },
      "given": [
        {
          "label": "Repo",
          "value": "/home/lab/demo"
        },
        {
          "label": "Điểm về",
          "value": "HEAD~1"
        },
        {
          "label": "Mode",
          "value": "--hard"
        }
      ],
      "task": "Hard reset về commit trước đó"
    },
    {
      "id": "day-31",
      "day": 31,
      "block": "git",
      "title": "Git Stash",
      "goal": "Thay đổi đang làm (kể cả file mới) cất vào stash tên `wip-login`. Working tree sạch, stash list còn bản đó.",
      "do": [
        "Nếu working tree đang sạch: sửa một file tracked và tạo một file mới.",
        "Stash kèm untracked, message `wip-login`.",
        "`git status` sạch. `stash list` có dòng `wip-login`."
      ],
      "hints": {
        "commands": [
          "git stash push -u -m wip-login",
          "git stash list",
          "git stash pop"
        ],
        "questions": [
          "File untracked có vào stash mặc định không?",
          "pop khác apply chỗ nào?"
        ],
        "explain": [
          {
            "term": "stash push -u",
            "text": "-u gồm untracked. Mặc định stash không lấy file mới."
          },
          {
            "term": "-m",
            "text": "Tên bản stash, dễ tìm trong stash list."
          },
          {
            "term": "pop",
            "text": "Apply rồi xóa khỏi stack. apply giữ bản stash."
          }
        ]
      },
      "given": [
        {
          "label": "Repo",
          "value": "/home/lab/demo"
        },
        {
          "label": "Tên stash",
          "value": "wip-login"
        },
        {
          "label": "Gồm untracked",
          "value": "có (cờ -u)"
        }
      ],
      "task": "Stash cả file untracked với tên wip-login"
    },
    {
      "id": "day-32",
      "day": 32,
      "block": "git",
      "title": "Git Rebase",
      "goal": "`feature/auth` đặt lại lên tip hiện tại của `main`. Không còn commit của main nằm sau commit feature.",
      "do": [
        "Chuyển sang `feature/auth`.",
        "Rebase lên `main`.",
        "Nếu conflict: sửa, add, `--continue`. Sau cùng, merge-base của hai nhánh là tip hiện tại của `main`."
      ],
      "hints": {
        "commands": [
          "git switch feature/auth",
          "git rebase main",
          "git rebase --continue"
        ],
        "questions": [
          "Rebase đổi hash commit không?",
          "rebase khác merge chỗ lịch sử?"
        ],
        "explain": [
          {
            "term": "rebase main",
            "text": "Đặt lại commit của branch hiện tại lên tip main. Hash đổi."
          },
          {
            "term": "--continue",
            "text": "Sau khi sửa conflict và add, đi tiếp commit còn lại."
          },
          {
            "term": "vs merge",
            "text": "Rebase sử lịch sử thẳng. Merge giữ nhánh và tạo merge commit."
          }
        ]
      },
      "given": [
        {
          "label": "Repo",
          "value": "/home/lab/demo"
        },
        {
          "label": "Branch làm",
          "value": "feature/auth"
        },
        {
          "label": "Đặt lên đầu",
          "value": "main"
        }
      ],
      "task": "Rebase feature/auth lên đầu main"
    },
    {
      "id": "day-33",
      "day": 33,
      "block": "git",
      "title": "Resolve Git Merge Conflicts",
      "goal": "Hai nhánh sửa cùng dòng `app.txt`. Sau khi merge (hoặc rebase), file không còn marker `<<<<<<<` / `=======` / `>>>>>>>`, thao tác kết thúc sạch.",
      "do": [
        "Tạo conflict: hai branch sửa cùng một dòng `app.txt` rồi merge (hoặc rebase).",
        "Mở `app.txt`, bỏ marker, giữ nội dung cuối cùng rõ ràng.",
        "Add file rồi tiếp tục merge/rebase. Không còn trạng thái conflict."
      ],
      "hints": {
        "commands": [
          "git status",
          "git add app.txt",
          "git merge --continue",
          "grep -n '<<<<<<' app.txt"
        ],
        "questions": [
          "`git add` sau khi sửa conflict để làm gì?",
          "Còn <<<<<<< thì merge xong chưa?"
        ],
        "explain": [
          {
            "term": "git add sau conflict",
            "text": "Đánh dấu file đã giải quyết. Thiếu add thì merge/rebase không đi tiếp."
          },
          {
            "term": "<<<<<<<",
            "text": "Marker Git chèn. Còn trong file là chưa xong."
          },
          {
            "term": "merge --continue",
            "text": "Tạo commit merge sau khi hết unmerged."
          }
        ]
      },
      "given": [
        {
          "label": "Repo",
          "value": "/home/lab/demo"
        },
        {
          "label": "File conflict",
          "value": "app.txt"
        },
        {
          "label": "File khi xong",
          "value": "không còn marker <<<<<<< / ======= / >>>>>>>"
        }
      ],
      "task": "Sửa merge conflict trong app.txt"
    },
    {
      "id": "day-34",
      "day": 34,
      "block": "git",
      "title": "Git Hook",
      "goal": "Hook `pre-commit` chạy được (không đuôi `.sample`). Commit có `FIXME` trong file staged bị từ chối; commit sạch thì qua.",
      "do": [
        "Viết hook `pre-commit`: nếu staged diff còn `FIXME` thì in lỗi và thoát mã 1.",
        "File executable (`755`). Tên đúng `pre-commit`, không `pre-commit.sample`.",
        "Thử commit file có `FIXME` — fail. Bỏ `FIXME` — commit được."
      ],
      "hints": {
        "commands": [
          "chmod 755 .git/hooks/pre-commit",
          "git grep -I --cached FIXME",
          "ls -l .git/hooks/pre-commit"
        ],
        "questions": [
          "File `.sample` có chạy không?",
          "Hook nằm trong repo khi clone không?"
        ],
        "explain": [
          {
            "term": "pre-commit",
            "text": "Git chạy file này trước commit. Phải executable, không đuôi .sample."
          },
          {
            "term": ".sample",
            "text": "Mẫu, không chạy."
          },
          {
            "term": "git grep --cached",
            "text": "Tìm trong index (staged), không phải working tree."
          },
          {
            "term": "clone",
            "text": ".git/hooks không đi theo repo. Máy khác phải cài hook lại (hoặc dùng framework)."
          }
        ]
      },
      "given": [
        {
          "label": "Repo",
          "value": "/home/lab/demo"
        },
        {
          "label": "File hook",
          "value": ".git/hooks/pre-commit"
        },
        {
          "label": "Từ chối khi",
          "value": "file staged còn chuỗi FIXME"
        }
      ],
      "task": "Hook pre-commit chặn chuỗi FIXME"
    },
    {
      "id": "day-35",
      "day": 35,
      "block": "docker",
      "title": "Install Docker Packages and Start Docker Service",
      "goal": "Máy có Docker Engine (không chỉ client). Unit `docker` active. `docker version` in được cả khối Server — thiếu Server nghĩa là daemon chưa chạy.",
      "do": [
        "Cài Docker Engine theo docs Docker (`docker-ce`). Chỉ dùng `docker.io` khi distro không có CE.",
        "Enable và start unit `docker`.",
        "`docker version` có Client và Server. Chỉ Client thì daemon chưa lên."
      ],
      "hints": {
        "commands": [
          "apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin",
          "systemctl enable --now docker",
          "docker version"
        ],
        "questions": [
          "`docker.io` khác `docker-ce` chỗ nào?",
          "Chỉ thấy Client nghĩa là gì?"
        ],
        "explain": [
          {
            "term": "docker-ce",
            "text": "Gói Docker Inc. docker.io là gói distro, CLI/daemon lệch bản."
          },
          {
            "term": "enable --now docker",
            "text": "Bật unit docker và start daemon."
          },
          {
            "term": "docker version Server",
            "text": "Khối Server = nói chuyện được với daemon. Chỉ Client = daemon chết hoặc không quyền."
          }
        ]
      },
      "given": [
        {
          "label": "Gói cài",
          "value": "docker-ce docker-ce-cli containerd.io"
        },
        {
          "label": "Unit systemd",
          "value": "docker"
        },
        {
          "label": "Khi xong",
          "value": "docker version có khối Server"
        }
      ],
      "task": "Cài Docker Engine và start service"
    },
    {
      "id": "day-36",
      "day": 36,
      "block": "docker",
      "title": "Deploy Nginx Container on Application Server",
      "goal": "Một container tên đúng `nginx36` đang Running, image `nginx:1.27-alpine`. Lab này không bắt map port ra host.",
      "do": [
        "Tạo và chạy container tên `nginx36` từ `nginx:1.27-alpine`, chạy nền.",
        "Container đó `Running` = true.",
        "Nếu tên đã tồn tại thì xóa hoặc đổi — không tạo container thứ hai cùng tên."
      ],
      "hints": {
        "commands": [
          "docker run --name nginx36 -d nginx:1.27-alpine",
          "docker ps --filter name=nginx36",
          "docker inspect -f '{{.State.Status}}' nginx36"
        ],
        "questions": [
          "`--name` trùng container cũ thì sao?",
          "`-d` làm gì?"
        ],
        "explain": [
          {
            "term": "--name",
            "text": "Đặt tên container. Trùng tên đang dùng thì run fail."
          },
          {
            "term": "-d",
            "text": "Detached: chạy nền, trả container ID."
          },
          {
            "term": "--filter name=",
            "text": "Lọc docker ps theo tên."
          }
        ]
      },
      "given": [
        {
          "label": "Tên container",
          "value": "nginx36"
        },
        {
          "label": "Image",
          "value": "nginx:1.27-alpine"
        },
        {
          "label": "Chế độ chạy",
          "value": "nền (detached)"
        }
      ],
      "task": "Chạy container nginx tên nginx36"
    },
    {
      "id": "day-37",
      "day": 37,
      "block": "docker",
      "title": "Copy File to Docker Container",
      "goal": "File trong container `nginx36` tại docroot nginx đúng nội dung `Hello Lab 37`. Dùng copy host→container, không rebuild image.",
      "do": [
        "Tạo `/opt/web/index.html` trên host, nội dung đúng `Hello Lab 37`.",
        "Copy file đó vào đúng path docroot trong `nginx36`.",
        "Đọc file trong container: khớp từng ký tự."
      ],
      "hints": {
        "commands": [
          "echo -n 'Hello Lab 37' > /opt/web/index.html",
          "docker cp /opt/web/index.html nginx36:/usr/share/nginx/html/index.html",
          "docker exec nginx36 cat /usr/share/nginx/html/index.html"
        ],
        "questions": [
          "`cp` có tự mkdir cha không?",
          "Container Exited còn cp được không?"
        ],
        "explain": [
          {
            "term": "docker cp",
            "text": "Copy giữa host và container. Cú pháp SRC DEST, một bên có TÊN:path."
          },
          {
            "term": "mkdir cha",
            "text": "cp không tạo thư mục cha trong container. Thiếu path thì lỗi."
          },
          {
            "term": "Exited",
            "text": "Container dừng vẫn cp được filesystem, không cần Running."
          }
        ]
      },
      "given": [
        {
          "label": "Container",
          "value": "nginx36"
        },
        {
          "label": "File trên host",
          "value": "/opt/web/index.html"
        },
        {
          "label": "Nội dung",
          "value": "Hello Lab 37"
        },
        {
          "label": "Đích trong container",
          "value": "/usr/share/nginx/html/index.html"
        }
      ],
      "task": "Copy file từ host vào container nginx36"
    },
    {
      "id": "day-38",
      "day": 38,
      "block": "docker",
      "title": "Pull Docker Image",
      "goal": "Image `httpd:2.4-alpine` có trên máy local. Không cần chạy container. Đừng kéo `latest` nếu không được yêu cầu.",
      "do": [
        "Kéo đúng tag `httpd:2.4-alpine`.",
        "Danh sách image local thấy repo `httpd` tag `2.4-alpine`.",
        "Không kéo `latest` trừ khi tự gõ nhầm."
      ],
      "hints": {
        "commands": [
          "docker pull httpd:2.4-alpine",
          "docker images httpd:2.4-alpine",
          "docker image inspect httpd:2.4-alpine"
        ],
        "questions": [
          "Không ghi tag thì Docker lấy gì?",
          "`tag` có tải layer mới không?"
        ],
        "explain": [
          {
            "term": "pull REPO:TAG",
            "text": "Kéo đúng tag. Không ghi tag thì Docker lấy latest."
          },
          {
            "term": "docker tag",
            "text": "Đặt thêm tên cho image đã có. Không tải layer mới."
          }
        ]
      },
      "given": [
        {
          "label": "Image cần kéo",
          "value": "httpd:2.4-alpine"
        },
        {
          "label": "Khi xong",
          "value": "docker image inspect httpd:2.4-alpine không lỗi"
        }
      ],
      "task": "Kéo image httpd:2.4-alpine về máy"
    },
    {
      "id": "day-39",
      "day": 39,
      "block": "docker",
      "title": "Create a Docker Image From Container",
      "goal": "Snapshot container `nginx36` thành image `lab/nginx36:1`. Image xuất hiện trong danh sách local. Bind-mount không đi vào image.",
      "do": [
        "Commit container `nginx36` thành image `lab/nginx36:1`.",
        "Inspect image mới không lỗi. `docker images` thấy repo `lab/nginx36` tag `1`.",
        "Lab này không cần volume — nhớ volume mount sẽ không nằm trong image."
      ],
      "hints": {
        "commands": [
          "docker commit nginx36 lab/nginx36:1",
          "docker images lab/nginx36"
        ],
        "questions": [
          "Volume mount có vào image commit không?",
          "commit khác Dockerfile chỗ nào?"
        ],
        "explain": [
          {
            "term": "docker commit",
            "text": "Chụp layer ghi của container thành image mới."
          },
          {
            "term": "volume",
            "text": "Mount không nằm trong layer — commit bỏ qua dữ liệu volume."
          },
          {
            "term": "Dockerfile",
            "text": "Build lặp lại được từ file. commit là chụp tay, khó review."
          }
        ]
      },
      "given": [
        {
          "label": "Container nguồn",
          "value": "nginx36"
        },
        {
          "label": "Image mới",
          "value": "lab/nginx36:1"
        }
      ],
      "task": "Tạo image từ container đang có"
    },
    {
      "id": "day-40",
      "day": 40,
      "block": "docker",
      "title": "Docker EXEC Operations",
      "goal": "Dùng `docker exec` trên `nginx36` để đọc file trong container. Container phải Running — Exited thì start lại trước. Thứ tự option Docker: cờ đứng trước tên container.",
      "do": [
        "`nginx36` phải đang Running. Nếu Exited thì start lại, đừng tạo container mới cùng tên.",
        "Exec vào container và đọc `/usr/share/nginx/html/index.html` (nội dung `Hello Lab 37` nếu đã làm ngày 37).",
        "Cờ của Docker đứng trước tên container, lệnh `cat …` đứng sau."
      ],
      "hints": {
        "commands": [
          "docker start nginx36",
          "docker exec nginx36 cat /usr/share/nginx/html/index.html",
          "docker exec -it nginx36 sh"
        ],
        "questions": [
          "`exec \"echo a && echo b\"` fail vì sao?",
          "Option `-it` đặt trước hay sau tên?"
        ],
        "explain": [
          {
            "term": "docker start",
            "text": "Chạy lại container Exited. exec chỉ trên Running."
          },
          {
            "term": "docker exec",
            "text": "Chạy process mới trong container. Option (-it) đứng trước tên container."
          },
          {
            "term": "-it",
            "text": "-i giữ stdin, -t cấp TTY. Cần khi vào shell."
          },
          {
            "term": "sh -c",
            "text": "Nhiều lệnh (&&) phải qua sh -c. exec \"echo a && echo b\" không qua shell."
          }
        ]
      },
      "given": [
        {
          "label": "Container",
          "value": "nginx36"
        },
        {
          "label": "Lệnh trong container",
          "value": "cat /usr/share/nginx/html/index.html"
        }
      ],
      "task": "Chạy lệnh trong container đang chạy"
    },
    {
      "id": "day-41",
      "day": 41,
      "block": "docker",
      "title": "Write a Docker File",
      "goal": "Tự xây một image web tĩnh. Thư mục `/opt/docker/web` là context build: chứa `Dockerfile` và `index.html`. Image kết quả tên `lab/web:1`. Container chạy từ image đó phục vụ đúng trang đó — không sửa image nginx sẵn bằng `docker cp`.",
      "do": [
        "Tạo thư mục `/opt/docker/web`. Trong đó có `index.html` chứa chữ `Lab Web 41`, và `Dockerfile`.",
        "`Dockerfile` lấy image nền `nginx:1.27-alpine`, copy `index.html` từ context vào docroot nginx `/usr/share/nginx/html/index.html`.",
        "Build image gắn tên `lab/web:1` từ đúng thư mục đó (argument cuối của build là context, không phải tên file Dockerfile).",
        "Chạy một container từ `lab/web:1` (lab: tên `web41`). Khi xong, container Running và trang mặc định là nội dung `index.html`."
      ],
      "hints": {
        "commands": [
          "docker build -t lab/web:1 /opt/docker/web",
          "CMD [\"nginx\",\"-g\",\"daemon off;\"]",
          "docker run --name web41 -d lab/web:1"
        ],
        "questions": [
          "`COPY` khác `ADD` khi nào?",
          "`EXPOSE` có mở port ra host không?"
        ],
        "explain": [
          {
            "term": "docker build -t",
            "text": "-t gắn tên:tag cho image. Argument cuối là context (thư mục)."
          },
          {
            "term": "COPY",
            "text": "Copy file từ context. ADD còn giải archive / URL — lab dùng COPY."
          },
          {
            "term": "EXPOSE",
            "text": "Metadata. Không publish port ra host. Muốn ra host thì -p."
          },
          {
            "term": "CMD exec",
            "text": "Dạng JSON: process PID 1 là nginx, nhận SIGTERM đúng."
          }
        ]
      },
      "given": [
        {
          "label": "Thư mục build (context)",
          "value": "/opt/docker/web"
        },
        {
          "label": "Tên image sau build",
          "value": "lab/web:1"
        },
        {
          "label": "Image nền",
          "value": "nginx:1.27-alpine"
        },
        {
          "label": "File đưa vào image",
          "value": "index.html → /usr/share/nginx/html/index.html"
        },
        {
          "label": "Chữ trên trang",
          "value": "Lab Web 41"
        }
      ],
      "task": "Viết Dockerfile và build image nginx"
    },
    {
      "id": "day-42",
      "day": 42,
      "block": "docker",
      "title": "Create a Docker Network",
      "goal": "Network tên `lab-net`, driver `bridge`, subnet `172.28.0.0/16`. Container gắn network này nói chuyện nhau bằng tên container.",
      "do": [
        "Tạo network `lab-net` driver `bridge`, subnet `172.28.0.0/16`.",
        "Inspect network: subnet đúng `172.28.0.0/16`.",
        "Không bắt buộc, nhưng có thể chạy một container `--network lab-net` để thử DNS theo tên."
      ],
      "hints": {
        "commands": [
          "docker network create --driver bridge --subnet 172.28.0.0/16 lab-net",
          "docker network ls",
          "docker network inspect lab-net"
        ],
        "questions": [
          "Không ghi `--driver` thì ra driver nào?",
          "Bridge mặc định có DNS theo tên container không?"
        ],
        "explain": [
          {
            "term": "--driver bridge",
            "text": "Mặc định cũng là bridge nếu không ghi."
          },
          {
            "term": "--subnet",
            "text": "CIDR cấp cho network. 172.28.0.0/16 là dải lab."
          },
          {
            "term": "user-defined bridge",
            "text": "Có DNS theo tên container. Bridge mặc định docker0 thì không."
          }
        ]
      },
      "given": [
        {
          "label": "Tên network",
          "value": "lab-net"
        },
        {
          "label": "Driver",
          "value": "bridge"
        },
        {
          "label": "Subnet",
          "value": "172.28.0.0/16"
        }
      ],
      "task": "Tạo Docker network bridge với subnet riêng"
    },
    {
      "id": "day-43",
      "day": 43,
      "block": "docker",
      "title": "Docker Ports Mapping",
      "goal": "Container `nginx43` Running, cổng 80 trong container map ra `8088` trên host. `curl` `http://127.0.0.1:8088/` trả 200. Số bên trái của `-p` là cổng host.",
      "do": [
        "Chạy `nginx43` từ `nginx:1.27-alpine`, map host `8088` → container `80`.",
        "`docker ps` cột PORTS có `8088->80`.",
        "`curl` tới `127.0.0.1:8088` = 200. Đừng map nhầm `80:8088`."
      ],
      "hints": {
        "commands": [
          "docker run --name nginx43 -d -p 8088:80 nginx:1.27-alpine",
          "docker ps --format '{{.Names}} {{.Ports}}'",
          "curl -sS -D - http://127.0.0.1:8088/ -o /dev/null"
        ],
        "questions": [
          "`8088:80` số nào là host?",
          "Publish có giúp hai container gọi nhau không?"
        ],
        "explain": [
          {
            "term": "-p 8088:80",
            "text": "Host:container. 8088 trên máy bạn, 80 trong Nginx."
          },
          {
            "term": "publish",
            "text": "Cho máy host (hoặc ngoài) vào container. Hai container cùng network gọi nhau bằng tên, không cần -p."
          }
        ]
      },
      "given": [
        {
          "label": "Tên container",
          "value": "nginx43"
        },
        {
          "label": "Image",
          "value": "nginx:1.27-alpine"
        },
        {
          "label": "Publish (host:container)",
          "value": "8088:80"
        },
        {
          "label": "Kiểm trên host",
          "value": "http://127.0.0.1:8088/ → 200"
        }
      ],
      "task": "Publish cổng container ra host 8088"
    },
    {
      "id": "day-44",
      "day": 44,
      "block": "docker",
      "title": "Write a Docker Compose File",
      "goal": "Một file Compose đứng lên hai service: `web` (nginx, ra host 8080) và `redis`. `up -d` rồi cả hai Running. `curl :8080` = 200. File không cần khóa `version`.",
      "do": [
        "Tạo `/opt/compose/compose.yaml` bắt đầu bằng `services:`. Không bắt buộc khóa `version`.",
        "Khai `web` image nginx, publish `8080:80`. Khai `redis` image redis. Không cần volume cho lab này.",
        "Chạy compose up nền từ đúng file. Hai service Running. `curl http://127.0.0.1:8080/` = 200."
      ],
      "hints": {
        "commands": [
          "docker compose -f /opt/compose/compose.yaml up -d",
          "docker compose -f /opt/compose/compose.yaml ps",
          "curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/"
        ],
        "questions": [
          "`localhost` trong service web là máy nào?",
          "Khóa `version:` còn quyết định V2 không?"
        ],
        "explain": [
          {
            "term": "-f",
            "text": "Chọn file compose không phải tên mặc định."
          },
          {
            "term": "up -d",
            "text": "Tạo/start service, chạy nền."
          },
          {
            "term": "localhost trong container",
            "text": "Là chính container đó, không phải service khác hay máy host."
          },
          {
            "term": "version:",
            "text": "Compose V2 bỏ qua khóa version. Không quyết định engine."
          }
        ]
      },
      "given": [
        {
          "label": "File Compose",
          "value": "/opt/compose/compose.yaml"
        },
        {
          "label": "Service web",
          "value": "nginx:1.27-alpine, ports 8080:80"
        },
        {
          "label": "Service redis",
          "value": "redis:7.4-alpine"
        }
      ],
      "task": "Viết Compose: nginx + redis rồi up"
    },
    {
      "id": "day-45",
      "day": 45,
      "block": "docker",
      "title": "Resolve Dockerfile Issues",
      "goal": "Context `/opt/docker/broken-web` đang build fail vì `COPY` trỏ sai chỗ (file thật nằm `app/index.html`). Sửa xong build ra `lab/fixed-web:1` exit 0.",
      "do": [
        "Thử build và đọc dòng fail — thường là `COPY` không thấy file trong context.",
        "Sửa `COPY` thành `app/index.html` (hoặc chuyển file đúng chỗ mà `COPY` đang trỏ).",
        "Build lại thành `lab/fixed-web:1` exit 0. Có thể `docker run --rm … nginx -t` để chắc image chạy được."
      ],
      "hints": {
        "commands": [
          "docker build -t lab/fixed-web:1 /opt/docker/broken-web",
          "COPY app/index.html /usr/share/nginx/html/index.html",
          "CMD [\"nginx\",\"-g\",\"daemon off;\"]"
        ],
        "questions": [
          "`not found` lúc COPY thường là gì?",
          "CMD chuỗi đi qua shell — ảnh hưởng signal thế nào?"
        ],
        "explain": [
          {
            "term": "COPY path",
            "text": "src phải có trong context. not found = sai đường hoặc .dockerignore nuốt."
          },
          {
            "term": "CMD chuỗi",
            "text": "Chạy qua /bin/sh -c. Signal gửi sh, app có thể không tắt sạch. Dạng exec tránh việc đó."
          }
        ]
      },
      "given": [
        {
          "label": "Thư mục build",
          "value": "/opt/docker/broken-web"
        },
        {
          "label": "Lỗi hay gặp",
          "value": "COPY index.html … nhưng file ở app/index.html"
        },
        {
          "label": "Image sau khi sửa",
          "value": "lab/fixed-web:1"
        }
      ],
      "task": "Sửa Dockerfile lỗi path COPY rồi build lại"
    },
    {
      "id": "day-46",
      "day": 46,
      "block": "docker",
      "title": "Deploy an App on Docker Containers",
      "goal": "Container `shop-app` từ image `lab/shop:1`, port host 3000 → 3000. `GET /health` trả body `ok`. Nếu container Exited, đọc log trước khi đoán.",
      "do": [
        "Build image `lab/shop:1` từ app của bạn: process nghe `0.0.0.0:3000`, route `GET /health` trả `ok`.",
        "Chạy container tên `shop-app`, map `3000:3000`.",
        "`curl` health = `ok`. Container chết thì đọc `docker logs shop-app`."
      ],
      "hints": {
        "commands": [
          "docker build -t lab/shop:1 .",
          "docker run --name shop-app -d -p 3000:3000 lab/shop:1",
          "curl -sS http://127.0.0.1:3000/health"
        ],
        "questions": [
          "App nghe 3000 trong container, `-p` viết thế nào?",
          "Container Exited ngay — xem lệnh nào?"
        ],
        "explain": [
          {
            "term": "-p 3000:3000",
            "text": "App listen 3000 trong container thì map host 3000:3000."
          },
          {
            "term": "docker logs",
            "text": "Container Exited ngay — đọc logs, thường sai CMD hoặc crash lúc start."
          }
        ]
      },
      "given": [
        {
          "label": "Tên container",
          "value": "shop-app"
        },
        {
          "label": "Image",
          "value": "lab/shop:1"
        },
        {
          "label": "Publish",
          "value": "3000:3000"
        },
        {
          "label": "Health URL",
          "value": "http://127.0.0.1:3000/health → body ok"
        }
      ],
      "task": "Chạy app trong container và mở health check"
    },
    {
      "id": "day-47",
      "day": 47,
      "block": "docker",
      "title": "Docker Python App",
      "goal": "Image `lab/pyapp:1` từ `/opt/pyapp`. Thứ tự lớp: copy `requirements.txt` và `pip install` trước, rồi mới copy source — để cache pip khi sửa code. App nghe `0.0.0.0:8000`. Đừng `python:latest`.",
      "do": [
        "`Dockerfile`: `FROM python:3.12-slim`, `WORKDIR /app`, copy `requirements.txt` rồi `pip install --no-cache-dir`, sau đó copy source, `CMD` chạy `app.py`.",
        "`app.py` listen `0.0.0.0:8000`. Pin tag `3.12-slim`, không `latest`.",
        "Build `lab/pyapp:1` từ `/opt/pyapp`. Chạy container map `8000:8000`. Cổng host 8000 trả lời được."
      ],
      "hints": {
        "commands": [
          "FROM python:3.12-slim",
          "COPY requirements.txt ./",
          "RUN pip install --no-cache-dir -r requirements.txt",
          "CMD [\"python\",\"app.py\"]"
        ],
        "questions": [
          "Copy source trước requirements thì cache layer sao?",
          "`pip install -r` lấy file nào?"
        ],
        "explain": [
          {
            "term": "COPY requirements trước",
            "text": "Layer pip cache khi chỉ sửa source. Copy cả source trước thì mỗi lần sửa code pip chạy lại."
          },
          {
            "term": "pip -r",
            "text": "Cài đúng file requirements.txt vừa COPY."
          },
          {
            "term": "--no-cache-dir",
            "text": "Không giữ wheel trong image — image nhỏ hơn."
          }
        ]
      },
      "given": [
        {
          "label": "Thư mục build",
          "value": "/opt/pyapp"
        },
        {
          "label": "Image nền",
          "value": "python:3.12-slim"
        },
        {
          "label": "Tên image",
          "value": "lab/pyapp:1"
        },
        {
          "label": "Lệnh mặc định",
          "value": "python app.py"
        },
        {
          "label": "Publish",
          "value": "8000:8000"
        }
      ],
      "task": "Dockerize app Python: cài dependency rồi copy source"
    },
    {
      "id": "day-48",
      "day": 48,
      "block": "kubernetes",
      "title": "Deploy Pods in Kubernetes Cluster",
      "goal": "Trong namespace `default` có Pod tên `nginx-pod`, image `nginx:1.27-alpine`, phase Running, Ready 1/1. `kubectl run` tạo Pod — không tạo Deployment trừ khi đề bắt.",
      "do": [
        "Tạo Pod `nginx-pod` image `nginx:1.27-alpine` (`kubectl run` hoặc manifest kind `Pod`).",
        "`kubectl get pod nginx-pod -n default`: STATUS `Running`, Ready 1/1.",
        "Không để lại Deployment cùng tên nếu bạn lỡ dùng lệnh tạo Deployment."
      ],
      "hints": {
        "commands": [
          "kubectl run nginx-pod --image=nginx:1.27-alpine",
          "kubectl get pod nginx-pod -n default",
          "kubectl delete pod nginx-pod"
        ],
        "questions": [
          "`kubectl run` tạo object nào?",
          "Xóa Pod trần thì có ai tạo lại không?"
        ],
        "explain": [
          {
            "term": "kubectl run",
            "text": "Tạo Pod, không phải Deployment. Xóa Pod là hết, không ai tạo lại."
          },
          {
            "term": "--image",
            "text": "Image của container trong Pod."
          },
          {
            "term": "-n default",
            "text": "Namespace. Thiếu thì dùng namespace hiện tại (thường default)."
          }
        ]
      },
      "given": [
        {
          "label": "Kind",
          "value": "Pod"
        },
        {
          "label": "Tên",
          "value": "nginx-pod"
        },
        {
          "label": "Image",
          "value": "nginx:1.27-alpine"
        },
        {
          "label": "Namespace",
          "value": "default"
        }
      ],
      "task": "Tạo một Pod nginx, không phải Deployment"
    },
    {
      "id": "day-49",
      "day": 49,
      "block": "kubernetes",
      "title": "Deploy Applications with Kubernetes Deployments",
      "goal": "Deployment `web-deploy` giữ 3 Pod. `selector` và `template.labels` cùng `app=web` — lệch label thì ReplicaSet không nhận Pod.",
      "do": [
        "Manifest kind Deployment: name `web-deploy`, `replicas: 3`, `selector.matchLabels app: web`, Pod template cùng label `app: web`.",
        "Apply. `kubectl get deploy web-deploy` READY `3/3`.",
        "Xóa một Pod — controller tự bù lại 3."
      ],
      "hints": {
        "commands": [
          "kubectl apply -f web-deploy.yaml",
          "kubectl get deploy web-deploy,pods -l app=web",
          "kubectl delete pod -l app=web --grace-period=0"
        ],
        "questions": [
          "Selector Service đọc tên Deployment hay label Pod?",
          "Ba chỗ label nào phải khớp?"
        ],
        "explain": [
          {
            "term": "kubectl apply -f",
            "text": "Tạo hoặc cập nhật object từ YAML."
          },
          {
            "term": "-l app=web",
            "text": "Lọc theo label. Service selector cũng đọc label Pod, không đọc tên Deployment."
          },
          {
            "term": "ba chỗ label",
            "text": "spec.selector, template.metadata.labels, và label Pod thật phải cùng key/value."
          },
          {
            "term": "--grace-period=0",
            "text": "Xóa nhanh để thấy Deployment bù replica."
          }
        ]
      },
      "given": [
        {
          "label": "Deployment",
          "value": "web-deploy"
        },
        {
          "label": "Số replica",
          "value": "3"
        },
        {
          "label": "Label / selector",
          "value": "app=web"
        },
        {
          "label": "Image",
          "value": "nginx:1.27-alpine"
        }
      ],
      "task": "Tạo Deployment 3 replica, label khớp selector"
    },
    {
      "id": "day-50",
      "day": 50,
      "block": "kubernetes",
      "title": "Set Resource Limits in Kubernetes Pods",
      "goal": "Container nginx trên `web-deploy` có đủ bốn số: requests 100m/128Mi, limits 200m/256Mi. Apply xong phải đợi rollout, không chỉ sửa file rồi thôi.",
      "do": [
        "Sửa `resources` của container tên `nginx` trong Pod template: đúng bốn giá trị trên.",
        "Apply và đợi rollout xong (Pod mới thay Pod cũ).",
        "Đọc lại spec container: requests và limits khớp."
      ],
      "hints": {
        "commands": [
          "resources.requests.cpu: 100m",
          "resources.requests.memory: 128Mi",
          "resources.limits.cpu: 200m",
          "resources.limits.memory: 256Mi"
        ],
        "questions": [
          "requests dùng lúc nào?",
          "limits là gì?"
        ],
        "explain": [
          {
            "term": "requests",
            "text": "Lượng scheduler dành chỗ trên node khi đặt Pod."
          },
          {
            "term": "limits",
            "text": "Trần runtime. CPU bị throttle, memory vượt thì OOMKill."
          },
          {
            "term": "100m",
            "text": "0.1 CPU (100 millicores)."
          }
        ]
      },
      "given": [
        {
          "label": "Deployment / container",
          "value": "web-deploy / nginx"
        },
        {
          "label": "requests.cpu",
          "value": "100m"
        },
        {
          "label": "requests.memory",
          "value": "128Mi"
        },
        {
          "label": "limits.cpu",
          "value": "200m"
        },
        {
          "label": "limits.memory",
          "value": "256Mi"
        }
      ],
      "task": "Đặt requests và limits cho container nginx"
    },
    {
      "id": "day-51",
      "day": 51,
      "block": "kubernetes",
      "title": "Execute Rolling Updates in Kubernetes",
      "goal": "`web-deploy` chuyển sang `nginx:1.26-alpine` bằng rolling update. `rollout status` Successful. ReplicaSet mới phục vụ traffic.",
      "do": [
        "Đổi image container `nginx` thành `nginx:1.26-alpine` (`set image` hoặc sửa YAML).",
        "Chờ `rollout status` thành công — đừng kết luận khi còn vài Pod image cũ.",
        "Spec hiện tại của Deployment phải là `nginx:1.26-alpine`."
      ],
      "hints": {
        "commands": [
          "kubectl set image deployment/web-deploy nginx=nginx:1.26-alpine",
          "kubectl rollout status deployment/web-deploy",
          "kubectl get rs -l app=web"
        ],
        "questions": [
          "Tên trước `=` trong set image là gì?",
          "Đổi replicas có tạo revision không?"
        ],
        "explain": [
          {
            "term": "set image",
            "text": "Cú pháp deploy/TÊN CONTAINER=image. Tên trước = là tên container trong Pod, không phải tên Deployment."
          },
          {
            "term": "rollout status",
            "text": "Chờ rollout xong hoặc lỗi. Exit khác 0 nếu timeout."
          },
          {
            "term": "scale",
            "text": "Đổi replicas không tạo revision mới."
          }
        ]
      },
      "given": [
        {
          "label": "Deployment",
          "value": "web-deploy"
        },
        {
          "label": "Tên container",
          "value": "nginx"
        },
        {
          "label": "Image mới",
          "value": "nginx:1.26-alpine"
        }
      ],
      "task": "Rolling update image của Deployment"
    },
    {
      "id": "day-52",
      "day": 52,
      "block": "kubernetes",
      "title": "Revert Deployment to Previous Version in Kubernetes",
      "goal": "Sau ngày 51, quay `web-deploy` về revision ngay trước đó (thường là `nginx:1.27-alpine`). Dùng `rollout undo`, không xóa Deployment viết lại.",
      "do": [
        "Xem `rollout history` để biết revision hiện tại và revision trước.",
        "Undo về revision ngay trước (không ghi `--to-revision` nếu chỉ cần lùi một bước).",
        "Rollout successful. Image khớp revision trước."
      ],
      "hints": {
        "commands": [
          "kubectl rollout history deployment/web-deploy",
          "kubectl rollout undo deployment/web-deploy",
          "kubectl rollout undo deployment/web-deploy --to-revision=2"
        ],
        "questions": [
          "undo không ghi revision thì về đâu?",
          "Scale có bị undo không?"
        ],
        "explain": [
          {
            "term": "rollout history",
            "text": "Danh sách revision của Deployment."
          },
          {
            "term": "undo",
            "text": "Không ghi --to-revision thì về revision ngay trước."
          },
          {
            "term": "--to-revision=N",
            "text": "Về đúng revision N."
          },
          {
            "term": "scale",
            "text": "Không phải thay đổi template — undo không hoàn tác số replica."
          }
        ]
      },
      "given": [
        {
          "label": "Deployment",
          "value": "web-deploy"
        },
        {
          "label": "Hành động",
          "value": "rollout undo (revision ngay trước)"
        },
        {
          "label": "Không dùng",
          "value": "--to-revision trừ khi đề bắt số cụ thể"
        }
      ],
      "task": "Undo Deployment về revision trước"
    },
    {
      "id": "day-53",
      "day": 53,
      "block": "kubernetes",
      "title": "Resolve VolumeMounts Issue in Kubernetes",
      "goal": "Pod `html-pod` Running. `volumeMounts.name` phải trùng `volumes[].name` (`html-vol`). Lệch tên thì kubelet không mount, Events báo failed mount.",
      "do": [
        "Nếu Events báo mount fail: so hai trường name — sửa cho cùng `html-vol`.",
        "Khai `emptyDir` dưới `volumes` tên `html-vol`, mount vào `/usr/share/nginx/html`.",
        "Pod Running. Exec vào Pod thấy thư mục mount tồn tại."
      ],
      "hints": {
        "commands": [
          "kubectl describe pod html-pod",
          "volumeMounts.name: html-vol",
          "volumes: [{ name: html-vol, emptyDir: {} }]"
        ],
        "questions": [
          "Hai name lệch thì triệu chứng gì?",
          "mountPath là path trên laptop hay trong container?"
        ],
        "explain": [
          {
            "term": "volumeMounts.name",
            "text": "Phải trùng volumes[].name. Lệch thì Pod không mount, Events báo lỗi."
          },
          {
            "term": "mountPath",
            "text": "Đường trong container, không phải path laptop."
          },
          {
            "term": "emptyDir",
            "text": "Volume rỗng theo đời Pod."
          }
        ]
      },
      "given": [
        {
          "label": "Pod",
          "value": "html-pod"
        },
        {
          "label": "Tên volume",
          "value": "html-vol"
        },
        {
          "label": "volumeMounts.name",
          "value": "html-vol (trùng volumes)"
        },
        {
          "label": "mountPath",
          "value": "/usr/share/nginx/html"
        }
      ],
      "task": "Sửa volumeMounts lệch tên với volumes"
    },
    {
      "id": "day-54",
      "day": 54,
      "block": "kubernetes",
      "title": "Kubernetes Shared Volumes",
      "goal": "`share-pod` có `writer` và `reader`, cùng mount `cache-vol` tại `/cache`. File writer tạo, reader đọc được — đó là volume dùng chung trong Pod, không phải Service.",
      "do": [
        "Một Pod, hai container, một `emptyDir` tên `cache-vol`, cả hai mount `/cache`.",
        "Từ container `writer` ghi file `/cache/note` nội dung `shared`.",
        "Từ container `reader` đọc được đúng `shared`."
      ],
      "hints": {
        "commands": [
          "emptyDir: {}",
          "kubectl exec share-pod -c writer -- cat /cache/note",
          "kubectl exec share-pod -c reader -- cat /cache/note"
        ],
        "questions": [
          "Hai Pod cùng emptyDir name có chia file không?",
          "Restart container trong cùng Pod còn file không?"
        ],
        "explain": [
          {
            "term": "emptyDir",
            "text": "Chia giữa container trong cùng Pod. Hai Pod khác nhau không thấy file nhau."
          },
          {
            "term": "-c",
            "text": "Chọn container khi exec/logs. Thiếu -c mà Pod nhiều container thì kubectl hỏi hoặc lấy mặc định."
          },
          {
            "term": "restart container",
            "text": "emptyDir còn khi container restart trong cùng Pod. Xóa Pod thì mất."
          }
        ]
      },
      "given": [
        {
          "label": "Pod",
          "value": "share-pod"
        },
        {
          "label": "Volume",
          "value": "cache-vol (emptyDir)"
        },
        {
          "label": "mountPath cả hai",
          "value": "/cache"
        },
        {
          "label": "Containers",
          "value": "writer, reader"
        }
      ],
      "task": "Hai container trong một Pod dùng chung emptyDir"
    },
    {
      "id": "day-55",
      "day": 55,
      "block": "kubernetes",
      "title": "Kubernetes Sidecar Containers",
      "goal": "`side-pod` có container app (nginx) và sidecar `shipper` (`tail -f` file log). Cùng `emptyDir` `logs-vol` tại `/var/log/app`. Sidecar không thay Service hay Ingress.",
      "do": [
        "Hai container trong một Pod: `app` (nginx) và `shipper` chạy `tail -f /var/log/app/access.log`.",
        "Cùng `emptyDir` `logs-vol`, mount `/var/log/app`.",
        "`kubectl logs … -c shipper` không kêu missing file mãi — tạo file trống trong command nếu nginx chưa ghi."
      ],
      "hints": {
        "commands": [
          "kubectl logs side-pod -c shipper",
          "kubectl exec side-pod -c app -- ls /var/log/app",
          "emptyDir: {}"
        ],
        "questions": [
          "Thiếu `-c` khi hai container thì sao?",
          "Init thường exit 0 — có `tail` mãi được không?"
        ],
        "explain": [
          {
            "term": "-c shipper",
            "text": "Lấy log/exec đúng sidecar. Thiếu -c khi hai container là lẫn output."
          },
          {
            "term": "init vs sidecar",
            "text": "init phải exit 0 rồi app mới start — không tail mãi. Sidecar nằm containers[] (hoặc init + restartPolicy Always trên bản mới)."
          }
        ]
      },
      "given": [
        {
          "label": "Pod",
          "value": "side-pod"
        },
        {
          "label": "App container",
          "value": "app — nginx:1.27-alpine"
        },
        {
          "label": "Sidecar",
          "value": "shipper — busybox:1.36, tail -f /var/log/app/access.log"
        },
        {
          "label": "Volume log",
          "value": "logs-vol → /var/log/app ở cả hai"
        }
      ],
      "task": "Thêm sidecar tail log cùng volume với app"
    },
    {
      "id": "day-56",
      "day": 56,
      "block": "kubernetes",
      "title": "Deploy Nginx Web Server on Kubernetes Cluster",
      "goal": "`nginx-web` Ready 1/1. Service `nginx-svc` port 80, selector khớp label Pod. `port-forward` 8080→80 vào được trang Nginx. Service không tự mở cổng máy bạn — cần forward hoặc NodePort.",
      "do": [
        "Deployment `nginx-web`, label `app=nginx-web`, `containerPort` 80.",
        "Service `nginx-svc` selector `app=nginx-web`, port 80 → targetPort 80.",
        "Port-forward Service 8080:80 rồi `curl http://127.0.0.1:8080/` ra trang Nginx."
      ],
      "hints": {
        "commands": [
          "kubectl apply -f nginx-web.yaml",
          "kubectl get deploy nginx-web,svc nginx-svc",
          "kubectl port-forward svc/nginx-svc 8080:80"
        ],
        "questions": [
          "Nginx official nghe port nào?",
          "ClusterIP gọi từ laptop được không?"
        ],
        "explain": [
          {
            "term": "containerPort 80",
            "text": "Nginx official listen 80."
          },
          {
            "term": "port-forward 8080:80",
            "text": "Cổng máy bạn : cổng Service. ClusterIP không gọi thẳng từ laptop."
          }
        ]
      },
      "given": [
        {
          "label": "Deployment",
          "value": "nginx-web, 1 replica, nginx:1.27-alpine"
        },
        {
          "label": "Service",
          "value": "nginx-svc ClusterIP 80 → targetPort 80"
        },
        {
          "label": "Kiểm",
          "value": "port-forward 8080:80 rồi curl :8080"
        }
      ],
      "task": "Deployment nginx kèm Service rồi port-forward"
    },
    {
      "id": "day-57",
      "day": 57,
      "block": "kubernetes",
      "title": "Print Environment Variables",
      "goal": "Pod `env-pod` Running. Trong process, `APP_ENV=production` và `APP_PORT=8080`. Giá trị lấy từ `env` trong spec, không hard-code trong image.",
      "do": [
        "Khai `env` hai biến đúng tên/giá trị. Command `sleep` đủ lâu để còn exec.",
        "Apply, đợi Running.",
        "Exec `printenv APP_ENV APP_PORT` ra đúng hai giá trị."
      ],
      "hints": {
        "commands": [
          "kubectl exec env-pod -- printenv APP_ENV",
          "kubectl exec env-pod -- printenv APP_PORT"
        ],
        "questions": [
          "env trong YAML đè ENV trong image không?",
          "Sửa env rồi không apply lại thì Pod cũ còn biến cũ?"
        ],
        "explain": [
          {
            "term": "env value",
            "text": "Đè biến cùng tên trong image."
          },
          {
            "term": "printenv",
            "text": "Đọc env process đang chạy. Sửa YAML mà không apply/rollout thì Pod cũ còn giá trị cũ."
          }
        ]
      },
      "given": [
        {
          "label": "Pod",
          "value": "env-pod"
        },
        {
          "label": "APP_ENV",
          "value": "production"
        },
        {
          "label": "APP_PORT",
          "value": "8080"
        },
        {
          "label": "Image",
          "value": "busybox:1.36 (sleep để còn exec)"
        }
      ],
      "task": "Gắn biến môi trường vào Pod"
    },
    {
      "id": "day-58",
      "day": 58,
      "block": "kubernetes",
      "title": "Deploy Grafana on Kubernetes Cluster",
      "goal": "Deployment Grafana image pin `grafana/grafana:11.3.0`, Ready 1/1. Service port 3000. Lab không bắt tạo dashboard — UI lên là đủ.",
      "do": [
        "Deployment tên `grafana`, container image `grafana/grafana:11.3.0`, port 3000.",
        "Service cùng tên, selector khớp, 3000 → 3000.",
        "Port-forward 3000:3000 — UI Grafana mở được."
      ],
      "hints": {
        "commands": [
          "kubectl apply -f grafana.yaml",
          "kubectl get pods -l app=grafana",
          "kubectl port-forward svc/grafana 3000:3000"
        ],
        "questions": [
          "Đây có khác Deployment Nginx về object không?",
          "Sai targetPort thì port-forward còn vào app?"
        ],
        "explain": [
          {
            "term": "Deployment + Service",
            "text": "Cùng kind với Nginx lab. Khác image và port 3000."
          },
          {
            "term": "targetPort",
            "text": "Cổng process trong Pod. Sai thì port-forward/Service không vào Grafana."
          }
        ]
      },
      "given": [
        {
          "label": "Deployment",
          "value": "grafana"
        },
        {
          "label": "Image",
          "value": "grafana/grafana:11.3.0"
        },
        {
          "label": "containerPort",
          "value": "3000"
        },
        {
          "label": "Service",
          "value": "grafana 3000 → 3000"
        }
      ],
      "task": "Chạy Grafana trên cluster (không bắt dashboard)"
    },
    {
      "id": "day-59",
      "day": 59,
      "block": "kubernetes",
      "title": "Troubleshoot Deployment issues in Kubernetes",
      "goal": "`broken-web` hết ImagePullBackOff / CrashLoopBackOff. Thường sai image tag — sửa thành `nginx:1.27-alpine`. READY 1/1.",
      "do": [
        "Xem Deployment và Pod: ghi STATUS.",
        "Describe Pod (Events: Failed to pull, CrashLoop) và `logs` / `logs --previous`.",
        "Sửa image (hoặc probe/command) rồi apply. Đợi READY 1/1."
      ],
      "hints": {
        "commands": [
          "kubectl get deploy broken-web,pods -l app=broken-web",
          "kubectl describe pod -l app=broken-web",
          "kubectl logs -l app=broken-web --tail=50"
        ],
        "questions": [
          "ImagePullBackOff xem field nào trước?",
          "READY 0/1 khác CrashLoop chỗ nào?"
        ],
        "explain": [
          {
            "term": "ImagePullBackOff",
            "text": "Xem image, tag, imagePullSecrets trong describe — pull registry fail."
          },
          {
            "term": "CrashLoopBackOff",
            "text": "Container start rồi chết. Đọc logs / logs --previous."
          },
          {
            "term": "READY 0/1",
            "text": "Process có thể còn sống nhưng probe chưa pass. Khác crash loop."
          },
          {
            "term": "--tail=50",
            "text": "50 dòng log cuối."
          }
        ]
      },
      "given": [
        {
          "label": "Deployment lỗi",
          "value": "broken-web"
        },
        {
          "label": "Image đúng sau sửa",
          "value": "nginx:1.27-alpine"
        },
        {
          "label": "Khi xong",
          "value": "READY 1/1, không ImagePullBackOff / CrashLoopBackOff"
        }
      ],
      "task": "Sửa Deployment broken-web cho Pod Ready"
    },
    {
      "id": "day-60",
      "day": 60,
      "block": "kubernetes",
      "title": "Persistent Volumes in Kubernetes",
      "goal": "`lab-pvc` trạng thái Bound. Pod `pv-pod` mount PVC tại `/data` (khai `claimName`, không ghi tên PV). File ghi `/data/note` còn sau khi xóa Pod và tạo lại cùng claim.",
      "do": [
        "Tạo PV `lab-pv` (hostPath `/mnt/lab-pv` hoặc StorageClass lab) 1Gi RWO, và PVC `lab-pvc` 1Gi RWO.",
        "`kubectl get pvc lab-pvc` = `Bound`.",
        "Pod mount `persistentVolumeClaim.claimName: lab-pvc` tại `/data`. Ghi file, xóa Pod, tạo lại — file còn."
      ],
      "hints": {
        "commands": [
          "kubectl get pv lab-pv,pvc lab-pvc",
          "kubectl describe pvc lab-pvc",
          "persistentVolumeClaim.claimName: lab-pvc"
        ],
        "questions": [
          "Pod trỏ tên PV hay tên PVC?",
          "PVC Pending thường vì sao?"
        ],
        "explain": [
          {
            "term": "claimName",
            "text": "Pod trỏ tên PVC, không trỏ tên PV."
          },
          {
            "term": "Bound",
            "text": "PVC đã khớp PV. Pending = chưa có PV đủ size/accessMode/storageClass."
          }
        ]
      },
      "given": [
        {
          "label": "PV",
          "value": "lab-pv — 1Gi, ReadWriteOnce"
        },
        {
          "label": "PVC",
          "value": "lab-pvc — 1Gi, ReadWriteOnce"
        },
        {
          "label": "Pod",
          "value": "pv-pod, claimName lab-pvc, mountPath /data"
        }
      ],
      "task": "PVC Bound và Pod ghi dữ liệu còn sau khi tạo lại"
    },
    {
      "id": "day-61",
      "day": 61,
      "block": "kubernetes",
      "title": "Init Containers in Kubernetes",
      "goal": "`init-pod`: container `setup` chạy xong (exit 0) rồi nginx mới start. App đọc được `/work/ready`. Init fail thì app không bao giờ Running.",
      "do": [
        "`initContainers`: `setup` mount `/work`, ghi `ready` vào `/work/ready` rồi thoát 0.",
        "`emptyDir` dùng chung; app cũng mount `/work`.",
        "Lúc đầu Pod `Init:0/1`, sau `Running`. Exec app: `cat /work/ready`."
      ],
      "hints": {
        "commands": [
          "kubectl get pod init-pod",
          "kubectl logs init-pod -c setup",
          "kubectl exec init-pod -c nginx -- cat /work/ready"
        ],
        "questions": [
          "Init:0/1 nghĩa là gì?",
          "Xóa Pod thì init có chạy lại không?"
        ],
        "explain": [
          {
            "term": "Init:0/1",
            "text": "Init container đầu chưa xong (0 trên 1). App chưa start."
          },
          {
            "term": "logs -c setup",
            "text": "Log đúng init container."
          },
          {
            "term": "xóa Pod",
            "text": "Pod mới chạy lại toàn bộ init từ đầu."
          }
        ]
      },
      "given": [
        {
          "label": "Pod",
          "value": "init-pod"
        },
        {
          "label": "initContainer",
          "value": "setup — busybox:1.36, ghi ready vào /work/ready"
        },
        {
          "label": "App",
          "value": "nginx:1.27-alpine, mount /work"
        }
      ],
      "task": "Init container tạo file trước khi app start"
    },
    {
      "id": "day-62",
      "day": 62,
      "block": "kubernetes",
      "title": "Manage Secrets in Kubernetes",
      "goal": "Secret `db-secret` key `password`. Pod đọc thành `DB_PASSWORD=LabPass62`. `get secret -o yaml` là base64 — không phải mã hóa.",
      "do": [
        "Tạo Secret generic `db-secret` từ literal `password=LabPass62`.",
        "Pod khai `env` `DB_PASSWORD` qua `secretKeyRef` (name + key), không `value:` trần.",
        "Exec `printenv DB_PASSWORD` = `LabPass62`."
      ],
      "hints": {
        "commands": [
          "kubectl create secret generic db-secret --from-literal=password=LabPass62",
          "secretKeyRef.name: db-secret",
          "kubectl get secret db-secret -o yaml"
        ],
        "questions": [
          "etcd mặc định encrypt Secret không?",
          "Sửa Secret có tự restart Pod không?"
        ],
        "explain": [
          {
            "term": "--from-literal",
            "text": "Tạo key/value trên command line. kubectl encode base64 giúp."
          },
          {
            "term": "secretKeyRef",
            "text": "Pod đọc một key thành biến env."
          },
          {
            "term": "base64",
            "text": "get -o yaml ra base64, không phải mã hóa. etcd mặc định không encrypt Secret."
          },
          {
            "term": "sửa Secret",
            "text": "Không restart Pod sẵn. Process cũ giữ env lúc start."
          }
        ]
      },
      "given": [
        {
          "label": "Secret",
          "value": "db-secret"
        },
        {
          "label": "Key / value",
          "value": "password = LabPass62"
        },
        {
          "label": "Env trong Pod",
          "value": "DB_PASSWORD ← secretKeyRef db-secret / password"
        }
      ],
      "task": "Secret thành biến môi trường, không nhét password vào image"
    },
    {
      "id": "day-63",
      "day": 63,
      "block": "kubernetes",
      "title": "Deploy Iron Gallery App on Kubernetes",
      "goal": "`gallery-web` và `gallery-db` đều Ready. Web kết nối DB bằng hostname `gallery-db` (tên Service), không `localhost` — localhost trong Pod là chính Pod đó.",
      "do": [
        "DB: MariaDB, mật khẩu từ Secret, Service cổng 3306 tên `gallery-db`.",
        "Web: env `DB_HOST=gallery-db`, Service cổng 80.",
        "Cả hai Deployment Ready. Từ Pod web, `getent hosts gallery-db` ra IP Service."
      ],
      "hints": {
        "commands": [
          "kubectl apply -f gallery/",
          "kubectl get deploy,svc",
          "DB_HOST=gallery-db"
        ],
        "questions": [
          "`localhost` trong Pod web là máy nào?",
          "Thứ tự: Secret/PVC trước hay Pod trước?"
        ],
        "explain": [
          {
            "term": "DB_HOST=gallery-db",
            "text": "Tên Service DNS trong cluster. localhost trong Pod web là chính Pod đó."
          },
          {
            "term": "thứ tự",
            "text": "Secret/PVC tồn tại trước khi Pod mount/ref. Apply thư mục thường ổn nếu không circular."
          }
        ]
      },
      "given": [
        {
          "label": "Front",
          "value": "Deployment + Service gallery-web:80"
        },
        {
          "label": "DB",
          "value": "Deployment + Service gallery-db:3306"
        },
        {
          "label": "Env web",
          "value": "DB_HOST=gallery-db, DB_NAME=gallery"
        },
        {
          "label": "Image gợi ý",
          "value": "web nginx:1.27-alpine — db mariadb:11.4"
        }
      ],
      "task": "Hai workload: web gọi DB bằng DNS Service"
    },
    {
      "id": "day-64",
      "day": 64,
      "block": "kubernetes",
      "title": "Fix Python App Deployed on Kubernetes Cluster",
      "goal": "`py-app` Ready. Process nghe `8000`, `containerPort` và `targetPort` cũng `8000`. Sửa manifest hiện có, không xóa hết viết lại.",
      "do": [
        "Describe + logs: sai image, port, hoặc command.",
        "Process Python nghe 8000. `containerPort` 8000. Service `targetPort` 8000.",
        "Rollout successful. Port-forward Service rồi thử endpoint."
      ],
      "hints": {
        "commands": [
          "kubectl describe deploy py-app",
          "kubectl logs -l app=py-app --tail=50",
          "kubectl apply -f py-app.yaml"
        ],
        "questions": [
          "CrashLoop xem logs hay tăng replica trước?",
          "containerPort lệch Service targetPort thì sao?"
        ],
        "explain": [
          {
            "term": "CrashLoop",
            "text": "Đọc logs trước. Tăng replica chỉ nhân bản Pod lỗi."
          },
          {
            "term": "targetPort",
            "text": "Phải trùng cổng process (8000). Lệch thì Service nhận request nhưng không vào app."
          }
        ]
      },
      "given": [
        {
          "label": "Deployment",
          "value": "py-app"
        },
        {
          "label": "App listen",
          "value": "0.0.0.0:8000"
        },
        {
          "label": "containerPort / targetPort",
          "value": "8000"
        },
        {
          "label": "Service",
          "value": "py-app 80 → 8000"
        }
      ],
      "task": "Sửa app Python: port process khớp Service"
    },
    {
      "id": "day-65",
      "day": 65,
      "block": "kubernetes",
      "title": "Deploy Redis Deployment on Kubernetes",
      "goal": "`redis-cache` Ready, image `redis:7.4-alpine`. Service ClusterIP 6379. Không bắt NodePort ra ngoài cluster. `redis-cli ping` = `PONG`.",
      "do": [
        "Deployment `redis-cache`, `containerPort` 6379, label `app=redis-cache`.",
        "Service cùng tên, 6379 → 6379, loại ClusterIP.",
        "Exec vào Pod: `redis-cli ping` = `PONG`."
      ],
      "hints": {
        "commands": [
          "containerPort: 6379",
          "kubectl apply -f redis-cache.yaml",
          "kubectl exec deploy/redis-cache -- redis-cli ping"
        ],
        "questions": [
          "Redis mặc định nghe port nào?",
          "Stateless Deployment + emptyDir reboot Pod còn data?"
        ],
        "explain": [
          {
            "term": "6379",
            "text": "Cổng mặc định Redis."
          },
          {
            "term": "redis-cli ping",
            "text": "PONG = process Redis nhận lệnh."
          },
          {
            "term": "emptyDir",
            "text": "Mất khi Pod bị thay. Cần giữ data thì PVC."
          }
        ]
      },
      "given": [
        {
          "label": "Deployment",
          "value": "redis-cache"
        },
        {
          "label": "Image",
          "value": "redis:7.4-alpine"
        },
        {
          "label": "Port",
          "value": "6379"
        },
        {
          "label": "Service",
          "value": "redis-cache ClusterIP 6379"
        }
      ],
      "task": "Deployment Redis kèm Service ClusterIP 6379"
    },
    {
      "id": "day-66",
      "day": 66,
      "block": "kubernetes",
      "title": "Deploy MySQL on Kubernetes",
      "goal": "MySQL Ready, root password từ Secret, data dir trên `mysql-pvc`. App khác gọi hostname `mysql-db`, không localhost.",
      "do": [
        "Tạo Secret `mysql-secret` literal `MYSQL_ROOT_PASSWORD=LabRoot66`.",
        "PVC `mysql-pvc` 1Gi. Deployment mount `/var/lib/mysql`, lấy env từ Secret.",
        "Service `mysql-db` 3306. PVC Bound, Deployment Ready."
      ],
      "hints": {
        "commands": [
          "kubectl create secret generic mysql-secret --from-literal=MYSQL_ROOT_PASSWORD=LabRoot66",
          "claimName: mysql-pvc",
          "kubectl get pvc mysql-pvc,deploy mysql-db,svc mysql-db"
        ],
        "questions": [
          "Password để env value thẳng trong YAML được không (nên)?",
          "RWO mount mấy Pod cùng lúc?"
        ],
        "explain": [
          {
            "term": "--from-literal",
            "text": "Nhét password vào Secret, không ghi value thẳng env trong YAML."
          },
          {
            "term": "claimName",
            "text": "Mount PVC vào /var/lib/mysql."
          },
          {
            "term": "RWO",
            "text": "ReadWriteOnce: một node/thường một Pod mount ghi. Nhiều replica MySQL cùng PVC sẽ lỗi."
          }
        ]
      },
      "given": [
        {
          "label": "Deployment",
          "value": "mysql-db"
        },
        {
          "label": "Image",
          "value": "mysql:8.0"
        },
        {
          "label": "Secret",
          "value": "mysql-secret — MYSQL_ROOT_PASSWORD=LabRoot66"
        },
        {
          "label": "PVC",
          "value": "mysql-pvc 1Gi RWO, mount /var/lib/mysql"
        },
        {
          "label": "Service",
          "value": "mysql-db 3306"
        }
      ],
      "task": "MySQL: password từ Secret, data trên PVC"
    },
    {
      "id": "day-67",
      "day": 67,
      "block": "kubernetes",
      "title": "Deploy Guest Book App on Kubernetes",
      "goal": "Ba Deployment + ba Service. Frontend gọi `gb-backend` và `gb-redis` bằng tên Service. Cả ba Ready. `localhost` trong frontend không tới được backend.",
      "do": [
        "Apply thứ tự: redis → backend → frontend (backend cần DNS redis).",
        "Env không dùng `localhost`. Cả ba Deployment Ready.",
        "Port-forward `gb-frontend` 8080:80. `curl` không 502."
      ],
      "hints": {
        "commands": [
          "kubectl apply -f guestbook/",
          "kubectl get deploy,svc",
          "kubectl port-forward svc/gb-frontend 8080:80"
        ],
        "questions": [
          "Một Service sai selector thì frontend thấy gì?",
          "Cùng namespace, DNS Service viết thế nào?"
        ],
        "explain": [
          {
            "term": "DNS Service",
            "text": "Cùng namespace: chỉ tên Service (gb-backend). Đủ: gb-backend.default.svc.cluster.local."
          },
          {
            "term": "selector sai",
            "text": "Service không có endpoint. Frontend connection refused hoặc timeout."
          }
        ]
      },
      "given": [
        {
          "label": "Frontend",
          "value": "gb-frontend — Service :80"
        },
        {
          "label": "Backend",
          "value": "gb-backend — Service :80"
        },
        {
          "label": "Store",
          "value": "gb-redis — Service :6379"
        },
        {
          "label": "Env frontend",
          "value": "BACKEND_HOST=gb-backend, REDIS_HOST=gb-redis"
        }
      ],
      "task": "Guestbook: frontend, backend, redis gọi nhau bằng DNS"
    },
    {
      "id": "day-68",
      "day": 68,
      "block": "cicd",
      "title": "Set Up Jenkins Server",
      "given": [
        {
          "label": "Unit systemd",
          "value": "jenkins"
        },
        {
          "label": "JENKINS_HOME",
          "value": "/var/lib/jenkins"
        },
        {
          "label": "HTTP",
          "value": "http://127.0.0.1:8080"
        },
        {
          "label": "Password lần đầu",
          "value": "/var/lib/jenkins/secrets/initialAdminPassword"
        }
      ],
      "goal": "Jenkins chạy như unit `jenkins`, HTTP `127.0.0.1:8080`. Trang `/login` trả 200. Lần đầu unlock bằng file `initialAdminPassword`.",
      "do": [
        "Cài Jenkins theo docs Linux (gói `jenkins` + Java). Enable và start unit.",
        "Lab kỳ vọng cổng `8080`. Nếu cổng bận, đừng đổi port rồi quên — xử lý process chiếm 8080 hoặc ghi rõ trong lab của bạn.",
        "`curl` `/login` = 200. Đọc `initialAdminPassword` và xong wizard lần đầu."
      ],
      "hints": {
        "commands": [
          "systemctl enable --now jenkins",
          "systemctl status jenkins",
          "sudo cat /var/lib/jenkins/secrets/initialAdminPassword",
          "curl -sS -D - http://127.0.0.1:8080/login -o /dev/null"
        ],
        "questions": [
          "JENKINS_HOME mặc định trên Linux là đâu?",
          "Cổng mặc định của Jenkins là gì?"
        ],
        "explain": [
          {
            "term": "enable --now jenkins",
            "text": "Bật unit và start controller."
          },
          {
            "term": "JENKINS_HOME",
            "text": "Mặc định Linux /var/lib/jenkins — job, plugin, config."
          },
          {
            "term": "8080",
            "text": "HTTP mặc định. Bận thì JENKINS_PORT trong systemd override."
          },
          {
            "term": "initialAdminPassword",
            "text": "Password unlock wizard lần đầu, trong secrets/."
          }
        ]
      },
      "task": "Cài Jenkins controller nghe 8080"
    },
    {
      "id": "day-69",
      "day": 69,
      "block": "cicd",
      "title": "Install Jenkins Plugins",
      "given": [
        {
          "label": "Plugin Git",
          "value": "git"
        },
        {
          "label": "Plugin Pipeline",
          "value": "workflow-aggregator"
        },
        {
          "label": "Plugin SSH agent",
          "value": "ssh-slaves"
        }
      ],
      "goal": "Controller có plugin ID `git`, `workflow-aggregator` (Pipeline), `ssh-slaves`. Tên trên UI khác ID — dùng đúng ID. Restart nếu Jenkins yêu cầu.",
      "do": [
        "Cài bằng Plugin Manager hoặc `jenkins-plugin-cli` với đúng ba plugin ID trên.",
        "Không đoán tên marketing — khớp ID.",
        "Manage Jenkins → Plugins: ba plugin Installed. Tạo được job loại Pipeline."
      ],
      "hints": {
        "commands": [
          "jenkins-plugin-cli --plugins git workflow-aggregator ssh-slaves",
          "systemctl restart jenkins"
        ],
        "questions": [
          "`workflow-aggregator` khác plugin tên hiển thị Pipeline chỗ nào?",
          "Cài plugin xong mà job Pipeline không có — thiếu bước nào?"
        ],
        "explain": [
          {
            "term": "--plugins",
            "text": "Danh sách plugin ID, cách nhau khoảng trắng."
          },
          {
            "term": "workflow-aggregator",
            "text": "Plugin ID của bộ Pipeline. Tên UI là Pipeline."
          },
          {
            "term": "restart",
            "text": "Nhiều plugin chỉ load sau restart. Thiếu restart thì job Pipeline chưa có."
          }
        ]
      },
      "task": "Cài ba plugin: Git, Pipeline, SSH agent"
    },
    {
      "id": "day-70",
      "day": 70,
      "block": "cicd",
      "title": "Configure Jenkins User Access",
      "given": [
        {
          "label": "Security realm",
          "value": "Jenkins’ own user database"
        },
        {
          "label": "User admin",
          "value": "jadmin — Overall/Administer, mật khẩu Jadmin70"
        },
        {
          "label": "User dev",
          "value": "jdev — Overall/Read, mật khẩu Jdev70"
        }
      ],
      "goal": "`jadmin` có Overall/Administer. `jdev` chỉ Overall/Read — không vào Manage Jenkins. Realm: Jenkins’ own user database + matrix.",
      "do": [
        "Bật security. Realm user database của Jenkins. Tạo `jadmin` và `jdev` với mật khẩu lab.",
        "Authorization matrix: `jadmin` Administer; `jdev` chỉ Read. Anonymous không Administer.",
        "Login `jdev` không thấy Manage Jenkins. `jadmin` thấy."
      ],
      "hints": {
        "commands": [
          "Manage Jenkins → Security",
          "Overall/Administer",
          "Overall/Read"
        ],
        "questions": [
          "Own user database khác LDAP chỗ nào?",
          "Thiếu Overall/Read thì jdev thấy gì khi mở / ?"
        ],
        "explain": [
          {
            "term": "own user database",
            "text": "User lưu trên Jenkins. LDAP lấy user từ thư mục ngoài."
          },
          {
            "term": "Overall/Administer",
            "text": "Vào được Manage Jenkins, cài plugin, sửa security."
          },
          {
            "term": "Overall/Read",
            "text": "Xem trang chính. Thiếu thì / trả Access Denied."
          }
        ]
      },
      "task": "Hai user Jenkins: admin đầy đủ, dev chỉ đọc"
    },
    {
      "id": "day-71",
      "day": 71,
      "block": "cicd",
      "title": "Configure Jenkins Job for Package Installation",
      "given": [
        {
          "label": "Tên job",
          "value": "install-nginx"
        },
        {
          "label": "Loại",
          "value": "Freestyle"
        },
        {
          "label": "Bước build",
          "value": "Execute shell: cài nginx (apt hoặc dnf)"
        }
      ],
      "goal": "Job `install-nginx` chạy shell cài nginx trên agent. Build SUCCESS. Trên agent, `nginx -v` chạy được.",
      "do": [
        "New Item → `install-nginx` → Freestyle. Thêm bước Execute shell.",
        "Lệnh cài nginx theo distro của agent (`apt-get` hoặc `dnf`), có `sudo` nếu user Jenkins không root.",
        "Build Now: console SUCCESS. Trên agent kiểm `nginx -v`."
      ],
      "hints": {
        "commands": [
          "sudo apt-get install -y nginx",
          "sudo dnf install -y nginx",
          "which nginx"
        ],
        "questions": [
          "Freestyle khác Pipeline chỗ nào?",
          "Job SUCCESS mà agent không có nginx — shell chạy máy nào?"
        ],
        "explain": [
          {
            "term": "Freestyle",
            "text": "Cấu hình từng bước trên UI (shell, trigger). Pipeline là Jenkinsfile/stages."
          },
          {
            "term": "Execute shell",
            "text": "Chạy trên agent được gán job, không phải máy bạn đang xem UI."
          }
        ]
      },
      "task": "Job Freestyle cài nginx trên agent"
    },
    {
      "id": "day-72",
      "day": 72,
      "block": "cicd",
      "title": "Jenkins Parameterized Builds",
      "given": [
        {
          "label": "Tên job",
          "value": "greet-build"
        },
        {
          "label": "Tham số",
          "value": "string NAME, defaultValue lab"
        },
        {
          "label": "Chạy thử",
          "value": "NAME=Ada → log có Hello Ada"
        }
      ],
      "goal": "Job `greet-build` nhận string `NAME` (mặc định `lab`). Build với `NAME=Ada` thì console có `Hello Ada` — không dùng default khi kiểm.",
      "do": [
        "Bật parameterized (Pipeline `parameters` hoặc Freestyle This project is parameterized).",
        "Một bước in `Hello` + giá trị `NAME` (`params.NAME` hoặc `$NAME`).",
        "Build with Parameters: `NAME=Ada`. Log có `Hello Ada`."
      ],
      "hints": {
        "commands": [
          "parameters { string(name: 'NAME', defaultValue: 'lab') }",
          "echo \"Hello ${params.NAME}\""
        ],
        "questions": [
          "`params.NAME` khác env NAME chỗ nào?",
          "Không bấm Build with Parameters thì NAME lấy gì?"
        ],
        "explain": [
          {
            "term": "string(name: 'NAME')",
            "text": "Khai parameter. defaultValue dùng khi không nhập."
          },
          {
            "term": "params.NAME",
            "text": "Object parameters của Pipeline. Env NAME cũng được export lúc build, nhưng params là chỗ đúng trong declarative."
          },
          {
            "term": "Build with Parameters",
            "text": "Không bấm thì nhận defaultValue (lab)."
          }
        ]
      },
      "task": "Job có tham số NAME, in Hello <tên>"
    },
    {
      "id": "day-73",
      "day": 73,
      "block": "cicd",
      "title": "Jenkins Scheduled Jobs",
      "given": [
        {
          "label": "Tên job",
          "value": "nightly-clean"
        },
        {
          "label": "Lịch cron",
          "value": "15 3 * * * (03:15 mỗi ngày)"
        },
        {
          "label": "Việc khi chạy",
          "value": "ghi cleaned vào /tmp/lab-clean/ok"
        }
      ],
      "goal": "`nightly-clean` có trigger cron `15 3 * * *`. Không cần ngồi đợi 03:15 — Build Now một lần để file `/tmp/lab-clean/ok` chứa `cleaned`.",
      "do": [
        "Gắn lịch `15 3 * * *` (Freestyle Build periodically hoặc Pipeline `triggers { cron(...) }`).",
        "Bước shell: tạo thư mục và ghi `cleaned` vào `/tmp/lab-clean/ok`.",
        "Build tay một lần để kiểm file. Lịch chỉ cần thấy trên config."
      ],
      "hints": {
        "commands": [
          "triggers { cron('15 3 * * *') }",
          "15 3 * * *",
          "mkdir -p /tmp/lab-clean && echo cleaned > /tmp/lab-clean/ok"
        ],
        "questions": [
          "`H` trong cron Jenkins dùng để làm gì?",
          "Build periodically khác Poll SCM chỗ nào?"
        ],
        "explain": [
          {
            "term": "15 3 * * *",
            "text": "Cron Jenkins: phút giờ DOM tháng DOW. 03:15 mỗi ngày."
          },
          {
            "term": "H",
            "text": "Hash: phân tán phút giữa các job, tránh cùng giây. Lab chốt 15 cho dễ kiểm."
          },
          {
            "term": "Build periodically",
            "text": "Chạy theo lịch, không nhìn Git. Poll SCM hỏi repo rồi mới build khi có commit."
          }
        ]
      },
      "task": "Job chạy theo lịch 03:15 và ghi file khi build"
    },
    {
      "id": "day-74",
      "day": 74,
      "block": "cicd",
      "title": "Jenkins Database Backup Job",
      "given": [
        {
          "label": "Tên job",
          "value": "backup-shopdb"
        },
        {
          "label": "Database",
          "value": "shopdb"
        },
        {
          "label": "User SQL",
          "value": "shop / ShopLab17"
        },
        {
          "label": "File dump",
          "value": "/var/backups/shopdb/shopdb-YYYYMMDD.sql"
        }
      ],
      "goal": "Job `backup-shopdb` dump `shopdb` ra `/var/backups/shopdb/shopdb-YYYYMMDD.sql`. Build SUCCESS, file không rỗng.",
      "do": [
        "Tạo `/var/backups/shopdb`, user agent ghi được.",
        "Shell dump (`mysqldump` hoặc `pg_dump`) ra file có ngày `YYYYMMDD`. Lab cho phép để password trên lệnh.",
        "Build Now. File dump tồn tại và size > 0."
      ],
      "hints": {
        "commands": [
          "mkdir -p /var/backups/shopdb",
          "mysqldump -u shop -pShopLab17 shopdb > /var/backups/shopdb/shopdb-$(date +%Y%m%d).sql",
          "test -s /var/backups/shopdb/shopdb-$(date +%Y%m%d).sql"
        ],
        "questions": [
          "Vì sao không nên echo mật khẩu trong log?",
          "`test -s` kiểm tra gì?"
        ],
        "explain": [
          {
            "term": "-u / -p",
            "text": "User và password MySQL. -p dính mật khẩu, không khoảng trắng."
          },
          {
            "term": "date +%Y%m%d",
            "text": "Thành 20261015 trong tên file."
          },
          {
            "term": "test -s",
            "text": "File tồn tại và size > 0."
          },
          {
            "term": "log",
            "text": "echo mật khẩu in vào console build — ai xem job cũng thấy."
          }
        ]
      },
      "task": "Job dump database shopdb ra file theo ngày"
    },
    {
      "id": "day-75",
      "day": 75,
      "block": "cicd",
      "title": "Jenkins Slave Nodes",
      "given": [
        {
          "label": "Tên node",
          "value": "agent-01"
        },
        {
          "label": "Label",
          "value": "linux"
        },
        {
          "label": "Remote root",
          "value": "/home/jenkins/agent-01"
        },
        {
          "label": "Launch",
          "value": "SSH deploy@127.0.0.1"
        }
      ],
      "goal": "Node `agent-01` Connected, label `linux`. Job `ping-agent` giới hạn label `linux` chạy trên `agent-01`, không trên built-in.",
      "do": [
        "New node `agent-01`, Permanent, remote root đúng path, Labels `linux`.",
        "Launch via SSH: host `127.0.0.1`, user `deploy`, credential khóa. Đừng dùng built-in làm agent thường.",
        "Job `ping-agent` restrict `linux`, in `$NODE_NAME`. Console phải là `agent-01`."
      ],
      "hints": {
        "commands": [
          "agent { label 'linux' }",
          "echo $NODE_NAME",
          "Manage Jenkins → Nodes"
        ],
        "questions": [
          "`agent any` có thể chọn built-in không?",
          "Label `linux` khớp field nào trên node?"
        ],
        "explain": [
          {
            "term": "label 'linux'",
            "text": "Khớp field Labels trên node. Sai chữ thì job xếp hàng."
          },
          {
            "term": "agent any",
            "text": "Mọi agent kể cả built-in (controller). Lab nhỏ dễ chạy nhầm trên controller."
          },
          {
            "term": "NODE_NAME",
            "text": "Tên node đang chạy build — phải là agent-01."
          }
        ]
      },
      "task": "Thêm node agent-01, job chạy trên agent không trên controller"
    },
    {
      "id": "day-76",
      "day": 76,
      "block": "cicd",
      "title": "Jenkins Project Security",
      "given": [
        {
          "label": "Tên job",
          "value": "secret-job"
        },
        {
          "label": "jadmin",
          "value": "Job/Configure + Job/Build"
        },
        {
          "label": "jdev",
          "value": "không Job/Build, không Job/Configure"
        }
      ],
      "goal": "`secret-job` bật Project-based Matrix. `jadmin` build được. `jdev` xem được job nhưng không Build.",
      "do": [
        "Bật Project-based Matrix (plugin `matrix-auth` nếu thiếu).",
        "Trên `secret-job`: enable project-based security. `jadmin` Configure + Build. `jdev` tối đa Job/Read.",
        "Login `jdev`: không Build được. `jadmin` Build Now được."
      ],
      "hints": {
        "commands": [
          "Project-based Matrix Authorization Strategy",
          "Job/Build",
          "Job/Configure"
        ],
        "questions": [
          "Project-based khác Matrix toàn cục chỗ nào?",
          "jdev có Overall/Read nhưng không Job/Build thì Build Now ra sao?"
        ],
        "explain": [
          {
            "term": "Project-based",
            "text": "Quyền theo từng job. Matrix toàn cục áp cho mọi item."
          },
          {
            "term": "Job/Build",
            "text": "Được bấm Build Now. Thiếu thì bị cấm dù Overall/Read."
          },
          {
            "term": "Job/Configure",
            "text": "Sửa cấu hình job."
          }
        ]
      },
      "task": "Khóa quyền Build trên một job"
    },
    {
      "id": "day-77",
      "day": 77,
      "block": "cicd",
      "title": "Jenkins Deploy Pipeline",
      "given": [
        {
          "label": "Tên job",
          "value": "deploy-web"
        },
        {
          "label": "Loại",
          "value": "Pipeline declarative"
        },
        {
          "label": "Stage Package",
          "value": "ghi packaged vào /tmp/web-artifact.txt"
        },
        {
          "label": "Stage Deploy",
          "value": "copy artifact tới /opt/web/release/index.html"
        }
      ],
      "goal": "Job `deploy-web` declarative, hai stage lần lượt. Sau SUCCESS, `/opt/web/release/index.html` trên agent chứa `packaged`.",
      "do": [
        "Pipeline `agent { label 'linux' }` (hoặc agent lab của bạn), hai `stage`.",
        "Package tạo artifact. Deploy copy sang `/opt/web/release/index.html`.",
        "Build: hai stage xanh. File đích chứa `packaged`."
      ],
      "hints": {
        "commands": [
          "pipeline { agent { label 'linux' } stages { } }",
          "sh 'echo packaged > /tmp/web-artifact.txt'",
          "sh 'mkdir -p /opt/web/release && cp /tmp/web-artifact.txt /opt/web/release/index.html'"
        ],
        "questions": [
          "Thiếu `agent` trên pipeline thì sao?",
          "Hai stage có phải hai agent không?"
        ],
        "explain": [
          {
            "term": "agent",
            "text": "Thiếu agent (và không agent none + agent từng stage) thì Pipeline không biết chạy đâu."
          },
          {
            "term": "stages",
            "text": "Hai stage lần lượt trên cùng agent đã cấp, không phải hai máy trừ khi mỗi stage khai agent riêng."
          },
          {
            "term": "sh",
            "text": "Chạy shell trên agent Linux."
          }
        ]
      },
      "task": "Pipeline hai stage: Package rồi Deploy artifact"
    },
    {
      "id": "day-78",
      "day": 78,
      "block": "cicd",
      "title": "Jenkins Conditional Pipeline",
      "given": [
        {
          "label": "Tên job",
          "value": "cond-deploy"
        },
        {
          "label": "Tham số",
          "value": "choice ENV: staging, prod (mặc định staging)"
        },
        {
          "label": "Stage luôn chạy",
          "value": "Build"
        },
        {
          "label": "Stage có when",
          "value": "Deploy-prod khi params.ENV == 'prod'"
        }
      ],
      "goal": "`cond-deploy` có choice `ENV`. `ENV=staging` thì stage `Deploy-prod` skip. `ENV=prod` thì stage chạy. Dùng `when { expression { } }`.",
      "do": [
        "Khai `parameters` choice `ENV`: `staging`, `prod`.",
        "Stage `Deploy-prod` có `when` đúng biểu thức `params.ENV == 'prod'`.",
        "Chạy hai build: staging (stage skip) và prod (stage chạy)."
      ],
      "hints": {
        "commands": [
          "choice(name: 'ENV', choices: ['staging', 'prod'])",
          "when { expression { params.ENV == 'prod' } }",
          "when { environment name: 'ENV', value: 'prod' }"
        ],
        "questions": [
          "`when` đặt trong `stage` hay ngoài `stages`?",
          "Choice đầu tiên trong mảng là default — đúng không?"
        ],
        "explain": [
          {
            "term": "choice",
            "text": "Phần tử đầu mảng là default (staging)."
          },
          {
            "term": "when",
            "text": "Đặt trong stage, không đặt ngoài stages. Sai điều kiện thì stage skip."
          },
          {
            "term": "expression",
            "text": "Groovy: params.ENV == 'prod'."
          },
          {
            "term": "environment",
            "text": "So khớp biến môi trường ENV=prod — params cũng được export thành env."
          }
        ]
      },
      "task": "Stage Deploy-prod chỉ chạy khi ENV=prod"
    },
    {
      "id": "day-79",
      "day": 79,
      "block": "cicd",
      "title": "Jenkins Deployment Job",
      "given": [
        {
          "label": "Tên job",
          "value": "deploy-shop"
        },
        {
          "label": "Host đích",
          "value": "deploy@127.0.0.1"
        },
        {
          "label": "Hành động",
          "value": "sudo systemctl reload nginx"
        },
        {
          "label": "Kiểm",
          "value": "http://127.0.0.1/ → 200"
        }
      ],
      "goal": "`deploy-shop` SSH `deploy@127.0.0.1` và reload nginx. Build SUCCESS. `curl http://127.0.0.1/` = 200. Khóa SSH của agent đã nằm trong `authorized_keys` của `deploy` (ngày 07).",
      "do": [
        "Bước shell hoặc `sh`: SSH BatchMode tới `deploy@127.0.0.1` chạy reload nginx.",
        "Nhớ credential/khóa: agent phải SSH được không hỏi passphrase.",
        "Build SUCCESS. `curl` trang chủ không Connection refused."
      ],
      "hints": {
        "commands": [
          "ssh -o BatchMode=yes deploy@127.0.0.1 'sudo systemctl reload nginx'",
          "curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1/"
        ],
        "questions": [
          "`BatchMode=yes` tránh gì?",
          "reload khác restart chỗ nào?"
        ],
        "explain": [
          {
            "term": "BatchMode=yes",
            "text": "SSH không hỏi password/yes-no. Fail ngay nếu thiếu khóa."
          },
          {
            "term": "reload",
            "text": "Nạp lại config, giữ connection. restart cắt process rồi start lại."
          },
          {
            "term": "-w '%{http_code}'",
            "text": "curl chỉ in HTTP status."
          }
        ]
      },
      "task": "Job SSH tới máy đích rồi reload nginx"
    },
    {
      "id": "day-80",
      "day": 80,
      "block": "cicd",
      "title": "Jenkins Chained Builds",
      "given": [
        {
          "label": "Job trước",
          "value": "build-app"
        },
        {
          "label": "Job sau",
          "value": "deploy-shop"
        },
        {
          "label": "Điều kiện",
          "value": "chỉ trigger khi build-app SUCCESS (stable)"
        }
      ],
      "goal": "Hai job xích nhau. `build-app` SUCCESS tự trigger `deploy-shop`. `build-app` FAIL thì `deploy-shop` không chạy.",
      "do": [
        "Freestyle: Post-build Build other projects → `deploy-shop`, chỉ khi stable. Hoặc Pipeline `build job:` ở stage cuối.",
        "Không trigger khi unstable/failed.",
        "Chạy `build-app` SUCCESS: history `deploy-shop` có build mới cùng lúc."
      ],
      "hints": {
        "commands": [
          "build job: 'deploy-shop'",
          "Build other projects → deploy-shop"
        ],
        "questions": [
          "Trigger only if stable nghĩa là gì?",
          "`build job` khác copy artifact chỗ nào?"
        ],
        "explain": [
          {
            "term": "build job:",
            "text": "Pipeline step gọi job khác. Không copy artifact trừ khi thêm copyArtifacts."
          },
          {
            "term": "only if stable",
            "text": "Chỉ trigger khi job trước SUCCESS (không UNSTABLE/FAIL)."
          }
        ]
      },
      "task": "build-app SUCCESS mới kích deploy-shop"
    },
    {
      "id": "day-81",
      "day": 81,
      "block": "cicd",
      "title": "Jenkins Multistage Pipeline",
      "given": [
        {
          "label": "Tên job",
          "value": "shop-ci"
        },
        {
          "label": "Jenkinsfile",
          "value": "/opt/shop/Jenkinsfile"
        },
        {
          "label": "Agent",
          "value": "label linux"
        },
        {
          "label": "Thứ tự stage",
          "value": "Checkout → Test → Package → Deploy"
        }
      ],
      "goal": "Job `shop-ci` đọc `/opt/shop/Jenkinsfile`. Bốn stage lần lượt trên agent `linux`, không `agent any` trên controller. Cả chuỗi SUCCESS.",
      "do": [
        "Job Pipeline from SCM hoặc script từ file `/opt/shop/Jenkinsfile`.",
        "Bốn stage: Checkout (thấy `/opt/shop`), Test (in `test-ok`), Package (tạo artifact), Deploy (copy vào `/opt/web/release/`).",
        "Không `agent any` trên controller. Build: bốn stage xanh đúng thứ tự."
      ],
      "hints": {
        "commands": [
          "pipeline { agent { label 'linux' } stages { } }",
          "stage('Checkout') { steps { sh 'ls /opt/shop' } }",
          "stage('Test') { steps { sh 'echo test-ok' } }",
          "stage('Package') { steps { sh 'echo pkg > /tmp/shop.tgz' } }",
          "stage('Deploy') { steps { sh 'cp /tmp/shop.tgz /opt/web/release/' } }"
        ],
        "questions": [
          "Thứ tự stage trong `stages` có phải thứ tự chạy không?",
          "`agent { label 'linux' }` khác `agent any` chỗ nào?"
        ],
        "explain": [
          {
            "term": "thứ tự stages",
            "text": "Declarative chạy lần lượt đúng thứ tự khai, trừ parallel."
          },
          {
            "term": "label 'linux'",
            "text": "Chỉ agent có label linux. agent any lấy bất kỳ, kể cả controller."
          }
        ]
      },
      "task": "Pipeline bốn stage đọc Jenkinsfile trên đĩa"
    },
    {
      "id": "day-82",
      "day": 82,
      "block": "ansible",
      "title": "Create Ansible Inventory for App Server Testing",
      "given": [
        {
          "label": "File inventory",
          "value": "/opt/ansible/inventory.ini"
        },
        {
          "label": "Group",
          "value": "app"
        },
        {
          "label": "Hosts",
          "value": "labapp01=192.168.56.11, labapp02=192.168.56.12"
        },
        {
          "label": "User / khóa SSH",
          "value": "deploy / /home/lab/.ssh/id_ed25519"
        }
      ],
      "goal": "File `/opt/ansible/inventory.ini` có group `app` gồm `labapp01` và `labapp02`. `ansible-inventory --graph` thấy đúng hai host, user SSH `deploy`.",
      "do": [
        "Tạo inventory dạng INI. Group `[app]`, mỗi host có `ansible_host` đúng IP, `ansible_user=deploy`.",
        "Biến group (hoặc all) trỏ `ansible_ssh_private_key_file` tới khóa lab.",
        "`ansible-inventory --graph`: `@app` có đủ `labapp01`, `labapp02`."
      ],
      "hints": {
        "commands": [
          "[app]",
          "labapp01 ansible_host=192.168.56.11 ansible_user=deploy",
          "ansible-inventory -i /opt/ansible/inventory.ini --graph"
        ],
        "questions": [
          "`ansible_host` khác tên host trong inventory chỗ nào?",
          "`-i` trỏ file nào?"
        ],
        "explain": [
          {
            "term": "[app]",
            "text": "Tên group. Play hosts: app khớp group này."
          },
          {
            "term": "ansible_host",
            "text": "IP/DNS để SSH. Tên labapp01 chỉ là inventory hostname."
          },
          {
            "term": "ansible_user",
            "text": "User SSH. Thiếu thì Ansible dùng user trên control node."
          },
          {
            "term": "-i",
            "text": "File inventory. --graph in cây group/host."
          }
        ]
      },
      "task": "Viết inventory INI: group app hai host"
    },
    {
      "id": "day-83",
      "day": 83,
      "block": "ansible",
      "title": "Troubleshoot and Create Ansible Playbook",
      "given": [
        {
          "label": "Playbook",
          "value": "/opt/ansible/playbooks/site.yml"
        },
        {
          "label": "hosts",
          "value": "app"
        },
        {
          "label": "Lỗi hay gặp",
          "value": "sai tên group, lệch indent, thiếu ---"
        }
      ],
      "goal": "`site.yml` YAML hợp lệ, `hosts: app` đúng tên group. `--syntax-check` exit 0, chạy thật mọi host `pong`.",
      "do": [
        "File bắt đầu `---`. Play `hosts: app` (đúng group, không `all` nếu đề bắt `app`).",
        "Một task `ansible.builtin.ping`. Indent 2 spaces, không tab lẫn space.",
        "Syntax-check exit 0. Chạy playbook: mọi host `pong`."
      ],
      "hints": {
        "commands": [
          "ansible-playbook -i /opt/ansible/inventory.ini /opt/ansible/playbooks/site.yml --syntax-check",
          "ansible-playbook -i /opt/ansible/inventory.ini /opt/ansible/playbooks/site.yml",
          "- hosts: app"
        ],
        "questions": [
          "`--syntax-check` có SSH tới host không?",
          "hosts: apps (thừa s) thì Ansible báo gì?"
        ],
        "explain": [
          {
            "term": "--syntax-check",
            "text": "Parse YAML/play, không SSH, không chạy task."
          },
          {
            "term": "hosts: app",
            "text": "Phải trùng group. hosts: apps (thừa s) = không khớp host, play skipped."
          }
        ]
      },
      "task": "Sửa playbook cho parse được rồi ping group app"
    },
    {
      "id": "day-84",
      "day": 84,
      "block": "ansible",
      "title": "Copy Data to App Servers using Ansible",
      "given": [
        {
          "label": "Playbook",
          "value": "/opt/ansible/playbooks/copy-motd.yml"
        },
        {
          "label": "src trên controller",
          "value": "/opt/ansible/files/motd.txt"
        },
        {
          "label": "dest trên host",
          "value": "/etc/motd"
        },
        {
          "label": "Nội dung / mode",
          "value": "Welcome to app lab / 0644"
        }
      ],
      "goal": "Module `copy` đưa nội dung `Welcome to app lab` vào `/etc/motd` trên mọi host group `app`, mode `0644`. Cần `become` vì `/etc` của root.",
      "do": [
        "Tạo file nguồn trên controller đúng nội dung.",
        "Task `copy`: src → dest, owner root, mode `0644`, `become: true`.",
        "Chạy playbook. `cat /etc/motd` trên `labapp01` khớp từng chữ."
      ],
      "hints": {
        "commands": [
          "ansible.builtin.copy:",
          "src: /opt/ansible/files/motd.txt",
          "dest: /etc/motd",
          "mode: '0644'"
        ],
        "questions": [
          "`src` là path trên control node hay managed node?",
          "Thiếu become khi dest=/etc thì sao?"
        ],
        "explain": [
          {
            "term": "src",
            "text": "Path trên control node (máy chạy ansible-playbook)."
          },
          {
            "term": "dest",
            "text": "Path trên managed node."
          },
          {
            "term": "mode '0644'",
            "text": "Quyền file đích. Nên ghi chuỗi để không mất số 0."
          },
          {
            "term": "become",
            "text": "dest=/etc cần root. Thiếu become thì Permission denied."
          }
        ]
      },
      "task": "Copy motd.txt thành /etc/motd trên mọi app"
    },
    {
      "id": "day-85",
      "day": 85,
      "block": "ansible",
      "title": "Create Files on App Servers using Ansible",
      "given": [
        {
          "label": "Playbook",
          "value": "/opt/ansible/playbooks/files.yml"
        },
        {
          "label": "Thư mục",
          "value": "/opt/app/ready — directory, 0755, owner deploy"
        },
        {
          "label": "File",
          "value": "/opt/app/ready/.keep — state=touch, 0644"
        }
      ],
      "goal": "Mọi host `app` có `/opt/app/ready` (directory 0755, owner `deploy`) và file `.keep` (touch, 0644). Dùng module `file`, không `shell mkdir`.",
      "do": [
        "Task 1: `file` `state=directory` path `/opt/app/ready`, owner `deploy`, mode `0755`.",
        "Task 2: `file` `state=touch` path `.keep`, mode `0644`. `become` nếu `/opt` của root.",
        "Chạy playbook. Trên host: thư mục và file đúng quyền."
      ],
      "hints": {
        "commands": [
          "ansible.builtin.file:",
          "state: directory",
          "state: touch",
          "mode: '0755'"
        ],
        "questions": [
          "`state=file` khác `state=touch` chỗ nào?",
          "Thiếu state=directory thì Ansible có mkdir không?"
        ],
        "explain": [
          {
            "term": "state: directory",
            "text": "Đảm bảo path là thư mục, tạo nếu thiếu."
          },
          {
            "term": "state: touch",
            "text": "Tạo file rỗng nếu chưa có; đã có thì chỉ cập nhật mtime."
          },
          {
            "term": "state: file",
            "text": "Đòi path đã là file. Không tạo mới."
          }
        ]
      },
      "task": "Tạo thư mục và file rỗng trên host app"
    },
    {
      "id": "day-86",
      "day": 86,
      "block": "ansible",
      "title": "Ansible Ping Module Usage",
      "given": [
        {
          "label": "Inventory",
          "value": "/opt/ansible/inventory.ini"
        },
        {
          "label": "Group",
          "value": "app"
        },
        {
          "label": "Module",
          "value": "ansible.builtin.ping"
        },
        {
          "label": "Khi xong",
          "value": "mỗi host SUCCESS, ping: pong"
        }
      ],
      "goal": "Lệnh `ansible` (ad-hoc) module `ping` trên group `app`. Cả `labapp01` và `labapp02` `pong`. UNREACHABLE thì sửa SSH/user/key ở inventory, đừng viết playbook.",
      "do": [
        "Không tạo playbook — dùng lệnh `ansible` một lần.",
        "Nhắm group `app`, inventory đúng file, module `ping`.",
        "Mỗi host trả `ping: pong`. UNREACHABLE thì sửa SSH ở inventory."
      ],
      "hints": {
        "commands": [
          "ansible app -i /opt/ansible/inventory.ini -m ansible.builtin.ping",
          "ansible app -i /opt/ansible/inventory.ini -m ping -u deploy"
        ],
        "questions": [
          "`-m ping` khác ICMP ping chỗ nào?",
          "UNREACHABLE khác FAILED chỗ nào?"
        ],
        "explain": [
          {
            "term": "-m ping",
            "text": "Module Ansible: mở SSH, chạy Python nhỏ, trả pong. Không phải ICMP."
          },
          {
            "term": "-u deploy",
            "text": "User SSH, đè ansible_user."
          },
          {
            "term": "UNREACHABLE",
            "text": "Không SSH được (mạng, khóa, user). FAILED = đã vào máy nhưng task lỗi."
          }
        ]
      },
      "task": "Ad-hoc ping group app, không viết playbook"
    },
    {
      "id": "day-87",
      "day": 87,
      "block": "ansible",
      "title": "Ansible Install Package",
      "given": [
        {
          "label": "Playbook",
          "value": "/opt/ansible/playbooks/pkg.yml"
        },
        {
          "label": "Gói",
          "value": "httpd (RHEL) hoặc apache2 (Debian)"
        },
        {
          "label": "Module",
          "value": "ansible.builtin.package — state=present"
        }
      ],
      "goal": "Module `package` `state=present`. RHEL: `httpd`. Debian: `apache2`. `become: true`. Sau play, gói có trên host.",
      "do": [
        "Play `hosts: app`, `become: true`. Task `package` name + `state=present`.",
        "Chọn tên gói theo OS, hoặc biến `pkg`. Điều kiện `when` hai distro làm kỹ ở ngày 93.",
        "Chạy playbook. `rpm -q` / `dpkg -l` thấy gói."
      ],
      "hints": {
        "commands": [
          "ansible.builtin.package:",
          "name: httpd",
          "state: present",
          "become: true"
        ],
        "questions": [
          "`package` khác `yum`/`apt` chỗ nào?",
          "`state=latest` có bắt buộc không?"
        ],
        "explain": [
          {
            "term": "package",
            "text": "Chọn yum/apt/dnf theo OS. yum/apt cố định một manager."
          },
          {
            "term": "state: present",
            "text": "Cài nếu thiếu, không nâng version. latest thì nâng."
          },
          {
            "term": "become: true",
            "text": "Cài gói hệ thống cần root."
          }
        ]
      },
      "task": "Playbook cài httpd hoặc apache2 trên app"
    },
    {
      "id": "day-88",
      "day": 88,
      "block": "ansible",
      "title": "Ansible Blockinfile Module",
      "given": [
        {
          "label": "Playbook",
          "value": "/opt/ansible/playbooks/block.yml"
        },
        {
          "label": "path",
          "value": "/var/www/html/index.html"
        },
        {
          "label": "block",
          "value": "<h1>App lab</h1>"
        },
        {
          "label": "create",
          "value": "true"
        }
      ],
      "goal": "`blockinfile` chèn `<h1>App lab</h1>` vào `/var/www/html/index.html`. File được tạo nếu chưa có. Marker mặc định còn trong file. Chạy lần hai: `changed=0`.",
      "do": [
        "Task `blockinfile`: path đúng, `create: true`, `block` là dòng HTML trên.",
        "`become: true`. Playbook `block.yml`.",
        "File có khối giữa marker BEGIN/END. Chạy lại: không changed."
      ],
      "hints": {
        "commands": [
          "ansible.builtin.blockinfile:",
          "path: /var/www/html/index.html",
          "create: true",
          "block: |\n  <h1>App lab</h1>"
        ],
        "questions": [
          "`create` mặc định là gì?",
          "Marker dùng để làm gì khi chạy lại?"
        ],
        "explain": [
          {
            "term": "block",
            "text": "Nhiều dòng chèn giữa marker BEGIN/END."
          },
          {
            "term": "create",
            "text": "Mặc định false. true thì tạo file nếu chưa có."
          },
          {
            "term": "marker",
            "text": "Lần chạy sau tìm marker, cập nhật khối, không nhân đôi."
          }
        ]
      },
      "task": "Chèn một khối HTML vào index bằng blockinfile"
    },
    {
      "id": "day-89",
      "day": 89,
      "block": "ansible",
      "title": "Ansible Manage Services",
      "given": [
        {
          "label": "Playbook",
          "value": "/opt/ansible/playbooks/svc.yml"
        },
        {
          "label": "Service",
          "value": "httpd (RHEL) hoặc apache2 (Debian)"
        },
        {
          "label": "state / enabled",
          "value": "started / true"
        }
      ],
      "goal": "Module `service`: started + enabled. `systemctl is-active` = active, `is-enabled` = enabled. Cài gói trước nếu ngày 87 chưa làm.",
      "do": [
        "Task `service` đúng tên unit theo OS, `state=started` `enabled=true`, `become`.",
        "Cài gói trước nếu service chưa tồn tại.",
        "Playbook xong: unit active và enabled trên host."
      ],
      "hints": {
        "commands": [
          "ansible.builtin.service:",
          "name: httpd",
          "state: started",
          "enabled: true"
        ],
        "questions": [
          "`state=started` khác `state=restarted` chỗ nào?",
          "`enabled` quyết định gì sau reboot?"
        ],
        "explain": [
          {
            "term": "state: started",
            "text": "Start nếu đang dừng. Đang chạy thì thôi — không restart."
          },
          {
            "term": "state: restarted",
            "text": "Luôn dừng rồi start lại."
          },
          {
            "term": "enabled: true",
            "text": "Bật unit lúc boot."
          }
        ]
      },
      "task": "Service web started và enabled trên mọi app"
    },
    {
      "id": "day-90",
      "day": 90,
      "block": "ansible",
      "title": "Managing ACLs Using Ansible",
      "given": [
        {
          "label": "Playbook",
          "value": "/opt/ansible/playbooks/acl.yml"
        },
        {
          "label": "File",
          "value": "/opt/app/data.txt — owner root, mode 0640"
        },
        {
          "label": "ACL",
          "value": "user deploy rwx"
        },
        {
          "label": "Collection",
          "value": "ansible.posix.acl"
        }
      ],
      "goal": "`/opt/app/data.txt` tồn tại, mode `0640` owner root. `getfacl` thấy `user:deploy:rwx`. Dùng `ansible.posix.acl`.",
      "do": [
        "Cài collection `ansible.posix` nếu thiếu.",
        "Tạo file mode `0640`. Task `acl` entity `deploy` etype `user` permissions `rwx` `state=present`.",
        "`getfacl` trên host thấy dòng user deploy. Không `chmod 777`."
      ],
      "hints": {
        "commands": [
          "ansible-galaxy collection install ansible.posix",
          "ansible.posix.acl:",
          "entity: deploy",
          "etype: user",
          "permissions: rwx"
        ],
        "questions": [
          "`etype=user` khác `etype=group` chỗ nào?",
          "ACL mất sau khi chmod không có -ACL thì sao?"
        ],
        "explain": [
          {
            "term": "entity",
            "text": "Tên user hoặc group nhận ACL."
          },
          {
            "term": "etype: user",
            "text": "ACL cho user. etype: group là cho group."
          },
          {
            "term": "permissions: rwx",
            "text": "Quyền ACL, độc lập mode 0640."
          },
          {
            "term": "chmod",
            "text": "chmod không có --preserve-root ACL có thể làm mất ACL tùy hệ thống — kiểm lại getfacl."
          }
        ]
      },
      "task": "Gắn ACL user deploy=rwx, không chmod 777"
    },
    {
      "id": "day-91",
      "day": 91,
      "block": "ansible",
      "title": "Ansible Lineinfile Module",
      "given": [
        {
          "label": "Playbook",
          "value": "/opt/ansible/playbooks/line.yml"
        },
        {
          "label": "path",
          "value": "/var/www/html/index.html"
        },
        {
          "label": "line",
          "value": "<!-- lab-91 -->"
        },
        {
          "label": "Vị trí",
          "value": "insertbefore: BOF (đầu file)"
        }
      ],
      "goal": "Dòng `<!-- lab-91 -->` nằm dòng đầu `/var/www/html/index.html` trên mọi app. `lineinfile`, không copy cả file. Chạy lại: `changed=0`.",
      "do": [
        "Task `lineinfile`: đúng `line`, `insertbefore: BOF`, `create: true` nếu file có thể chưa có.",
        "`become: true`.",
        "Dòng 1 của file khớp. Chạy playbook lần hai không changed."
      ],
      "hints": {
        "commands": [
          "ansible.builtin.lineinfile:",
          "path: /var/www/html/index.html",
          "line: '<!-- lab-91 -->'",
          "insertbefore: BOF"
        ],
        "questions": [
          "`insertbefore: BOF` khác `insertafter: EOF` chỗ nào?",
          "Không có regexp thì module quyết định thế nào?"
        ],
        "explain": [
          {
            "term": "line",
            "text": "Dòng cần có trong file."
          },
          {
            "term": "insertbefore: BOF",
            "text": "Chèn trước dòng đầu (Beginning of File). insertafter: EOF là cuối file."
          },
          {
            "term": "không regexp",
            "text": "Module tìm đúng dòng; chưa có thì chèn theo insertbefore/after."
          }
        ]
      },
      "task": "Chèn một dòng vào đầu file bằng lineinfile"
    },
    {
      "id": "day-92",
      "day": 92,
      "block": "ansible",
      "title": "Managing Jinja2 Templates Using Ansible",
      "given": [
        {
          "label": "Playbook",
          "value": "/opt/ansible/playbooks/tpl.yml"
        },
        {
          "label": "File .j2",
          "value": "/opt/ansible/templates/index.html.j2"
        },
        {
          "label": "dest",
          "value": "/var/www/html/index.html"
        },
        {
          "label": "Biến trong template",
          "value": "{{ inventory_hostname }}"
        }
      ],
      "goal": "`template` render `index.html.j2` ra `/var/www/html/index.html`. Body chứa đúng `inventory_hostname` của từng host — `labapp01` khác `labapp02`. Không hard-code tên.",
      "do": [
        "Template chỉ dùng `{{ inventory_hostname }}`, không ghi chết `labapp01`.",
        "Task `template` src → dest, mode `0644`.",
        "Trên `labapp01` thấy `labapp01`, trên `labapp02` thấy `labapp02`."
      ],
      "hints": {
        "commands": [
          "ansible.builtin.template:",
          "src: /opt/ansible/templates/index.html.j2",
          "dest: /var/www/html/index.html",
          "<h1>{{ inventory_hostname }}</h1>"
        ],
        "questions": [
          "`template` khác `copy` chỗ nào?",
          "`inventory_hostname` lấy từ đâu?"
        ],
        "explain": [
          {
            "term": "template",
            "text": "Render Jinja2 rồi copy. copy không thay {{ }}."
          },
          {
            "term": "src .j2",
            "text": "File trên control node."
          },
          {
            "term": "inventory_hostname",
            "text": "Tên host trong inventory (labapp01), không nhất là ansible_host."
          }
        ]
      },
      "task": "Template Jinja2: mỗi host ra tên inventory_hostname"
    },
    {
      "id": "day-93",
      "day": 93,
      "block": "ansible",
      "title": "Using Ansible Conditionals",
      "given": [
        {
          "label": "Playbook",
          "value": "/opt/ansible/playbooks/when.yml"
        },
        {
          "label": "Khi RedHat",
          "value": "package httpd — ansible_os_family == 'RedHat'"
        },
        {
          "label": "Khi Debian",
          "value": "package apache2 — ansible_os_family == 'Debian'"
        }
      ],
      "goal": "Một playbook, `gather_facts: true`. Task `httpd` khi `RedHat`, task `apache2` khi `Debian`. Task kia `skipped` — không fail.",
      "do": [
        "`gather_facts: true`. Task 1: `package httpd` + `when` RedHat.",
        "Task 2: `package apache2` + `when` Debian. `become: true`.",
        "Recap: host RedHat — httpd chạy, apache2 skipped (và ngược lại)."
      ],
      "hints": {
        "commands": [
          "gather_facts: true",
          "when: ansible_os_family == 'RedHat'",
          "when: ansible_os_family == 'Debian'"
        ],
        "questions": [
          "Thiếu gather_facts thì `ansible_os_family` có không?",
          "`when` đặt cùng cấp `name` của task, không phải trong module — đúng không?"
        ],
        "explain": [
          {
            "term": "gather_facts",
            "text": "true thì setup chạy, có ansible_os_family. false thì biến đó không có, when lỗi hoặc skip sai."
          },
          {
            "term": "when",
            "text": "Cùng cấp name của task, không nhét trong module. False → task skipped."
          },
          {
            "term": "ansible_os_family",
            "text": "RedHat (RHEL/Rocky/Alma), Debian (Debian/Ubuntu)."
          }
        ]
      },
      "task": "Hai task package, chỉ task khớp OS mới chạy"
    }
  ]
}
