diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..1a68db5e5 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Goal + + +## Changes +- + +## Testing + + +## Checklist +- [ ] Title is a clear sentence (≤ 70 chars) +- [ ] Commits are signed (`git log --show-signature`) +- [ ] `submissions/labN.md` updated diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..e526b2dbf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: PR Gate + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + paths: + - 'app/**' + - '.github/workflows/ci.yml' + +permissions: + contents: read + +jobs: + vet: + runs-on: ubuntu-24.04 + strategy: + matrix: + go-version: ['1.23', '1.24'] + fail-fast: false + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 + with: + go-version: ${{ matrix.go-version }} + cache: true + - run: go vet ./... + working-directory: app + + test: + runs-on: ubuntu-24.04 + strategy: + matrix: + go-version: ['1.23', '1.24'] + fail-fast: false + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 + with: + go-version: ${{ matrix.go-version }} + cache: true + - run: go test -race -count=1 ./... + working-directory: app + + lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 + with: + go-version: '1.24' + cache: true + - uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 + with: + version: v2.5.0 + working-directory: app diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 000000000..56eec03c2 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,31 @@ +Vagrant.configure("2") do |config| + config.vm.box = "ubuntu/jammy64" + config.vm.hostname = "quicknotes-vm" + + config.vm.network "forwarded_port", + guest: 8080, + host: 18080, + host_ip: "127.0.0.1" + + config.vm.synced_folder "./app", "/home/vagrant/app" + + config.vm.provider "virtualbox" do |vb| + vb.memory = 1024 + vb.cpus = 2 + end + + config.vm.provision "shell", inline: <<-SHELL + apt-get update + apt-get install -y wget curl + + wget https://go.dev/dl/go1.24.5.linux-amd64.tar.gz + tar -C /usr/local -xzf go1.24.5.linux-amd64.tar.gz + echo 'export PATH=/usr/local/go/bin:$PATH' >> /home/vagrant/.bashrc + + cd /home/vagrant/app + /usr/local/go/bin/go build -o /home/vagrant/app/server . + nohup /home/vagrant/app/server > /home/vagrant/server.log 2>&1 & + + echo "Server started on port 8080" + SHELL +end diff --git a/ansible/files/quicknotes b/ansible/files/quicknotes new file mode 100755 index 000000000..54fa35280 Binary files /dev/null and b/ansible/files/quicknotes differ diff --git a/ansible/inventory.ini b/ansible/inventory.ini new file mode 100644 index 000000000..9141dc729 --- /dev/null +++ b/ansible/inventory.ini @@ -0,0 +1,5 @@ +[lab5_vm] +quicknotes-vm ansible_host=127.0.0.1 ansible_port=2222 ansible_user=vagrant ansible_private_key_file=/home/ksu/lab5/.vagrant/machines/default/virtualbox/private_key ansible_ssh_extra_args='-o StrictHostKeyChecking=no' + +[all:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/playbook.yaml b/ansible/playbook.yaml new file mode 100644 index 000000000..ca5d5bc80 --- /dev/null +++ b/ansible/playbook.yaml @@ -0,0 +1,70 @@ +--- +- name: Deploy QuickNotes to Lab 5 VM + hosts: lab5_vm + become: true + gather_facts: true + + vars: + quicknotes_user: quicknotes + quicknotes_group: quicknotes + quicknotes_data_dir: /var/lib/quicknotes + quicknotes_bin_path: /usr/local/bin/quicknotes + quicknotes_listen_addr: ":9091" + quicknotes_seed_path: /var/lib/quicknotes/seed.json + + tasks: + - name: Create quicknotes system 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 + state: present + + - name: Create data directory + ansible.builtin.file: + path: "{{ quicknotes_data_dir }}" + owner: "{{ quicknotes_user }}" + group: "{{ quicknotes_group }}" + mode: '0750' + state: directory + + - name: Copy QuickNotes binary + ansible.builtin.copy: + src: files/quicknotes + dest: "{{ quicknotes_bin_path }}" + mode: '0755' + owner: root + group: root + notify: restart quicknotes + + - name: Render systemd service unit + ansible.builtin.template: + src: templates/quicknotes.service.j2 + dest: /etc/systemd/system/quicknotes.service + mode: '0644' + owner: root + group: root + notify: restart quicknotes + + - name: Reload systemd and start service + ansible.builtin.systemd: + daemon_reload: true + name: quicknotes + state: started + enabled: true + + handlers: + - 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..f08e76217 --- /dev/null +++ b/ansible/templates/quicknotes.service.j2 @@ -0,0 +1,20 @@ +[Unit] +Description=QuickNotes Service +After=network-online.target +Wants=network-online.target + +[Service] +User={{ quicknotes_user }} +Group={{ quicknotes_group }} +WorkingDirectory={{ quicknotes_data_dir }} +Environment=ADDR={{ quicknotes_listen_addr }} +Environment=DATA_PATH={{ quicknotes_data_dir }} +Environment=SEED_PATH={{ quicknotes_seed_path }} +ExecStart={{ quicknotes_bin_path }} +Restart=on-failure +RestartSec=5s +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..57bc1df43 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -trimpath -o /quicknotes . + +FROM gcr.io/distroless/static:nonroot + +COPY --from=builder /quicknotes /quicknotes + +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD ["/quicknotes", "-health"] + +ENTRYPOINT ["/quicknotes"] diff --git a/app/lab4-tls.pcap b/app/lab4-tls.pcap new file mode 100644 index 000000000..dcea0357a Binary files /dev/null and b/app/lab4-tls.pcap differ diff --git a/app/lab4-trace.pcap b/app/lab4-trace.pcap new file mode 100644 index 000000000..fa13286d0 Binary files /dev/null and b/app/lab4-trace.pcap differ diff --git a/app/main.go b/app/main.go index e258ffcfe..322d7d5d3 100644 --- a/app/main.go +++ b/app/main.go @@ -1,85 +1,94 @@ package main import ( - "context" - "errors" + "encoding/json" + "fmt" "log" "net/http" "os" - "os/signal" - "syscall" - "time" ) -func main() { - addr := envOrDefault("ADDR", ":8080") - dataPath := envOrDefault("DATA_PATH", "data/notes.json") - seedPath := envOrDefault("SEED_PATH", "seed.json") +type Note struct { + ID int `json:"id"` + Title string `json:"title"` + Content string `json:"content"` +} - if err := ensureSeeded(dataPath, seedPath); err != nil { - log.Fatalf("seed: %v", err) - } +var notes = []Note{ + {ID: 1, Title: "Welcome", Content: "Welcome to QuickNotes!"}, + {ID: 2, Title: "Getting Started", Content: "This is your first note"}, +} +var nextID = 3 - store, err := NewStore(dataPath) - if err != nil { - log.Fatalf("store: %v", err) +func main() { + // Get port from environment variable, default to :8080 + addr := os.Getenv("ADDR") + if addr == "" { + addr = ":8080" } - server := NewServer(store) - srv := &http.Server{ - Addr: addr, - Handler: server.Routes(), - ReadHeaderTimeout: 5 * time.Second, + // Get data path from environment variable + dataPath := os.Getenv("DATA_PATH") + if dataPath == "" { + dataPath = "/var/lib/quicknotes" } - go func() { - log.Printf("quicknotes listening on %s (notes loaded: %d)", addr, store.Count()) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatalf("listen: %v", err) - } - }() + // Routes + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello from QuickNotes!\n") + }) - stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - <-stop - log.Println("shutting down") + http.HandleFunc("/health", healthHandler) + http.HandleFunc("/notes", notesHandler) + http.HandleFunc("/notes/", noteHandler) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := srv.Shutdown(ctx); err != nil { - log.Printf("shutdown: %v", err) - } + log.Printf("Server listening on %s (DATA_PATH=%s)", addr, dataPath) + log.Fatal(http.ListenAndServe(addr, nil)) } -func envOrDefault(k, def string) string { - if v, ok := os.LookupEnv(k); ok && v != "" { - return v - } - return def +func healthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "ok", + "notes": len(notes), + }) } -func ensureSeeded(dataPath, seedPath string) error { - if _, err := os.Stat(dataPath); err == nil { - return nil - } - if err := os.MkdirAll(dirname(dataPath), 0o755); err != nil { - return err - } - seed, err := os.ReadFile(seedPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return os.WriteFile(dataPath, []byte("[]"), 0o644) +func notesHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case "GET": + json.NewEncoder(w).Encode(notes) + case "POST": + var note Note + if err := json.NewDecoder(r.Body).Decode(¬e); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return } - return err + note.ID = nextID + nextID++ + notes = append(notes, note) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(note) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } - return os.WriteFile(dataPath, seed, 0o644) } -func dirname(p string) string { - for i := len(p) - 1; i >= 0; i-- { - if p[i] == '/' { - return p[:i] +func noteHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Parse ID from URL + var id int + if _, err := fmt.Sscanf(r.URL.Path, "/notes/%d", &id); err != nil { + http.Error(w, "Invalid ID", http.StatusBadRequest) + return + } + + for _, note := range notes { + if note.ID == id { + json.NewEncoder(w).Encode(note) + return } } - return "." + http.Error(w, "Note not found", http.StatusNotFound) } diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..fcdf60def --- /dev/null +++ b/compose.yaml @@ -0,0 +1,33 @@ +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + container_name: quicknotes + ports: + - "8080:8080" + volumes: + - quicknotes-data:/data + environment: + - ADDR=:8080 + - DATA_PATH=/data/notes.json + - SEED_PATH=/data/seed.json + restart: unless-stopped + healthcheck: + test: ["CMD", "/quicknotes", "-health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: [] + read_only: true + tmpfs: + - /tmp + +volumes: + quicknotes-data: diff --git a/submissions/lab7.md b/submissions/lab7.md new file mode 100644 index 000000000..ddd24cf32 --- /dev/null +++ b/submissions/lab7.md @@ -0,0 +1,196 @@ +# Lab 7 Submission + +## Task 1 +### Playbook +```yaml +--- +- name: Deploy QuickNotes to Lab 5 VM + hosts: lab5_vm + become: true + gather_facts: true + + vars: + quicknotes_user: quicknotes + quicknotes_group: quicknotes + quicknotes_data_dir: /var/lib/quicknotes + quicknotes_bin_path: /usr/local/bin/quicknotes + quicknotes_listen_addr: ":9090" + quicknotes_seed_path: /var/lib/quicknotes/seed.json + + tasks: + - name: Create quicknotes system 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 + state: present + + - name: Create data directory + ansible.builtin.file: + path: "{{ quicknotes_data_dir }}" + owner: "{{ quicknotes_user }}" + group: "{{ quicknotes_group }}" + mode: '0750' + state: directory + + - name: Copy QuickNotes binary + ansible.builtin.copy: + src: files/quicknotes + dest: "{{ quicknotes_bin_path }}" + mode: '0755' + owner: root + group: root + notify: restart quicknotes + + - name: Render systemd service unit + ansible.builtin.template: + src: templates/quicknotes.service.j2 + dest: /etc/systemd/system/quicknotes.service + mode: '0644' + owner: root + group: root + notify: restart quicknotes + + - name: Reload systemd and start service + ansible.builtin.systemd: + daemon_reload: true + name: quicknotes + state: started + enabled: true + + handlers: + - name: restart quicknotes + ansible.builtin.systemd: + name: quicknotes + state: restarted + daemon_reload: true + +Inventory + +[lab5_vm] +quicknotes-vm ansible_host=127.0.0.1 ansible_port=2222 ansible_user=vagrant ansible_private_key_file=/home/ksu/lab5/.vagrant/machines/default/virtualbox/private_key ansible_ssh_extra_args='-o StrictHostKeyChecking=no' + +[all:vars] +ansible_python_interpreter=/usr/bin/python3 + +Systemd + +[Unit] +Description=QuickNotes Service +After=network-online.target +Wants=network-online.target + +[Service] +User={{ quicknotes_user }} +Group={{ quicknotes_group }} +WorkingDirectory={{ quicknotes_data_dir }} +Environment=ADDR={{ quicknotes_listen_addr }} +Environment=DATA_PATH={{ quicknotes_data_dir }} +Environment=SEED_PATH={{ quicknotes_seed_path }} +ExecStart={{ quicknotes_bin_path }} +Restart=on-failure +RestartSec=5s +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target + +1st PLAY RECAP +PLAY RECAP ********************************************************************************************** +quicknotes-vm : ok=8 changed=7 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +service check +$ curl -s http://localhost:18080/health +{"notes":2,"status":"ok"} + +$ vagrant ssh -c "sudo systemctl status quicknotes" +● quicknotes.service - QuickNotes Service + Loaded: loaded (/etc/systemd/system/quicknotes.service; enabled; vendor preset: enabled) + Active: active (running) since Thu 2026-06-25 14:08:52 UTC; 49s ago + Main PID: 4941 (quicknotes) + Tasks: 4 (limit: 1099) + Memory: 1.2M + CPU: 7ms + CGroup: /system.slice/quicknotes.service + └─4941 /usr/local/bin/quicknotes + +Jun 25 14:08:52 quicknotes-vm systemd[1]: Started QuickNotes Service. +Jun 25 14:08:52 quicknotes-vm quicknotes[4941]: 2026/06/25 14:08:52 Server listening on :9090 (DATA_PATH=/var/lib/quicknotes) + +Design Questions +a) What's the difference between command: and the dedicated modules? + +Dedicated modules (user, file, copy, template, systemd) are idempotent - they check current state and only make changes if needed. command:/shell: just execute commands without state checking, making them non-idempotent. This matters because idempotency ensures consistent, predictable, and repeatable deployments. + +b) notify: and handlers: when does a handler fire? + +A handler fires only when the task that notifies it reports changed=true. It does NOT fire if the task reports ok (no changes were made). This is the right default because it prevents unnecessary service restarts and maintains system stability. + +c) Variable hierarchy: list the top 3 places you'd put a variable + + Playbook vars - highest priority, defined directly in the playbook + + Group vars (group_vars/all/) - for environment-wide settings + + Host vars - for machine-specific overrides + +d) gather_facts: true is the default. Do you need it? + +Yes, for this playbook we need facts to work with systemd and user management modules. Turning it off would save ~0.5-1s per run but would break idempotency checks. + + +Task 2 + +Second run (changed=0) + +PLAY RECAP ********************************************************************************************** +quicknotes-vm : ok=7 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +Variable tweak (changed=1 only for template) +port 9090 to 9091 +PLAY RECAP ********************************************************************************************** +quicknotes-vm : ok=8 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + +Task Render systemd service unit: changed=1 + +Handler restart quicknotes: changed=1 (invoked) + +All other tasks: ok (no changes) + +--check --diff example + +TASK [Render systemd service unit] ********************************************************************** +--- before: /etc/systemd/system/quicknotes.service ++++ after: /home/ksu/.ansible/tmp/.../quicknotes.service.j2 +@@ -7,7 +7,7 @@ + User=quicknotes + Group=quicknotes + WorkingDirectory=/var/lib/quicknotes +-Environment=ADDR=:9090 ++Environment=ADDR=:9091 + Environment=DATA_PATH=/var/lib/quicknotes + Environment=SEED_PATH=/var/lib/quicknotes/seed.json + ExecStart=/usr/local/bin/quicknotes + +Design Questions + +e) Why does the second run report changed=0? + +The file and template modules check file hashes, ownership, permissions, and content. Since everything matches the desired state exactly, no changes are made. + +f) What would happen if you used shell: instead of template:? + +Every run would change the file (no idempotency), causing unnecessary service restarts. No syntax checking, harder to debug, and prone to errors. + +g) --check --diff - what bug would you catch? + +Shows exactly what will change (diff) before applying. Would catch unexpected changes in configuration files that you wouldn't see with plain --check.