Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Vagrantfile
Original file line number Diff line number Diff line change
@@ -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
Binary file added ansible/files/quicknotes
Binary file not shown.
26 changes: 26 additions & 0 deletions ansible/files/seed.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
2 changes: 2 additions & 0 deletions ansible/inventory.ini
Original file line number Diff line number Diff line change
@@ -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'
10 changes: 10 additions & 0 deletions ansible/inventory.yaml
Original file line number Diff line number Diff line change
@@ -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"
99 changes: 99 additions & 0 deletions ansible/playbook.yaml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions ansible/templates/quicknotes.service.j2
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
31 changes: 31 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -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:
20 changes: 16 additions & 4 deletions labs/lab1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions labs/lab2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions labs/lab3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`:
Expand Down Expand Up @@ -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
Expand Down
Loading