Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
075cb36
docs: add PR template
linxel Jun 4, 2026
cbb252d
test: unsigned commit (should fail)
linxel Jun 4, 2026
c3d9769
docs: upstream moved while you worked
linxel Jun 5, 2026
28e35b3
ci(lab3): add PR-gate
linxel Jun 10, 2026
9d6e9f1
debug: intentionally break test to verify CI fails
linxel Jun 10, 2026
ccbce4c
fix: restore correct test expectation
linxel Jun 10, 2026
86ab1b5
docs(lab3): add submission file
linxel Jun 10, 2026
0596915
Update lab3.md
linxel Jun 10, 2026
0a0063f
Update lab3.md
linxel Jun 10, 2026
a031f4a
ci(lab3): final with cache, matrix, path filter
linxel Jun 10, 2026
f1103a8
ci(lab3): add task 2
linxel Jun 10, 2026
e1c3346
Update lab3.md
linxel Jun 10, 2026
889ed22
Update lab3.md
linxel Jun 10, 2026
509437f
ci(lab3): bonus - add alpine image and GOFLAGS
linxel Jun 10, 2026
f1e8dd7
ci(lab3): bonus - add alpine image and GOFLAGS
linxel Jun 10, 2026
db02af0
docs(lab3): add bonus performance investigation
linxel Jun 10, 2026
2fc93e9
Lab4: complete
linxel Jun 11, 2026
33bda9f
remove lab3 from feature/lab4 branch
linxel Jun 11, 2026
8581c55
Lab 5: Vagrantfile and lab5 task 1
linxel Jun 18, 2026
e300b43
Remove lab4.md from feature/lab5 branch
linxel Jun 18, 2026
dcf6e4a
Lab 5: task 2 done
linxel Jun 18, 2026
0d8e026
Lab 5: update report
linxel Jun 18, 2026
f84fdc5
Lab5: add bonus comparison table and analysis
linxel Jun 18, 2026
74b6516
Lab6: multi-stage Dockerfile with distroless, compose, healthcheck, s…
linxel Jun 18, 2026
fc3f11c
Remove lab5.md from lab6 branch
linxel Jun 18, 2026
7299ded
Lab 7: Complete Ansible deployment with idempotency and documentation
linxel Jun 25, 2026
e50b16b
docs: remove lab6.md from submissions
linxel Jun 25, 2026
5982565
Lab 8: Prometheus + Grafana monitoring with Golden Signals dashboard …
linxel Jun 25, 2026
0e44cb5
Lab 8: complete submission with screenshots
linxel Jun 25, 2026
18bd17d
Update lab8.md
linxel Jun 25, 2026
5601303
Lab 8: add Grafana provisioning files
linxel Jun 25, 2026
f9de73f
Merge branch 'feature/lab8' of github.com:linxel/DevOps-Intro into fe…
linxel Jun 25, 2026
2c1f24a
Lab 8: add dashboard JSON
linxel Jun 25, 2026
0461c1b
Lab 8: update design questions with detailed answers
linxel Jun 25, 2026
1745aac
Create golden-signals.json
linxel Jun 25, 2026
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
13 changes: 13 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Goal
<!-- What does this PR accomplish? 1 sentence. -->

## Changes
-

## Testing
<!-- How did you verify it? -->

## Checklist
- [ ] Title is a clear sentence (≤ 70 chars)
- [ ] Commits are signed (`git log --show-signature`)
- [ ] `submissions/labN.md` updated
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions Vagrantfile
Original file line number Diff line number Diff line change
@@ -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
Binary file added ansible/files/quicknotes
Binary file not shown.
5 changes: 5 additions & 0 deletions ansible/inventory.ini
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions ansible/playbook.yaml
Original file line number Diff line number Diff line change
@@ -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

20 changes: 20 additions & 0 deletions ansible/templates/quicknotes.service.j2
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Binary file added app/lab4-tls.pcap
Binary file not shown.
Binary file added app/lab4-trace.pcap
Binary file not shown.
125 changes: 67 additions & 58 deletions app/main.go
Original file line number Diff line number Diff line change
@@ -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(&note); 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)
}
Loading