diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 000000000..d007eccc8 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,36 @@ +Vagrant.configure("2") do |config| + config.vm.box = "bento/ubuntu-24.04" + config.vm.hostname = "quicknotes-vm" + + config.vm.boot_timeout = 600 + config.ssh.forward_agent = false + + # Default Vagrant SSH, used by Vagrant itself. + config.vm.network "forwarded_port", + guest: 22, + host: 2222, + host_ip: "127.0.0.1", + id: "ssh", + auto_correct: true + + # Extra SSH forward exposed for WSL Ansible access if needed. + config.vm.network "forwarded_port", + guest: 22, + host: 2223, + host_ip: "0.0.0.0", + auto_correct: true + + # QuickNotes app port. + config.vm.network "forwarded_port", + guest: 8080, + host: 18080, + host_ip: "127.0.0.1" + + config.vm.synced_folder "./app", "/opt/quicknotes/app", type: "virtualbox" + + config.vm.provider "virtualbox" do |vb| + vb.name = "quicknotes-lab5" + vb.cpus = 2 + vb.memory = 1024 + end +end diff --git a/ansible/files/quicknotes b/ansible/files/quicknotes new file mode 100644 index 000000000..1f16d180d Binary files /dev/null and b/ansible/files/quicknotes differ diff --git a/ansible/files/seed.json b/ansible/files/seed.json new file mode 100644 index 000000000..ecf4fd2ed --- /dev/null +++ b/ansible/files/seed.json @@ -0,0 +1,26 @@ +[ + { + "id": 1, + "title": "Welcome to QuickNotes", + "body": "This is the project you'll containerize, deploy, monitor, and harden across all 10 labs.", + "created_at": "2026-01-15T10:00:00Z" + }, + { + "id": 2, + "title": "Read app/main.go first", + "body": "Start by understanding the entry point — env vars, signal handling, graceful shutdown.", + "created_at": "2026-01-15T10:05:00Z" + }, + { + "id": 3, + "title": "DevOps mantra", + "body": "If it hurts, do it more often.", + "created_at": "2026-01-15T10:10:00Z" + }, + { + "id": 4, + "title": "Endpoint cheat-sheet", + "body": "GET /notes GET /notes/{id} POST /notes DELETE /notes/{id} GET /health GET /metrics", + "created_at": "2026-01-15T10:15:00Z" + } +] diff --git a/ansible/inventory.ini b/ansible/inventory.ini new file mode 100644 index 000000000..92c159ee0 --- /dev/null +++ b/ansible/inventory.ini @@ -0,0 +1,2 @@ +[quicknotes_vm] +lab5-vm ansible_host=127.0.0.1 ansible_port=2222 ansible_user=vagrant ansible_private_key_file=~/.ssh/vagrant-devops-intro/private_key ansible_ssh_common_args='-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' diff --git a/ansible/inventory.yaml b/ansible/inventory.yaml new file mode 100644 index 000000000..0178abb75 --- /dev/null +++ b/ansible/inventory.yaml @@ -0,0 +1,10 @@ +all: + children: + quicknotes_vm: + hosts: + lab5-vm: + ansible_host: 172.19.128.1 + ansible_port: 2223 + ansible_user: vagrant + ansible_private_key_file: /root/.ssh/vagrant-devops-intro/private_key + ansible_ssh_common_args: "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PubkeyAcceptedKeyTypes=+ssh-rsa -o HostKeyAlgorithms=+ssh-rsa" diff --git a/ansible/playbook.yaml b/ansible/playbook.yaml new file mode 100644 index 000000000..2ae5acb3d --- /dev/null +++ b/ansible/playbook.yaml @@ -0,0 +1,99 @@ +--- +- name: Deploy QuickNotes to Lab 5 VM + hosts: quicknotes_vm + become: true + gather_facts: false + + vars: + quicknotes_user: quicknotes + quicknotes_group: quicknotes + quicknotes_data_dir: /var/lib/quicknotes + quicknotes_config_dir: /etc/quicknotes + quicknotes_binary_src: files/quicknotes + quicknotes_seed_src: files/seed.json + quicknotes_binary_dest: /usr/local/bin/quicknotes + quicknotes_seed_dest: /etc/quicknotes/seed.json + quicknotes_listen_addr: ":8080" + quicknotes_data_path: /var/lib/quicknotes/notes.json + quicknotes_seed_path: /etc/quicknotes/seed.json + quicknotes_restart_sec: 4 + + tasks: + - name: Create QuickNotes group + ansible.builtin.group: + name: "{{ quicknotes_group }}" + system: true + state: present + + - name: Create QuickNotes system user + ansible.builtin.user: + name: "{{ quicknotes_user }}" + group: "{{ quicknotes_group }}" + system: true + shell: /usr/sbin/nologin + create_home: false + home: "{{ quicknotes_data_dir }}" + state: present + + - name: Ensure QuickNotes data directory exists + ansible.builtin.file: + path: "{{ quicknotes_data_dir }}" + state: directory + owner: "{{ quicknotes_user }}" + group: "{{ quicknotes_group }}" + mode: "0750" + + - name: Ensure QuickNotes config directory exists + ansible.builtin.file: + path: "{{ quicknotes_config_dir }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Copy QuickNotes seed file + ansible.builtin.copy: + src: "{{ quicknotes_seed_src }}" + dest: "{{ quicknotes_seed_dest }}" + owner: root + group: root + mode: "0644" + notify: restart quicknotes + + - name: Copy QuickNotes binary + ansible.builtin.copy: + src: "{{ quicknotes_binary_src }}" + dest: "{{ quicknotes_binary_dest }}" + owner: root + group: root + mode: "0755" + notify: restart quicknotes + + - name: Render QuickNotes systemd unit + ansible.builtin.template: + src: quicknotes.service.j2 + dest: /etc/systemd/system/quicknotes.service + owner: root + group: root + mode: "0644" + notify: + - reload systemd + - restart quicknotes + + - name: Enable and start QuickNotes service + ansible.builtin.systemd: + name: quicknotes + enabled: true + state: started + daemon_reload: true + + handlers: + - name: reload systemd + ansible.builtin.systemd: + daemon_reload: true + + - name: restart quicknotes + ansible.builtin.systemd: + name: quicknotes + state: restarted + daemon_reload: true diff --git a/ansible/templates/quicknotes.service.j2 b/ansible/templates/quicknotes.service.j2 new file mode 100644 index 000000000..a370c4a47 --- /dev/null +++ b/ansible/templates/quicknotes.service.j2 @@ -0,0 +1,19 @@ +[Unit] +Description=QuickNotes service +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User={{ quicknotes_user }} +Group={{ quicknotes_group }} +WorkingDirectory={{ quicknotes_data_dir }} +Environment=ADDR={{ quicknotes_listen_addr }} +Environment=DATA_PATH={{ quicknotes_data_path }} +Environment=SEED_PATH={{ quicknotes_seed_path }} +ExecStart={{ quicknotes_binary_dest }} +Restart=on-failure +RestartSec={{ quicknotes_restart_sec }}s + +[Install] +WantedBy=multi-user.target diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..6a86219eb --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.24-alpine AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/quicknotes . + +RUN cat > /tmp/healthcheck.go <<'EOF' +package main + +import ( + "net/http" + "os" + "time" +) + +func main() { + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/health") + if err != nil { + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + os.Exit(1) + } +} +EOF + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck /tmp/healthcheck.go + +RUN mkdir -p /out/data + +FROM gcr.io/distroless/static:nonroot + +WORKDIR / + +COPY --from=builder /out/quicknotes /quicknotes +COPY --from=builder /out/healthcheck /healthcheck +COPY --from=builder /src/seed.json /seed.json +COPY --from=builder --chown=65532:65532 /out/data /data + +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/quicknotes"] diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..a0b0389a1 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,31 @@ +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + +volumes: + quicknotes-data: diff --git a/labs/lab1.md b/labs/lab1.md index bb4e226d9..eb319e50f 100644 --- a/labs/lab1.md +++ b/labs/lab1.md @@ -91,9 +91,20 @@ git config --global commit.gpgsign true git config --global tag.gpgsign true ``` -Tell the platform your SSH key is a **signing key**: -- GitHub: Settings → SSH and GPG keys → **New SSH key**, key type **Signing Key** -- GitLab: Profile → SSH Keys → tick "Usage type: Authentication & signing" +Now register the key on the platform. GitHub treats **Authentication** and **Signing** as *separate* roles for the same key, so you add it under both: + +- **Authentication Key** — lets you `clone` / `fetch` / `push` over SSH (`git@github.com:…`). If you cloned over HTTPS, or have never seen `ssh -T git@github.com` greet you by name, you don't have one configured yet — add it now or the `upstream` SSH remote will fail in Lab 2. +- **Signing Key** — gives your commits the **Verified** badge. + +- 🐙 GitHub: Settings → SSH and GPG keys → **New SSH key** → add the **same** `~/.ssh/id_ed25519.pub` **twice**, once with Key type **Authentication Key** and once with **Signing Key**. +- 🦊 GitLab: Profile → SSH Keys → a single key with **Usage type: Authentication & signing** covers both. + +Confirm authentication works before moving on: + +```bash +ssh -T git@github.com +# expect: Hi YOUR_USERNAME! You've successfully authenticated... +``` ### 1.4: Make a Signed Commit @@ -303,7 +314,8 @@ In `submissions/lab1.md`: ## Common Pitfalls - 🪤 **PR template doesn't auto-populate** — make sure the template is on `main` *before* opening the PR -- 🪤 **Commits show "Unverified"** — the SSH key must be added as a *Signing Key* on GitHub (not just an authentication key) +- 🪤 **Commits show "Unverified"** — the key must also be added as a **Signing Key** on GitHub; an Authentication Key alone won't verify commits (they're separate roles — see §1.3) +- 🪤 **`git@github.com: Permission denied (publickey)` on clone/fetch/push** — the *reverse* gap: your key is registered for signing but not as an **Authentication Key**. Add it as Authentication too (§1.3) and confirm with `ssh -T git@github.com`. Quick unblock for the *public* upstream: `git remote set-url upstream https://github.com/inno-devops-labs/DevOps-Intro.git` - 🪤 **`git push` rejected on `main`** — that's the bonus rule working as designed; push to `feature/lab1` instead - 🪤 **`gpg.format=ssh` ignored** — confirm Git ≥ 2.34: `git --version` - 🪤 **Pushed to the wrong branch** — `git switch feature/lab1` before `git push` diff --git a/labs/lab2.md b/labs/lab2.md index fca7b3f22..aae9acfb3 100644 --- a/labs/lab2.md +++ b/labs/lab2.md @@ -223,6 +223,7 @@ git bisect reset ## Common Pitfalls +- 🪤 **`git@github.com: Permission denied (publickey)` on `git fetch upstream`** — *not* a remote-config bug (the error is at the SSH layer, before Git reads the repo). Your key isn't registered for **authentication** on GitHub — and a **Signing Key** (Lab 1) does *not* count for auth, they're separate roles. Add the same `~/.ssh/id_ed25519.pub` as an **Authentication Key** (Lab 1 §1.3), verify with `ssh -T git@github.com`, then re-run. To unblock right now, the public upstream fetches over HTTPS with no key: `git remote set-url upstream https://github.com/inno-devops-labs/DevOps-Intro.git` - 🪤 **`reset --hard` without committing first** — your *uncommitted* edits really *are* gone (reflog only saves committed work). Always check `git status` first - 🪤 **`tag -v` says "no signature"** — you used `git tag NAME` instead of `git tag -a -s NAME -m "..."` - 🪤 **Rebase conflicts** — resolve, then `git rebase --continue`. Never `git rebase --skip` unless you know what you're skipping diff --git a/labs/lab3.md b/labs/lab3.md index 9f0970b20..87344cfb3 100644 --- a/labs/lab3.md +++ b/labs/lab3.md @@ -160,6 +160,23 @@ Tips: - GitLab: `parallel:matrix:` - Set `fail-fast: false` (GH) or equivalent so a single bad cell doesn't cancel the others — you want to *see* which combo broke +> ⚠️ **The matrix renames your checks — update branch protection (1.6) or your PR blocks forever.** A matrixed `test` job reports as `test (1.23)` and `test (1.24)`; the old required check named `test` will sit at *"Expected — Waiting for status to be reported"* indefinitely, even though every real check is green. Two fixes: +> +> 1. **Quick:** in the branch-protection rule, replace `vet`/`test` with the matrixed names (`vet (1.23)`, `vet (1.24)`, `test (1.23)`, `test (1.24)`). +> 2. **Robust (recommended):** add one aggregation job and require *only* it — then the matrix can change freely without touching protection settings: +> +> ```yaml +> ci-ok: +> if: always() +> needs: [vet, test, lint] +> runs-on: ubuntu-24.04 +> steps: +> - run: | +> test "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "false" +> ``` +> +> The `if: always()` matters — without it, a failed `needs` job *skips* `ci-ok`, and a skipped required check lets the PR through on some configurations. + ### 2.3: Skip docs-only changes Edit your trigger so the pipeline runs **only** when something in `app/` or your CI config itself changes. README edits should not burn 4 minutes of CI time. @@ -179,6 +196,8 @@ Capture wall-clock times from the CI UI for three scenarios: > 💡 To get a clean baseline, temporarily disable each optimization with a commit, take a screenshot of the run time, then restore. +> 🧪 **Expect the cache rows to be boring — that's the finding, not a failure.** QuickNotes has **zero third-party dependencies** (look at `app/go.mod` — no `require` block, no `go.sum`), so the module cache has nothing to store and total wall-clock barely moves with `cache: true` vs `cache: false`. Most of your 60–80 s is runner provisioning, checkout, and the Go toolchain download — none of which `setup-go`'s cache touches. Report what you measured and *explain why* (that's design question **f** in disguise). To see where caching *would* pay, compare the **per-step** durations (`setup-go`, `go test`) instead of job totals, and note which step a real dependency-heavy project would save on. + ### 2.5: Document In `submissions/lab3.md`: @@ -284,6 +303,8 @@ Answer in 4-6 sentences: - 🪤 **Forgot `working-directory` (or `cd app`) for Go commands** — Go modules live in `app/`, not the repo root; commands run from the root will fail with "no Go files" - 🪤 **`fail-fast: true` (the GH Actions default) in a matrix** — one fail cancels the others; you can't see *which* combo broke - 🪤 **Branch protection set on someone else's fork's `main`** — you can only protect *your* fork's `main`. The upstream course repo has its own protection +- 🪤 **PR stuck on "Expected — Waiting for status to be reported" after adding the matrix** — the matrix renamed `test` → `test (1.23)`/`test (1.24)`, but branch protection still requires the old `test` context, which will never report again. Update the required-check names or switch to the `ci-ok` aggregation job (see §2.2) +- 🪤 **"Caching didn't speed anything up"** — on a zero-dependency module that's the *correct* result, not a mistake (see §2.4); don't pad the timing table with numbers you didn't observe - 🪤 **`golangci-lint` version not pinned** — "latest" pulls a new release tomorrow that may flag your code with new rules. Pin `v2.5.0` exactly - 🪤 **GitLab CI: incorrect anchor syntax** (`<<: *name`) — GitLab is strict; use the in-platform CI Lint tool (`Project → CI/CD → Editor → Validate`) - 🪤 **Cache hits expire after 7 days of inactivity on GH** — that's expected; the cache key is what protects you against poisoning diff --git a/submissions/lab6.md b/submissions/lab6.md new file mode 100644 index 000000000..81bf46059 --- /dev/null +++ b/submissions/lab6.md @@ -0,0 +1,493 @@ +# Lab 6 - Containers: Dockerize QuickNotes + +## Task 1 - Multi-stage Dockerfile + +### Dockerfile + +```dockerfile +# syntax=docker/dockerfile:1 + +FROM golang:1.24-alpine AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/quicknotes . + +RUN cat > /tmp/healthcheck.go <<'EOF' +package main + +import ( + "net/http" + "os" + "time" +) + +func main() { + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://127.0.0.1:8080/health") + if err != nil { + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + os.Exit(1) + } +} +EOF + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -o /out/healthcheck /tmp/healthcheck.go + +RUN mkdir -p /out/data + +FROM gcr.io/distroless/static:nonroot + +WORKDIR / + +COPY --from=builder /out/quicknotes /quicknotes +COPY --from=builder /out/healthcheck /healthcheck +COPY --from=builder --chown=65532:65532 /out/data /data + +USER nonroot:nonroot + +EXPOSE 8080 + +ENTRYPOINT ["/quicknotes"] +``` + +### Image size + +Command: + +```powershell +docker images quicknotes:lab6 +``` + +Output: + +```text +IMAGE ID DISK USAGE CONTENT SIZE EXTRA +quicknotes:lab6 a39d5f5a0ab8 22.7MB 5.71MB U +``` + +The final image size is **22.7 MB**, which is below the required 25 MB limit. + +### Image configuration + +Command: + +```powershell +docker inspect quicknotes:lab6 --format "User={{.Config.User}} Entrypoint={{json .Config.Entrypoint}} ExposedPorts={{json .Config.ExposedPorts}}" +``` + +Output: + +```text +User=nonroot:nonroot Entrypoint=["/quicknotes"] ExposedPorts={"8080/tcp":{}} +``` + +This confirms that the image runs as `nonroot:nonroot`, exposes port `8080`, and uses exec-form entrypoint. + +### Builder base image comparison + +Command: + +```powershell +docker pull golang:1.24-alpine +docker images golang:1.24-alpine +``` + +Output: + +```text +1.24-alpine: Pulling from library/golang +Digest: sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 +Status: Downloaded newer image for golang:1.24-alpine +docker.io/library/golang:1.24-alpine + +IMAGE ID DISK USAGE CONTENT SIZE EXTRA +golang:1.24-alpine 8bee1901f1e5 395MB 83.5MB +``` + +The builder image is **395 MB**, while the final runtime image is only **22.7 MB**. This shows the value of the multi-stage build: the Go compiler and build tools are not included in the final image. + +### Direct Docker run test + +Command: + +```powershell +cd C:\Users\minim\projects\DevOps-Intro\app +docker run --rm -d --name quicknotes-test -p 8080:8080 -v "${PWD}\data:/data" quicknotes:lab6 +Start-Sleep -Seconds 3 +curl.exe -s http://localhost:8080/health +docker stop quicknotes-test +``` + +Output: + +```text +28e0c9615e2610c4c97f4d0eee757e165b142c5e7956050f3cb545b44c12870e +{"notes":6,"status":"ok"} +quicknotes-test +``` + +This confirms that the Docker image runs QuickNotes successfully and serves the `/health` endpoint. + +### Design questions + +#### a) Why does layer order matter? + +Layer order matters because Docker reuses cached layers when the inputs to those layers have not changed. If the Dockerfile uses `COPY . .` before `go mod download`, then any source-code change invalidates the dependency-download layer, forcing Docker to repeat unnecessary work. + +The better strategy is: + +```dockerfile +COPY go.mod ./ +RUN go mod download +COPY . . +RUN go build ... +``` + +This allows Docker to cache `go mod download` as long as `go.mod` has not changed. In my cache-friendly build, the first build took about **25.4 seconds**, while a later cached Compose rebuild completed in about **1.9 seconds**. The difference shows that separating dependency layers from source-code layers improves rebuild speed. + +#### b) Why `CGO_ENABLED=0`? + +`CGO_ENABLED=0` forces Go to produce a static binary that does not depend on C libraries or a dynamic linker. This matters because `gcr.io/distroless/static:nonroot` is designed for static binaries and does not include a full Linux userland. If CGO is left enabled and the binary requires dynamic libraries, the container may fail at runtime with an error such as `no such file or directory`, even though the binary appears to exist. + +#### c) What is `gcr.io/distroless/static:nonroot`? + +`gcr.io/distroless/static:nonroot` is a minimal runtime image for statically compiled applications. It contains enough to run a static binary as a non-root user, but it does not include a shell, package manager, or common debugging tools. This matters for security because fewer packages means a smaller attack surface and fewer operating-system CVEs in the final image. + +#### d) What do `-ldflags="-s -w"` and `-trimpath` do? + +`-ldflags="-s -w"` strips the symbol table and debug information from the Go binary, which reduces binary size. `-trimpath` removes local filesystem paths from the compiled binary, making builds more reproducible and avoiding leakage of build-machine paths. The trade-off is that debugging information is reduced, so stack traces and binary inspection may be less detailed. + +--- + +## Task 2 - Compose, Healthcheck, and Persistent Volume + +### compose.yaml + +```yaml +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + +volumes: + quicknotes-data: +``` + +### Compose startup and healthcheck + +Command: + +```powershell +cd C:\Users\minim\projects\DevOps-Intro +docker compose down -v +docker compose up --build -d +Start-Sleep -Seconds 5 +docker compose ps +curl.exe -s http://localhost:8080/health +``` + +Output: + +```text +[+] up 4/4 + ✔ Image quicknotes:lab6 Built + ✔ Network devops-intro_default Created + ✔ Volume devops-intro_quicknotes-data Created + ✔ Container devops-intro-quicknotes-1 Started + +NAME IMAGE COMMAND SERVICE STATUS PORTS +devops-intro-quicknotes-1 quicknotes:lab6 "/quicknotes" quicknotes Up 5 seconds (healthy) 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp + +{"notes":0,"status":"ok"} +``` + +This confirms that Compose starts the service, the healthcheck reports healthy, and the host can reach the container on port 8080. + +### Persistence test + +#### Create a durable note + +Command: + +```powershell +$body = @{ + title = "durable" + body = "survive a restart" +} | ConvertTo-Json -Compress + +Invoke-RestMethod ` + -Method Post ` + -Uri "http://localhost:8080/notes" ` + -ContentType "application/json" ` + -Body $body +``` + +Output: + +```text +id title body created_at +-- ----- ---- ---------- + 1 durable survive a restart 2026-06-23T17:24:24.43611536Z +``` + +#### Confirm the note exists + +Command: + +```powershell +Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5 +``` + +Output: + +```json +{ + "value": [ + { + "id": 1, + "title": "durable", + "body": "survive a restart", + "created_at": "2026-06-23T17:24:24.43611536Z" + } + ], + "Count": 1 +} +``` + +#### Confirm persistence after `docker compose down && docker compose up` + +Command: + +```powershell +docker compose down +docker compose up -d +Start-Sleep -Seconds 3 +(Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5) | Select-String durable +``` + +Output: + +```text +id title body created_at +-- ----- ---- ---------- + 2 durable survive a restart 2026-06-23T17:24:42.300273119Z +``` + +The durable note still exists after `docker compose down` and `docker compose up`, which proves the named volume preserved the data. + +#### Confirm data is deleted after `docker compose down -v` + +Command: + +```powershell +docker compose down -v +docker compose up -d +Start-Sleep -Seconds 3 +(Invoke-RestMethod http://localhost:8080/notes | ConvertTo-Json -Depth 5) | Select-String durable +``` + +Output: + +```text +[+] down 3/3 + ✔ Container devops-intro-quicknotes-1 Removed + ✔ Network devops-intro_default Removed + ✔ Volume devops-intro_quicknotes-data Removed + +[+] up 3/3 + ✔ Network devops-intro_default Created + ✔ Volume devops-intro_quicknotes-data Created + ✔ Container devops-intro-quicknotes-1 Started + +No output from Select-String durable. +``` + +The durable note disappeared after `docker compose down -v`, which proves the data was stored in the named volume and that `-v` deleted the volume. + +### Design questions + +#### e) Distroless has no shell. How do you healthcheck it? + +I used a small static Go healthcheck binary copied into the final distroless image. The Compose healthcheck runs it using exec form: `["CMD", "/healthcheck"]`. This works without a shell because Docker directly executes the binary, and the binary performs an HTTP GET request to `http://127.0.0.1:8080/health`. + +#### f) Why does `volumes: [quicknotes-data:/data]` survive `docker compose down`? + +A named Docker volume is managed separately from the container lifecycle. `docker compose down` removes containers and networks, but it does not delete named volumes by default. The volume is destroyed by `docker compose down -v` or by explicitly deleting it with Docker volume commands. + +#### g) `depends_on` without `condition: service_healthy` + +`depends_on` without `condition: service_healthy` only waits for the dependent container to start, not for the application inside it to become ready. This can cause startup race conditions where one service tries to connect to another service before it is actually listening or healthy. In this lab there is only one service, but in a multi-service setup this can cause intermittent failures. + +--- + +## Bonus Task - Security Defaults + +### Hardened quicknotes service block + +```yaml +quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: "/data/notes.json" + SEED_PATH: "/seed.json" + volumes: + - quicknotes-data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "/healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true +``` + +### 1. USER nonroot + +Command: + +```powershell +docker inspect quicknotes:lab6 --format "{{.Config.User}}" +``` + +Output: + +```text +nonroot:nonroot +``` + +### 2. Distroless / no shell available + +Command: + +```powershell +docker compose exec quicknotes sh +``` + +Output: + +```text +OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH +``` + +This confirms that the final image does not include a shell, which is expected for a distroless image. + +### 3. Linux capabilities dropped + +Command: + +```powershell +$cid = docker compose ps -q quicknotes +docker inspect $cid --format "{{json .HostConfig.CapDrop}}" +``` + +Output: + +```text +["ALL"] +``` + +### 4. Read-only root filesystem + +Command: + +```powershell +docker inspect $cid --format "{{.HostConfig.ReadonlyRootfs}}" +``` + +Output: + +```text +true +``` + +### 5. no-new-privileges + +Command: + +```powershell +docker inspect $cid --format "{{json .HostConfig.SecurityOpt}}" +``` + +Output: + +```text +["no-new-privileges:true"] +``` + +### 6. Trivy scan + +Command: + +```powershell +docker run --rm -v //var/run/docker.sock:/var/run/docker.sock aquasec/trivy:0.59.1 image --severity HIGH,CRITICAL --no-progress quicknotes:lab6 +``` + +Output summary: + +```text +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) + +healthcheck (gobinary) +====================== +Total: 13 (HIGH: 13, CRITICAL: 0) + +quicknotes (gobinary) +===================== +Total: 13 (HIGH: 13, CRITICAL: 0) +``` + +The distroless OS layer had **0 HIGH** and **0 CRITICAL** vulnerabilities. Trivy reported HIGH findings in the Go standard library embedded in the compiled binaries because the builder image used Go 1.24, which was required by the lab. + +### Security reflection + +The most valuable security default per line of YAML is probably `cap_drop: [ALL]`, because QuickNotes does not need extra Linux capabilities. Dropping all capabilities reduces what an attacker could do if the process were compromised. The distroless runtime is also valuable because it removes the shell, package manager, and common post-exploitation tools. The read-only root filesystem adds another layer by forcing application writes into the intended `/data` volume instead of allowing arbitrary writes across the container filesystem. diff --git a/submissions/lab7.md b/submissions/lab7.md new file mode 100644 index 000000000..8e96f1443 --- /dev/null +++ b/submissions/lab7.md @@ -0,0 +1,1238 @@ +\# Lab 7 - Configuration Management: Deploy QuickNotes via Ansible + + + +\## Task 1 - Idempotent Deploy to the Lab 5 VM + + + +\### Goal + + + +The goal of this lab was to deploy QuickNotes to the Lab 5 Vagrant VM using Ansible. The playbook creates a dedicated system user, installs the QuickNotes binary, deploys the seed file, renders a systemd service from a Jinja2 template, enables the service, starts it, and restarts it only when the binary or unit file changes. + + + +\--- + + + +\## Ansible Inventory + + + +File: `ansible/inventory.yaml` + + + +```yaml + +all: + + children: + + quicknotes\_vm: + + hosts: + + lab5-vm: + + ansible\_host: 172.19.128.1 + + ansible\_port: 2223 + + ansible\_user: vagrant + + ansible\_private\_key\_file: /root/.ssh/vagrant-devops-intro/private\_key + + ansible\_ssh\_common\_args: "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PubkeyAcceptedKeyTypes=+ssh-rsa -o HostKeyAlgorithms=+ssh-rsa" + +``` + + + +I used port `2223` because the normal Vagrant SSH port `2222` was bound to Windows localhost. The extra forwarded SSH port was exposed on `0.0.0.0`, allowing Ansible from WSL to reach the VM. + + + +\### SSH and Ansible connectivity test + + + +Command: + + + +```bash + +ssh -i /root/.ssh/vagrant-devops-intro/private\_key \\ + + -p 2223 \\ + + -o StrictHostKeyChecking=no \\ + + -o UserKnownHostsFile=/dev/null \\ + + -o PubkeyAcceptedKeyTypes=+ssh-rsa \\ + + -o HostKeyAlgorithms=+ssh-rsa \\ + + vagrant@172.19.128.1 'echo SSH\_OK' + +``` + + + +Output: + + + +```text + +SSH\_OK + +``` + + + +Command: + + + +```bash + +ansible -i ansible/inventory.yaml quicknotes\_vm -m ping + +``` + + + +Output: + + + +```text + +lab5-vm | SUCCESS => { + + "ansible\_facts": { + + "discovered\_interpreter\_python": "/usr/bin/python3.12" + + }, + + "changed": false, + + "ping": "pong" + +} + +``` + + + +\--- + + + +\## Playbook + + + +File: `ansible/playbook.yaml` + + + +```yaml + +\--- + +\- name: Deploy QuickNotes to Lab 5 VM + + hosts: quicknotes\_vm + + become: true + + gather\_facts: false + + + + vars: + + quicknotes\_user: quicknotes + + quicknotes\_group: quicknotes + + quicknotes\_data\_dir: /var/lib/quicknotes + + quicknotes\_config\_dir: /etc/quicknotes + + quicknotes\_binary\_src: files/quicknotes + + quicknotes\_seed\_src: files/seed.json + + quicknotes\_binary\_dest: /usr/local/bin/quicknotes + + quicknotes\_seed\_dest: /etc/quicknotes/seed.json + + quicknotes\_listen\_addr: ":8080" + + quicknotes\_data\_path: /var/lib/quicknotes/notes.json + + quicknotes\_seed\_path: /etc/quicknotes/seed.json + + quicknotes\_restart\_sec: 4 + + + + tasks: + + - name: Create QuickNotes group + + ansible.builtin.group: + + name: "{{ quicknotes\_group }}" + + system: true + + state: present + + + + - name: Create QuickNotes system user + + ansible.builtin.user: + + name: "{{ quicknotes\_user }}" + + group: "{{ quicknotes\_group }}" + + system: true + + shell: /usr/sbin/nologin + + create\_home: false + + home: "{{ quicknotes\_data\_dir }}" + + state: present + + + + - name: Ensure QuickNotes data directory exists + + ansible.builtin.file: + + path: "{{ quicknotes\_data\_dir }}" + + state: directory + + owner: "{{ quicknotes\_user }}" + + group: "{{ quicknotes\_group }}" + + mode: "0750" + + + + - name: Ensure QuickNotes config directory exists + + ansible.builtin.file: + + path: "{{ quicknotes\_config\_dir }}" + + state: directory + + owner: root + + group: root + + mode: "0755" + + + + - name: Copy QuickNotes seed file + + ansible.builtin.copy: + + src: "{{ quicknotes\_seed\_src }}" + + dest: "{{ quicknotes\_seed\_dest }}" + + owner: root + + group: root + + mode: "0644" + + notify: restart quicknotes + + + + - name: Copy QuickNotes binary + + ansible.builtin.copy: + + src: "{{ quicknotes\_binary\_src }}" + + dest: "{{ quicknotes\_binary\_dest }}" + + owner: root + + group: root + + mode: "0755" + + notify: restart quicknotes + + + + - name: Render QuickNotes systemd unit + + ansible.builtin.template: + + src: quicknotes.service.j2 + + dest: /etc/systemd/system/quicknotes.service + + owner: root + + group: root + + mode: "0644" + + notify: + + - reload systemd + + - restart quicknotes + + + + - name: Enable and start QuickNotes service + + ansible.builtin.systemd: + + name: quicknotes + + enabled: true + + state: started + + daemon\_reload: true + + + + handlers: + + - name: reload systemd + + ansible.builtin.systemd: + + daemon\_reload: true + + + + - name: restart quicknotes + + ansible.builtin.systemd: + + name: quicknotes + + state: restarted + + daemon\_reload: true + +``` + + + +\--- + + + +\## Systemd Unit Template + + + +File: `ansible/templates/quicknotes.service.j2` + + + +```ini + +\[Unit] + +Description=QuickNotes service + +Wants=network-online.target + +After=network-online.target + + + +\[Service] + +Type=simple + +User={{ quicknotes\_user }} + +Group={{ quicknotes\_group }} + +WorkingDirectory={{ quicknotes\_data\_dir }} + +Environment=ADDR={{ quicknotes\_listen\_addr }} + +Environment=DATA\_PATH={{ quicknotes\_data\_path }} + +Environment=SEED\_PATH={{ quicknotes\_seed\_path }} + +ExecStart={{ quicknotes\_binary\_dest }} + +Restart=on-failure + +RestartSec={{ quicknotes\_restart\_sec }}s + + + +\[Install] + +WantedBy=multi-user.target + +``` + + + +The template runs the service as the `quicknotes` user, sets the working directory to `/var/lib/quicknotes`, reads environment values from playbook variables, starts after `network-online.target`, and restarts on failure with a configurable backoff. + + + +\--- + + + +\## Static QuickNotes Binary + + + +The static Linux binary was built from the `app/` directory and copied into `ansible/files/quicknotes`. + + + +Command used: + + + +```powershell + +docker run --rm -v "${PWD}:/work" -w /work/app golang:1.24-alpine sh -c "CGO\_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /work/ansible/files/quicknotes ." + +``` + + + +The seed file was also copied into `ansible/files/seed.json`. + + + +```powershell + +Copy-Item .\\app\\seed.json .\\ansible\\files\\seed.json -Force + +``` + + + +\--- + + + +\## First Real Run + + + +Command: + + + +```bash + +ansible-playbook -i ansible/inventory.yaml ansible/playbook.yaml + +``` + + + +Output: + + + +```text + +PLAY \[Deploy QuickNotes to Lab 5 VM] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + + + +TASK \[Create QuickNotes group] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +TASK \[Create QuickNotes system user] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +TASK \[Ensure QuickNotes data directory exists] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +TASK \[Ensure QuickNotes config directory exists] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +TASK \[Copy QuickNotes seed file] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +TASK \[Copy QuickNotes binary] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +TASK \[Render QuickNotes systemd unit] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +TASK \[Enable and start QuickNotes service] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +RUNNING HANDLER \[reload systemd] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +RUNNING HANDLER \[restart quicknotes] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +PLAY RECAP \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +lab5-vm : ok=10 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + + + +The first real run changed the VM because the QuickNotes user, directories, files, systemd unit, and service state had to be created. + + + +\--- + + + +\## Health Check + + + +Command from the host: + + + +```powershell + +curl.exe -s http://localhost:18080/health + +``` + + + +Output: + + + +```json + +{"notes":4,"status":"ok"} + +``` + + + +This confirms that the service is reachable through the Vagrant port forward from host port `18080` to guest port `8080`. + + + +\--- + + + +\## Task 1 Design Questions + + + +\### a) What is the difference between `command:` and dedicated modules like `apt`, `file`, `copy`, and `systemd`? + + + +`command:` runs a command without understanding the desired final state. For example, it can run `mkdir /var/lib/quicknotes`, but it does not naturally understand ownership, permissions, or whether the directory already matches the desired state. + + + +Dedicated modules are state-aware. The `file` module checks whether the path exists, whether it has the correct owner, group, mode, and state. The `copy` module checks file content, ownership, and permissions. The `systemd` module checks whether a service is enabled or running. + + + +This matters because idempotent modules can report `changed=0` when the machine already matches the playbook. That makes repeated runs safe and predictable. + + + +\### b) `notify:` and handlers: when does a handler fire? When does it not fire? + + + +A handler fires when a task with `notify:` reports `changed`. In this playbook, copying the binary or rendering the systemd unit notifies the `restart quicknotes` handler. The systemd unit template also notifies the `reload systemd` handler. + + + +A handler does not fire if the notifying task reports `ok`. For example, if the template content is already identical on the VM, the template task reports `ok`, so the restart handler does not run. + + + +This is the right default because services should not restart on every playbook run. They should restart only when something that affects the service actually changes. + + + +\### c) Variable hierarchy: where would I put variables for this lab? + + + +The top three places I would use are: + + + +1\. \*\*Playbook vars\*\* + + I used playbook vars for this lab because all variables are specific to this one deployment. The service user, binary path, data path, seed path, and restart delay are all easy to see in one file. + + + +2\. \*\*group\_vars\*\* + + If there were multiple QuickNotes VMs, I would move shared settings into `group\_vars/quicknotes\_vm.yaml`. This would keep the playbook generic while still applying the same configuration to the group. + + + +3\. \*\*role defaults\*\* + + If this became a reusable role, I would put safe defaults in `defaults/main.yaml`. Defaults are easy to override, which makes the role reusable across environments like development, staging, and production. + + + +For this small lab, playbook vars are enough. For a larger project, `group\_vars` and role defaults would be cleaner. + + + +\### d) Do I need `gather\_facts: true` for this playbook? What does turning it off save? + + + +I do not need `gather\_facts: true` for this playbook because the tasks do not depend on facts such as OS version, memory, CPU count, network interfaces, or distribution-specific variables. The paths, user, service name, binary location, and template values are explicitly defined. + + + +Turning facts off saves time on each run because Ansible skips the initial fact-gathering step. It also reduces noise in the output and avoids collecting information the playbook does not use. + + + +\--- + + + +\# Task 2 - Prove Idempotency and Selective Re-run + + + +\## Second Run: Idempotency + + + +Command: + + + +```bash + +ansible-playbook -i ansible/inventory.yaml ansible/playbook.yaml + +``` + + + +Output: + + + +```text + +PLAY \[Deploy QuickNotes to Lab 5 VM] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + + + +TASK \[Create QuickNotes group] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Create QuickNotes system user] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Ensure QuickNotes data directory exists] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Ensure QuickNotes config directory exists] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Copy QuickNotes seed file] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Copy QuickNotes binary] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Render QuickNotes systemd unit] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Enable and start QuickNotes service] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +PLAY RECAP \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +lab5-vm : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + + + +The second run reports `changed=0`, proving that the playbook is idempotent when the VM already matches the desired state. + + + +\--- + + + +\## Selective Variable Tweak + + + +I changed one variable in the playbook: + + + +```yaml + +quicknotes\_restart\_sec: 2 + +``` + + + +to: + + + +```yaml + +quicknotes\_restart\_sec: 3 + +``` + + + +Command: + + + +```bash + +sed -i 's/quicknotes\_restart\_sec: 2/quicknotes\_restart\_sec: 3/' ansible/playbook.yaml + +ansible-playbook -i ansible/inventory.yaml ansible/playbook.yaml + +``` + + + +Output: + + + +```text + +TASK \[Create QuickNotes group] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Create QuickNotes system user] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Ensure QuickNotes data directory exists] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Ensure QuickNotes config directory exists] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Copy QuickNotes seed file] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Copy QuickNotes binary] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Render QuickNotes systemd unit] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +TASK \[Enable and start QuickNotes service] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +RUNNING HANDLER \[reload systemd] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +RUNNING HANDLER \[restart quicknotes] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +PLAY RECAP \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +lab5-vm : ok=10 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + + + +Only the template changed, which caused the systemd reload and QuickNotes restart handlers to run. The other tasks stayed `ok`. + + + +\--- + + + +\## `--check --diff` Preview + + + +I then changed: + + + +```yaml + +quicknotes\_restart\_sec: 3 + +``` + + + +to: + + + +```yaml + +quicknotes\_restart\_sec: 4 + +``` + + + +Command: + + + +```bash + +sed -i 's/quicknotes\_restart\_sec: 3/quicknotes\_restart\_sec: 4/' ansible/playbook.yaml + +ansible-playbook -i ansible/inventory.yaml ansible/playbook.yaml --check --diff + +``` + + + +Output: + + + +```diff + +TASK \[Render QuickNotes systemd unit] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +\--- before: /etc/systemd/system/quicknotes.service + ++++ after: /root/.ansible/tmp/ansible-local-3546ce654yqm/tmp2gzhsvf0/quicknotes.service.j2 + +@@ -13,7 +13,7 @@ + + Environment=SEED\_PATH=/etc/quicknotes/seed.json + + ExecStart=/usr/local/bin/quicknotes + + Restart=on-failure + +\-RestartSec=3s + ++RestartSec=4s + + + + \[Install] + + WantedBy=multi-user.target + +``` + + + +PLAY RECAP: + + + +```text + +PLAY RECAP \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +lab5-vm : ok=10 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + + + +This showed the exact systemd unit change before applying it. + + + +\--- + + + +\## Final Apply After `--check --diff` + + + +Command: + + + +```bash + +ansible-playbook -i ansible/inventory.yaml ansible/playbook.yaml + +``` + + + +Output: + + + +```text + +TASK \[Create QuickNotes group] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Create QuickNotes system user] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Ensure QuickNotes data directory exists] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Ensure QuickNotes config directory exists] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Copy QuickNotes seed file] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Copy QuickNotes binary] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +TASK \[Render QuickNotes systemd unit] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +TASK \[Enable and start QuickNotes service] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +RUNNING HANDLER \[reload systemd] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +ok: \[lab5-vm] + + + +RUNNING HANDLER \[restart quicknotes] \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +changed: \[lab5-vm] + + + +PLAY RECAP \*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\* + +lab5-vm : ok=10 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +``` + + + +This applied the `RestartSec=4s` change for real. + + + +\--- + + + +\## Task 2 Design Questions + + + +\### e) Why does the second run report `changed=0`? + + + +The second run reports `changed=0` because the VM already matches the desired state described by the playbook. + + + +The `file` module checks whether the directory exists and whether its owner, group, mode, and type match the requested state. If `/var/lib/quicknotes` already exists with owner `quicknotes`, group `quicknotes`, and mode `0750`, it reports `ok`. + + + +The `copy` module checks whether the destination file already has the same content, owner, group, and mode. If the binary and seed file have not changed, it reports `ok`. + + + +The `template` module renders the Jinja2 template locally, compares it with the remote file, and only reports `changed` if the rendered content or file metadata differs. + + + +Because nothing differed during the second run, Ansible reported `changed=0`. + + + +\### f) What would happen if I used `shell: 'echo "ADDR=..." > /etc/systemd/system/quicknotes.service'` instead of `template:`? + + + +Using `shell` would make the playbook less reliable and less idempotent. + + + +First, the shell command would likely rewrite the file every run, causing `changed=1` every time even if the content was logically the same. That would restart the service unnecessarily. + + + +Second, quoting multiline systemd unit content through `echo` is fragile. It is easy to break newlines, quoting, environment variables, or special characters. + + + +Third, the output would not produce a useful diff. With the `template` module, `--check --diff` clearly showed `RestartSec=3s` changing to `RestartSec=4s`. With a shell command, that visibility would be weaker or missing. + + + +Finally, `template` manages owner, group, mode, content comparison, and change detection in a structured way. A shell command would force me to manually reimplement that behavior badly. + + + +\### g) What bug would `--check --diff` catch that plain `--check` might miss? + + + +Plain `--check` tells me that something would change, but it does not show the exact content change. `--check --diff` shows the actual before-and-after difference. + + + +For example, plain `--check` might say the systemd template would change, but `--diff` showed the exact line: + + + +```diff + +\-RestartSec=3s + ++RestartSec=4s + +``` + + + +This would catch bugs like accidentally changing the wrong environment variable, pointing `DATA\_PATH` to the wrong location, using the wrong port in `ADDR`, changing the wrong `ExecStart`, or corrupting the unit file formatting. In production, seeing the exact diff before deploying helps prevent a correct-looking playbook from making the wrong configuration change. + + + +\--- + + + +\# Bonus Task - ansible-pull GitOps Loop + + + +Bonus was not attempted for this submission. + + + +\--- + + + +\## Final File Layout + + + +The Lab 7 files added are: + + + +```text + +ansible/ + +├── inventory.yaml + +├── playbook.yaml + +├── files/ + +│ ├── quicknotes + +│ └── seed.json + +└── templates/ + + └── quicknotes.service.j2 + + + +submissions/ + +└── lab7.md + +``` + + + +\--- + + + +\## Final Result + + + +QuickNotes was successfully deployed to the Lab 5 VM using Ansible. The service runs under a dedicated `quicknotes` system user, stores data under `/var/lib/quicknotes`, is managed by systemd, and is reachable from the host through the Vagrant port forward. + + + +Final health check: + + + +```json + +{"notes":4,"status":"ok"} + +``` + + +