diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml new file mode 100644 index 0000000000..73cb223eac --- /dev/null +++ b/.github/workflows/go-ci.yml @@ -0,0 +1,91 @@ +name: Go CI/CD Pipeline + +on: + push: + branches: [ "master", "lab3" ] + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + pull_request: + branches: [ "master", "lab3" ] + paths: + - 'app_go/**' + - '.github/workflows/go-ci.yml' + +env: + REGISTRY: docker.io + IMAGE_NAME: zsalavat/devops-info-service-go + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22.x' + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('app_go/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + working-directory: app_go + run: go mod download + + - name: Lint with golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: v1.59.1 + working-directory: app_go + args: --timeout=2m + + - name: Run unit tests + working-directory: app_go + run: go test ./... -v + + cd: + needs: ci + runs-on: ubuntu-latest + if: > + github.event_name == 'push' && + (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/lab3') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata for Docker tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=raw,value={{date 'YYYY.MM'}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: app_go + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000000..05ee2c4125 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,106 @@ +name: Python CI/CD Pipeline + +on: + push: + branches: [ "master", "lab3" ] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' + pull_request: + branches: [ "master", "lab3" ] + paths: + - 'app_python/**' + - '.github/workflows/python-ci.yml' +env: + REGISTRY: docker.io + IMAGE_NAME: zsalavat/devops-info-service-python + +jobs: + ci: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f app_python/requirements.txt ]; then + pip install -r app_python/requirements.txt + fi + pip install pytest pylint pip-audit + + - name: Lint with pylint + run: | + pylint --disable=R,C,W1203,W1514,W0621,W0611,E0401 app_python/app.py app_python/tests/ + + - name: Run unit tests with coverage + run: | + pytest -v --tb=short --cov=app_python --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + flags: python + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + + - name: Run security scan with pip-audit + working-directory: app_python + run: pip-audit -r requirements.txt + + cd: + needs: ci + runs-on: ubuntu-latest + if: > + github.event_name == 'push' && + (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/lab3') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata for Docker tags + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=raw,value={{date 'YYYY.MM'}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: app_python + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/terraform-ci.yml b/.github/workflows/terraform-ci.yml new file mode 100644 index 0000000000..437b1a66cc --- /dev/null +++ b/.github/workflows/terraform-ci.yml @@ -0,0 +1,81 @@ +name: Terraform CI/CD Pipeline + +on: + push: + branches: ["master", "lab4"] + paths: + - 'terraform/**' + pull_request: + branches: ["master", "lab4"] + paths: + - 'terraform/**' + +jobs: + validate: + name: Validate Terraform + runs-on: ubuntu-latest + defaults: + run: + shell: bash + + env: + TF_IN_AUTOMATION: "true" + TF_INPUT: "false" + TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform.d/plugin-cache + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.6.6 + terraform_wrapper: false + + - name: Prepare Terraform plugin cache + run: | + mkdir -p "${TF_PLUGIN_CACHE_DIR}" + + - name: Cache Terraform plugins + uses: actions/cache@v4 + with: + path: .terraform.d/plugin-cache + key: ${{ runner.os }}-terraform-plugins-${{ hashFiles('terraform/.terraform.lock.hcl') }} + restore-keys: | + ${{ runner.os }}-terraform-plugins- + + - name: Terraform fmt (check) + working-directory: terraform + run: | + echo "::group::terraform fmt" + terraform fmt -check -recursive -diff + echo "::endgroup::" + + - name: Terraform init (no backend) + working-directory: terraform + run: | + echo "::group::terraform init" + terraform init -backend=false -no-color + echo "::endgroup::" + + - name: Terraform validate + working-directory: terraform + run: | + echo "::group::terraform validate" + terraform validate -no-color + echo "::endgroup::" + + - name: Set up TFLint + uses: terraform-linters/setup-tflint@v4 + with: + tflint_version: v0.53.0 + + - name: TFLint (lint) + working-directory: terraform + run: | + echo "::group::tflint" + tflint --version + # If you keep placeholder/unused variables for later labs, disable this rule. + tflint --disable-rule=terraform_unused_declarations + echo "::endgroup::" diff --git a/.gitignore b/.gitignore index 30d74d2584..c23b129b86 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,19 @@ -test \ No newline at end of file +test +app_python/.venv +app_python/.pytest_cache +app_python/__pycache__ + + +# Terraform files +*.tfstate +*.tfstate.* +*.tfvars +.terraform/ +terraform.tfvars + +# Sensitive files +secrets.auto.tfvars + +Pulumi.*.yaml +venv/ +__pycache__/ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000..ab1f4164ed --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/DevOps-Core-Course.iml b/.idea/DevOps-Core-Course.iml new file mode 100644 index 0000000000..460d4026f7 --- /dev/null +++ b/.idea/DevOps-Core-Course.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000000..1f2ea11e7f --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000000..105ce2da2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000000..ba6f591e01 --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000000..1d3ce46ba0 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000..71d30f3527 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000..35eb1ddfbb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app_go/.dockerignore b/app_go/.dockerignore new file mode 100644 index 0000000000..2ef3b4ff5d --- /dev/null +++ b/app_go/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +.vscode/ +.idea/ + +bin/ +build/ +dist/ +coverage/ +*.out + +docs/ +tests/ +*.md + +__pycache__/ diff --git a/app_go/.gitignore b/app_go/.gitignore new file mode 100644 index 0000000000..85dc74b2a8 --- /dev/null +++ b/app_go/.gitignore @@ -0,0 +1,8 @@ + +# Binaries +devops-info-service +*.exe + +# Build outputs +*.out + diff --git a/app_go/Dockerfile b/app_go/Dockerfile new file mode 100644 index 0000000000..6ab2b7a52b --- /dev/null +++ b/app_go/Dockerfile @@ -0,0 +1,31 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /src + +COPY go.mod ./ + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + go mod download + +COPY . . + +RUN --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" -buildvcs=false -o /out/app ./main.go + +FROM gcr.io/distroless/static:nonroot + +WORKDIR /app + +COPY --from=builder /out/app /app/app + +USER nonroot:nonroot + +EXPOSE 5000 + +# Default envs (can be overridden at runtime) +ENV HOST=0.0.0.0 PORT=5000 DEBUG=false + +# Run the binary +ENTRYPOINT ["/app/app"] diff --git a/app_go/README.md b/app_go/README.md new file mode 100644 index 0000000000..f46a767603 --- /dev/null +++ b/app_go/README.md @@ -0,0 +1,42 @@ + +# DevOps Course Info Service (Go) + +![Go CI/CD Pipeline](https://github.com/setterwars/DevOps-Core-Course/actions/workflows/go-ci.yml/badge.svg) + + +## Overview +Go implementation of the `app_python` DevOps Info Service. + +## Prerequisites +- Go 1.22+ + +## Run +From the `app_go/` directory: + +- `go run .` +- `HOST=127.0.0.1 PORT=8080 DEBUG=true go run .` + +## Build +- `CGO_ENABLED=0 go build -ldflags "-s -w" -o devops-info-service .` +- `./devops-info-service` + +## Endpoints +- `GET /` — returns service/system/runtime/request metadata +- `GET /health` — health check + +## Docs +- Implementation details: `docs/LAB01.md` +- Language justification: `docs/GO.md` + +## Docker + +### Build + +```bash +docker build -t ${DOCKER_USER}/devops-info-service-go:lab02 ./app_go +``` + +### Run +```bash +docker run --rm -p 5000:5000 ${DOCKER_USER}/devops-info-service-go:lab02 +``` \ No newline at end of file diff --git a/app_go/docs/GO.md b/app_go/docs/GO.md new file mode 100644 index 0000000000..e2da37051e --- /dev/null +++ b/app_go/docs/GO.md @@ -0,0 +1,3 @@ +# Why is go? + +Go is a great choice for this lab because it compiles the service into a single small, fast binary that’s easy to ship in a minimal multi-stage Docker image. \ No newline at end of file diff --git a/app_go/docs/LAB01.md b/app_go/docs/LAB01.md new file mode 100644 index 0000000000..d36e4e47ac --- /dev/null +++ b/app_go/docs/LAB01.md @@ -0,0 +1,34 @@ +# Choosen language: Go + +# Best practices followed +- Use virtual environment for dependency management +- Clear function and variable names +- Logging for monitoring and debugging +- Error handling for robustness +- Modular code structure for maintainability + +# API documentation + +- endpoints: + - `GET /` - Returns system metadata including hostname, IP address, and current timestamp. + - `GET /health` - Returns the health status of the service. + +## Testing commands in basic configuration + + curl http://localhost:5000/ + curl http://localhost:5000/health + +# Testing evidence + +Basic endpoint test: + +![Basic Endpoint Test json](screenshots/base_request_json.png) +![Basic Endpoint Test text](screenshots/base_request_terminal.png) + +Health endpoint test: + +![Health Endpoint Test json](screenshots/health_request_json.png) +![Health Endpoint Test text](screenshots/health_request_terminal.png) + +# Challenges and solutions + No challenges faced during the lab \ No newline at end of file diff --git a/app_go/docs/LAB02.md b/app_go/docs/LAB02.md new file mode 100644 index 0000000000..4c7ad39904 --- /dev/null +++ b/app_go/docs/LAB02.md @@ -0,0 +1,54 @@ +## Strategy Overview + +- **Stage 1 (Builder)**: Use `golang:1.22-alpine` to compile a static Linux binary with `CGO_ENABLED=0`, `-trimpath`, and stripped symbols (`-ldflags "-s -w"`). Cache Go modules and build artifacts to speed up incremental builds. +- **Stage 2 (Runtime)**: Use `gcr.io/distroless/static:nonroot` to ship only the binary. No package manager, no shell, and runs as non-root by default → minimal attack surface. + +## Size Comparison + +```bash +➜ app_go git:(lab2) ✗ docker images | grep devops-info-service-go +WARNING: This output is designed for human readability. For machine-readable output, please use --format. +zsalavat/devops-info-service-go:lab02 067f534f40f3 13.3MB 2.99MB +``` + +## Why Multi-Stage Matters + +- **Smaller images**: Faster pulls/pushes and less disk/memory footprint. +- **Security**: Fewer components → fewer CVEs and reduced attack surface; distroless has no shell or package manager. +- **Performance & Deployability**: Static binaries start quickly and work consistently across environments. +- **Best practice**: Keep build-time and runtime concerns separate. + +--- + +## Build & Run Process + +### Build +![build-test](screenshots/docker-build-terminal.png) + +### Run +![docker-run](screenshots/docker-run.png) + +### Test Endpoints +![build-test](screenshots/docker_curl_test.png) + +--- + +## Technical Explanation of Each Stage + +- **Builder stage**: + - `go mod download` with cache mounts speeds up dependency resolution. + - `CGO_ENABLED=0` produces a fully static binary suitable for `distroless/static`. + - `-trimpath` removes local path info; `-ldflags "-s -w"` strips symbol/debug info → smaller binary. + - Output binary at `/out/app` for clean handoff to runtime stage. + +- **Runtime stage**: + - `distroless/static:nonroot` includes just enough to run the binary; no shell or glibc needed for static binaries. + - Runs as non-root (`USER nonroot:nonroot`) by default. + - `EXPOSE 5000` documents the port; environment variables allow overrides. + +## Security Implications + +- **Reduced attack surface**: No compilers or package managers in runtime. +- **Least privilege** +- **Determinism** + diff --git a/app_go/docs/LAB03.md b/app_go/docs/LAB03.md new file mode 100644 index 0000000000..e46338c000 --- /dev/null +++ b/app_go/docs/LAB03.md @@ -0,0 +1,34 @@ +# Lab 3 — Go App CI (Bonus) + +## Workflow Summary +- Location: [.github/workflows/go-ci.yml](../../.github/workflows/go-ci.yml) +- Stages: + - Setup Go 1.22 + - Lint with golangci-lint + - go test ./... -v. + - Build & push Docker image using BUILDX + - latest and CalVer tags + +## Path Filters (Selective CI) +- Path filter created Now then edit files in `app_python` triggered `python-ci.yml` then in `app_go` triggered `go-ci.yml` + +## Versioning Strategy +- Strategy: CalVer (monthly), tags like 2026.02 + latest. +- Rationale: services deploy continuously; date-based tags communicate freshness and cadence. + +## Evidence (Replace with your links) +- Successful workflow run:https://github.com/setterwars/DevOps-Core-Course/actions/runs/21711862898 +- Docker Hub image/tags: https://hub.docker.com/repository/docker/zsalavat/devops-info-service-go/general +![ci-cd-done](screenshots/worked-ci-cd.png) + + +## Best Practices Applied +- Path filters to scope runs per app. +- Dependency caching for faster builds. +- Job dependency: CD waits for CI to pass. +- Conditional push: only on master/lab3 branches. +- Buildx for reproducible multi-arch-friendly builds. + +## IMPORTANT NOTE + +Cant use synk in this part API TOKEN only in Enterprice Subscribtion \ No newline at end of file diff --git a/app_go/docs/screenshots/.gitkeep b/app_go/docs/screenshots/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_go/docs/screenshots/base_request_json.png b/app_go/docs/screenshots/base_request_json.png new file mode 100644 index 0000000000..83fb58e301 Binary files /dev/null and b/app_go/docs/screenshots/base_request_json.png differ diff --git a/app_go/docs/screenshots/base_request_terminal.png b/app_go/docs/screenshots/base_request_terminal.png new file mode 100644 index 0000000000..c7073cc245 Binary files /dev/null and b/app_go/docs/screenshots/base_request_terminal.png differ diff --git a/app_go/docs/screenshots/docker-build-terminal.png b/app_go/docs/screenshots/docker-build-terminal.png new file mode 100644 index 0000000000..255bb2811b Binary files /dev/null and b/app_go/docs/screenshots/docker-build-terminal.png differ diff --git a/app_go/docs/screenshots/docker-run.png b/app_go/docs/screenshots/docker-run.png new file mode 100644 index 0000000000..b59b99d0a6 Binary files /dev/null and b/app_go/docs/screenshots/docker-run.png differ diff --git a/app_go/docs/screenshots/docker_curl_test.png b/app_go/docs/screenshots/docker_curl_test.png new file mode 100644 index 0000000000..439b25f80f Binary files /dev/null and b/app_go/docs/screenshots/docker_curl_test.png differ diff --git a/app_go/docs/screenshots/health_request_json.png b/app_go/docs/screenshots/health_request_json.png new file mode 100644 index 0000000000..8f7c0b9850 Binary files /dev/null and b/app_go/docs/screenshots/health_request_json.png differ diff --git a/app_go/docs/screenshots/health_request_terminal.png b/app_go/docs/screenshots/health_request_terminal.png new file mode 100644 index 0000000000..4607d769e8 Binary files /dev/null and b/app_go/docs/screenshots/health_request_terminal.png differ diff --git a/app_go/docs/screenshots/worked-ci-cd.png b/app_go/docs/screenshots/worked-ci-cd.png new file mode 100644 index 0000000000..97bf61eb33 Binary files /dev/null and b/app_go/docs/screenshots/worked-ci-cd.png differ diff --git a/app_go/go.mod b/app_go/go.mod new file mode 100644 index 0000000000..f0a7a672ef --- /dev/null +++ b/app_go/go.mod @@ -0,0 +1,3 @@ +module app_go + +go 1.22 diff --git a/app_go/main.go b/app_go/main.go new file mode 100644 index 0000000000..77fef12eee --- /dev/null +++ b/app_go/main.go @@ -0,0 +1,190 @@ +package main + +import ( + "encoding/json" + "log" + "net" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "time" +) + +var startTime = time.Now().UTC() + +type jsonMap map[string]any + +func main() { + host := getEnv("HOST", "0.0.0.0") + port := getEnvInt("PORT", 5000) + debug := strings.EqualFold(getEnv("DEBUG", "False"), "true") + + logger := log.New(os.Stdout, "", log.LstdFlags) + logger.Printf("Starting devops-info-service (go). host=%s port=%d debug=%v", host, port, debug) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + writeJSON(w, http.StatusNotFound, jsonMap{"error": "Not Found", "message": "Endpoint does not exist"}) + return + } + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if debug { + logger.Printf("Received request from %s. Method: %s, Path: %s", r.RemoteAddr, r.Method, r.URL.Path) + } + + uptimeSeconds, uptimeHuman := getUptime() + + payload := jsonMap{ + "service": jsonMap{ + "name": "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service", + }, + "system": jsonMap{ + "hostname": getHostname(), + "platform": runtime.GOOS, + "platform_version": getKernelRelease(), + "architecture": runtime.GOARCH, + "cpu_count": runtime.NumCPU(), + // Kept for strict JSON parity with the Python version. + "python_version": runtime.Version(), + }, + "runtime": jsonMap{ + "uptime_seconds": uptimeSeconds, + "uptime_human": uptimeHuman, + "current-time": time.Now().UTC().Format(time.RFC3339Nano), + "timezone": "UTC", + }, + "request": jsonMap{ + "client_ip": getClientIP(r), + "user_agent": r.Header.Get("User-Agent"), + "method": r.Method, + "path": r.URL.Path, + }, + "endpoints": []jsonMap{ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + }, + } + + writeJSON(w, http.StatusOK, payload) + }) + + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if debug { + logger.Printf("Health check requested") + } + uptimeSeconds, _ := getUptime() + writeJSON(w, http.StatusOK, jsonMap{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "uptime_seconds": uptimeSeconds, + }) + }) + + addr := net.JoinHostPort(host, strconv.Itoa(port)) + server := &http.Server{ + Addr: addr, + Handler: recoverMiddleware(logger, mux), + ReadHeaderTimeout: 5 * time.Second, + } + + logger.Printf("Listening on http://%s", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatalf("server error: %v", err) + } +} + +func recoverMiddleware(logger *log.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + logger.Printf("panic recovered: %v", rec) + writeJSON(w, http.StatusInternalServerError, jsonMap{ + "error": "Internal Server Error", + "message": "An unexpected error occurred", + }) + } + }() + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(payload) +} + +func getUptime() (int, string) { + delta := time.Since(startTime) + sec := int(delta.Seconds()) + hours := sec / 3600 + minutes := (sec % 3600) / 60 + return sec, strconv.Itoa(hours) + " hours, " + strconv.Itoa(minutes) + " minutes" +} + +func getClientIP(r *http.Request) string { + xff := r.Header.Get("X-Forwarded-For") + if xff != "" { + parts := strings.Split(xff, ",") + if len(parts) > 0 { + return strings.TrimSpace(parts[0]) + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil && host != "" { + return host + } + return r.RemoteAddr +} + +func getHostname() string { + h, err := os.Hostname() + if err != nil { + return "" + } + return h +} + +func getKernelRelease() string { + // Best-effort Linux kernel release; empty string on non-Linux or when unavailable. + b, err := os.ReadFile("/proc/sys/kernel/osrelease") + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func getEnv(key, def string) string { + v := os.Getenv(key) + if v == "" { + return def + } + return v +} + +func getEnvInt(key string, def int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} diff --git a/app_go/tests/__init__.go b/app_go/tests/__init__.go new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/.dockerignore b/app_python/.dockerignore new file mode 100644 index 0000000000..20345b9d49 --- /dev/null +++ b/app_python/.dockerignore @@ -0,0 +1,18 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +venv/ +.venv/ +env/ +ENV/ +.git +.gitignore +tests/ +docs/ +*.md +*.sqlite3 +__pycache__ + +!app.py +!requirements.txt diff --git a/app_python/.gitignore b/app_python/.gitignore new file mode 100644 index 0000000000..1b78e926d6 --- /dev/null +++ b/app_python/.gitignore @@ -0,0 +1,13 @@ +# Python +__pycache__/ +*.py[cod] +venv/ +.venv/ +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store \ No newline at end of file diff --git a/app_python/Dockerfile b/app_python/Dockerfile new file mode 100644 index 0000000000..b3ae24fb35 --- /dev/null +++ b/app_python/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN groupadd -r app && useradd -r -g app app + +WORKDIR /app + +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py /app/ + +RUN chown -R app:app /app + +USER app + +EXPOSE 5000 + +CMD ["python", "app.py"] diff --git a/app_python/README.md b/app_python/README.md new file mode 100644 index 0000000000..d40cea9573 --- /dev/null +++ b/app_python/README.md @@ -0,0 +1,81 @@ +# DevOps Course Info Service + +## CI/CD status & coverage + +![Python CI/CD Pipeline](https://github.com/setterwars/DevOps-Core-Course/actions/workflows/python-ci.yml/badge.svg) +[![codecov](https://codecov.io/gh/setterwars/DevOps-Core-Course/branch/master/graph/badge.svg)](https://codecov.io/gh/setterwars/DevOps-Core-Course) + + +## Overview + +DevOps Info Service - small Flask based service what return and report system metadata and information. + +## Prerequisites +- Python 3.11+ +- pip +- Linux / macOS / Windows + +## Installation guid + +1. Clone the repository: + ```bash + git clone git@github.com:setterwars/DevOps-Core-Course.git + +2. Navigate to the project directory: + ```bash + cd app_python + +3. (Optional) Create and activate a virtual environment: + ```bash + python3 -m venv venv + +4. Install the required dependencies: + ```bash + pip install -r requirements.txt + +5. Run the application: + ```bash + python3 app.py # in default mode + PORT=8080 HOST=127.0.0.1 DEBUG=True python3 app.py # in custom mode + +## Available Endpoints +- `GET /` - Returns system metadata including hostname, IP address, and current timestamp. +- `GET /health` - Returns the health status of the service. + + +## Docker + +### Build the image locally (pattern) +```bash +docker build -t ${DOCKER_USER}devops-info-service-python:latest +``` + +### Run a container (pattern) +```bash +docker run --rm \ + -p 5000:5000 \ + -e HOST=0.0.0.0 \ + -e PORT=5000 \ + -e DEBUG=True \ + ${DOCKER_USER}/devops-info-service-python:latest +``` + +### Pull from Docker Hub +Link to docker hub: + +https://hub.docker.com/repository/docker/zsalavat/devops-info-service-python/general + +```bash +docker pull zsalavat/devops-info-service-python +docker run --rm -p 5000:5000 zsalavat/devops-info-service-python:latest +``` + +### Test Running + +For running tests used `pytest` + +for run test use: + +```bash +pytest +``` \ No newline at end of file diff --git a/app_python/app.py b/app_python/app.py new file mode 100644 index 0000000000..987f0df1f2 --- /dev/null +++ b/app_python/app.py @@ -0,0 +1,115 @@ +from __future__ import annotations +import os +import socket +import platform +import logging +from datetime import datetime, timezone +from typing import Dict + +from flask import Flask, request, jsonify + +app = Flask(__name__) + + +# take parameters from environment variables with defaults +HOST = os.getenv("HOST", "0.0.0.0") +PORT = int(os.getenv("PORT", "5000")) +DEBUG = os.getenv("DEBUG", "False").lower() == "true" + + +# logger configuration +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', +) + +logger = logging.getLogger("devops-info-service") +logger.info("Starting devops-info-service") + +# save service start time +START_TIME = datetime.now(timezone.utc) + +# utility functions +def get_system_info() -> Dict[str, object]: + return { + "hostname": socket.gethostname(), + "platform": platform.system(), + "platform_version": platform.version(), + "architecture": platform.machine(), + "cpu_count": os.cpu_count() or 1, + "python_version": platform.python_version(), + } + +def get_uptime() -> Dict[str, object]: + delta = datetime.now(timezone.utc) - START_TIME + sec = int(delta.total_seconds()) + hours = sec // 3600 + minutes = (sec % 3600) // 60 + return { + "seconds": sec, + "human": f"{hours} hours, {minutes} minutes", + } + +def get_request_info() -> Dict[str, object]: + xff = request.headers.get("X-Forwarded-For") + client_ip = xff.split(",")[0].strip() if xff else request.remote_addr + return { + "client_ip": client_ip, + "user_agent": request.headers.get("User-Agent"), + "method": request.method, + "path": request.path, + } + +# main endpoints for getting service info +@app.route("/", methods=["GET"]) +def index(): + logger.info(f"Received request from {request.remote_addr}. Method: {request.method}, Path: {request.path}") + payload = { + "service": { + "name" : "devops-info-service", + "version": "1.0.0", + "description": "DevOps course info service" + }, + "system": get_system_info(), + "runtime": { + "uptime_seconds": get_uptime()["seconds"], + "uptime_human": get_uptime()["human"], + "current-time": datetime.now(timezone.utc).isoformat(), + "timezone": "UTC", + }, + "request": get_request_info(), + "endpoints": [ + {"path": "/", "method": "GET", "description": "Service information"}, + {"path": "/health", "method": "GET", "description": "Health check"}, + ], + } + return jsonify(payload) + +# health check endpoint +@app.route("/health", methods=["GET"]) +def health(): + logger.info("Health check requested") + return jsonify( + { + "status": "healthy", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": get_uptime()["seconds"], + } + ), 200 + +# error handlers +@app.errorhandler(404) +def not_found(_error): + return jsonify({"error": "Not Found", "message": "Endpoint does not exist"}), 404 + + +@app.errorhandler(500) +def internal_error(_error): + logger.exception("Internal server error") + return ( + jsonify({"error": "Internal Server Error", "message": "An unexpected error occurred"}), + 500, + ) + +if __name__ == "__main__": + app.run(host=HOST, port=PORT, debug=DEBUG) \ No newline at end of file diff --git a/app_python/docs/LAB01.md b/app_python/docs/LAB01.md new file mode 100644 index 0000000000..2178883fce --- /dev/null +++ b/app_python/docs/LAB01.md @@ -0,0 +1,43 @@ +# Framework selection + +**chosen framework:** Flask + +comparison table: + +| Framework | information | +|-----------|-------------------------------------------------------------| +| flask | lightweight, easy to use | +| FastAPI | async-first, auto-docs, better for hight perfomance API's | +| Django | heavy-weight, includes ORM and admin, overkill for this lab | + +# Best practices followed +- Use virtual environment for dependency management +- Clear function and variable names +- Logging for monitoring and debugging +- Error handling for robustness +- Modular code structure for maintainability + +# API documentation + +- endpoints: + - `GET /` - Returns system metadata including hostname, IP address, and current timestamp. + - `GET /health` - Returns the health status of the service. + +## Testing commands in basic configuration + + curl http://localhost:5000/ + curl http://localhost:5000/health + +# Testing evidence + +Basic endpoint test: + +![Basic Endpoint Test json](screenshots/base_request_json_output.png) +![Basic Endpoint Test text](screenshots/terminal_output_base_request.png) + +Health endpoint test: +![Health Endpoint Test json](screenshots/health_request_json_output.png) +![Health Endpoint Test text](screenshots/health_request_terminal_output.png) + +# Challenges and solutions + No challenges faced during the lab \ No newline at end of file diff --git a/app_python/docs/LAB02.md b/app_python/docs/LAB02.md new file mode 100644 index 0000000000..1241d57a57 --- /dev/null +++ b/app_python/docs/LAB02.md @@ -0,0 +1,63 @@ +## 1. Docker Best Practices Applied +- **Non-root user**: `USER app` +- **Layer caching with proper order** +- **Minimal copy** +- **`.dockerignore`** +- **Logging and bytecode settings** +- **Expose runtime port** + +## 2. Image Information & Decisions + +- **Base image chosen**: `python:3.13-slim` small footprint with Debian base; better compatibility with many Python wheels compared to `alpine` (musl). Pins major/minor for reproducibility. +- **Final image size**: 182mb +- **Layer structure (conceptual)**: + - Base: Python runtime + - Env + user setup + - `requirements.txt` copied, `pip install` layer + - `app.py` copied + - Ownership change and `USER app` + - `EXPOSE` and `CMD` + +--- + +## 3. Build & Run Process + +### Build +![build-termina](screenshots/build-terminal.png) + +### Run (pattern) + +![docker-run-terminal](screenshots/docker-run-terminal.png) + +### Test endpoints + + +![docker-test](screenshots/docker-run-terminal.png) + +### Docker Hub repository URL +- URL: https://hub.docker.com/r/zsalavat/devops-info-service-python +--- + +## 4. Technical Analysis + +### Why this Dockerfile works +It work because we create well typed Dockerfile, and after running it system using name spaces isolated place for running required app + +### What changes in layer order would do +- if we change requirements installing and code running with places every time then we change code we will triger pip install. +- Skipping `chown` the app user might not read/execute files +- Copying the full repo later can take more time +### Security choices +- Run as a non-root user to limit damage if something goes wrong. +- Use the slim base image to reduce the number of packages +- Keep the command simple + +### How `.dockerignore` helps +- Faster builds by sending a smaller context to the Docker daemon. +- More stable caching because irrelevant files don’t change the context checksum. +- Prevents accidental inclusion of dev artifacts, virtualenvs, `.git`, and docs. +- Avoids copying sensitive files into images by mistake. + + +## 5. Challenges & Solutions +No challenge in lab doing proccess. \ No newline at end of file diff --git a/app_python/docs/LAB03.md b/app_python/docs/LAB03.md new file mode 100644 index 0000000000..f9bbf497b6 --- /dev/null +++ b/app_python/docs/LAB03.md @@ -0,0 +1,40 @@ +## Unit Testing + +- Create unit tests for `app.py` +- Added to requirements.txt new libs +```txt +flask==3.1.0 +pytest==8.2.2 +pytest-cov==5.0.0 +ruff==0.6.9 +``` +- Checked it tests locally. + +![pytest-local-result](screenshots/pytest_local_result.png) + +## Github Action CI workflow + +- `python-ci.yml` triggered only in `lab3` and `master` branches. Because in the CI/CD I push docker image to the docker hub, but in docker hub must be only latest and working, in good practise master always has working and latest code. And docker image pushed to the docker hub only from the `master` branch. +- As actions I choose basic actions what I always use then create CI/CD pipelines. +- As tagging of images I use Calendar Versioning. Because this stragegy is usefull and if in developing process we notice what some part of app is down by this tag we can find there it will down. + +- **Link to passed CI/CD:** https://github.com/setterwars/DevOps-Core-Course/actions/runs/21708886646 +![image-from-github](screenshots/image-from-github.png) + +As you can see CD job is skiped because it run only then we pushed something on main because in the docker hub we want see only the latest version of application. + +## Best practice CI/CD pipeline + +- Created Pipeline Budget in `app_python/README.md` +![pipeline-budget](screenshots/image.png) +- Added caching in python libs +- Try to add Synk but in proccess notice what sync now need paiment sub so used free pip-audit and found one lib with vulnerability. flask 3.1.0 changed to flask 3.1.1 +- CalVer tagging in Docker Images +- auto pushing in docker hub +- non breacking scanning +- verified action +- CI/CD separation + +![flask](screenshots/found-flask-error.png) + +![pipeline-with-best-practice](screenshots/pipeline-with-best-practice.png) \ No newline at end of file diff --git a/app_python/docs/screenshots/base_request_json_output.png b/app_python/docs/screenshots/base_request_json_output.png new file mode 100644 index 0000000000..382a25938c Binary files /dev/null and b/app_python/docs/screenshots/base_request_json_output.png differ diff --git a/app_python/docs/screenshots/build-terminal.png b/app_python/docs/screenshots/build-terminal.png new file mode 100644 index 0000000000..9e0da4593b Binary files /dev/null and b/app_python/docs/screenshots/build-terminal.png differ diff --git a/app_python/docs/screenshots/docker-curl.png b/app_python/docs/screenshots/docker-curl.png new file mode 100644 index 0000000000..b39eacb608 Binary files /dev/null and b/app_python/docs/screenshots/docker-curl.png differ diff --git a/app_python/docs/screenshots/docker-run-terminal.png b/app_python/docs/screenshots/docker-run-terminal.png new file mode 100644 index 0000000000..000d2f2407 Binary files /dev/null and b/app_python/docs/screenshots/docker-run-terminal.png differ diff --git a/app_python/docs/screenshots/found-flask-error.png b/app_python/docs/screenshots/found-flask-error.png new file mode 100644 index 0000000000..03fa36b74c Binary files /dev/null and b/app_python/docs/screenshots/found-flask-error.png differ diff --git a/app_python/docs/screenshots/health_request_json_output.png b/app_python/docs/screenshots/health_request_json_output.png new file mode 100644 index 0000000000..32ec837122 Binary files /dev/null and b/app_python/docs/screenshots/health_request_json_output.png differ diff --git a/app_python/docs/screenshots/health_request_terminal_output.png b/app_python/docs/screenshots/health_request_terminal_output.png new file mode 100644 index 0000000000..5b3257b8b0 Binary files /dev/null and b/app_python/docs/screenshots/health_request_terminal_output.png differ diff --git a/app_python/docs/screenshots/image-from-github.png b/app_python/docs/screenshots/image-from-github.png new file mode 100644 index 0000000000..2da2135710 Binary files /dev/null and b/app_python/docs/screenshots/image-from-github.png differ diff --git a/app_python/docs/screenshots/image.png b/app_python/docs/screenshots/image.png new file mode 100644 index 0000000000..b4109d380f Binary files /dev/null and b/app_python/docs/screenshots/image.png differ diff --git a/app_python/docs/screenshots/pipeline-budget.png b/app_python/docs/screenshots/pipeline-budget.png new file mode 100644 index 0000000000..b4109d380f Binary files /dev/null and b/app_python/docs/screenshots/pipeline-budget.png differ diff --git a/app_python/docs/screenshots/pipeline-with-best-practice.png b/app_python/docs/screenshots/pipeline-with-best-practice.png new file mode 100644 index 0000000000..fb1824b62b Binary files /dev/null and b/app_python/docs/screenshots/pipeline-with-best-practice.png differ diff --git a/app_python/docs/screenshots/pytest_local_result.png b/app_python/docs/screenshots/pytest_local_result.png new file mode 100644 index 0000000000..93f5e3c98e Binary files /dev/null and b/app_python/docs/screenshots/pytest_local_result.png differ diff --git a/app_python/docs/screenshots/terminal_output_base_request.png b/app_python/docs/screenshots/terminal_output_base_request.png new file mode 100644 index 0000000000..ef4c86e346 Binary files /dev/null and b/app_python/docs/screenshots/terminal_output_base_request.png differ diff --git a/app_python/requirements.txt b/app_python/requirements.txt new file mode 100644 index 0000000000..846beb997a --- /dev/null +++ b/app_python/requirements.txt @@ -0,0 +1,4 @@ +flask==3.1.1 +pytest==8.2.2 +pytest-cov==5.0.0 +ruff==0.6.9 \ No newline at end of file diff --git a/app_python/tests/__init__.py b/app_python/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app_python/tests/test_app.py b/app_python/tests/test_app.py new file mode 100644 index 0000000000..d19f755278 --- /dev/null +++ b/app_python/tests/test_app.py @@ -0,0 +1,167 @@ +import pytest +from datetime import datetime, timezone +from unittest.mock import patch +import sys +import os + +# Add parent directory to path to import app +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from app import app, get_system_info, get_uptime, get_request_info + + +@pytest.fixture +def client(): + """Create a test client for the Flask app.""" + app.config['TESTING'] = True + with app.test_client() as client: + yield client + + +def test_get_system_info(): + """Test that system info returns expected keys and types.""" + info = get_system_info() + assert isinstance(info, dict) + assert "hostname" in info + assert "platform" in info + assert "platform_version" in info + assert "architecture" in info + assert "cpu_count" in info + assert "python_version" in info + + assert isinstance(info["hostname"], str) + assert isinstance(info["platform"], str) + assert isinstance(info["cpu_count"], int) + assert info["cpu_count"] >= 1 + + +def test_get_uptime(): + """Test uptime calculation logic.""" + uptime = get_uptime() + assert "seconds" in uptime + assert "human" in uptime + assert isinstance(uptime["seconds"], int) + assert uptime["seconds"] >= 0 + assert isinstance(uptime["human"], str) + assert "hours" in uptime["human"] + assert "minutes" in uptime["human"] + + +def test_get_request_info_with_context(): + """Test get_request_info inside a request context.""" + # Test with X-Forwarded-For + with app.test_request_context( + "/test-path", + headers={"User-Agent": "pytest-agent", "X-Forwarded-For": "192.0.2.1"}, + environ_base={"REMOTE_ADDR": "10.0.0.1"} + ): + info = get_request_info() + assert info["client_ip"] == "192.0.2.1" + assert info["user_agent"] == "pytest-agent" + assert info["method"] == "GET" + assert info["path"] == "/test-path" + + # Test without X-Forwarded-For (use remote_addr) + with app.test_request_context( + "/", + environ_base={"REMOTE_ADDR": "192.168.1.100"} + ): + info = get_request_info() + assert info["client_ip"] == "192.168.1.100" + + +def test_index_endpoint(client): + """Test the main '/' endpoint returns correct structure and data.""" + response = client.get("/") + assert response.status_code == 200 + data = response.get_json() + + assert "service" in data + assert "system" in data + assert "runtime" in data + assert "request" in data + assert "endpoints" in data + + service = data["service"] + assert service["name"] == "devops-info-service" + assert service["version"] == "1.0.0" + assert "description" in service + + system = data["system"] + assert "hostname" in system + assert "platform" in system + + runtime = data["runtime"] + assert "uptime_seconds" in runtime + assert "uptime_human" in runtime + assert "current-time" in runtime + assert runtime["timezone"] == "UTC" + + current_time = datetime.fromisoformat(runtime["current-time"].replace("Z", "+00:00")) + assert current_time.tzinfo == timezone.utc + + req_info = data["request"] + assert "client_ip" in req_info + assert "user_agent" in req_info + assert req_info["method"] == "GET" + assert req_info["path"] == "/" + + endpoints = data["endpoints"] + assert len(endpoints) == 2 + paths = {e["path"] for e in endpoints} + assert "/" in paths + assert "/health" in paths + + +def test_health_endpoint(client): + """Test the /health endpoint.""" + response = client.get("/health") + assert response.status_code == 200 + data = response.get_json() + + assert "status" in data + assert data["status"] == "healthy" + assert "timestamp" in data + assert "uptime_seconds" in data + + ts = datetime.fromisoformat(data["timestamp"].replace("Z", "+00:00")) + assert ts.tzinfo == timezone.utc + + assert isinstance(data["uptime_seconds"], int) + assert data["uptime_seconds"] >= 0 + + +def test_404_error_handler(client): + """Test that invalid routes return 404 with proper JSON.""" + response = client.get("/nonexistent") + assert response.status_code == 404 + data = response.get_json() + assert "error" in data + assert data["error"] == "Not Found" + assert "message" in data + + +def test_uptime_consistency_between_endpoints(client): + """Ensure uptime is consistent between / and /health at roughly same time.""" + resp1 = client.get("/") + resp2 = client.get("/health") + + data1 = resp1.get_json() + data2 = resp2.get_json() + + uptime1 = data1["runtime"]["uptime_seconds"] + uptime2 = data2["uptime_seconds"] + + assert abs(uptime1 - uptime2) <= 2 + + +def test_timezone_is_utc(client): + """Ensure all timestamps are in UTC.""" + resp = client.get("/") + data = resp.get_json() + current_time_str = data["runtime"]["current-time"] + + assert current_time_str.endswith("Z") or "+00:00" in current_time_str + + dt = datetime.fromisoformat(current_time_str.replace("Z", "+00:00")) + assert dt.utcoffset().total_seconds() == 0 \ No newline at end of file diff --git a/docs/LAB04.md b/docs/LAB04.md new file mode 100644 index 0000000000..b4b74ef677 --- /dev/null +++ b/docs/LAB04.md @@ -0,0 +1,757 @@ +# Cloud provider seletion + +As cloud provider used Yandex Cloud service. Why this choose? Because Yandex Cloud has clearly guide for using terraform for Yandex Cloud VM. +Also it give grant for new billing account 4000 rub. + + +# Task 1 + +## Terraform setup in local machine + +All setups of the terraform done by guid from the Yandex Cloud Service + +Link: https://yandex.cloud/ru/docs/tutorials/infrastructure-management/terraform-quickstart + +## Terraform version what used. + +As terraform version choosen 3.14 because it is avalable from AUR package in Arch linux + +Installed using command: + +```bash +yay -S terraform +``` + +## Added terraform files to `.gitignore` + +```.gitignore +# Terraform files +*.tfstate +*.tfstate.* +*.tfvars +.terraform/ +terraform.tfvars + +# Sensitive files +secrets.auto.tfvars +``` + +## terraform.tfvars + +This file in `.gitignore` but in the `terraform` directory you can found `terraform.tfvars.example` file + +## `terraform plan` command output + +```bash +➜ terraform git:(lab4) ✗ terraform plan +data.yandex_vpc_network.default: Reading... +yandex_compute_disk.boot-disk-1: Refreshing state... [id=fv4975a78m65hdgfp9ck] +data.yandex_vpc_network.default: Read complete after 1s [id=enpgtmn84rsa6f087a0q] +yandex_vpc_subnet.subnet-1: Refreshing state... [id=fl8kq39okktku0aaglvb] + +Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + + create +-/+ destroy and then create replacement + +Terraform will perform the following actions: + + # yandex_compute_disk.boot-disk-1 must be replaced +-/+ resource "yandex_compute_disk" "boot-disk-1" { + ~ created_at = "2026-02-14T13:52:02Z" -> (known after apply) + ~ folder_id = "b1gv65t8e2aljrlbd9ek" -> (known after apply) + ~ id = "fv4975a78m65hdgfp9ck" -> (known after apply) + - labels = {} -> null + name = "boot-disk-1" + ~ product_ids = [ + - "f2erq6hp9j4r8leept6g", + ] -> (known after apply) + ~ status = "ready" -> (known after apply) + ~ zone = "ru-central1-d" -> "ru-central1-a" # forces replacement + # (6 unchanged attributes hidden) + + ~ disk_placement_policy (known after apply) + - disk_placement_policy { + # (1 unchanged attribute hidden) + } + + ~ hardware_generation (known after apply) + - hardware_generation { + - legacy_features { + - pci_topology = "PCI_TOPOLOGY_V1" -> null + } + } + } + + # yandex_compute_instance.vm-1 will be created + + resource "yandex_compute_instance" "vm-1" { + + created_at = (known after apply) + + folder_id = (known after apply) + + fqdn = (known after apply) + + gpu_cluster_id = (known after apply) + + hardware_generation = (known after apply) + + hostname = (known after apply) + + id = (known after apply) + + maintenance_grace_period = (known after apply) + + maintenance_policy = (known after apply) + + metadata = { + + "ssh-keys" = <<-EOT + ubuntu:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAByPOk3aT5p1UzFU+KcISYZSVKofjNm0ZLC2XAqw7dX s.zaynulin@innopolis.university + EOT + } + + name = "terraform1" + + network_acceleration_type = "standard" + + platform_id = "standard-v1" + + status = (known after apply) + + zone = "ru-central1-a" + + + boot_disk { + + auto_delete = true + + device_name = (known after apply) + + disk_id = (known after apply) + + mode = (known after apply) + + + initialize_params (known after apply) + } + + + metadata_options (known after apply) + + + network_interface { + + index = (known after apply) + + ip_address = (known after apply) + + ipv4 = true + + ipv6 = (known after apply) + + ipv6_address = (known after apply) + + mac_address = (known after apply) + + nat = true + + nat_ip_address = (known after apply) + + nat_ip_version = (known after apply) + + subnet_id = (known after apply) + } + + + placement_policy (known after apply) + + + resources { + + core_fraction = 100 + + cores = 2 + + memory = 2 + } + + + scheduling_policy (known after apply) + } + + # yandex_vpc_subnet.subnet-1 must be replaced +-/+ resource "yandex_vpc_subnet" "subnet-1" { + ~ created_at = "2026-02-14T13:52:02Z" -> (known after apply) + ~ folder_id = "b1gv65t8e2aljrlbd9ek" -> (known after apply) + ~ id = "fl8kq39okktku0aaglvb" -> (known after apply) + ~ labels = {} -> (known after apply) + name = "subnet-terraform" + ~ v6_cidr_blocks = [] -> (known after apply) + ~ zone = "ru-central1-d" -> "ru-central1-a" # forces replacement + # (4 unchanged attributes hidden) + } + +Plan: 3 to add, 0 to change, 2 to destroy. + +Changes to Outputs: + + external_ip_address_vm_1 = (known after apply) + + internal_ip_address_vm_1 = (known after apply) +╷ +│ Warning: Cannot connect to YC tool initialization service. Network connectivity to the service is required for provider version control. +│ +│ +│ with provider["registry.terraform.io/yandex-cloud/yandex"], +│ on main.tf line 11, in provider "yandex": +│ 11: provider "yandex" { +│ +╵ + +─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + +Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now. +``` + +## Terraform apply output + +```bash +➜ terraform git:(lab4) ✗ terraform apply +data.yandex_vpc_network.default: Reading... +yandex_compute_disk.boot-disk-1: Refreshing state... [id=fv4975a78m65hdgfp9ck] +data.yandex_vpc_network.default: Read complete after 1s [id=enpgtmn84rsa6f087a0q] +yandex_vpc_subnet.subnet-1: Refreshing state... [id=fl8kq39okktku0aaglvb] + +Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + + create +-/+ destroy and then create replacement + +Terraform will perform the following actions: + + # yandex_compute_disk.boot-disk-1 must be replaced +-/+ resource "yandex_compute_disk" "boot-disk-1" { + ~ created_at = "2026-02-14T13:52:02Z" -> (known after apply) + ~ folder_id = "b1gv65t8e2aljrlbd9ek" -> (known after apply) + ~ id = "fv4975a78m65hdgfp9ck" -> (known after apply) + - labels = {} -> null + name = "boot-disk-1" + ~ product_ids = [ + - "f2erq6hp9j4r8leept6g", + ] -> (known after apply) + ~ status = "ready" -> (known after apply) + ~ zone = "ru-central1-d" -> "ru-central1-a" # forces replacement + # (6 unchanged attributes hidden) + + ~ disk_placement_policy (known after apply) + - disk_placement_policy { + # (1 unchanged attribute hidden) + } + + ~ hardware_generation (known after apply) + - hardware_generation { + - legacy_features { + - pci_topology = "PCI_TOPOLOGY_V1" -> null + } + } + } + + # yandex_compute_instance.vm-1 will be created + + resource "yandex_compute_instance" "vm-1" { + + created_at = (known after apply) + + folder_id = (known after apply) + + fqdn = (known after apply) + + gpu_cluster_id = (known after apply) + + hardware_generation = (known after apply) + + hostname = (known after apply) + + id = (known after apply) + + maintenance_grace_period = (known after apply) + + maintenance_policy = (known after apply) + + metadata = { + + "ssh-keys" = <<-EOT + ubuntu:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAByPOk3aT5p1UzFU+KcISYZSVKofjNm0ZLC2XAqw7dX s.zaynulin@innopolis.university + EOT + } + + name = "terraform1" + + network_acceleration_type = "standard" + + platform_id = "standard-v1" + + status = (known after apply) + + zone = "ru-central1-a" + + + boot_disk { + + auto_delete = true + + device_name = (known after apply) + + disk_id = (known after apply) + + mode = (known after apply) + + + initialize_params (known after apply) + } + + + metadata_options (known after apply) + + + network_interface { + + index = (known after apply) + + ip_address = (known after apply) + + ipv4 = true + + ipv6 = (known after apply) + + ipv6_address = (known after apply) + + mac_address = (known after apply) + + nat = true + + nat_ip_address = (known after apply) + + nat_ip_version = (known after apply) + + subnet_id = (known after apply) + } + + + placement_policy (known after apply) + + + resources { + + core_fraction = 100 + + cores = 2 + + memory = 2 + } + + + scheduling_policy (known after apply) + } + + # yandex_vpc_subnet.subnet-1 must be replaced +-/+ resource "yandex_vpc_subnet" "subnet-1" { + ~ created_at = "2026-02-14T13:52:02Z" -> (known after apply) + ~ folder_id = "b1gv65t8e2aljrlbd9ek" -> (known after apply) + ~ id = "fl8kq39okktku0aaglvb" -> (known after apply) + ~ labels = {} -> (known after apply) + name = "subnet-terraform" + ~ v6_cidr_blocks = [] -> (known after apply) + ~ zone = "ru-central1-d" -> "ru-central1-a" # forces replacement + # (4 unchanged attributes hidden) + } + +Plan: 3 to add, 0 to change, 2 to destroy. + +Changes to Outputs: + + external_ip_address_vm_1 = (known after apply) + + internal_ip_address_vm_1 = (known after apply) +╷ +│ Warning: Cannot connect to YC tool initialization service. Network connectivity to the service is required for provider version control. +│ +│ +│ with provider["registry.terraform.io/yandex-cloud/yandex"], +│ on main.tf line 11, in provider "yandex": +│ 11: provider "yandex" { +│ +╵ + +Do you want to perform these actions? + Terraform will perform the actions described above. + Only 'yes' will be accepted to approve. + + Enter a value: yes + +yandex_vpc_subnet.subnet-1: Destroying... [id=fl8kq39okktku0aaglvb] +yandex_compute_disk.boot-disk-1: Destroying... [id=fv4975a78m65hdgfp9ck] +yandex_vpc_subnet.subnet-1: Destruction complete after 1s +yandex_vpc_subnet.subnet-1: Creating... +yandex_vpc_subnet.subnet-1: Creation complete after 0s [id=e9b33qjag13gak3luifr] +yandex_compute_disk.boot-disk-1: Destruction complete after 6s +yandex_compute_disk.boot-disk-1: Creating... +yandex_compute_disk.boot-disk-1: Creation complete after 8s [id=fhm304muqn8491urb9n2] +yandex_compute_instance.vm-1: Creating... +yandex_compute_instance.vm-1: Still creating... [00m10s elapsed] +yandex_compute_instance.vm-1: Still creating... [00m20s elapsed] +yandex_compute_instance.vm-1: Still creating... [00m30s elapsed] +yandex_compute_instance.vm-1: Creation complete after 31s [id=fhm7dkjmnuqj3v57f8vi] +╷ +│ Warning: Cannot connect to YC tool initialization service. Network connectivity to the service is required for provider version control. +│ +│ +│ with provider["registry.terraform.io/yandex-cloud/yandex"], +│ on main.tf line 11, in provider "yandex": +│ 11: provider "yandex" { +│ +╵ + +Apply complete! Resources: 3 added, 0 changed, 2 destroyed. + +Outputs: + +external_ip_address_vm_1 = "51.250.8.110" +internal_ip_address_vm_1 = "192.168.10.7" +➜ terraform git:(lab4) ✗ +``` + +## Result in yandex cloud + +![yandex-cloud-result](screenshots/yandex-cloud-result.png) + +## ssh connection command + +In output of terraform apply added this to parametr to see internal and external IP addresses + +```bash +external_ip_address_vm_1 = "51.250.8.110" +internal_ip_address_vm_1 = "192.168.10.7" +``` + +In the procees of creating VM using we use public ssh key what created in the system. + +```tf + metadata = { + ssh-keys = "ubuntu:${file("~/.ssh/id_ed25519.pub")}" + } +``` + +So for connecting to the server we just need use this command + +```bash +ssh -i ~/.ssh/id_ed25519 ubuntu@51.250.8.110 +``` + +# Task 2 + +## `terraform destroy` output + +```bash +➜ terraform git:(lab4) ✗ terraform destroy +data.yandex_vpc_network.default: Reading... +yandex_compute_disk.boot-disk-1: Refreshing state... [id=fhm304muqn8491urb9n2] +data.yandex_vpc_network.default: Read complete after 1s [id=enpgtmn84rsa6f087a0q] +yandex_vpc_subnet.subnet-1: Refreshing state... [id=e9b33qjag13gak3luifr] +yandex_compute_instance.vm-1: Refreshing state... [id=fhm7dkjmnuqj3v57f8vi] + +Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + - destroy + +Terraform will perform the following actions: + + # yandex_compute_disk.boot-disk-1 will be destroyed + - resource "yandex_compute_disk" "boot-disk-1" { + - block_size = 4096 -> null + - created_at = "2026-02-14T13:56:12Z" -> null + - folder_id = "b1gv65t8e2aljrlbd9ek" -> null + - id = "fhm304muqn8491urb9n2" -> null + - image_id = "fd800c7s2p483i648ifv" -> null + - labels = {} -> null + - name = "boot-disk-1" -> null + - product_ids = [ + - "f2erq6hp9j4r8leept6g", + ] -> null + - size = 20 -> null + - status = "ready" -> null + - type = "network-hdd" -> null + - zone = "ru-central1-a" -> null + # (2 unchanged attributes hidden) + + - disk_placement_policy { + # (1 unchanged attribute hidden) + } + + - hardware_generation { + - legacy_features { + - pci_topology = "PCI_TOPOLOGY_V1" -> null + } + } + } + + # yandex_compute_instance.vm-1 will be destroyed + - resource "yandex_compute_instance" "vm-1" { + - created_at = "2026-02-14T13:56:20Z" -> null + - folder_id = "b1gv65t8e2aljrlbd9ek" -> null + - fqdn = "fhm7dkjmnuqj3v57f8vi.auto.internal" -> null + - hardware_generation = [ + - { + - generation2_features = [] + - legacy_features = [ + - { + - pci_topology = "PCI_TOPOLOGY_V1" + }, + ] + }, + ] -> null + - id = "fhm7dkjmnuqj3v57f8vi" -> null + - labels = {} -> null + - metadata = { + - "ssh-keys" = <<-EOT + ubuntu:ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAByPOk3aT5p1UzFU+KcISYZSVKofjNm0ZLC2XAqw7dX s.zaynulin@innopolis.university + EOT + } -> null + - name = "terraform1" -> null + - network_acceleration_type = "standard" -> null + - platform_id = "standard-v1" -> null + - status = "running" -> null + - zone = "ru-central1-a" -> null + # (5 unchanged attributes hidden) + + - boot_disk { + - auto_delete = true -> null + - device_name = "fhm304muqn8491urb9n2" -> null + - disk_id = "fhm304muqn8491urb9n2" -> null + - mode = "READ_WRITE" -> null + + - initialize_params { + - block_size = 4096 -> null + - image_id = "fd800c7s2p483i648ifv" -> null + - name = "boot-disk-1" -> null + - size = 20 -> null + - type = "network-hdd" -> null + # (3 unchanged attributes hidden) + } + } + + - metadata_options { + - aws_v1_http_endpoint = 1 -> null + - aws_v1_http_token = 2 -> null + - gce_http_endpoint = 1 -> null + - gce_http_token = 1 -> null + } + + - network_interface { + - index = 0 -> null + - ip_address = "192.168.10.7" -> null + - ipv4 = true -> null + - ipv6 = false -> null + - mac_address = "d0:0d:76:d2:76:bf" -> null + - nat = true -> null + - nat_ip_address = "51.250.8.110" -> null + - nat_ip_version = "IPV4" -> null + - security_group_ids = [] -> null + - subnet_id = "e9b33qjag13gak3luifr" -> null + # (1 unchanged attribute hidden) + } + + - placement_policy { + - host_affinity_rules = [] -> null + - placement_group_partition = 0 -> null + # (1 unchanged attribute hidden) + } + + - resources { + - core_fraction = 100 -> null + - cores = 2 -> null + - gpus = 0 -> null + - memory = 2 -> null + } + + - scheduling_policy { + - preemptible = false -> null + } + } + + # yandex_vpc_subnet.subnet-1 will be destroyed + - resource "yandex_vpc_subnet" "subnet-1" { + - created_at = "2026-02-14T13:56:06Z" -> null + - folder_id = "b1gv65t8e2aljrlbd9ek" -> null + - id = "e9b33qjag13gak3luifr" -> null + - labels = {} -> null + - name = "subnet-terraform" -> null + - network_id = "enpgtmn84rsa6f087a0q" -> null + - v4_cidr_blocks = [ + - "192.168.10.0/24", + ] -> null + - v6_cidr_blocks = [] -> null + - zone = "ru-central1-a" -> null + # (2 unchanged attributes hidden) + } + +Plan: 0 to add, 0 to change, 3 to destroy. + +Changes to Outputs: + - external_ip_address_vm_1 = "51.250.8.110" -> null + - internal_ip_address_vm_1 = "192.168.10.7" -> null +╷ +│ Warning: Cannot connect to YC tool initialization service. Network connectivity to the service is required for provider version control. +│ +│ +│ with provider["registry.terraform.io/yandex-cloud/yandex"], +│ on main.tf line 11, in provider "yandex": +│ 11: provider "yandex" { +│ +╵ + +Do you really want to destroy all resources? + Terraform will destroy all your managed infrastructure, as shown above. + There is no undo. Only 'yes' will be accepted to confirm. + + Enter a value: yes + +yandex_compute_instance.vm-1: Destroying... [id=fhm7dkjmnuqj3v57f8vi] +yandex_compute_instance.vm-1: Still destroying... [id=fhm7dkjmnuqj3v57f8vi, 00m10s elapsed] +yandex_compute_instance.vm-1: Still destroying... [id=fhm7dkjmnuqj3v57f8vi, 00m20s elapsed] +yandex_compute_instance.vm-1: Still destroying... [id=fhm7dkjmnuqj3v57f8vi, 00m30s elapsed] +yandex_compute_instance.vm-1: Destruction complete after 33s +yandex_vpc_subnet.subnet-1: Destroying... [id=e9b33qjag13gak3luifr] +yandex_compute_disk.boot-disk-1: Destroying... [id=fhm304muqn8491urb9n2] +yandex_compute_disk.boot-disk-1: Destruction complete after 0s +yandex_vpc_subnet.subnet-1: Destruction complete after 6s +╷ +│ Warning: Cannot connect to YC tool initialization service. Network connectivity to the service is required for provider version control. +│ +│ +│ with provider["registry.terraform.io/yandex-cloud/yandex"], +│ on main.tf line 11, in provider "yandex": +│ 11: provider "yandex" { +│ +╵ + +Destroy complete! Resources: 3 destroyed. +``` + +## Language for pulumi + +For pulumi in tasks will choosen `python`. Because it more easy to use. + +## Pulumi project initialization + +To init pulumi project use this command: + +```bash + pulumi new python --name vm-infrastructure --description "yandex-cloud-vm" --stack dev +``` + +As libs version control system will be choosen `poetry` + +## Pulumi configuration setup + +For setup pulumi configs will be used this commands: + +```bash +poetry run pulumi config set yandex:token "ваш_oauth_токен_или_iam" +poetry run pulumi config set yandex:cloud-id "ваш_cloud_id" +poetry run pulumi config set yandex:folder-id "ваш_folder_id" +poetry run pulumi config set yandex:zone "ru-central1-a" +``` + +## `pulumi preview` command output + +```bash +(venv) ➜ pulumi git:(lab4) ✗ pulumi preview +Previewing update (dev) + +View in Browser (Ctrl+O): https://app.pulumi.com/setterwars-org/yc-infra/dev/previews/63ed8df9-f76b-42e8-920a-1f6f11045a43 + + Type Name Plan + + pulumi:pulumi:Stack yc-infra-dev create + + ├─ yandex:index:VpcNetwork main-network create + + ├─ yandex:index:VpcSubnet main-subnet create + + ├─ yandex:index:VpcSecurityGroup web-sg create + + ├─ yandex:index:VpcSecurityGroupRule http-rule create + + ├─ yandex:index:VpcSecurityGroupRule egress-rule create + + ├─ yandex:index:ComputeInstance web-server create + + └─ yandex:index:VpcSecurityGroupRule ssh-rule create + +Outputs: + instance_id: [unknown] + public_ip : [unknown] + +Resources: + + 8 to create + +(venv) ➜ pulumi git:(lab4) ✗ +``` + +## `pulumi up` command output + +```bash + (venv) ➜ pulumi git:(lab4) ✗ pulumi up +Previewing update (dev) + +View in Browser (Ctrl+O): https://app.pulumi.com/setterwars-org/yc-infra/dev/previews/79cbccc7-1978-40cc-bdb7-47712a2b489f + + Type Name Plan + + pulumi:pulumi:Stack yc-infra-dev create + + ├─ yandex:index:VpcNetwork main-network create + + ├─ yandex:index:VpcSubnet main-subnet create + + ├─ yandex:index:VpcSecurityGroup web-sg create + + ├─ yandex:index:VpcSecurityGroupRule ssh-rule create + + ├─ yandex:index:VpcSecurityGroupRule http-rule create + + ├─ yandex:index:ComputeInstance web-server create + + └─ yandex:index:VpcSecurityGroupRule egress-rule create + +Outputs: + instance_id: [unknown] + public_ip : [unknown] + +Resources: + + 8 to create + +Do you want to perform this update? yes +Updating (dev) + +View in Browser (Ctrl+O): https://app.pulumi.com/setterwars-org/yc-infra/dev/updates/1 + + Type Name Status + + pulumi:pulumi:Stack yc-infra-dev created (52s) + + ├─ yandex:index:VpcNetwork main-network created (5s) + + ├─ yandex:index:VpcSubnet main-subnet created (0.94s) + + ├─ yandex:index:VpcSecurityGroup web-sg created (3s) + + ├─ yandex:index:VpcSecurityGroupRule ssh-rule created (1s) + + ├─ yandex:index:VpcSecurityGroupRule http-rule created (1s) + + ├─ yandex:index:ComputeInstance web-server created (41s) + + └─ yandex:index:VpcSecurityGroupRule egress-rule created (2s) + +Outputs: + instance_id: "fhm3ectribap57rejbap" + public_ip : "51.250.95.39" + +Resources: + + 8 created + +Duration: 54s + +(venv) ➜ pulumi git:(lab4) ✗ +``` + +## Result from yandex cloud + +![yandex-clud](screenshots/yandex-cloud-pulumi-res.png) + +## Public IP +In `__main__.py` you can see this rows: + +```python +pulumi.export("public_ip", instance.network_interfaces[0].nat_ip_address) +pulumi.export("instance_id", instance.id) +``` + +This row after completing will return to you public IP and instance id for your needs. + +Output in solution: + +```bash +Outputs: + - instance_id: "fhm3ectribap57rejbap" + - public_ip : "51.250.95.39" +``` + +## Command for connection to VM + +```bash +ssh -i id_ed25519.pub ubuntu@51.250.95.39 +``` + +## `pulumi destroy` command output + +```bash +(venv) ➜ pulumi git:(lab4) ✗ pulumi destroy +Previewing destroy (dev) + +View in Browser (Ctrl+O): https://app.pulumi.com/setterwars-org/yc-infra/dev/previews/238d5ff4-0c1b-49dc-ac79-3a90bc8fd945 + + Type Name Plan + - pulumi:pulumi:Stack yc-infra-dev delete + - ├─ yandex:index:VpcSecurityGroupRule ssh-rule delete + - ├─ yandex:index:VpcSecurityGroupRule egress-rule delete + - ├─ yandex:index:VpcSecurityGroupRule http-rule delete + - ├─ yandex:index:VpcSubnet main-subnet delete + - ├─ yandex:index:VpcNetwork main-network delete + - ├─ yandex:index:ComputeInstance web-server delete + - └─ yandex:index:VpcSecurityGroup web-sg delete + +Outputs: + - instance_id: "fhm3ectribap57rejbap" + - public_ip : "51.250.95.39" + +Resources: + - 8 to delete + +Do you want to perform this destroy? yes +Destroying (dev) + +View in Browser (Ctrl+O): https://app.pulumi.com/setterwars-org/yc-infra/dev/updates/2 + + Type Name Status + - pulumi:pulumi:Stack yc-infra-dev deleted (0.25s) + - ├─ yandex:index:VpcSecurityGroupRule egress-rule deleted (2s) + - ├─ yandex:index:VpcSecurityGroupRule ssh-rule deleted (3s) + - ├─ yandex:index:VpcSecurityGroupRule http-rule deleted (4s) + - ├─ yandex:index:ComputeInstance web-server deleted (34s) + - ├─ yandex:index:VpcSecurityGroup web-sg deleted (0.91s) + - ├─ yandex:index:VpcSubnet main-subnet deleted (2s) + - └─ yandex:index:VpcNetwork main-network deleted (1s) + +Outputs: + - instance_id: "fhm3ectribap57rejbap" + - public_ip : "51.250.95.39" + +Resources: + - 8 deleted + +Duration: 41s + +The resources in the stack have been deleted, but the history and configuration associated with the stack are still maintained. +If you want to remove the stack completely, run `pulumi stack rm dev`. +(venv) ➜ pulumi git:(lab4) +``` + +## Terraform vs Pulumi + +For me terraform be more easier, because then I work with pulumi I have some problems. For example yandex cloud provider does not work clearly with python 3.14 so I need to down grade to python 3.11. In terraform I just install it using aur packages and it work from package. + +# Lab 5 preparation + +For lab 5 I will keep my VM, created from the terraform. But for now I stoped it because Deposit in yandex cloud not endless. + +# Bonus task + +## Created `terraform-ci.yml` + +In this task will be created ci/cd pipeline for terraform files. Created linters, fmt and validate for correctness of the +`main.tf` + +## Screenshot of the completed CI/CD pipeline + diff --git a/docs/screenshots/yandex-cloud-pulumi-res.png b/docs/screenshots/yandex-cloud-pulumi-res.png new file mode 100644 index 0000000000..0cbaca011d Binary files /dev/null and b/docs/screenshots/yandex-cloud-pulumi-res.png differ diff --git a/docs/screenshots/yandex-cloud-result.png b/docs/screenshots/yandex-cloud-result.png new file mode 100644 index 0000000000..3acea4a5c6 Binary files /dev/null and b/docs/screenshots/yandex-cloud-result.png differ diff --git a/pulumi/.gitignore b/pulumi/.gitignore new file mode 100644 index 0000000000..a3807e5bdb --- /dev/null +++ b/pulumi/.gitignore @@ -0,0 +1,2 @@ +*.pyc +venv/ diff --git a/pulumi/Pulumi.yaml b/pulumi/Pulumi.yaml new file mode 100644 index 0000000000..c5474c1459 --- /dev/null +++ b/pulumi/Pulumi.yaml @@ -0,0 +1,11 @@ +name: yc-infra +description: yandex-cloud-vm +runtime: + name: python + options: + toolchain: pip + virtualenv: venv +config: + pulumi:tags: + value: + pulumi:template: python diff --git a/pulumi/__main__.py b/pulumi/__main__.py new file mode 100644 index 0000000000..777dc2ce46 --- /dev/null +++ b/pulumi/__main__.py @@ -0,0 +1,92 @@ +import pulumi +import pulumi_yandex as yandex +import os + +vpc_network = yandex.VpcNetwork( + "main-network", + name="main-network" +) + +subnet = yandex.VpcSubnet( + "main-subnet", + zone="ru-central1-a", + network_id=vpc_network.id, + v4_cidr_blocks=["10.0.1.0/24"], + name="main-subnet" +) + +security_group = yandex.VpcSecurityGroup( + "web-sg", + network_id=vpc_network.id, + name="web-sg", + description="Allow SSH and HTTP" +) + +ssh_rule = yandex.VpcSecurityGroupRule( + "ssh-rule", + security_group_binding=security_group.id, + direction="ingress", + protocol="TCP", + port=22, + v4_cidr_blocks=["0.0.0.0/0"], + description="Allow SSH" +) + +http_rule = yandex.VpcSecurityGroupRule( + "http-rule", + security_group_binding=security_group.id, + direction="ingress", + protocol="TCP", + port=80, + v4_cidr_blocks=["0.0.0.0/0"], + description="Allow HTTP" +) + +egress_rule = yandex.VpcSecurityGroupRule( + "egress-rule", + security_group_binding=security_group.id, + direction="egress", + protocol="ANY", + from_port=0, + to_port=65535, + v4_cidr_blocks=["0.0.0.0/0"], + description="Allow all outbound" +) + +ssh_key_path = os.path.expanduser("~/.ssh/id_ed25519.pub") + +with open(ssh_key_path, "r") as f: + ssh_public_key = f.read().strip() + +instance = yandex.ComputeInstance( + "web-server", + name="web-server", + zone="ru-central1-a", + platform_id="standard-v2", + + resources={ + "cores": 2, + "memory": 2, + }, + + boot_disk={ + "initialize_params": { + "image_id": "fd84n8eontaojc77hp0u", # Ubuntu 22.04 LTS + "type": "network-hdd", + "size": 10, + } + }, + + network_interfaces=[{ + "subnet_id": subnet.id, + "nat": True, + "security_group_ids": [security_group.id], + }], + + metadata={ + "ssh-keys": f"ubuntu:{ssh_public_key}" + } +) + +pulumi.export("public_ip", instance.network_interfaces[0].nat_ip_address) +pulumi.export("instance_id", instance.id) \ No newline at end of file diff --git a/pulumi/requirements.txt b/pulumi/requirements.txt new file mode 100644 index 0000000000..bc4e43087b --- /dev/null +++ b/pulumi/requirements.txt @@ -0,0 +1 @@ +pulumi>=3.0.0,<4.0.0 diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000000..125205b318 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,9 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/yandex-cloud/yandex" { + version = "0.186.0" + hashes = [ + "h1:sR0wAL+16ZL2MK1+VcHb52hZ6J1W/sqiLx13+SoNFO4=", + ] +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..73465ff437 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,57 @@ +terraform { + required_providers { + yandex = { + source = "yandex-cloud/yandex" + version = ">= 0.80" + } + } + required_version = ">= 0.13" +} + +provider "yandex" { + cloud_id = var.yc_cloud_id + folder_id = var.yc_folder_id + token = var.yc_token +} + +data "yandex_vpc_network" "default" { + name = "default" +} + +resource "yandex_vpc_subnet" "subnet-1" { + name = "subnet-terraform" + zone = var.zone + network_id = data.yandex_vpc_network.default.id + v4_cidr_blocks = ["192.168.10.0/24"] +} + +resource "yandex_compute_disk" "boot-disk-1" { + name = "boot-disk-1" + type = "network-hdd" + zone = var.zone + size = 20 + image_id = "fd800c7s2p483i648ifv" +} + +resource "yandex_compute_instance" "vm-1" { + name = var.vm_name + zone = var.zone + + resources { + cores = 2 + memory = 2 + } + + boot_disk { + disk_id = yandex_compute_disk.boot-disk-1.id + } + + network_interface { + subnet_id = yandex_vpc_subnet.subnet-1.id + nat = true + } + + metadata = { + ssh-keys = "ubuntu:${trimspace(var.ssh_public_key)}" + } +} \ No newline at end of file diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..f3cad81927 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,9 @@ +output "internal_ip_address_vm_1" { + description = "Internal IP address of the first virtual machine" + value = yandex_compute_instance.vm-1.network_interface[0].ip_address +} + +output "external_ip_address_vm_1" { + description = "External (NAT) IP address of the first virtual machine" + value = yandex_compute_instance.vm-1.network_interface[0].nat_ip_address +} \ No newline at end of file diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000000..699ba9e857 --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,5 @@ +# RENAME TO terraform.tfvars AND FILL VALUES +yc_folder_id = "b1gvmob95yysaplct532" # Get from: yc config list +yc_cloud_id = "b1g8ad32v091og5d7e5l" # Get from: yc config list +my_ip_cidr = "95.123.45.67/32" # YOUR CURRENT IP (get via: curl ifconfig.me) +ssh_public_key = "ssh-rsa AAAAB3NzaC1yc2E... your_email@example.com" \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..a46d15d5c3 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,48 @@ +variable "yc_token" { + description = "Yandex Cloud IAM token (get via: yc iam create-token)" + type = string + sensitive = true +} + +variable "yc_folder_id" { + description = "Yandex Cloud folder ID (required for free tier)" + type = string +} + +variable "yc_cloud_id" { + description = "Yandex Cloud ID" + type = string +} + +variable "region" { + description = "Region for resources" + type = string + default = "ru-central1" +} + +variable "zone" { + description = "Availability zone" + type = string + default = "ru-central1-a" +} + +variable "vm_name" { + description = "VM instance name" + type = string + default = "free-tier-vm" +} + +variable "my_ip_cidr" { + description = "Your public IP address in CIDR format (e.g., 95.123.45.67/32) for SSH access" + type = string + validation { + condition = can(cidrhost(var.my_ip_cidr, 0)) + error_message = "Must be a valid CIDR block (e.g., 95.123.45.67/32). Get your IP: curl ifconfig.me" + } +} + +variable "ssh_public_key" { + description = "SSH public key for VM access (content of ~/.ssh/id_rsa.pub)" + type = string + sensitive = true +} \ No newline at end of file