diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..d3bb44554 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI + +on: + push: + branches: [ main ] + paths: + - 'app/**' + - '.github/workflows/ci.yml' + pull_request: + branches: [ main ] + paths: + - 'app/**' + - '.github/workflows/ci.yml' + +permissions: + contents: read + +jobs: + vet: + name: vet (${{ matrix.go-version }}) + runs-on: ubuntu-24.04 + strategy: + matrix: + go-version: [ '1.23', '1.24' ] + fail-fast: false + steps: + - name: Checkout code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2 + + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.3.0 + with: + go-version: ${{ matrix.go-version }} + cache: true + cache-dependency-path: app/go.sum + + - name: go vet + working-directory: app + 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 + steps: + - name: Checkout code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2 + + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.3.0 + with: + go-version: ${{ matrix.go-version }} + cache: true + cache-dependency-path: app/go.sum + + - name: go test -race + working-directory: app + run: go test -race -count=1 ./... + + lint: + name: lint + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2 + + - name: Set up Go + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.3.0 + with: + go-version: '1.24' + cache: true + cache-dependency-path: app/go.sum + + - name: Install golangci-lint v2.5.0 + working-directory: app + run: | + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0 + + - name: Run golangci-lint + working-directory: app + run: golangci-lint run + + ci-ok: + name: ci-ok + if: always() + needs: [ vet, test, lint ] + runs-on: ubuntu-24.04 + steps: + - name: Check results + run: | + if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then + echo "Some jobs failed or were cancelled" + exit 1 + fi + echo "All jobs passed" \ No newline at end of file diff --git a/.github/workflows/ci.yml.backup b/.github/workflows/ci.yml.backup new file mode 100644 index 000000000..d3bb44554 --- /dev/null +++ b/.github/workflows/ci.yml.backup @@ -0,0 +1,98 @@ +name: CI + +on: + push: + branches: [ main ] + paths: + - 'app/**' + - '.github/workflows/ci.yml' + pull_request: + branches: [ main ] + paths: + - 'app/**' + - '.github/workflows/ci.yml' + +permissions: + contents: read + +jobs: + vet: + name: vet (${{ matrix.go-version }}) + runs-on: ubuntu-24.04 + strategy: + matrix: + go-version: [ '1.23', '1.24' ] + fail-fast: false + steps: + - name: Checkout code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2 + + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.3.0 + with: + go-version: ${{ matrix.go-version }} + cache: true + cache-dependency-path: app/go.sum + + - name: go vet + working-directory: app + 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 + steps: + - name: Checkout code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2 + + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.3.0 + with: + go-version: ${{ matrix.go-version }} + cache: true + cache-dependency-path: app/go.sum + + - name: go test -race + working-directory: app + run: go test -race -count=1 ./... + + lint: + name: lint + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2 + + - name: Set up Go + uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.3.0 + with: + go-version: '1.24' + cache: true + cache-dependency-path: app/go.sum + + - name: Install golangci-lint v2.5.0 + working-directory: app + run: | + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0 + + - name: Run golangci-lint + working-directory: app + run: golangci-lint run + + ci-ok: + name: ci-ok + if: always() + needs: [ vet, test, lint ] + runs-on: ubuntu-24.04 + steps: + - name: Check results + run: | + if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then + echo "Some jobs failed or were cancelled" + exit 1 + fi + echo "All jobs passed" \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..a5991f7c7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release to ghcr.io + +on: + push: + tags: + - 'v*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }}/quicknotes + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Log in to ghcr.io + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + + - name: Build and push + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + with: + context: ./app + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 000000000..9b33c0f3a --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,25 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +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 git + wget -q 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=$PATH:/usr/local/go/bin' >> /home/vagrant/.bashrc + echo 'export PATH=$PATH:/usr/local/go/bin' >> /root/.bashrc + export PATH=$PATH:/usr/local/go/bin + go version + SHELL +end \ No newline at end of file diff --git a/Vagrantfile.txt b/Vagrantfile.txt new file mode 100644 index 000000000..0c40ac5be --- /dev/null +++ b/Vagrantfile.txt @@ -0,0 +1,35 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +Vagrant.configure("2") do |config| + # 1. Базовый образ Ubuntu 22.04 (работает быстрее, чем 24.04) + config.vm.box = "ubuntu/jammy64" + + # 2. Хостнейм + config.vm.hostname = "quicknotes-vm" + + # 3. Проброс порта 18080 хост → 8080 гость + config.vm.network "forwarded_port", guest: 8080, host: 18080, host_ip: "127.0.0.1" + + # 4. Синхронизация папки ./app → /home/vagrant/app + config.vm.synced_folder "./app", "/home/vagrant/app" + + # 5. Ресурсы: 2 vCPU, 1024 MB RAM + config.vm.provider "virtualbox" do |vb| + vb.memory = "1024" + vb.cpus = 2 + end + + # 6. Provisioning: установка Go 1.24.5 + config.vm.provision "shell", inline: <<-SHELL + apt-get update + apt-get install -y wget git + wget -q 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=$PATH:/usr/local/go/bin' >> /home/vagrant/.bashrc + echo 'export PATH=$PATH:/usr/local/go/bin' >> /root/.bashrc + # для текущей сессии + export PATH=$PATH:/usr/local/go/bin + go version + SHELL +end \ No newline at end of file diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 000000000..291fdf123 --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,4 @@ +[ssh_connection] +control_path = /dev/null +control_master = false +scp_if_ssh = True diff --git a/ansible/files/quicknotes b/ansible/files/quicknotes new file mode 100644 index 000000000..8cb022abd 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..45bc127c6 --- /dev/null +++ b/ansible/inventory.ini @@ -0,0 +1,2 @@ +[quicknotes-vm] +quicknotes-vm ansible_host=127.0.0.1 ansible_port=2222 ansible_user=vagrant ansible_private_key_file=/c/Users/kudin/Desktop/DevOpsCourse/Lab1/DevOps-Intro/DevOps-Intro-new/.vagrant/machines/default/virtualbox/private_key ansible_ssh_extra_args="-o StrictHostKeyChecking=no -o ControlMaster=no -o ControlPersist=no" diff --git a/ansible/playbook.yaml b/ansible/playbook.yaml new file mode 100644 index 000000000..155e397f9 --- /dev/null +++ b/ansible/playbook.yaml @@ -0,0 +1,63 @@ +--- +- name: Deploy QuickNotes to Lab 5 VM + hosts: quicknotes-vm + become: true + gather_facts: false + + vars: + listen_addr: ":9090" + data_path: "/var/lib/quicknotes/data.json" + seed_path: "/var/lib/quicknotes/seed.json" + binary_path: "/usr/local/bin/quicknotes" + data_dir: "/var/lib/quicknotes" + user: "quicknotes" + group: "quicknotes" + + tasks: + - name: Create system user quicknotes + ansible.builtin.user: + name: "{{ user }}" + system: true + shell: /usr/sbin/nologin + create_home: false + state: present + + - name: Ensure data directory exists + ansible.builtin.file: + path: "{{ data_dir }}" + state: directory + owner: "{{ user }}" + group: "{{ group }}" + mode: '0750' + + - name: Copy QuickNotes binary + ansible.builtin.copy: + src: files/quicknotes + dest: "{{ binary_path }}" + mode: '0755' + owner: root + group: root + notify: restart quicknotes + + - name: Render systemd unit file + 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..5ecc17aaa --- /dev/null +++ b/ansible/templates/quicknotes.service.j2 @@ -0,0 +1,18 @@ +[Unit] +Description=QuickNotes Service +After=network-online.target +Wants=network-online.target + +[Service] +User=quicknotes +Group=quicknotes +WorkingDirectory=/var/lib/quicknotes +ExecStart=/usr/local/bin/quicknotes +Restart=on-failure +RestartSec=5 +Environment=ADDR={{ listen_addr }} +Environment=DATA_PATH={{ data_path }} +Environment=SEED_PATH={{ seed_path }} + +[Install] +WantedBy=multi-user.target diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..eacad3b81 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,36 @@ +# ===== СТЕЙДЖ 1: СБОРКА ===== +FROM golang:1.24-alpine AS builder + +WORKDIR /build + +# Кешируем зависимости +COPY go.mod go.sum ./ +RUN go mod download + +# Копируем исходники +COPY . . + +# Собираем статический бинарник +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags='-s -w' \ + -trimpath \ + -o quicknotes . + +# ===== СТЕЙДЖ 2: РАНТАЙМ ===== +FROM gcr.io/distroless/static:nonroot + +WORKDIR /app + +COPY --from=builder /build/quicknotes . + +COPY --from=busybox:stable-musl /bin/busybox /bin/busybox + +# Создаём каталог /data и даём права пользователю 65532 +USER root +RUN ["/bin/busybox", "mkdir", "-p", "/data"] +RUN ["/bin/busybox", "chown", "65532:65532", "/data"] +USER 65532 + +EXPOSE 8080 + +ENTRYPOINT ["/app/quicknotes"] \ No newline at end of file diff --git a/app/go.sum b/app/go.sum new file mode 100644 index 000000000..e69de29bb diff --git a/app/main.go b/app/main.go index e258ffcfe..a04120025 100644 --- a/app/main.go +++ b/app/main.go @@ -28,7 +28,7 @@ func main() { server := NewServer(store) srv := &http.Server{ Addr: addr, - Handler: server.Routes(), + Handler: SecurityHeaders(server.Routes()), ReadHeaderTimeout: 5 * time.Second, } @@ -50,7 +50,16 @@ func main() { log.Printf("shutdown: %v", err) } } - +// SecurityHeaders middleware добавляет защитные заголовки ко всем ответам. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'none'") + w.Header().Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) +} func envOrDefault(k, def string) string { if v, ok := os.LookupEnv(k); ok && v != "" { return v diff --git a/app/security_test.go b/app/security_test.go new file mode 100644 index 000000000..45658cc91 --- /dev/null +++ b/app/security_test.go @@ -0,0 +1,29 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestSecurityHeaders(t *testing.T) { + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + handler.ServeHTTP(w, req) + + if w.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Error("X-Content-Type-Options header missing") + } + if w.Header().Get("X-Frame-Options") != "DENY" { + t.Error("X-Frame-Options header missing") + } + if w.Header().Get("Content-Security-Policy") != "default-src 'none'" { + t.Error("Content-Security-Policy header missing") + } + if w.Header().Get("Referrer-Policy") != "no-referrer" { + t.Error("Referrer-Policy header missing") + } +} \ No newline at end of file diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..8600f8a27 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,57 @@ +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + volumes: + - quicknotes-data:/data + environment: + - ADDR=:8080 + - DATA_PATH=/data/notes.json + - SEED_PATH=/data/seed.json + healthcheck: + test: ["CMD", "/bin/busybox", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + restart: unless-stopped + user: "65532:65532" + read_only: true + tmpfs: + - /tmp + - /run + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + + prometheus: + image: prom/prometheus:v3.3.0 + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + depends_on: + quicknotes: + condition: service_healthy + + grafana: + image: grafana/grafana:11.1.0 + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=securepassword123 + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning + - ./monitoring/grafana/provisioning/dashboards:/var/lib/grafana/dashboards + depends_on: + - prometheus + +volumes: + quicknotes-data: diff --git a/monitoring/grafana/provisioning/dashboards/dashboard.yml b/monitoring/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 000000000..eb5ae22ba --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + options: + path: /var/lib/grafana/dashboards diff --git a/monitoring/grafana/provisioning/dashboards/golden-signals.json b/monitoring/grafana/provisioning/dashboards/golden-signals.json new file mode 100644 index 000000000..c52bc1e23 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/golden-signals.json @@ -0,0 +1,45 @@ +{ + "title": "Golden Signals - QuickNotes", + "panels": [ + { + "title": "Latency (p95)", + "type": "graph", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(quicknotes_http_request_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p95" + } + ] + }, + { + "title": "Traffic (req/s)", + "type": "graph", + "targets": [ + { + "expr": "rate(quicknotes_http_requests_total[5m])", + "legendFormat": "requests" + } + ] + }, + { + "title": "Error Ratio", + "type": "graph", + "targets": [ + { + "expr": "sum(rate(quicknotes_http_requests_total{status=~\"4..|5..\"}[5m])) / sum(rate(quicknotes_http_requests_total[5m]))", + "legendFormat": "error_ratio" + } + ] + }, + { + "title": "Saturation (Total Notes)", + "type": "graph", + "targets": [ + { + "expr": "quicknotes_notes_total", + "legendFormat": "notes_count" + } + ] + } + ] +} diff --git a/monitoring/grafana/provisioning/datasources/datasource.yml b/monitoring/grafana/provisioning/datasources/datasource.yml new file mode 100644 index 000000000..86fd3465e --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasource.yml @@ -0,0 +1,8 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true diff --git a/monitoring/prometheus/alerts.yml b/monitoring/prometheus/alerts.yml new file mode 100644 index 000000000..c98c2fb8a --- /dev/null +++ b/monitoring/prometheus/alerts.yml @@ -0,0 +1,11 @@ +groups: + - name: quicknotes_alerts + rules: + - alert: HighErrorRate + expr: sum(rate(quicknotes_http_requests_total{status=~"4..|5.."}[5m])) / sum(rate(quicknotes_http_requests_total[5m])) > 0.05 + for: 2m + labels: + severity: page + annotations: + summary: "High error rate detected for QuickNotes" + runbook_url: "https://github.com/RusKudinov/DevOps-Intro/blob/main/docs/runbook/high-error-rate.md" \ No newline at end of file diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 000000000..7289a4c80 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 15s + +rule_files: + - "alerts.yml" + +scrape_configs: + - job_name: quicknotes + static_configs: + - targets: ['quicknotes:8080'] \ No newline at end of file diff --git a/sbom.json b/sbom.json new file mode 100644 index 000000000..ed9e0b924 Binary files /dev/null and b/sbom.json differ diff --git a/submissions/BuildSuccess.png b/submissions/BuildSuccess.png new file mode 100644 index 000000000..b0d8d9537 Binary files /dev/null and b/submissions/BuildSuccess.png differ diff --git a/submissions/New_headers.png b/submissions/New_headers.png new file mode 100644 index 000000000..05229504a Binary files /dev/null and b/submissions/New_headers.png differ diff --git a/submissions/PASS.png b/submissions/PASS.png new file mode 100644 index 000000000..f51de156e Binary files /dev/null and b/submissions/PASS.png differ diff --git a/submissions/UP.png b/submissions/UP.png new file mode 100644 index 000000000..8ceeefe0b Binary files /dev/null and b/submissions/UP.png differ diff --git a/submissions/ZAP.png b/submissions/ZAP.png new file mode 100644 index 000000000..de735e5bc Binary files /dev/null and b/submissions/ZAP.png differ diff --git a/submissions/branch.png b/submissions/branch.png new file mode 100644 index 000000000..4820f11fa Binary files /dev/null and b/submissions/branch.png differ diff --git a/submissions/change6-1.png b/submissions/change6-1.png new file mode 100644 index 000000000..49fd4785c Binary files /dev/null and b/submissions/change6-1.png differ diff --git a/submissions/change6.png b/submissions/change6.png new file mode 100644 index 000000000..49fd4785c Binary files /dev/null and b/submissions/change6.png differ diff --git a/submissions/change_diff-1.png b/submissions/change_diff-1.png new file mode 100644 index 000000000..94ed55b97 Binary files /dev/null and b/submissions/change_diff-1.png differ diff --git a/submissions/change_diff.png b/submissions/change_diff.png new file mode 100644 index 000000000..94ed55b97 Binary files /dev/null and b/submissions/change_diff.png differ diff --git a/submissions/changed0-1.png b/submissions/changed0-1.png new file mode 100644 index 000000000..2c4dd0f6a Binary files /dev/null and b/submissions/changed0-1.png differ diff --git a/submissions/changed0.png b/submissions/changed0.png new file mode 100644 index 000000000..2c4dd0f6a Binary files /dev/null and b/submissions/changed0.png differ diff --git a/submissions/changed2-1.png b/submissions/changed2-1.png new file mode 100644 index 000000000..fc4c26f7b Binary files /dev/null and b/submissions/changed2-1.png differ diff --git a/submissions/changed2.png b/submissions/changed2.png new file mode 100644 index 000000000..fc4c26f7b Binary files /dev/null and b/submissions/changed2.png differ diff --git a/submissions/curl_exe_ok.png b/submissions/curl_exe_ok.png new file mode 100644 index 000000000..276f95f6b Binary files /dev/null and b/submissions/curl_exe_ok.png differ diff --git a/submissions/curlexe.png b/submissions/curlexe.png new file mode 100644 index 000000000..f3019c25e Binary files /dev/null and b/submissions/curlexe.png differ diff --git a/submissions/fail.png b/submissions/fail.png new file mode 100644 index 000000000..4367dad25 Binary files /dev/null and b/submissions/fail.png differ diff --git a/submissions/go_version.png b/submissions/go_version.png new file mode 100644 index 000000000..3399f0874 Binary files /dev/null and b/submissions/go_version.png differ diff --git a/submissions/graphs.png b/submissions/graphs.png new file mode 100644 index 000000000..af0c1d856 Binary files /dev/null and b/submissions/graphs.png differ diff --git a/submissions/image1.png b/submissions/image1.png new file mode 100644 index 000000000..ebab70ba4 Binary files /dev/null and b/submissions/image1.png differ diff --git a/submissions/image10.png b/submissions/image10.png new file mode 100644 index 000000000..de685b086 Binary files /dev/null and b/submissions/image10.png differ diff --git a/submissions/image11.png b/submissions/image11.png new file mode 100644 index 000000000..d0c4a812b Binary files /dev/null and b/submissions/image11.png differ diff --git a/submissions/image12.png b/submissions/image12.png new file mode 100644 index 000000000..eea3dd9c9 Binary files /dev/null and b/submissions/image12.png differ diff --git a/submissions/image2.png b/submissions/image2.png new file mode 100644 index 000000000..44b400579 Binary files /dev/null and b/submissions/image2.png differ diff --git a/submissions/image3.png b/submissions/image3.png new file mode 100644 index 000000000..b674b8aa7 Binary files /dev/null and b/submissions/image3.png differ diff --git a/submissions/image4.png b/submissions/image4.png new file mode 100644 index 000000000..f06e763f5 Binary files /dev/null and b/submissions/image4.png differ diff --git a/submissions/image5.png b/submissions/image5.png new file mode 100644 index 000000000..4a471b21d Binary files /dev/null and b/submissions/image5.png differ diff --git a/submissions/image6.png b/submissions/image6.png new file mode 100644 index 000000000..f64d1fe6f Binary files /dev/null and b/submissions/image6.png differ diff --git a/submissions/image7.png b/submissions/image7.png new file mode 100644 index 000000000..7f5558454 Binary files /dev/null and b/submissions/image7.png differ diff --git a/submissions/image8.png b/submissions/image8.png new file mode 100644 index 000000000..49ca7da81 Binary files /dev/null and b/submissions/image8.png differ diff --git a/submissions/image9.png b/submissions/image9.png new file mode 100644 index 000000000..ea4fa4f0c Binary files /dev/null and b/submissions/image9.png differ diff --git a/submissions/image_.png b/submissions/image_.png new file mode 100644 index 000000000..73e08acf8 Binary files /dev/null and b/submissions/image_.png differ diff --git a/submissions/inactive.png b/submissions/inactive.png new file mode 100644 index 000000000..d7a562bb3 Binary files /dev/null and b/submissions/inactive.png differ diff --git a/submissions/lab10.md b/submissions/lab10.md new file mode 100644 index 000000000..95dccb956 --- /dev/null +++ b/submissions/lab10.md @@ -0,0 +1,213 @@ + +# Lab 10 — Cloud Computing: Ship QuickNotes to a Real Cloud + +## Выполнил: Кудинов Руслан +## Дата: 08.07.2026 + +--- + +## 1. Task 1 — CI-Automated Push to `ghcr.io` + +### 1.1 Workflow файл + +Файл `.github/workflows/release.yml`: + +```yaml +name: Release to ghcr.io + +on: + push: + tags: + - 'v*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ruskudinov/devops-intro/quicknotes + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Log in to ghcr.io + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + + - name: Build and push + uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + with: + context: ./app + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max +``` + +### 1.2 Registry URL + +Образ доступен по адресу: +`ghcr.io/ruskudinov/devops-intro/quicknotes:v0.1.0` + +Ссылка на страницу пакета в GitHub: +`https://github.com/RusKudinov/DevOps-Intro/packages` +### 1.3 Проверка публичного pull + +```bash +docker pull ghcr.io/ruskudinov/devops-intro/quicknotes:v0.1.0 +``` + +Вывод: +``` +v0.1.0: Pulling from ruskudinov/devops-intro/quicknotes +... +Status: Downloaded newer image for ghcr.io/ruskudinov/devops-intro/quicknotes:v0.1.0 +``` + +### 1.4 CI-билд + +Ссылка на успешный workflow: +`https://github.com/RusKudinov/DevOps-Intro/actions/runs/28970496513` +![alt text](BuildSuccess.png) +--- + +## 2. Task 2 — Hugging Face Spaces (замена на ngrok) + +**Примечание:** Hugging Face Spaces изменил политику — Docker Spaces теперь требуют платной подписки. Вместо этого использован `ngrok`, который также предоставляет публичный URL без необходимости вводить платёжные данные. + +### 2.1 Публичный URL + +QuickNotes доступен по адресу: +`https://washboard-feeble-swan.ngrok-free.dev` + +![alt text](tunnel.png) + +### 2.2 Проверка доступности + +```bash +curl.exe "https://washboard-feeble-swan.ngrok-free.dev/health?ngrok-skip-browser-warning=true" +``` + +Вывод: +```json +{"notes":0,"status":"ok"} +``` +![alt text](curl_exe_ok.png) +### 2.3 Измерение latency + +**Warm latency (10 запросов):** + +```bash +for ($i=1; $i -le 10; $i++) { curl.exe -o nul -s -w "%{time_total}\n" "https://washboard-feeble-swan.ngrok-free.dev/health?ngrok-skip-browser-warning=true" } +``` + +Результаты (сек): +``` +0.422072 +0.297846 +0.298205 +0.298964 +0.314358 +0.324368 +0.298974 +0.296548 +0.297904 +0.298461 +``` + +- **p50:** 0.299 сек (299 мс) +- **p95:** 0.324 сек (324 мс) + +**Cold start (3 измерения):** + +```bash +docker compose down +docker compose up -d; curl.exe -o nul -s -w "%{time_total}\n" "https://washboard-feeble-swan.ngrok-free.dev/health?ngrok-skip-browser-warning=true" +``` + +Результаты (сек): +- 22.198 +- 0.993 +- 0.476 + +### 2.4 Таблица сравнения + +| Метрика | ngrok Tunnel | +|---------|--------------| +| Warm p50 | 299 мс | +| Warm p95 | 324 мс | +| Cold start 1 | 22.198 с | +| Cold start 2 | 0.993 с | +| Cold start 3 | 0.476 с | +| URL стабильность | эфемерный (меняется при перезапуске) | +| Стоимость | бесплатно | + +--- + +## 3. Ответы на вопросы + +### a) OIDC vs GITHUB_TOKEN + +`GITHUB_TOKEN` с `packages: write` достаточно для пуша в ghcr.io из того же репозитория. OIDC нужен, когда нужно получить временные токены для доступа к другим облакам (AWS, GCP) без хранения секретов. OIDC даёт более безопасный подход без долгоживущих ключей. + +### b) Зачем `:latest` рядом с версией? + +`:latest` используется для указания самой свежей релизной версии. Это удобно для быстрых деплоев и CI/CD, которые ожидают тег `:latest`. Однако для воспроизводимости и откатов критично иметь immutable-теги (например, `v0.1.0`), чтобы знать точную версию в продакшене. + +### c) Почему только `packages: write`? + +Принцип минимальных привилегий — давать только те разрешения, которые необходимы. `packages: write` даёт возможность пушить в Container Registry, но не позволяет изменять код, секреты или настройки репозитория. Это снижает риск при компрометации токена. + +### d) HF Spaces "sleep" vs Cloud Run scale-to-zero + +HF Spaces «засыпает» полностью — контейнер останавливается, и его нужно заново запустить, что занимает 5–20 секунд (зависит от размера образа). Cloud Run сохраняет контейнер в памяти, что даёт холодный старт <1 секунды. HF оптимизирован для демонстраций, а не для продакшена. + +### e) Зачем `app_port: 8080` + +По умолчанию HF ожидает, что приложение слушает порт 7860. QuickNotes слушает 8080, поэтому нужно явно указать `app_port: 8080`, чтобы HF перенаправлял внешний трафик на правильный порт внутри контейнера. + +### f) Pull vs build внутри Space + +Pull из ghcr.io экономит время сборки и обеспечивает единый образ. Build внутри Space замедляет деплой и усложняет воспроизводимость. Использование готового образа из реестра — правильный подход. + +### g) Архитектура HF Spaces vs Cloudflare Tunnel + +HF Spaces — настоящий облачный хостинг (контейнер работает в дата-центре HF), а Cloudflare Tunnel — это просто туннель к локальному серверу. Для пользователя разница минимальна, но для SLA и надёжности предпочтительнее облачный хостинг. + +### h) Доминанты задержки + +- **HF Spaces:** задержка определяется сетью между пользователем и дата-центром HF. +- **Cloudflare Tunnel:** задержка складывается из времени до Cloudflare edge и обратно до локального сервера. + +### i) Когда использовать Cloudflare Tunnel + +Cloudflare Tunnel хорош для демонстраций, тестирования, доступа к локальным сервисам извне. Для продакшена не подходит из-за нестабильного URL и зависимости от локальной машины. + +--- + +## 4. Заключение + +Все задачи Lab 10 выполнены: +- CI workflow для ghcr.io реализован и протестирован. +- Публичный образ доступен из реестра. +- Публичный URL получен через ngrok (альтернатива HF Spaces). +- Измерены warm и cold latency, данные задокументированы. + + + + diff --git a/submissions/lab3.md b/submissions/lab3.md new file mode 100644 index 000000000..bb2e355db --- /dev/null +++ b/submissions/lab3.md @@ -0,0 +1,63 @@ +# Lab 3 — CI/CD: A PR-Gated Pipeline for QuickNotes + +**Студент:** Руслан Кудинов +**Путь:** GitHub Actions +**Дата:** 17.06.2026 + +## Выбранный путь +Я выбрал GitHub Actions, так как это стандартный инструмент для курса. Из-за проблем с биллингом на основном аккаунте пришлось создать новый (RusKudinov). + +## Ссылка на зелёный CI run +https://github.com/RusKudinov/DevOps-Intro/actions/runs/27708444304 + +## Доказательство работы гейта +- **Сломанный тест:** ![альтернативный текст](fail.png) +- **Исправление:** ![альтернативный текст](PASS.png) +- **Коммит с поломкой:** `3b6b086 ` (можно указать) +- **Коммит с исправлением:** `014941a` + +## Скриншот branch protection +![альтернативный текст](branch.png) +--- + +## Ответы на дизайн-вопросы (Task 1.2) + +### a) Почему пиннить `ubuntu-24.04`, а не `ubuntu-latest`? +`ubuntu-latest` — плавающий тег, который может измениться (например, перейти на 26.04). Это приведёт к непредсказуемым изменениям окружения (версия ядра, библиотеки). Пиннинг фиксирует конкретную версию, делая сборку воспроизводимой. + +### b) Почему разделить vet, test, lint на отдельные джобы? +Если объединить их в одну, при падении lint мы не узнаем, прошли ли тесты. Разделение даёт параллельность, независимый статус каждой проверки и возможность использовать матрицу только для vet и test. + +### c) Какую атаку предотвращает SHA‑пиннинг? (GH path) +В марте 2025 года был скомпрометирован экшен `tj-actions/changed-files`. Злоумышленник внёс вредоносный код в тег `v4`. Все, кто использовал `@v4`, автоматически подхватили его. Пиннинг по SHA фиксирует конкретный коммит, который мы проверили, и защищает от таких supply‑chain‑атак. + +### d) Что такое `permissions:` и какой принцип? +`permissions:` определяет уровень доступа токена GITHUB_TOKEN в workflow. Мы устанавливаем `contents: read` — только чтение кода. Это принцип наименьших привилегий: даём минимум прав, необходимых для работы. Снижает ущерб при компрометации. + +--- + +## Ответы на вопросы Task 2 + +### f) Почему кэшировать по `go.sum`, а не по build‑output? +`go.sum` содержит хеши зависимостей — это надёжный идентификатор набора модулей. Build‑output зависит от архитектуры, версии Go, флагов сборки и может меняться без изменения кода. Кэширование по `go.sum` гарантирует, что при одинаковых входных данных кэш подходит. + +### g) Что делает `fail-fast: false` и когда нужен `true`? +`fail-fast: false` в матрице позволяет продолжать выполнение остальных комбинаций, даже если одна упала. Так мы видим все ошибки (например, тесты падают только на Go 1.24). `fail-fast: true` (по умолчанию) останавливает всё при первом падении — ускоряет фидбек, если ошибка, скорее всего, общая. + +### h) Какой риск кэша, созданного вредоносным PR? +Злоумышленник может попытаться записать кэш с вредоносными зависимостями. Однако GitHub не позволяет PR из форка читать кэш основной ветки и не даёт записывать кэш, который будет использован в `main`. Кэш привязан к ветке и SHA. Это задокументировано в официальной документации GitHub. + +--- + +## Таблица времени (Task 2.4) +![alt text](test1.png) +![alt text](test2.png) +![alt text](test3.png) +| Сценарий | Wall‑clock (сек) | +|----------|------------------| +| Без кэша, одна версия Go, без path‑фильтра | 81 | +| С кэшем (один Go) | 70 | +| С кэшем + матрица (две версии) | 80 | + +**Замечание:** В QuickNotes нет внешних зависимостей, поэтому кэш почти не даёт выигрыша. Основное время уходит на установку Go и старт раннера. В реальном проекте с сотнями модулей выгода была бы значительной. + diff --git a/submissions/lab5.md b/submissions/lab5.md new file mode 100644 index 000000000..44115ab31 --- /dev/null +++ b/submissions/lab5.md @@ -0,0 +1,186 @@ +# Lab 5 — Virtualization: QuickNotes in a Vagrant VM + +## Выполнил: Ruslan Kudinov +## Дата: 24.06.2026 + +--- + +## 1. Vagrantfile (файл в корне репозитория) + +```ruby +# -*- mode: ruby -*- +# vi: set ft=ruby : + +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 git + wget -q 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=$PATH:/usr/local/go/bin' >> /home/vagrant/.bashrc + echo 'export PATH=$PATH:/usr/local/go/bin' >> /root/.bashrc + export PATH=$PATH:/usr/local/go/bin + go version + SHELL +end +``` + +--- + +## 2. Выводы команд + +### 2.1 Первые строки `vagrant up` + + +![alt text](var_up.png) + +``` +==> default: Box 'ubuntu/jammy64' could not be found. Attempting to find and install... + default: Box Provider: virtualbox + default: Box Version: >= 0 +==> default: Loading metadata for box 'ubuntu/jammy64' + default: URL: https://vagrantcloud.com/api/v2/vagrant/ubuntu/jammy64 +==> default: Adding box 'ubuntu/jammy64' (v20241002.0.0) for provider: virtualbox + default: Downloading: https://vagrantcloud.com/ubuntu/boxes/jammy64/versions/20241002.0.0/providers/virtualbox/unknown/vagrant.box +==> default: Successfully added box 'ubuntu/jammy64' (v20241002.0.0) for 'virtualbox'! +==> default: Importing base box 'ubuntu/jammy64'... +==> default: Matching MAC address for NAT networking... +==> default: Checking if box 'ubuntu/jammy64' version '20241002.0.0' is up to date... +==> default: Setting the name of the VM: DevOps-Intro-new_default_1782329346451_41824 +``` + +### 2.2 Проверка установки Go внутри VM (после восстановления) + + +![alt text](go_version.png) +**Команда:** +```bash +vagrant ssh -c "/usr/local/go/bin/go version" +``` +**Вывод:** +``` +go version go1.24.5 linux/amd64 +``` + +### 2.3 Запуск QuickNotes и проверка с хоста + +![alt text](nohup.png) + + +![alt text](curlexe.png) + +**Запуск внутри VM:** +```bash +vagrant ssh -c "cd /home/vagrant/app && nohup ./quicknotes > /tmp/qn.log 2>&1 &" +``` + +**Проверка с хоста:** +```bash +curl.exe http://localhost:18080/health +``` +**Вывод:** +```json +{"notes":4,"status":"ok"} +``` + +--- + +## 3. Task 2 — Snapshots + +### 3.1 Команды и выводы + + + +![![alt text](image.png)](snap1.png) + +![alt text](snap2.png) + +![alt text](snap3.png) + +```powershell +# Сохраняем снэпшот +vagrant snapshot save working +# Вывод: Snapshot saved! + +# Ломаем VM (удаляем Go) +vagrant ssh -c "sudo rm -rf /usr/local/go" + +# Проверяем, что сломали +vagrant ssh -c "go version" +# Вывод: bash: line 1: go: command not found + +# Восстанавливаем и замеряем время +Measure-Command { vagrant snapshot restore working } +# Вывод: TotalSeconds : 26.7161966 + +# Проверяем восстановление (через полный путь) +vagrant ssh -c "/usr/local/go/bin/go version" +# Вывод: go version go1.24.5 linux/amd64 +``` + +### 3.2 Результаты + +- **Команда для поломки:** `vagrant ssh -c "sudo rm -rf /usr/local/go"` +- **Результат проверки поломки:** `bash: line 1: go: command not found` +- **Время восстановления:** `26.716 секунд` (из `Measure-Command`) +- **Результат проверки после восстановления:** `go version go1.24.5 linux/amd64` + +--- + +## 4. Ответы на вопросы + +### a) Synced folders: какой тип и почему? + +Я использовал тип `virtualbox` (по умолчанию). Он прост в настройке и не требует дополнительных служб (как NFS). Недостаток — производительность может быть ниже, но для учебного проекта это некритично. + +### b) NAT vs Bridged vs Host-only: какая сеть и почему `127.0.0.1` безопаснее? + +Используется NAT (сеть по умолчанию). Проброс порта на `127.0.0.1` делает сервис доступным только с локальной машины, а не из всей сети. Это безопаснее для курсовой работы, т.к. никто извне не сможет подключиться к VM. + +### c) Какой провайдер для provisioning и почему? + +Использован `shell`-провайдер, потому что он самый простой и не требует дополнительных инструментов (Ansible, Chef и т.д.). Достаточно одной команды для установки Go. + +### d) Почему pin Go к конкретной версии (1.24.5), а не `1.24`? + +Фиксация точной версии гарантирует, что все студенты получат одинаковое окружение, что исключает ошибки из-за различий в минорных версиях. Также это упрощает воспроизводимость. + +### e) Почему снэпшоты — не бэкапы? + +Снэпшот хранит состояние виртуальной машины в момент времени, но не защищает от потери данных, если диск VM повреждён. Также снэпшот не может быть восстановлен на другом хосте. Бэкап — это копия данных, которую можно перенести и восстановить независимо от VM. + +### f) Copy-on-write: что это значит для дискового пространства при 10 снэпшотах? + +При Copy-on-Write каждый снэпшот хранит только изменения с момента предыдущего. Поэтому 10 снэпшотов могут занимать не 10× размер VM, а только сумму изменений. Однако они всё равно потребляют место, и при длинной цепочке производительность может упасть. + +### g) Когда снэпшоты — антипаттерн? + +Снэпшоты становятся антипаттерном при длинных цепочках (например, >5), так как они замедляют работу VM, занимают много места и усложняют управление. Их стоит использовать только для кратковременных экспериментов, а не для постоянного резервирования. + +--- + +## 5. Бонус — VM vs Container Resource Baseline + +**Бонус не выполнялся** из-за нехватки времени и конфликта Hyper-V с VirtualBox. + +--- + +## Заключение + +Все требования Lab 5 выполнены: +- Vagrantfile создан, VM поднимается с Go 1.24.5. +- Порт 18080 проброшен, QuickNotes доступен с хоста. +- Снэпшоты созданы, поломка и восстановление подтверждены. + +--- + diff --git a/submissions/lab6.md b/submissions/lab6.md new file mode 100644 index 000000000..67d6297bc --- /dev/null +++ b/submissions/lab6.md @@ -0,0 +1,332 @@ +# Lab 6 — Containers: Dockerize QuickNotes + +## Выполнил: Кудинов Рулсан +## Дата: 24.06.2026 + +--- + +## 1. Dockerfile (файл `app/Dockerfile`) + +```dockerfile +# ===== СТЕЙДЖ 1: СБОРКА ===== +FROM golang:1.24-alpine AS builder + +WORKDIR /build + +# Кешируем зависимости +COPY go.mod go.sum ./ +RUN go mod download + +# Копируем исходники +COPY . . + +# Собираем статический бинарник +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags='-s -w' \ + -trimpath \ + -o quicknotes . + +# ===== СТЕЙДЖ 2: РАНТАЙМ ===== +FROM gcr.io/distroless/static:nonroot + +WORKDIR /app + +COPY --from=builder /build/quicknotes . + +COPY --from=busybox:stable-musl /bin/busybox /bin/busybox + +# Создаём каталог /data с правами nonroot +USER root +RUN ["/bin/busybox", "mkdir", "-p", "/data"] +RUN ["/bin/busybox", "chown", "65532:65532", "/data"] +USER 65532 + +EXPOSE 8080 + +ENTRYPOINT ["/app/quicknotes"] +``` + +--- + +## 2. Compose-файл (`compose.yaml`) + +```yaml +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + volumes: + - quicknotes-data:/data + environment: + - ADDR=:8080 + - DATA_PATH=/data/notes.json + - SEED_PATH=/data/seed.json + healthcheck: + test: ["CMD", "/bin/busybox", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + restart: unless-stopped + + # Security hardening (6 defaults) + user: "65532:65532" + read_only: true + tmpfs: + - /tmp + - /run + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + +volumes: + quicknotes-data: +``` + +--- + +## 3. Проверка размера образа + +**Команда:** +```powershell +docker images quicknotes:lab6 +``` + +**Скриншот 1** — ![](image1.png) + +**Вывод (текстовый):** +``` +IMAGE ID DISK USAGE CONTENT SIZE EXTRA +quicknotes:lab6 9dd77b1e8c59 16.8MB 4.09MB U +``` +Размер: **16.8 МБ** — условие выполнено. + +--- + +## 4. Проверка работы приложения (эндпоинт /health) + +**Команда:** +```powershell +curl.exe http://localhost:8080/health +``` + +**Скриншот 2** ![](image2.png) + +**Вывод:** +```json +{"notes":0,"status":"ok"} +``` + +--- + +## 5. Тест сохранения данных (persistence) + +### 5.1 Создание заметки и проверка наличия + +**Команда:** +```powershell +$body = '{"title":"durable","body":"survive a restart"}' +Invoke-WebRequest -Uri http://localhost:8080/notes -Method POST -Body $body -ContentType "application/json" +``` + +**Скриншот 3** — ![скриншот с ответом 201 Created и содержимым заметки.](image3.png) + +**Команда проверки:** +```powershell +curl.exe -s http://localhost:8080/notes +``` +**Скриншот 4** — ![скриншот с выводом JSON-массива, содержащего созданную заметку.](image4.png) + +**Вывод:** +```json +[{"id":1,"title":"durable","body":"survive a restart","created_at":"2026-06-24T17:34:50.077826506Z"}] +``` + +### 5.2 Перезапуск контейнера (том сохраняется) + +```powershell +docker compose down +docker compose up -d +curl.exe -s http://localhost:8080/notes +``` + +**Скриншот 5** — ![скриншот, где после `docker compose up -d` команда `curl` снова показывает ту же заметку.](image5.png) + +**Вывод:** +```json +[{"id":1,"title":"durable","body":"survive a restart","created_at":"2026-06-24T17:34:50.077826506Z"}] +``` + +### 5.3 Удаление тома и проверка + +```powershell +docker compose down -v +docker compose up -d +curl.exe -s http://localhost:8080/notes +``` + +**Скриншот 6** — ![скриншот, где после `docker compose down -v` и повторного подъёма команда `curl` возвращает `\[\]`.](image6.png) + +**Вывод:** +```json +[] +``` + +**Вывод:** данные сохраняются после перезапуска и исчезают только при удалении тома — persistence работает. + +--- + +## 6. Проверка security-настроек (бонус) + +Все команды выполнялись из корня проекта. + +### 6.1 Пользователь nonroot + +**Команда:** +```powershell +docker inspect quicknotes:lab6 --format '{{ .Config.User }}' +``` + +**Скриншот 7** — ![вывод `65532`](image7.png) + +**Вывод:** +``` +65532 +``` + +### 6.2 Отсутствие шелла + +**Команда:** +```powershell +docker compose exec quicknotes sh +``` + +**Скриншот 8** — ![ошибка `exec: "sh": executable file not found in $PATH`.](image8.png) + +**Вывод:** +``` +OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH +``` + +### 6.3 Сброс capabilities + +**Команда:** +```powershell +docker inspect $(docker compose ps -q quicknotes) --format '{{ .HostConfig.CapDrop }}' +``` + +**Скриншот 9** — ![вывод `\[ALL\]`.](image9.png) + +**Вывод:** +``` +[ALL] +``` + +### 6.4 Read-only root + +**Команда:** +```powershell +docker compose exec quicknotes /bin/busybox touch /etc/test 2>&1 +``` + +**Скриншот 10** — ![ошибка `touch: /etc/test: Read-only file system`.](image10.png) + +**Вывод:** +``` +touch: /etc/test: Read-only file system +``` + +### 6.5 no-new-privileges + +**Команда:** +```powershell +docker inspect $(docker compose ps -q quicknotes) --format '{{ .HostConfig.SecurityOpt }}' +``` + +**Скриншот 11** — ![вывод `\[no-new-privileges:true\]`.](image11.png) + +**Вывод:** +``` +[no-new-privileges:true] +``` + +### 6.6 Trivy сканирование + +**Команда:** +```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 +``` + +**Скриншот 12** — ![полный вывод Trivy (или хотя бы итоговая таблица).](image12.png) +![alt text](image.png) +**Сокращённый вывод:** +``` +quicknotes:lab6 (debian 13.5) +============================= +Total: 0 (HIGH: 0, CRITICAL: 0) + +app/quicknotes (gobinary) +========================= +Total: 13 (HIGH: 13, CRITICAL: 0) +... +``` +На уровне ОС уязвимостей нет, в Go-библиотеке stdlib обнаружены 13 HIGH, но они исправлены в более новых версиях Go. + +--- + +## 7. Ответы на вопросы + +### a) Почему порядок слоёв важен? + +Порядок инструкций в Dockerfile влияет на использование кеша слоёв. Сначала копируются только `go.mod` и `go.sum`, затем выполняется `go mod download`. Этот слой пересобирается только при изменении зависимостей. Если сначала скопировать весь код, то любое изменение в исходниках приведёт к повторной загрузке всех зависимостей, что замедляет сборку. + +### b) Зачем `CGO_ENABLED=0`? + +Отключает использование C-кода и динамическую линковку. Бинарник становится полностью статическим и не требует внешних библиотек. В образе `distroless/static` нет динамического линковщика, поэтому без этого флага запуск невозможен (ошибка `no such file or directory`). + +### c) Что такое `gcr.io/distroless/static:nonroot`? + +Это минимальный образ от Google, содержащий только статически скомпилированный бинарник и минимальные системные файлы (например, `ca-certificates`, `timezone`). В нём нет оболочки, пакетного менеджера, утилит. Это снижает поверхность атак и количество уязвимостей. + +### d) `-ldflags='-s -w'` и `-trimpath` + +- `-s` — удаляет таблицу символов. +- `-w` — удаляет отладочную информацию (DWARF). +Эти флаги уменьшают размер бинарника. +- `-trimpath` — убирает абсолютные пути к исходникам, делая сборку воспроизводимой. +Цена — потеря отладочных символов, что затрудняет отладку в продакшене, но для финального образа это приемлемо. + +### e) Как сделать healthcheck без шелла? + +Мы скопировали статический `busybox` в образ и используем его команду `wget` для проверки `/health`. Поскольку `busybox` собран как статический бинарник, он работает без динамических библиотек и не требует оболочки. + +### f) Почему том сохраняется после `docker compose down`? + +Именованный том (`quicknotes-data`) управляется Docker отдельно от контейнера. Команда `docker compose down` без флага `-v` удаляет только контейнеры и сеть, но не тома. При следующем `up` том подключается с теми же данными. + +### g) `depends_on` без `condition: service_healthy` + +`depends_on` без условия `service_healthy` дожидается только запуска контейнера (статус `running`), но не проверяет, что сервис внутри готов принимать запросы. Это может привести к ошибкам, если зависимый сервис ещё не инициализировался. + +--- + +## 8. Бонус: какая из 6 мер даёт больше всего безопасности на строчку YAML? + +Самый большой эффект дают `cap_drop: ALL` и `read_only: true`. Они кардинально ограничивают возможности контейнера даже в случае взлома приложения, практически не увеличивая сложность конфигурации. + +--- + +## Заключение + +Все требования Lab 6 выполнены: +- Многостадийный Dockerfile собран, размер образа ≤ 25 МБ. +- Compose-файл с томом, healthcheck и security-параметрами работает. +- Persistence подтверждена. +- Все 6 security defaults применены и верифицированы. +- Trivy запущен, результаты задокументированы. + diff --git a/submissions/lab7.md b/submissions/lab7.md new file mode 100644 index 000000000..5f073c627 --- /dev/null +++ b/submissions/lab7.md @@ -0,0 +1,376 @@ +# Lab 7 — Configuration Management: Deploy QuickNotes via Ansible + +## Выполнил: [Твоё имя] +## Дата: 01.07.2026 + +--- + +## 1. Файлы Ansible + +### 1.1 `ansible/inventory.ini` + +```ini +[quicknotes-vm] +quicknotes-vm ansible_host=127.0.0.1 ansible_port=2222 ansible_user=vagrant ansible_private_key_file=/c/Users/kudin/Desktop/DevOpsCourse/Lab1/DevOps-Intro/DevOps-Intro-new/.vagrant/machines/default/virtualbox/private_key ansible_ssh_extra_args="-o StrictHostKeyChecking=no -o ControlMaster=no -o ControlPersist=no" +``` + +### 1.2 `ansible/playbook.yaml` + +```yaml +--- +- name: Deploy QuickNotes to Lab 5 VM + hosts: quicknotes-vm + become: true + gather_facts: false + + vars: + listen_addr: ":8080" + data_path: "/var/lib/quicknotes/notes.json" + seed_path: "/var/lib/quicknotes/seed.json" + binary_path: "/usr/local/bin/quicknotes" + data_dir: "/var/lib/quicknotes" + user: "quicknotes" + group: "quicknotes" + + tasks: + - name: Create system user quicknotes + ansible.builtin.user: + name: "{{ user }}" + system: true + shell: /usr/sbin/nologin + create_home: false + state: present + + - name: Ensure data directory exists + ansible.builtin.file: + path: "{{ data_dir }}" + state: directory + owner: "{{ user }}" + group: "{{ group }}" + mode: '0750' + + - name: Copy QuickNotes binary + ansible.builtin.copy: + src: files/quicknotes + dest: "{{ binary_path }}" + mode: '0755' + owner: root + group: root + notify: restart quicknotes + + - name: Render systemd unit file + 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 +``` + +### 1.3 `ansible/templates/quicknotes.service.j2` + +```jinja +[Unit] +Description=QuickNotes Service +After=network-online.target +Wants=network-online.target + +[Service] +User=quicknotes +Group=quicknotes +WorkingDirectory=/var/lib/quicknotes +ExecStart=/usr/local/bin/quicknotes +Restart=on-failure +RestartSec=5 +Environment=ADDR={{ listen_addr }} +Environment=DATA_PATH={{ data_path }} +Environment=SEED_PATH={{ seed_path }} + +[Install] +WantedBy=multi-user.target +``` + +--- + +## 2. Выводы команд + +### 2.1 Первый запуск плейбука (развёртывание) + +**Команда:** +```bash +ansible-playbook -i ansible/inventory.ini ansible/playbook.yaml +``` + +**Вывод:** +``` +PLAY [Deploy QuickNotes to Lab 5 VM] *************************************************************** + +TASK [Create system user quicknotes] *************************************************************** +changed: [quicknotes-vm] + +TASK [Ensure data directory exists] **************************************************************** +changed: [quicknotes-vm] + +TASK [Copy QuickNotes binary] ********************************************************************** +changed: [quicknotes-vm] + +TASK [Render systemd unit file] ******************************************************************** +changed: [quicknotes-vm] + +TASK [Reload systemd and start service] ************************************************************ +changed: [quicknotes-vm] + +RUNNING HANDLER [restart quicknotes] *************************************************************** +changed: [quicknotes-vm] + +PLAY RECAP ***************************************************************************************** +quicknotes-vm : ok=6 changed=6 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + +![alt text](change6-1.png) +--- + +### 2.2 Второй запуск (без изменений) — доказательство идемпотентности + +**Команда:** +```bash +ansible-playbook -i ansible/inventory.ini ansible/playbook.yaml +``` + +**Вывод:** +``` +PLAY [Deploy QuickNotes to Lab 5 VM] *************************************************************** + +TASK [Create system user quicknotes] *************************************************************** +ok: [quicknotes-vm] + +TASK [Ensure data directory exists] **************************************************************** +ok: [quicknotes-vm] + +TASK [Copy QuickNotes binary] ********************************************************************** +ok: [quicknotes-vm] + +TASK [Render systemd unit file] ******************************************************************** +ok: [quicknotes-vm] + +TASK [Reload systemd and start service] ************************************************************ +ok: [quicknotes-vm] + +PLAY RECAP ***************************************************************************************** +quicknotes-vm : ok=5 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + + +![alt text](changed0-1.png) +--- + +### 2.3 Изменение переменной (порт с 8080 на 9090) — selective change + +**Изменение в `playbook.yaml`:** +```yaml +listen_addr: ":9090" +``` + +**Команда:** +```bash +ansible-playbook -i ansible/inventory.ini ansible/playbook.yaml +``` + +**Вывод:** +``` +PLAY [Deploy QuickNotes to Lab 5 VM] *************************************************************** + +TASK [Create system user quicknotes] *************************************************************** +ok: [quicknotes-vm] + +TASK [Ensure data directory exists] **************************************************************** +ok: [quicknotes-vm] + +TASK [Copy QuickNotes binary] ********************************************************************** +ok: [quicknotes-vm] + +TASK [Render systemd unit file] ******************************************************************** +changed: [quicknotes-vm] + +TASK [Reload systemd and start service] ************************************************************ +ok: [quicknotes-vm] + +RUNNING HANDLER [restart quicknotes] *************************************************************** +changed: [quicknotes-vm] + +PLAY RECAP ***************************************************************************************** +quicknotes-vm : ok=6 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +``` + + +![alt text](changed2-1.png) +--- + +### 2.4 Проверка `--check --diff` + +**Изменение в `playbook.yaml`:** +```yaml +data_path: "/var/lib/quicknotes/data.json" +``` + +**Команда:** +```bash +ansible-playbook -i ansible/inventory.ini ansible/playbook.yaml --check --diff +``` + +**Вывод (diff):** +``` +TASK [Render systemd unit file] ******************************************************************** +--- before: /etc/systemd/system/quicknotes.service ++++ after: /home/kudin/.ansible/tmp/ansible-local-2086739ykota/tmpt5vxnobu/quicknotes.service.j2 +@@ -11,7 +11,7 @@ + Restart=on-failure + RestartSec=5 + Environment=ADDR=:9090 +-Environment=DATA_PATH=/var/lib/quicknotes/notes.json ++Environment=DATA_PATH=/var/lib/quicknotes/data.json + Environment=SEED_PATH=/var/lib/quicknotes/seed.json + + [Install] +``` + + +![alt text](change_diff-1.png) +--- + +### 2.5 Проверка работоспособности QuickNotes + +**Команда (с хоста):** +```powershell +curl.exe http://localhost:18080/health +``` + +**Вывод:** +```json +{"notes":0,"status":"ok"} +``` + + +![alt text](status_ok-1.png) +--- + +## 3. Ответы на вопросы + +### a) В чём разница между `command:` и модулями (`apt`, `file`, `copy`, `systemd`)? Какой из них идемпотентный и почему? + +`command:` и `shell:` всегда выполняют команду, независимо от состояния системы. Они не проверяют, нужно ли что-то менять — просто запускают указанную команду каждый раз. + +Модули (`file`, `copy`, `template`, `systemd`) являются идемпотентными — они проверяют текущее состояние системы и применяют изменения только если оно не соответствует желаемому. Это делает повторные запуски безопасными и предсказуемыми. + +Идемпотентность важна для автоматизации, потому что позволяет перезапускать плейбуки без риска сломать систему или выполнить лишние действия. + +--- + +### b) `notify:` и handlers: когда handler срабатывает, а когда нет? + +Handler срабатывает **в конце выполнения всех задач**, если хотя бы одна задача его «нотифицировала» и при этом **действительно изменила** состояние системы (т.е. задача завершилась со статусом `changed`). + +Handler **не срабатывает**, если: +- Задача не была изменена (`ok`). +- Задача не нотифицировала handler. +- Handler был нотифицирован, но задача завершилась с ошибкой. + +Это правильное поведение по умолчанию, потому что перезапуск сервиса нужен только когда что-то действительно изменилось (бинарник или конфиг), а не при каждом запуске плейбука. + +--- + +### c) Переменные в Ansible: какие места для их задания использованы в этой лабе? + +В этой лабе переменные заданы в трёх местах: + +1. **В плейбуке** (секция `vars:`) — это самое простое и наглядное место для задания переменных, относящихся к конкретному плейбуку. +2. **В инвентаре** (`inventory.ini`) — можно задавать переменные для конкретных хостов или групп, но в этой лабе инвентарь содержит только параметры подключения. +3. **В шаблоне** — переменные из плейбука подставляются в шаблон Jinja2 через `{{ переменная }}`. + +Такой подход обеспечивает гибкость: можно легко переопределить переменные для разных окружений без изменения самого плейбука. + +--- + +### d) Нужно ли `gather_facts: true` для этого плейбука? + +В этом плейбуке мы отключили `gather_facts: false`, потому что он не использует информацию о системе (ОС, архитектура, интерфейсы). Все задачи работают с конкретными путями и пользователями, которые мы задали явно. + +Отключение `gather_facts` ускоряет выполнение плейбука, так как Ansible не тратит время на сбор системной информации. Для простых плейбуков, работающих с известной ОС, это безопасно и эффективно. + +--- + +### e) Почему второй запуск показывает `changed=0`? + +Модули Ansible проверяют текущее состояние системы и сравнивают его с желаемым. Если всё уже соответствует описанию, задачи завершаются со статусом `ok`, а не `changed`. + +Например: +- `user` — проверяет, существует ли пользователь `quicknotes`. +- `file` — проверяет, существует ли папка `/var/lib/quicknotes` и совпадают ли права. +- `copy` — проверяет контрольную сумму файла и его права. +- `template` — сравнивает содержимое файла на VM с шаблоном. + +Если ни одно условие не требует изменений, весь плейбук завершается с `changed=0`. + +--- + +### f) Что было бы при использовании `shell` вместо `template`? + +Если бы вместо `template:` использовался `shell` для записи systemd-юнита, это сломало бы идемпотентность: + +1. **Команда выполнялась бы каждый раз** — даже если содержимое юнита не изменилось. +2. **Не было бы проверки изменений** — нельзя определить, нужно ли перезапускать сервис. +3. **Сложно отслеживать изменения** — нет diff, нет истории. +4. **Ошибки сложнее отлаживать** — shell-скрипты менее читаемы и труднее поддерживаются. + +Использование модулей — это правильный подход, потому что они абстрагируют детали и обеспечивают идемпотентность. + +--- + +### g) Что даёт `--check --diff` и какую ошибку можно поймать? + +`--check` — это сухой прогон (dry-run), который показывает, что бы изменилось, но ничего не применяет. + +`--diff` — показывает конкретные различия в файлах, которые будут изменены. + +Вместе они позволяют: +- Увидеть, какие файлы изменятся и как именно. +- Обнаружить неожиданные изменения (например, случайное изменение порта или пути). +- Проверить, что изменения соответствуют ожиданиям до их применения в продакшене. + +Это профессиональная практика, которая помогает избежать ошибок и неожиданных последствий при деплое. + +--- + +## 4. Бонус — `ansible-pull` GitOps Loop + +**Бонус не выполнялся** из-за ограничений по времени и сложностей с настройкой WSL на Windows. + +--- + +## 5. Заключение + +Все требования Lab 7 выполнены: +- Ansible плейбук разворачивает QuickNotes на VM из Lab 5. +- Сервис запущен и доступен через порт 18080. +- Идемпотентность доказана (второй запуск — `changed=0`). +- Selective change показан (изменение порта → `changed=2`, только template и handler). +- `--check --diff` показан для другого изменения. +- Ответы на все вопросы даны. + +--- + diff --git a/submissions/lab8.md b/submissions/lab8.md new file mode 100644 index 000000000..8e8d1c37f --- /dev/null +++ b/submissions/lab8.md @@ -0,0 +1,313 @@ +# Lab 8 — SRE & Monitoring: Golden Signals Dashboard + One Good Alert + +## Выполнил: [Твоё имя] +## Дата: 01.07.2026 + +--- + +## 1. Конфигурационные файлы + +### 1.1 `monitoring/prometheus/prometheus.yml` + +```yaml +global: + scrape_interval: 15s + +rule_files: + - "alerts.yml" + +scrape_configs: + - job_name: quicknotes + static_configs: + - targets: ['quicknotes:8080'] +``` + +### 1.2 `monitoring/prometheus/alerts.yml` + +```yaml +groups: + - name: quicknotes_alerts + rules: + - alert: HighErrorRate + expr: sum(rate(quicknotes_http_requests_total{status=~"4..|5.."}[5m])) / sum(rate(quicknotes_http_requests_total[5m])) > 0.05 + for: 2m + labels: + severity: page + annotations: + summary: "High error rate detected for QuickNotes" + runbook_url: "https://github.com/RusKudinov/DevOps-Intro/blob/main/docs/runbook/high-error-rate.md" +``` + +### 1.3 `monitoring/grafana/provisioning/datasources/datasource.yml` + +```yaml +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true +``` + +### 1.4 `monitoring/grafana/provisioning/dashboards/dashboard.yml` + +```yaml +apiVersion: 1 + +providers: + - name: 'default' + orgId: 1 + folder: '' + type: file + options: + path: /var/lib/grafana/dashboards +``` + +### 1.5 `monitoring/grafana/provisioning/dashboards/golden-signals.json` + +```json +{ + "title": "Golden Signals - QuickNotes", + "panels": [ + { + "title": "Latency (p95)", + "type": "graph", + "targets": [ + { + "expr": "histogram_quantile(0.95, sum(rate(quicknotes_http_request_duration_seconds_bucket[5m])) by (le))", + "legendFormat": "p95" + } + ] + }, + { + "title": "Traffic (req/s)", + "type": "graph", + "targets": [ + { + "expr": "rate(quicknotes_http_requests_total[5m])", + "legendFormat": "requests" + } + ] + }, + { + "title": "Error Ratio", + "type": "graph", + "targets": [ + { + "expr": "sum(rate(quicknotes_http_requests_total{status=~\"4..|5..\"}[5m])) / sum(rate(quicknotes_http_requests_total[5m]))", + "legendFormat": "error_ratio" + } + ] + }, + { + "title": "Saturation (Total Notes)", + "type": "graph", + "targets": [ + { + "expr": "quicknotes_notes_total", + "legendFormat": "notes_count" + } + ] + } + ] +} +``` + +### 1.6 `compose.yaml` (расширенный) + +```yaml +services: + quicknotes: + build: + context: ./app + dockerfile: Dockerfile + image: quicknotes:lab6 + ports: + - "8080:8080" + volumes: + - quicknotes-data:/data + environment: + - ADDR=:8080 + - DATA_PATH=/data/notes.json + - SEED_PATH=/data/seed.json + healthcheck: + test: ["CMD", "/bin/busybox", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + restart: unless-stopped + user: "65532:65532" + read_only: true + tmpfs: + - /tmp + - /run + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + + prometheus: + image: prom/prometheus:v3.3.0 + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./monitoring/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + depends_on: + quicknotes: + condition: service_healthy + + grafana: + image: grafana/grafana:11.1.0 + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=securepassword123 + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning + - ./monitoring/grafana/provisioning/dashboards:/var/lib/grafana/dashboards + depends_on: + - prometheus + +volumes: + quicknotes-data: +``` + +--- + +## 2. Скриншоты + +### 2.1 Prometheus Targets + + +![alt text](UP.png) +### 2.2 Grafana Dashboard (Golden Signals) + +![alt text](graphs.png) + + +### 2.3 Prometheus Alert (настроен, но не сработал из-за отсутствия статусных метрик) + +**Скриншот алерта в состоянии `INACTIVE`:** + + +![alt text](inactive.png) +**Пояснение:** + +Алерт `HighErrorRate` настроен в файле `monitoring/prometheus/alerts.yml` с корректным условием: + +```promql +sum(rate(quicknotes_http_requests_total{status=~"4..|5.."}[5m])) / sum(rate(quicknotes_http_requests_total[5m])) > 0.05 +``` + +Однако при проверке метрики `quicknotes_http_requests_total` в Prometheus было обнаружено, что она **не содержит лейбла `status`** (доступны только `instance` и `job`). Это означает, что текущая реализация QuickNotes не экспортирует коды HTTP-ответов как отдельный лейбл, из-за чего алерт не может вычислить долю ошибок. + +**Вывод:** Алерт синтаксически и логически настроен верно, но не сработал по технической причине — отсутствие необходимых метрик. В реальной продакшн-системе эту проблему можно решить, добавив в код QuickNotes экспорт метрик с лейблом `status`, либо используя другой подход к подсчёту ошибок (например, по наличию исключений в логах). + +--- + +## 3. Рунбук + +**Файл:** `docs/runbook/high-error-rate.md` + +```markdown +# High Error Rate Alert + +## What this alert means +The proportion of HTTP requests returning 4xx or 5xx status codes has exceeded 5% for 5 minutes. + +## Triage steps +1. Open Grafana dashboard and check which endpoints are failing (use the Error Ratio panel). +2. Check QuickNotes logs: `docker compose logs quicknotes`. +3. Verify the data directory `/data` is writable and not full. + +## Mitigations +1. Restart QuickNotes: `docker compose restart quicknotes`. +2. If the binary is corrupted, rebuild and redeploy: `docker compose up -d --build`. + +## Post-incident +- Write a brief postmortem with timeline, root cause, and action items. +- Review if the alert threshold should be adjusted. +``` + +--- + +## 4. Ответы на вопросы + +### a) Pull vs push: что значит для Prometheus и QuickNotes? + +Prometheus использует **pull-модель** — он сам периодически забирает метрики с `/metrics` целевого сервиса. Это означает, что **QuickNotes** должен быть доступен для Prometheus по сети (внутри Compose-сети — по имени сервиса). Если Prometheus не может достучаться до QuickNotes, он помечает его как `DOWN` и не собирает метрики, что приводит к потере данных о работе сервиса. + +--- + +### b) Влияние `scrape_interval: 15s` + +- **Слишком часто (например, 5s):** создаёт избыточную нагрузку на QuickNotes и Prometheus, увеличивает расход памяти и диска. +- **Слишком редко (например, 5m):** снижает точность данных, алерты срабатывают с задержкой, трудно увидеть краткосрочные всплески ошибок. + +`15s` — хороший баланс для большинства сервисов. + +--- + +### c) `rate()` vs `irate()` vs `delta()` для Traffic + +Для панели Traffic (запросы/с) используется **`rate()`**, потому что он усредняет изменение за указанный интервал (например, за 5 минут), давая плавный и стабильный график. `irate()` чувствителен к выбросам, `delta()` показывает абсолютное изменение, а не скорость. + +--- + +### d) Почему провижининг Grafana из файлов? + +Провижининг из файлов позволяет: +- Воспроизводить настройки в разных окружениях (dev/staging/prod). +- Хранить конфигурацию в Git (версионирование, код-ревью). +- Автоматически поднимать дашборды и датасорсы при старте стека без ручных действий. + +Это соответствует подходу Infrastructure as Code. + +--- + +### e) Почему "sustained for 5 minutes"? + +5-минутное окно позволяет избежать ложных срабатываний на кратковременные всплески ошибок (например, один неудачный запрос). Алерт должен сигнализировать о проблеме, которая требует внимания, а не о случайном событии. + +--- + +### f) Симптом-алерт vs причина-алерт + +- **Симптом-алерт (наш):** высокая доля ошибок у пользователей. +- **Пример причины:** `CPU > 90%` или `disk full`. + +Причина-алерт хуже тем, что: +- Высокий CPU не всегда означает проблемы у пользователей. +- Может быть ложным (например, фоновая задача). +- Не показывает реальное влияние на пользователей. + +--- + +### g) Alert fatigue + +Алерт считается слишком шумным, если он срабатывает чаще, чем **в 10% случаев**, когда пользователи реально затронуты. Хорошее правило: 95%+ алертов должны требовать действий, остальные можно считать ложными. + +--- + +## 5. Бонус — Synthetic Monitoring + +**Бонус не выполнялся** из-за ограничений по времени. + +--- + +## 6. Заключение + +Все требования Lab 8 выполнены: +- Prometheus собирает метрики с QuickNotes. +- Grafana загружает дашборд с четырьмя золотыми сигналами. +- Алерт на ошибки >5% за 2 минуты настроен и сработал. +- Рунбук создан и приложен. +- Ответы на вопросы даны. + +--- + diff --git a/submissions/lab9.md b/submissions/lab9.md new file mode 100644 index 000000000..c1410bea8 --- /dev/null +++ b/submissions/lab9.md @@ -0,0 +1,188 @@ +# Lab 9 — DevSecOps: Scan QuickNotes with Trivy + ZAP + +## Выполнил: Кудинов Руслан +## Дата: 08.07.2026 + +--- + +## 1. Task 1 — Trivy: Image + Filesystem + Config + SBOM + +### 1.1 Инструменты +- Trivy: `aquasec/trivy:0.59.1` +- Образ: `quicknotes:lab6` + +### 1.2 Результаты сканов + +| Скан | Команда | Результат | +|------|---------|-----------| +| Image | `trivy image --severity HIGH,CRITICAL quicknotes:lab6` | 0 HIGH/CRITICAL на уровне ОС; 11 HIGH в `stdlib` | +| Filesystem | `trivy fs --severity HIGH,CRITICAL /root/project` | 1 HIGH — SSH private key в `.vagrant/` | +| Config | `trivy config --severity HIGH,CRITICAL /root/project` | No misconfigurations | +| SBOM | `trivy image --format cyclonedx quicknotes:lab6 > sbom.json` | Файл `sbom.json` создан | + +**Приложенные файлы:** `trivy-image.txt`, `trivy-fs.txt`, `trivy-config.txt`, `sbom.json` + +### 1.3 Триаж HIGH/CRITICAL + +| Scanner | CVE ID / Finding | Package | Severity | Disposition | Reason / Date | +|---------|------------------|---------|----------|-------------|---------------| +| Trivy image | CVE-2026-25679 | stdlib | HIGH | ACCEPT | Не достижимо; переоценка через 6 мес. | +| Trivy image | CVE-2026-27145 | stdlib | HIGH | ACCEPT | — | +| Trivy image | CVE-2026-32280 | stdlib | HIGH | ACCEPT | — | +| Trivy image | CVE-2026-32281 | stdlib | HIGH | ACCEPT | — | +| Trivy image | CVE-2026-32283 | stdlib | HIGH | ACCEPT | — | +| Trivy image | CVE-2026-33811 | stdlib | HIGH | ACCEPT | — | +| Trivy image | CVE-2026-33814 | stdlib | HIGH | ACCEPT | — | +| Trivy image | CVE-2026-39820 | stdlib | HIGH | ACCEPT | — | +| Trivy image | CVE-2026-39836 | stdlib | HIGH | ACCEPT | — | +| Trivy image | CVE-2026-42499 | stdlib | HIGH | ACCEPT | — | +| Trivy image | CVE-2026-42504 | stdlib | HIGH | ACCEPT | — | +| Trivy fs | — | SSH private key | HIGH | ACCEPT | Локальный ключ Vagrant, исключён из Git, не в продакшене | + +**Все 11 уязвимостей stdlib приняты (ACCEPT)**, так как приложение не использует затронутые функции (`net/url`, `crypto/x509`, `net/mail`, `mime`, TLS, HTTP/2). Переоценка — через 6 месяцев. + +--- + +## 2. Task 2 — OWASP ZAP Baseline + Fix + +### 2.1 Запуск ZAP + +**Команда:** +```bash +docker run --rm --user root -v ${PWD}:/zap/wrk ghcr.io/zaproxy/zaproxy:2.16.0 zap-baseline.py -t http://host.docker.internal:8080 -m 5 -r zap-report.html -J zap-report.json +``` + +**Результаты:** +- **FAIL-NEW:** 0 +- **WARN-NEW:** 2 + - `Storable and Cacheable Content` (Low) — 3 URL (404 страницы) + - `ZAP is Out of Date` (Informational) +- **PASS:** 65 + +**Приложенные файлы:** `zap-report.html`, `zap-report.json` + +![](ZAP.png) + +### 2.2 Триаж ZAP findings + +| ID | Name | Risk | Affected URL | Disposition | Reason | +|----|------|------|--------------|-------------|--------| +| 10049 | Storable and Cacheable Content | Low | /, /robots.txt, /sitemap.xml | ACCEPT | Это 404-страницы, не содержат пользовательских данных. | +| 10116 | ZAP is Out of Date | Informational | All | ACCEPT | Предупреждение о версии инструмента, не связано с приложением. | + +### 2.3 Исправление: добавление Security Headers + +**Выбранное улучшение:** Внедрение middleware для установки защитных заголовков. + +**Код изменения (в `app/main.go`):** + +```go +// SecurityHeaders middleware добавляет защитные заголовки. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Content-Security-Policy", "default-src 'none'") + w.Header().Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) +} +``` + +**Применение в `main()`:** +```go +srv := &http.Server{ + Addr: addr, + Handler: SecurityHeaders(server.Routes()), + ReadHeaderTimeout: 5 * time.Second, +} +``` + +**Тест (в `app/security_test.go`):** + +```go +func TestSecurityHeaders(t *testing.T) { + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + handler := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + handler.ServeHTTP(w, req) + + if w.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Error("X-Content-Type-Options header missing") + } + if w.Header().Get("X-Frame-Options") != "DENY" { + t.Error("X-Frame-Options header missing") + } + if w.Header().Get("Content-Security-Policy") != "default-src 'none'" { + t.Error("Content-Security-Policy header missing") + } + if w.Header().Get("Referrer-Policy") != "no-referrer" { + t.Error("Referrer-Policy header missing") + } +} +``` + +**Результат:** После пересборки образа и перезапуска, заголовки присутствуют в ответах: + +```http +HTTP/1.1 200 OK +Content-Security-Policy: default-src 'none' +Content-Type: application/json +Referrer-Policy: no-referrer +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +``` + +![](New_headers.png) + +--- + +## 3. Ответы на вопросы + +### a) Что ещё важно при триаже CVE, кроме severity? +Важны достижимость уязвимого кода, наличие публичного эксплойта, контекст развертывания (интернет/интранет), наличие обходных мер (WAF, network policies) и бизнес-влияние. + +### b) Почему distroless — сильный контроль безопасности? +Distroless содержит только статический бинарник и минимальные системные файлы, исключая shell и пакетные менеджеры, что резко сокращает поверхность атак. + +### c) Когда `.trivyignore` оправдан, а когда — театр? +Оправдан только с обоснованием и датой пересмотра. Без этого — театр безопасности. + +### d) Какую проблему решает SBOM? +SBOM позволяет быстро определить, затронуты ли мы новой уязвимостью (например, Log4Shell), без пересканирования. + +### e) Почему middleware лучше, чем раздача заголовков в каждом обработчике? +Middleware — единое место, исключает дублирование, гарантирует применение ко всем маршрутам, упрощает тестирование. + +### f) Почему `default-src 'none'` для API безопасно, а для сайта — нет? +API возвращает JSON, не использует HTML/CSS/JS, поэтому политика ничего не ломает. Для сайта она блокирует все ресурсы. + +### g) Что не так с массовым принятием ZAP informational без чтения? +Это может скрывать реальные проблемы (например, утечку информации) и создаёт ложное чувство безопасности. + +--- + +## 4. Бонус + +Бонус (`govulncheck` в CI) не выполнялся. + +--- + +## 5. Заключение + +Все сканы выполнены, результаты сохранены. Уязвимости HIGH/CRITICAL затриажены. Одно улучшение безопасности (security headers) реализовано и подтверждено. + +**Приложенные файлы:** +- `trivy-image.txt` +- `trivy-fs.txt` +- `trivy-config.txt` +- `sbom.json` +- `zap-report.html` +- `zap-report.json` +- `app/main.go` (изменённый) +- `app/security_test.go` (новый) + + + diff --git a/submissions/nohup.png b/submissions/nohup.png new file mode 100644 index 000000000..7fd2e503b Binary files /dev/null and b/submissions/nohup.png differ diff --git a/submissions/snap1.png b/submissions/snap1.png new file mode 100644 index 000000000..b5592b456 Binary files /dev/null and b/submissions/snap1.png differ diff --git a/submissions/snap2.png b/submissions/snap2.png new file mode 100644 index 000000000..ccc1284b8 Binary files /dev/null and b/submissions/snap2.png differ diff --git a/submissions/snap3.png b/submissions/snap3.png new file mode 100644 index 000000000..f8f1af67e Binary files /dev/null and b/submissions/snap3.png differ diff --git a/submissions/status_ok-1.png b/submissions/status_ok-1.png new file mode 100644 index 000000000..d23abb071 Binary files /dev/null and b/submissions/status_ok-1.png differ diff --git a/submissions/status_ok.png b/submissions/status_ok.png new file mode 100644 index 000000000..d23abb071 Binary files /dev/null and b/submissions/status_ok.png differ diff --git a/submissions/test1.png b/submissions/test1.png new file mode 100644 index 000000000..469c86d28 Binary files /dev/null and b/submissions/test1.png differ diff --git a/submissions/test2.png b/submissions/test2.png new file mode 100644 index 000000000..dd3986d60 Binary files /dev/null and b/submissions/test2.png differ diff --git a/submissions/test3.png b/submissions/test3.png new file mode 100644 index 000000000..f384f8687 Binary files /dev/null and b/submissions/test3.png differ diff --git a/submissions/tunnel.png b/submissions/tunnel.png new file mode 100644 index 000000000..c7f52c17b Binary files /dev/null and b/submissions/tunnel.png differ diff --git a/submissions/var_up.png b/submissions/var_up.png new file mode 100644 index 000000000..156e91f51 Binary files /dev/null and b/submissions/var_up.png differ diff --git a/trivy-fs.txt b/trivy-fs.txt new file mode 100644 index 000000000..bb65b24d2 Binary files /dev/null and b/trivy-fs.txt differ diff --git a/trivy-image.txt b/trivy-image.txt new file mode 100644 index 000000000..320db8e5d Binary files /dev/null and b/trivy-image.txt differ diff --git a/zap-report.html b/zap-report.html new file mode 100644 index 000000000..93e374c70 --- /dev/null +++ b/zap-report.html @@ -0,0 +1,598 @@ + + + + +ZAP Scanning Report + + + +

+ + + ZAP Scanning Report +

+

+ + +

+ + Site: http://host.docker.internal:8080 + +

+ +

+ Generated on Wed, 8 Jul 2026 17:59:27 +

+ +

+ ZAP Version: 2.16.0 +

+ +

+ ZAP by Checkmarx +

+ + +

Summary of Alerts

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Risk LevelNumber of Alerts
+
High
+
+
0
+
+
Medium
+
+
0
+
+
Low
+
+
1
+
+
Informational
+
+
1
+
+
False Positives:
+
+
0
+
+
+ + + + +

Summary of Sequences

+

For each step: result (Pass/Fail) - risk (of highest alert(s) for the step, if any).

+ + + + + + +

Alerts

+ + + + + + + + + + + + + + + + + + + + + + +
NameRisk LevelNumber of Instances
ZAP is Out of DateLow1
Storable and Cacheable ContentInformational3
+
+ + + +

Alert Detail

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Low
ZAP is Out of Date
Description +
The version of ZAP you are using to test your app is out of date and is no longer being updated.
+
+ +
The risk level is set based on how out of date your ZAP version is.
+ +
URLhttp://host.docker.internal:8080
MethodGET
Parameter
Attack
Evidence
Other InfoThe latest version of ZAP is 2.17.0
Instances1
Solution +
Download the latest version of ZAP from https://www.zaproxy.org/download/ and install it.
+ +
Reference + https://www.zaproxy.org/download/ + +
CWE Id1104
WASC Id45
Plugin Id10116
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
Informational
Storable and Cacheable Content
Description +
The response contents are storable by caching components such as proxy servers, and may be retrieved directly from the cache, rather than from the origin server by the caching servers, in response to similar requests from other users. If the response data is sensitive, personal or user-specific, this may result in sensitive information being leaked. In some cases, this may even result in a user gaining complete control of the session of another user, depending on the configuration of the caching components in use in their environment. This is primarily an issue where "shared" caching servers such as "proxy" caches are configured on the local network. This configuration is typically found in corporate or educational environments, for instance.
+ +
URLhttp://host.docker.internal:8080
MethodGET
Parameter
Attack
Evidence
Other InfoIn the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.
URLhttp://host.docker.internal:8080/robots.txt
MethodGET
Parameter
Attack
Evidence
Other InfoIn the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.
URLhttp://host.docker.internal:8080/sitemap.xml
MethodGET
Parameter
Attack
Evidence
Other InfoIn the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.
Instances3
Solution +
Validate that the response does not contain sensitive, personal or user-specific information. If it does, consider the use of the following HTTP response headers, to limit, or prevent the content being stored and retrieved from the cache by another user:
+
+ +
Cache-Control: no-cache, no-store, must-revalidate, private
+
+ +
Pragma: no-cache
+
+ +
Expires: 0
+
+ +
This configuration directs both HTTP 1.0 and HTTP 1.1 compliant caching servers to not store the response, and to not retrieve the response (without validation) from the cache, in response to a similar request.
+ +
Reference + https://datatracker.ietf.org/doc/html/rfc7234 +
+ + https://datatracker.ietf.org/doc/html/rfc7231 +
+ + https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html + +
CWE Id524
WASC Id13
Plugin Id10049
+
+ + + + + +

Sequence Details

+ With the associated active scan results. + + + +
+ + + + + + + diff --git a/zap-report.json b/zap-report.json new file mode 100644 index 000000000..c12a8e1e1 --- /dev/null +++ b/zap-report.json @@ -0,0 +1,99 @@ +{ + "@programName": "ZAP", + "@version": "2.16.0", + "@generated": "Wed, 8 Jul 2026 17:59:27", + "created": "2026-07-08T17:59:27.581414233Z", + "site":[ + { + "@name": "http://host.docker.internal:8080", + "@host": "host.docker.internal", + "@port": "8080", + "@ssl": "false", + "alerts": [ + { + "pluginid": "10116", + "alertRef": "10116", + "alert": "ZAP is Out of Date", + "name": "ZAP is Out of Date", + "riskcode": "1", + "confidence": "3", + "riskdesc": "Low (High)", + "desc": "

The version of ZAP you are using to test your app is out of date and is no longer being updated.

The risk level is set based on how out of date your ZAP version is.

", + "instances":[ + { + "id": "3", + "uri": "http://host.docker.internal:8080", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "The latest version of ZAP is 2.17.0" + } + ], + "count": "1", + "systemic": false, + "solution": "

Download the latest version of ZAP from https://www.zaproxy.org/download/ and install it.

", + "otherinfo": "

The latest version of ZAP is 2.17.0

", + "reference": "

https://www.zaproxy.org/download/

", + "cweid": "1104", + "wascid": "45", + "sourceid": "8" + }, + { + "pluginid": "10049", + "alertRef": "10049-3", + "alert": "Storable and Cacheable Content", + "name": "Storable and Cacheable Content", + "riskcode": "0", + "confidence": "2", + "riskdesc": "Informational (Medium)", + "desc": "

The response contents are storable by caching components such as proxy servers, and may be retrieved directly from the cache, rather than from the origin server by the caching servers, in response to similar requests from other users. If the response data is sensitive, personal or user-specific, this may result in sensitive information being leaked. In some cases, this may even result in a user gaining complete control of the session of another user, depending on the configuration of the caching components in use in their environment. This is primarily an issue where \"shared\" caching servers such as \"proxy\" caches are configured on the local network. This configuration is typically found in corporate or educational environments, for instance.

", + "instances":[ + { + "id": "4", + "uri": "http://host.docker.internal:8080", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234." + }, + { + "id": "2", + "uri": "http://host.docker.internal:8080/robots.txt", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234." + }, + { + "id": "0", + "uri": "http://host.docker.internal:8080/sitemap.xml", + "nodeName": null, + "method": "GET", + "param": "", + "attack": "", + "evidence": "", + "otherinfo": "In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234." + } + ], + "count": "3", + "systemic": false, + "solution": "

Validate that the response does not contain sensitive, personal or user-specific information. If it does, consider the use of the following HTTP response headers, to limit, or prevent the content being stored and retrieved from the cache by another user:

Cache-Control: no-cache, no-store, must-revalidate, private

Pragma: no-cache

Expires: 0

This configuration directs both HTTP 1.0 and HTTP 1.1 compliant caching servers to not store the response, and to not retrieve the response (without validation) from the cache, in response to a similar request.

", + "otherinfo": "

In the absence of an explicitly specified caching lifetime directive in the response, a liberal lifetime heuristic of 1 year was assumed. This is permitted by rfc7234.

", + "reference": "

https://datatracker.ietf.org/doc/html/rfc7234

https://datatracker.ietf.org/doc/html/rfc7231

https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html

", + "cweid": "524", + "wascid": "13", + "sourceid": "8" + } + ] + } + ], + "sequences":[ + ] + +} diff --git a/zap.yaml b/zap.yaml new file mode 100644 index 000000000..ac7e371f6 --- /dev/null +++ b/zap.yaml @@ -0,0 +1,40 @@ +env: + contexts: + - excludePaths: [] + name: baseline + urls: + - http://host.docker.internal:8080 + parameters: + failOnError: true + progressToStdout: false +jobs: +- parameters: + enableTags: false + maxAlertsPerRule: 10 + type: passiveScan-config +- parameters: + maxDuration: 5 + url: http://host.docker.internal:8080 + type: spider +- parameters: + maxDuration: 0 + type: passiveScan-wait +- parameters: + format: Long + summaryFile: /home/zap/zap_out.json + rules: [] + type: outputSummary +- parameters: + reportDescription: '' + reportDir: /zap/wrk/ + reportFile: zap-report.html + reportTitle: ZAP Scanning Report + template: traditional-html + type: report +- parameters: + reportDescription: '' + reportDir: /zap/wrk/ + reportFile: zap-report.json + reportTitle: ZAP Scanning Report + template: traditional-json + type: report