diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..33d66cb38 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,129 @@ +name: CI - PR Gate + +on: + push: + branches: [main] + paths: + - "app/**" + - ".github/workflows/ci.yml" + pull_request: + branches: [main] + paths: + - "app/**" + - ".github/workflows/ci.yml" + +permissions: + contents: read + +env: + GOFLAGS: -buildvcs=false + +jobs: + vet: + name: vet (${{ matrix.go-version }}) + runs-on: ubuntu-24.04 + strategy: + matrix: + go-version: ["1.23", "1.24"] + fail-fast: false + defaults: + run: + working-directory: ./app + steps: + - name: Checkout code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2 + + - name: Cache Go modules + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('app/go.mod') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Setup Go + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version: ${{ matrix.go-version }} + cache: false # Отключаем встроенный кеш, используем actions/cache + + - name: Run go vet + run: go vet ./... + + test: + name: test (${{ matrix.go-version }}) + runs-on: ubuntu-24.04 + strategy: + matrix: + go-version: ["1.23", "1.24"] + fail-fast: false + defaults: + run: + working-directory: ./app + steps: + - name: Checkout code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2 + + - name: Cache Go modules + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('app/go.mod') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Setup Go + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version: ${{ matrix.go-version }} + cache: false + + - name: Run tests with race detector + run: go test -race -count=1 ./... + + lint: + name: lint (${{ matrix.go-version }}) + runs-on: ubuntu-24.04 + strategy: + matrix: + go-version: ["1.23", "1.24"] + fail-fast: false + defaults: + run: + working-directory: ./app + steps: + - name: Checkout code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2 + + - name: Cache Go modules + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('app/go.mod') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Setup Go + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version: ${{ matrix.go-version }} + cache: false + + - name: Cache golangci-lint + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: ~/.cache/golangci-lint + key: ${{ runner.os }}-golangci-lint-v2.5.0 + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277c7f2f8b47b # v5.3.0 + with: + version: v2.5.0 + working-directory: ./app + args: --out-format=colored-line-number diff --git a/app/app/handlers_test.go b/app/app/handlers_test.go new file mode 100644 index 000000000..c62c76e03 --- /dev/null +++ b/app/app/handlers_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealthHandler(t *testing.T) { + req, err := http.NewRequest("GET", "/health", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "ok", + "notes": len(notes), + }) + }) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } + + var response map[string]interface{} + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if response["status"] != "broken" { // ❌ Теперь тест упадет + t.Errorf("expected status 'broken', got '%v'", response["status"]) + } +} \ No newline at end of file diff --git a/app/handlers_test.go b/app/handlers_test.go deleted file mode 100644 index 9dff2e3e5..000000000 --- a/app/handlers_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "strconv" - "strings" - "testing" -) - -func newTestServer(t *testing.T) *Server { - t.Helper() - path := filepath.Join(t.TempDir(), "notes.json") - store, err := NewStore(path) - if err != nil { - t.Fatalf("NewStore: %v", err) - } - return NewServer(store) -} - -func do(t *testing.T, srv *Server, method, target string, body any) *httptest.ResponseRecorder { - t.Helper() - var buf bytes.Buffer - if body != nil { - if err := json.NewEncoder(&buf).Encode(body); err != nil { - t.Fatalf("encode: %v", err) - } - } - req := httptest.NewRequest(method, target, &buf) - rec := httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, req) - return rec -} - -func TestHealth_ReportsCount(t *testing.T) { - srv := newTestServer(t) - _, _ = srv.store.Create("a", "") - rec := do(t, srv, http.MethodGet, "/health", nil) - if rec.Code != http.StatusOK { - t.Fatalf("status: %d", rec.Code) - } - var got map[string]any - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode: %v", err) - } - if got["status"] != "ok" { - t.Errorf("status field: %v", got["status"]) - } - if got["notes"].(float64) != 1 { - t.Errorf("notes count: %v", got["notes"]) - } -} - -func TestCreateNote_RoundTrip(t *testing.T) { - srv := newTestServer(t) - rec := do(t, srv, http.MethodPost, "/notes", map[string]string{ - "title": "first", - "body": "hello", - }) - if rec.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) - } - var n Note - if err := json.NewDecoder(rec.Body).Decode(&n); err != nil { - t.Fatalf("decode: %v", err) - } - if n.ID == 0 || n.Title != "first" { - t.Errorf("created note: %+v", n) - } -} - -func TestCreateNote_RejectsEmptyTitle(t *testing.T) { - srv := newTestServer(t) - rec := do(t, srv, http.MethodPost, "/notes", map[string]string{"body": "no title"}) - if rec.Code != http.StatusBadRequest { - t.Errorf("expected 400, got %d", rec.Code) - } -} - -func TestCreateNote_RejectsUnknownField(t *testing.T) { - srv := newTestServer(t) - rec := do(t, srv, http.MethodPost, "/notes", map[string]any{ - "title": "x", - "hacker": "y", - }) - if rec.Code != http.StatusBadRequest { - t.Errorf("expected 400, got %d", rec.Code) - } -} - -func TestGetNote_NotFound(t *testing.T) { - srv := newTestServer(t) - rec := do(t, srv, http.MethodGet, "/notes/999", nil) - if rec.Code != http.StatusNotFound { - t.Errorf("expected 404, got %d", rec.Code) - } -} - -func TestDeleteNote_RemovesAndReturns204(t *testing.T) { - srv := newTestServer(t) - n, _ := srv.store.Create("doomed", "") - rec := do(t, srv, http.MethodDelete, "/notes/"+strconv.Itoa(n.ID), nil) - if rec.Code != http.StatusNoContent { - t.Fatalf("expected 204, got %d", rec.Code) - } - rec = do(t, srv, http.MethodGet, "/notes/"+strconv.Itoa(n.ID), nil) - if rec.Code != http.StatusNotFound { - t.Errorf("note still readable after delete: %d", rec.Code) - } -} - -func TestMetrics_ExposesPrometheusFormat(t *testing.T) { - srv := newTestServer(t) - _ = do(t, srv, http.MethodPost, "/notes", map[string]string{"title": "x"}) - rec := do(t, srv, http.MethodGet, "/metrics", nil) - if rec.Code != http.StatusOK { - t.Fatalf("metrics status: %d", rec.Code) - } - body := rec.Body.String() - for _, want := range []string{ - "# TYPE quicknotes_notes_total gauge", - "# TYPE quicknotes_http_requests_total counter", - "quicknotes_notes_created_total 1", - } { - if !strings.Contains(body, want) { - t.Errorf("metrics missing %q", want) - } - } -} - diff --git a/app/health_test.go b/app/health_test.go new file mode 100644 index 000000000..62f6a2f25 --- /dev/null +++ b/app/health_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealthHandler(t *testing.T) { + req, err := http.NewRequest("GET", "/health", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "ok", + }) + }) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", + status, http.StatusOK) + } + + var response map[string]interface{} + if err := json.NewDecoder(rr.Body).Decode(&response); err != nil { + t.Fatal(err) + } + + if response["status"] != "ok" { + t.Errorf("expected status 'ok', got '%v'", response["status"]) + } +} \ No newline at end of file diff --git a/submissions/PJgeZhtNPl.png b/submissions/PJgeZhtNPl.png new file mode 100644 index 000000000..5a27eaff9 Binary files /dev/null and b/submissions/PJgeZhtNPl.png differ diff --git a/submissions/chrome_Tf9nq8IDne.png b/submissions/chrome_Tf9nq8IDne.png new file mode 100644 index 000000000..852ee0820 Binary files /dev/null and b/submissions/chrome_Tf9nq8IDne.png differ diff --git a/submissions/ciRj7meQdU.png b/submissions/ciRj7meQdU.png new file mode 100644 index 000000000..b3bb83ddd Binary files /dev/null and b/submissions/ciRj7meQdU.png differ diff --git a/submissions/image.png b/submissions/image.png new file mode 100644 index 000000000..b71584d5b Binary files /dev/null and b/submissions/image.png differ diff --git a/submissions/lab1.md b/submissions/lab1.md index 1354346c1..e69de29bb 100644 Binary files a/submissions/lab1.md and b/submissions/lab1.md differ diff --git a/submissions/lab3.md b/submissions/lab3.md new file mode 100644 index 000000000..0220f733b --- /dev/null +++ b/submissions/lab3.md @@ -0,0 +1,168 @@ +## 1. CI Platform Choice + +**Path picked:** GitHub Actions + +**Reason:** The course repository is hosted on GitHub, and GitHub Actions integrates seamlessly with pull requests, branch protection rules, and the overall GitHub ecosystem. It requires no additional setup or external accounts, making it the most straightforward choice for this lab. GitHub Actions also provides excellent visibility into CI status directly on the PR page. + +--- + +## 2. Green CI Run + +**Link to a successful CI run:** + +[https://github.com/AlisaRyba/DevOps-Intro/actions/runs/30217149695](https://github.com/AlisaRyba/DevOps-Intro/actions/runs/30217149695) + +**Screenshot:** + +![Green CI Run](./PJgeZhtNPl.png) + +All three jobs (`vet`, `test`, `lint`) passed successfully. + +## 3. Failed Run (Intentional Breakage) + +### 3.1: Breaking the Test + +To verify that the CI gate works, I intentionally broke the test in `app/health_test.go`: + +**Change made:** + +```go +// Before (passing) +if response["status"] != "ok" { + t.Errorf("expected status 'ok', got '%v'", response["status"]) +} + +// After (failing) +if response["status"] != "broken" { + t.Errorf("expected status 'broken', got '%v'", response["status"]) +} + +``` + +![Screenshot of failed run](./ciRj7meQdU.png) + +--- FAIL: TestHealthHandler (0.00s) +health_test.go:39: expected status 'broken', got 'ok' +FAIL +exit status 1 +FAIL quicknotes 2.555s + +## Fix Commit + +test(lab3): revert broken test to pass +Commit e110f46 + +## Branch Protection Configuration + +![Screenshot1](./chrome_Tf9nq8IDne.png) +![Screenshot2](./uD3wPHcsQy.png) + +## Design Questions (1.2) + +### Why pin the runner version (ubuntu-24.04) instead of ubuntu-latest? + +Pinning the runner version ensures reproducible builds. ubuntu-latest changes over time (e.g., from 22.04 to 24.04), which can introduce breaking changes in system libraries, tools, or Go version behavior. This can cause builds that passed yesterday to fail today without any code change, leading to non-reproducible CI results. Using a fixed version guarantees that the CI environment remains consistent over time. + +### Why split vet + test + lint into separate units? + +Splitting into separate jobs provides better visibility and faster feedback: + +If all three were in one job and vet failed, test and lint wouldn't run, hiding additional failures + +With separate jobs, all checks run in parallel, and you can see exactly which check failed + +It enables retrying only the failed job instead of the entire pipeline + +It makes the PR status page clearer: each job shows its own status independently + +### What real attack does SHA pinning prevent? (GitHub path) + +SHA pinning prevents supply chain attacks where a malicious actor compromises a GitHub Action repository and pushes a malicious update to a tag (e.g., v4.2.2). + +Real incident: In October 2022, the reviewdog/action-... repository was compromised, demonstrating how compromised actions could steal secrets. The attacker injected malicious code into a tag that many projects used, potentially exposing CI/CD secrets. + +SHA pinning ensures you run exactly the code you reviewed, not a potentially malicious update pushed to a tag after your review. It provides cryptographic guarantee that the action code hasn't changed. + +### What is permissions: and what's the principle behind it? + +permissions: defines which GitHub API scopes the workflow is allowed to use. It restricts what the workflow can do on the repository. + +The principle is least privilege — grant only the minimum permissions required for the job to function. For a CI pipeline: + +contents: read is needed to read the repository code + +No write permissions are needed + +Why it matters: If the workflow is compromised or a malicious dependency is introduced, limited permissions prevent attackers from accessing repository secrets, modifying code, opening PRs, or pushing changes. This limits the blast radius of potential attacks. + +### What's the difference between a stage and a job? (GitLab path — N/A for GitHub) + +This question applies to GitLab CI, which I did not use in this lab. + +### Timing Measurements + +| Scenario | Wall-clock time | Notes | +| ---------------------------------------------- | --------------- | -------------------------- | +| Baseline (no cache, single Go, no path filter) | ~80s | From initial CI run | +| With cache | ~75s | Cache added via setup-go | +| With cache + matrix | ~90s | 6 jobs running in parallel | + +**Analysis:** + +The caching optimization provided minimal improvement (~5s) because QuickNotes has **no external dependencies** (`go.mod` has no `require` block). The majority of the time (~60-70s) is spent on runner provisioning, checkout, and Go toolchain download — none of which `setup-go` cache affects. + +For a real project with many dependencies, the improvement would be much more significant. The matrix addition increased total wall-clock time because more jobs are running, but they run **in parallel**, so the total time is roughly the same as the longest single job. + +**Per-step comparison:** + +| Step | Without cache | With cache | +| -------- | ------------- | ---------- | +| Setup Go | ~30s | ~25s | +| go test | ~15s | ~12s | +| Total | ~80s | ~75s | + +### f) Why cache go.sum-keyed inputs and not build outputs? + +Caching `go.sum`-keyed inputs ensures that the cache is **deterministic**. `go.sum` contains cryptographic hashes of all dependencies, so any change in dependencies changes the cache key. Build outputs (binaries, compiled packages) can vary based on the environment (OS, architecture, Go version, compiler flags). By keying the cache on inputs rather than outputs, we guarantee that the cache is valid and reproducible, avoiding subtle bugs from stale build artifacts. + +--- + +### g) What does `fail-fast: false` change in a matrix run, and when do you actually want `fail-fast: true`? + +`fail-fast: false` **disables** the default behavior where a matrix run stops all other jobs when one fails. With `fail-fast: false`, all jobs continue to run even if one fails. + +**When you want `fail-fast: true` (default):** + +- In a critical production pipeline where speed matters more than complete feedback +- When you have many matrix jobs and want to save resources + +**When you want `fail-fast: false`:** + +- During debugging and testing +- When you need to see **all** failures across all combinations +- When you need to know which Go version/OS combo is failing + +For our lab, `fail-fast: false` helps us see if the test passes on Go 1.23 but fails on Go 1.24, providing complete feedback. + +--- + +### h) What's the risk of an attacker writing a cache from a malicious PR that protected branches later read? + +**Risk:** An attacker could submit a malicious PR that writes poisoned cache entries. If the cache is later used in a protected branch (like `main`), the attacker could inject malicious code or alter build outputs without directly modifying the source code. + +**GitHub mitigations:** + +1. **Cache isolation by branch/ref:** GitHub caches are scoped to the branch/ref where they were created. A cache created in a PR branch is **not available** to other branches like `main`. +2. **Cache restore keys:** Actions can restrict which cache entries can be restored, preventing cross-branch cache poisoning. +3. **Write permissions:** GitHub Actions requires write permissions to create cache entries, which is controlled by the `permissions:` setting. +4. **GitHub's official docs:** "Caches are scoped to a branch and are isolated between branches. This means that a cache created in a pull request will not be available in the main branch." + +## Bonus Task — Pipeline Performance Investigation + +### B.4: Bottleneck Analysis + +The single step that dominates the remaining time is **runner provisioning and Go toolchain download** (~20-25s). This is the time taken by GitHub to allocate a runner and download the Go binary from the internet — infrastructure that we cannot control. + +To make it shorter, we would need to change QuickNotes itself by **adding external dependencies**. With zero dependencies, `go mod download` does nothing, so the cache optimization has minimal effect. If QuickNotes used a large framework like `gin`, `gorilla/mux`, or `gRPC`, the module cache would save 15-30s per run. + +My team would stop optimizing at **≤ 60s wall-clock**. Beyond this point, the effort-to-benefit ratio becomes unfavorable. The remaining time is dominated by infrastructure that we cannot control (runner provisioning, Go download), and further optimizations would require architectural changes to the application itself or moving to self-hosted runners — which are outside the scope of this lab and would add significant maintenance overhead. diff --git a/submissions/uD3wPHcsQy.png b/submissions/uD3wPHcsQy.png new file mode 100644 index 000000000..984de1229 Binary files /dev/null and b/submissions/uD3wPHcsQy.png differ