diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e69de29 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e3eb2c7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,42 @@ + + +## Summary + +Brief description of what this PR does. + +## Related Issues + +- Closes # +- Relates to # + +## Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] CI/CD improvement +- [ ] Refactoring + +## Testing + +- [ ] `go test ./...` passes +- [ ] `go vet ./...` passes +- [ ] `gofmt -l .` returns no output +- [ ] `docker build` works (if Dockerfile changed) +- [ ] New unit tests added for new functionality + +## Checklist + +- [ ] Code follows project style guidelines +- [ ] Self-review of code completed +- [ ] Documentation updated (README, docs, etc.) +- [ ] Tests added for new features +- [ ] All existing tests pass +- [ ] No secrets or credentials in code + +## Stacking Notes + +This PR is part of a stacked PR series. If stacked: +- Parent PR: # +- Child PRs: # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea4cfac..660f469 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,18 +9,23 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: lint: name: Lint runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: '1.24' + go-version-file: go.mod - name: Download dependencies run: go mod download @@ -30,117 +35,309 @@ jobs: - name: gofmt check run: | - # Normalize CRLF -> LF before gofmt, since the repo is authored - # on Windows and gofmt on the Linux runner expects LF. find . -name '*.go' -type f -exec sed -i 's/\r$//' {} + fmt_out=$(gofmt -l .) if [ -n "$fmt_out" ]; then echo "gofmt issues in:" echo "$fmt_out" + gofmt -d . exit 1 fi - - name: actionlint + - name: actionlint (workflows of this repository) run: | go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 - "$(go env GOPATH)"/bin/actionlint -color .github/workflows/ci.yml + "$(go env GOPATH)"/bin/actionlint -color .github/workflows/ci.yml .github/workflows/release.yml test: name: Test runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - go-version: ['1.24'] + timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - name: Set up Go ${{ matrix.go-version }} - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: ${{ matrix.go-version }} + go-version-file: go.mod - name: Download dependencies run: go mod download - - name: Test with coverage + - name: Test with race detector and coverage run: | - go test -coverprofile=coverage.out ./internal/... + go test -race -cover -coverprofile=coverage.out ./internal/... | tee coverage-pkg.txt go tool cover -func=coverage.out > coverage.txt - cat coverage.txt - - name: Enforce coverage floor + - name: Enforce coverage floor (80% per core package and total) run: | - TOTAL=$(grep -E '^total:' coverage.txt | awk '{print $NF}' | sed 's/%//') - echo "Total coverage: $TOTAL%" - awk -v t="$TOTAL" 'BEGIN { if (t < 80) { print "coverage " t "% is below 80% floor"; exit 1 } }' + fail=0 + for pkg in config detector generator validator filesystem; do + cov=$(grep -E "/internal/${pkg}[[:space:]]" coverage-pkg.txt | grep -oE 'coverage: [0-9.]+' | grep -oE '[0-9.]+' | head -1) + if [ -z "$cov" ]; then + echo "::error::no coverage reported for internal/${pkg}" + fail=1 + continue + fi + echo "${pkg} coverage: ${cov}%" + if awk -v c="$cov" 'BEGIN { exit !(c < 80) }'; then + echo "::error::internal/${pkg} coverage ${cov}% is below the 80% floor" + fail=1 + fi + done + total=$(grep -E '^total:' coverage.txt | awk '{print $NF}' | sed 's/%//') + echo "total coverage: ${total}%" + if awk -v c="$total" 'BEGIN { exit !(c < 80) }'; then + echo "::error::total coverage ${total}% is below the 80% floor" + fail=1 + fi + exit $fail - name: Upload coverage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage - path: coverage.txt + path: | + coverage.txt + coverage-pkg.txt build: - name: Build - runs-on: ubuntu-latest + name: Build (${{ matrix.name }}) + runs-on: ${{ matrix.os }} needs: [lint, test] + timeout-minutes: 15 strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - arch: [amd64] + include: + - { os: ubuntu-latest, name: linux-amd64, ext: "" } + - { os: macos-latest, name: macos-arm64, ext: "" } + - { os: windows-latest, name: windows-amd64, ext: ".exe" } steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: '1.24' + go-version-file: go.mod - name: Build - run: go build -o devopsgen-${{ matrix.os }}-${{ matrix.arch }} ./cmd/devopsgen + shell: bash + run: go build -o "devopsgen-${{ matrix.name }}${{ matrix.ext }}" ./cmd/devopsgen - - name: Smoke test + - name: Smoke test (native runner) shell: bash run: | - ./devopsgen-${{ matrix.os }}-${{ matrix.arch }} version - ./devopsgen-${{ matrix.os }}-${{ matrix.arch }} --help + "./devopsgen-${{ matrix.name }}${{ matrix.ext }}" version + "./devopsgen-${{ matrix.name }}${{ matrix.ext }}" --help - name: Upload binary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: devopsgen-${{ matrix.os }}-${{ matrix.arch }} - path: devopsgen-${{ matrix.os }}-${{ matrix.arch }} + name: devopsgen-${{ matrix.name }} + path: devopsgen-${{ matrix.name }}${{ matrix.ext }} - e2e: - name: End-to-end + docker-build-go: + name: Docker Build (Go) runs-on: ubuntu-latest needs: [build] + timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: - go-version: '1.24' + go-version-file: go.mod - - name: Run end-to-end generation - shell: bash + - name: Generate Go artifacts + run: | + # Build the generator OUTSIDE the repository: the Docker build context is the + # repository root, and a stray host binary there would let a broken Dockerfile pass. + go build -o "$RUNNER_TEMP/devopsgen" ./cmd/devopsgen + mkdir -p /tmp/e2e-go + "$RUNNER_TEMP/devopsgen" init \ + --stack go --ci github-actions --registry ghcr \ + --image bangkah/my-app --port 8080 \ + --test-cmd "go test ./..." \ + --build-path ./cmd/devopsgen \ + --tag v1 --k8s \ + --output-dir /tmp/e2e-go + + - name: Lint generated workflow (actionlint) + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + "$(go env GOPATH)"/bin/actionlint -color /tmp/e2e-go/.github/workflows/ci.yml + + - name: Lint generated Dockerfile (hadolint) + run: docker run --rm -i hadolint/hadolint:v2.12.0 hadolint --failure-threshold warning - < /tmp/e2e-go/Dockerfile + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build Go Docker image + run: | + # Build context is the repository root: devopsgen itself is the sample Go project. + test ! -e devopsgen # no prebuilt binary may leak into the build context + GO_VERSION=$(awk '/^go /{print $2}' go.mod) + docker build \ + --build-arg "GO_VERSION=${GO_VERSION}" \ + -t devopsgen-go-test \ + -f /tmp/e2e-go/Dockerfile \ + . + + - name: Run built image (smoke test) + run: docker run --rm devopsgen-go-test version + + docker-build-nodejs: + name: Docker Build (Node.js) + runs-on: ubuntu-latest + needs: [build] + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - name: Create sample Node.js project + run: | + mkdir -p /tmp/e2e-node + cat > /tmp/e2e-node/package.json <<'JSON' + { + "name": "e2e-sample", + "version": "1.0.0", + "private": true, + "scripts": { "start": "node server.js" } + } + JSON + cat > /tmp/e2e-node/server.js <<'JS' + const http = require('http'); + http.createServer((req, res) => res.end('ok')).listen(3000, '0.0.0.0'); + JS + + - name: Generate Node.js artifacts run: | go build -o devopsgen ./cmd/devopsgen - mkdir -p /tmp/e2e-out + ./devopsgen init \ + --stack nodejs --app-type express --ci github-actions \ + --registry ghcr --image bangkah/api --port 3000 \ + --start-cmd "node server.js" \ + --output-dir /tmp/e2e-node + + - name: Lint generated workflow (actionlint) + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + "$(go env GOPATH)"/bin/actionlint -color /tmp/e2e-node/.github/workflows/ci.yml + + - name: Lint generated Dockerfile (hadolint) + run: docker run --rm -i hadolint/hadolint:v2.12.0 hadolint --failure-threshold warning - < /tmp/e2e-node/Dockerfile + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build Node.js Docker image + run: docker build -t devopsgen-node-test -f /tmp/e2e-node/Dockerfile /tmp/e2e-node + + - name: Run built image and check HTTP response + run: | + docker run -d --name e2e-node -p 3000:3000 devopsgen-node-test + for _ in $(seq 1 15); do + if curl -fsS http://localhost:3000 >/dev/null; then break; fi + sleep 1 + done + curl -fsS http://localhost:3000 | grep -q '^ok$' + + - name: Container logs on failure + if: failure() + run: docker logs e2e-node || true + + - name: Cleanup + if: always() + run: docker rm -f e2e-node || true + + k8s-validate: + name: Validate Kubernetes Manifests + runs-on: ubuntu-latest + needs: [build] + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - name: Generate K8s artifacts + run: | + go build -o devopsgen ./cmd/devopsgen + mkdir -p /tmp/e2e-k8s ./devopsgen init \ --stack go --ci github-actions --registry ghcr \ --image bangkah/my-app --port 8080 \ - --build-cmd "go build -o server ." --test-cmd "go test ./..." \ - --tag v1 --k8s --ingress \ + --k8s --ingress \ --ingress-host app.example.com --health-path /health \ - --output-dir /tmp/e2e-out - test -f /tmp/e2e-out/Dockerfile - test -f /tmp/e2e-out/.github/workflows/ci.yml - test -f /tmp/e2e-out/kubernetes/deployment.yaml - ./devopsgen validate --dir /tmp/e2e-out \ No newline at end of file + --output-dir /tmp/e2e-k8s + + - name: Install kubeconform + run: | + curl -fsSL https://github.com/yannh/kubeconform/releases/download/v0.7.0/kubeconform-linux-amd64.tar.gz \ + | tar xz -C /usr/local/bin kubeconform + + - name: Validate Kubernetes manifests with kubeconform + run: kubeconform -strict -summary /tmp/e2e-k8s/kubernetes/ + + - name: Validate with DevOpsGen validator + run: ./devopsgen validate --dir /tmp/e2e-k8s + + e2e: + name: End-to-end + runs-on: ubuntu-latest + needs: [docker-build-go, docker-build-nodejs, k8s-validate] + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - name: Generate, validate, and check conflict/dry-run behavior + shell: bash + run: | + go build -o devopsgen ./cmd/devopsgen + mkdir -p /tmp/e2e-final + ARGS=(init + --stack go --ci github-actions --registry ghcr + --image bangkah/my-app --port 8080 + --build-cmd "go build -o server ." --test-cmd "go test ./..." + --tag v1 --k8s --ingress + --ingress-host app.example.com --health-path /health + --output-dir /tmp/e2e-final) + + ./devopsgen "${ARGS[@]}" + for f in Dockerfile .github/workflows/ci.yml \ + kubernetes/deployment.yaml kubernetes/service.yaml kubernetes/ingress.yaml; do + test -f "/tmp/e2e-final/$f" + done + ./devopsgen validate --dir /tmp/e2e-final + + # FR-11: a second run without --force must fail with exit code 3 (conflict). + code=0; ./devopsgen "${ARGS[@]}" || code=$? + test "$code" -eq 3 + + # FR-10: --dry-run with --force must succeed and leave files untouched. + before=$(cd /tmp/e2e-final && find . -type f -exec sha256sum {} + | sort) + ./devopsgen "${ARGS[@]}" --dry-run --force + after=$(cd /tmp/e2e-final && find . -type f -exec sha256sum {} + | sort) + test "$before" = "$after" \ No newline at end of file diff --git a/.gitignore b/.gitignore index b4fe18c..7c4d4fc 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,6 @@ out.log templates/local/ # Generated output -/dist/ \ No newline at end of file +/dist/ +/Dockerfile +/kubernetes/ \ No newline at end of file diff --git a/.graphite/config.yml b/.graphite/config.yml new file mode 100644 index 0000000..c7a9323 --- /dev/null +++ b/.graphite/config.yml @@ -0,0 +1,26 @@ +# Graphite configuration for DevOpsGen +# https://graphite.dev + +github_repo: Bangkahdev/DevOpsGen +github_org: Bangkahdev +stacking: + default_merge_strategy: rebase + delete_merged_branches: true + auto_rebase: true + auto_merge_on_approval: false + diff_with_stack_base: true + +pull_request: + draft_on_create: false + add_reviewers: true + add_projects: false + +triggers: + on_merge: + - rebase_dependents + on_merge_conflict: + - notify_author + +github: + repo: Bangkahdev/DevOpsGen + owner: Bangkahdev \ No newline at end of file diff --git a/docs/SRS.md b/docs/SRS.md new file mode 100644 index 0000000..57316b8 --- /dev/null +++ b/docs/SRS.md @@ -0,0 +1,1076 @@ +# SOFTWARE REQUIREMENTS SPECIFICATION (SRS) + +## DevOpsGen: Developer-Oriented DevOps Scaffolding Tool + +| Informasi | Detail | +|---|---| +| Nama proyek | DevOpsGen | +| Versi dokumen | 1.2.1 | +| Status | Approved for Implementation (revisi 1.2.1 menunggu peninjauan maintainer) | +| Jenis aplikasi | Command Line Interface (CLI) dan Terminal User Interface (TUI) | +| Bahasa pemrograman | Go (Golang) | +| Lisensi | Apache License 2.0 | +| Target pengguna | Software Engineer, Backend Developer, DevOps Engineer, dan mahasiswa | +| Target platform | Linux, macOS Intel/Apple Silicon, dan Windows pada arsitektur yang dinyatakan didukung dan diuji | +| Tanggal baseline | 16 Oktober 2026 (v1.2.0) | +| Tanggal revisi terakhir | 17 Oktober 2026 (v1.2.1) | + +### Riwayat Revisi + +| Versi | Tanggal | Perubahan | +|---|---|---| +| 1.0.0 | — | Baseline awal spesifikasi. | +| 1.1.0 | — | Penambahan matriks parameter, enum AppType, kontrak core engine, baseline benchmark, dan persyaratan validasi artefak. | +| 1.2.0 | 2026-10-16 | Finalisasi aturan resolusi konfigurasi, alur dan prioritas kegagalan, semantik test stage yang dilewati, semantik image/tag, keselamatan filesystem, rollback best-effort, serta perilaku port dan health probe. | +| 1.2.1 | 2026-10-17 | Penyelarasan dengan implementasi MVP: parameter `build-path` dan nilai turunan `package-manager`; strategi runtime Go (`scratch`, `GO_VERSION`); perilaku template Vite, Next.js, dan NestJS; aturan validasi `tag` lengkap; penamaan resource Kubernetes; urutan artefak deterministik; kontrak `Config` dan `Validator`; status validator eksternal; struktur repositori; AC-18. Pembersihan kalimat ganda dan penggabungan dua salinan dokumen menjadi satu sumber (`docs/SRS.md`). Menambahkan Lampiran A (catatan kesesuaian implementasi). | + +--- + +## 1. Pendahuluan + +### 1.1 Tujuan + +Dokumen ini mendefinisikan kebutuhan fungsional, nonfungsional, arsitektur, keamanan, pengujian, dan kriteria penerimaan DevOpsGen. Dokumen ini menjadi baseline bagi desain, implementasi, pengujian, dokumentasi, serta distribusi perangkat lunak. + +DevOpsGen adalah *developer-oriented DevOps scaffolding tool* yang membantu pengembang menghasilkan konfigurasi containerization, CI/CD, dan deployment Kubernetes berdasarkan karakteristik proyek dan konfigurasi yang diberikan pengguna. + +### 1.2 Latar Belakang + +Pembuatan Dockerfile, workflow CI/CD, dan manifest Kubernetes secara manual dapat menimbulkan konfigurasi berulang, inkonsistensi, kesalahan sintaks, serta risiko operasional. DevOpsGen mengurangi pekerjaan tersebut dengan menghasilkan artefak yang terstandarisasi, dapat divalidasi, deterministik, dan dibuat dengan kebijakan filesystem yang aman. + +Nilai utama DevOpsGen bukan sekadar membuat file konfigurasi. Tool ini harus mendukung proses yang dapat diuji, diotomatisasi, dan dikembangkan tanpa ketergantungan pada antarmuka terminal. + +### 1.3 Ruang Lingkup + +DevOpsGen menyediakan: + +1. Mode interaktif berbasis TUI. +2. Mode non-interaktif untuk automation dan scripting. +3. Deteksi awal stack proyek. +4. Generasi Dockerfile. +5. Generasi GitHub Actions untuk pengujian dan publikasi container image. +6. Generasi manifest Kubernetes. +7. Validasi konfigurasi dan artefak. +8. Dry-run, no-overwrite, overwrite eksplisit, backup, serta best-effort rollback. +9. Distribusi executable binary untuk platform yang dinyatakan didukung dan diuji. + +### 1.4 Tujuan Produk + +DevOpsGen harus: + +- Mengurangi kebutuhan menulis konfigurasi deployment dari nol. +- Menghasilkan artefak deterministik untuk input dan versi generator yang sama. +- Mencegah perubahan file tanpa persetujuan eksplisit. +- Memisahkan core engine dari CLI dan TUI. +- Mendukung automation dan pengujian tanpa credential eksternal. +- Memudahkan penambahan stack, template, dan provider di masa mendatang. +- Menyampaikan asumsi, nilai default, dan batasan hasil generasi secara transparan. + +### 1.5 Definisi dan Singkatan + +| Istilah | Definisi | +|---|---| +| CLI | Command Line Interface. | +| TUI | Terminal User Interface. | +| CI/CD | Continuous Integration / Continuous Delivery. | +| Stack | Bahasa pemrograman, framework, dan karakteristik aplikasi. | +| Registry | Layanan penyimpanan container image. | +| Artifact | File konfigurasi yang dihasilkan DevOpsGen. | +| Dry-run | Simulasi rencana perubahan tanpa menulis ke direktori target. | +| Headless mode | Mode non-interaktif yang tidak menunggu input terminal. | +| Deterministic output | Isi dan urutan artefak konsisten untuk versi serta input yang sama. | +| Rollback | Upaya mengembalikan perubahan yang dibuat oleh satu transaksi generasi. | +| Immutable tag | Tag yang mengidentifikasi build tertentu, dalam MVP berupa commit SHA. | +| Baseline | Nilai awal yang dapat disesuaikan, bukan klaim bahwa nilai tersebut optimal untuk semua aplikasi. | + +--- + +## 2. Gambaran Umum Sistem + +### 2.1 Perspektif Produk + +DevOpsGen merupakan aplikasi mandiri yang dijalankan dari terminal di direktori proyek pengguna. Sistem membaca indikator proyek, menerima atau menyelesaikan konfigurasi, merender template di memori, memvalidasi artefak, menyusun rencana perubahan, lalu menulis hasil sesuai kebijakan filesystem. + +Core engine tidak boleh bergantung pada Cobra, Bubble Tea, input terminal, layanan cloud, atau credential registry. + +### 2.2 Target Pengguna + +Target pengguna meliputi developer yang membutuhkan konfigurasi container dan CI/CD, DevOps engineer yang ingin menstandarkan scaffolding proyek, mahasiswa atau pemula yang mempelajari Docker dan Kubernetes, serta kontributor open-source yang membutuhkan generator konfigurasi yang dapat diotomatisasi. + +Pengguna diasumsikan memahami dasar terminal dan struktur proyek perangkat lunak. Hasil generasi tetap perlu ditinjau sebelum digunakan pada lingkungan produksi. + +### 2.3 Batasan MVP + +MVP mencakup: + +- Stack Go dan Node.js. +- GitHub Actions sebagai satu-satunya provider CI/CD. +- Docker Hub dan GitHub Container Registry (GHCR). +- Dockerfile. +- Manifest Kubernetes standar: Deployment dan Service, serta Ingress jika diminta. +- CLI, TUI, deteksi stack, validasi, dan filesystem safety. + +Python, PHP/Laravel, Helm, Argo CD, Flux, provider CI/CD lain, serta mekanisme deployment otomatis berada di luar scope MVP. Kode, template, dan dokumentasi penggunaan MVP tidak boleh mengekspos dukungan untuk hal-hal tersebut. + +### 2.4 Asumsi dan Dependensi + +1. Pengguna memiliki akses baca ke direktori proyek. +2. Proses generasi membutuhkan izin tulis, kecuali dalam mode dry-run. +3. Deteksi stack bergantung pada indikator proyek yang tersedia dan tidak menjamin pemahaman penuh terhadap perilaku aplikasi. +4. Publikasi image membutuhkan konfigurasi autentikasi pada platform CI. +5. Validator eksternal bersifat opsional; validasi internal tetap wajib. +6. Ketersediaan Docker, Git, kubectl, dan tool Kubernetes tidak boleh diasumsikan tanpa pemeriksaan. +7. Pengujian core tidak boleh membutuhkan akses cloud atau credential sungguhan. +8. DevOpsGen tidak menjalankan build container, publikasi image, atau deployment sebagai efek samping dari generasi. + +--- + +## 3. Dukungan Stack dan Deteksi + +### 3.1 Stack Go + +Sistem mendeteksi kandidat proyek Go melalui keberadaan `go.mod`. Keberadaan file tersebut merupakan indikator awal, bukan bukti bahwa direktori pasti dapat dibangun. + +Dockerfile yang dihasilkan harus: + +- Menggunakan multi-stage build. +- Menjalankan kompilasi pada tahap build. +- Memisahkan tool build dari runtime jika sesuai. +- Menggunakan runtime minimal yang kompatibel dengan hasil build. +- Mengekspos port konfigurasi sebagai metadata container. +- Menggunakan non-root user apabila kompatibel dengan aplikasi dan runtime. +- Memperhitungkan dependensi runtime, sertifikat CA, dan kebutuhan file aplikasi. + +Template Go MVP memakai strategi berikut, yang menjadi dasar terdokumentasi untuk asumsi binary statis: + +- Tahap build memakai `golang:${GO_VERSION}-alpine` dengan `CGO_ENABLED=0`, sehingga binary tidak membutuhkan libc. +- Runtime memakai `scratch`; sertifikat CA disalin dari tahap build dan proses berjalan sebagai user non-root `1000:1000`. +- `GO_VERSION` adalah `ARG` build dengan default `1.24`. Nilainya harus lebih besar atau sama dengan direktif `go` pada `go.mod` proyek dan dapat diganti dengan `docker build --build-arg GO_VERSION=`. Template menetapkan `GOTOOLCHAIN=auto` sebagai jaring pengaman, yang membutuhkan akses jaringan saat build. +- Paket yang dibangun ditentukan oleh parameter `build-path` (default `.`). +- Proyek yang membutuhkan cgo atau komponen runtime tambahan (misalnya shell atau data zona waktu) tidak kompatibel dengan template bawaan dan harus menyesuaikan Dockerfile hasil generasi. + +Template tidak boleh menggunakan asumsi lain tentang libc atau runtime di luar yang tercantum di atas. + +Nilai port atau instruksi `EXPOSE` tidak membuat aplikasi otomatis mendengarkan pada port tersebut. Aplikasi wajib mengikat listener ke port yang benar-benar dikonfigurasikan; DevOpsGen tidak mengubah source code aplikasi. + +### 3.2 Stack Node.js + +Sistem mendeteksi kandidat proyek Node.js melalui keberadaan `package.json` dan memeriksa: + +- `dependencies` dan `devDependencies`. +- `scripts` yang tersedia. +- Lockfile seperti `package-lock.json`, `pnpm-lock.yaml`, atau `yarn.lock`. +- Indikator framework dan konfigurasi yang dapat dibaca secara statis. + +Framework yang dapat dikenali mencakup Express, Next.js, Vite, dan NestJS. Deteksi hanya menghasilkan kandidat; indikator yang saling bertentangan harus dianggap ambigu. + +#### 3.2.1 Resolusi Perintah + +Resolusi build dan startup mengikuti urutan berikut: + +1. Nilai yang diberikan secara eksplisit oleh pengguna. +2. Script proyek yang terdeteksi dan memenuhi kebutuhan stack. Dalam TUI, nilai ditampilkan untuk dikonfirmasi; dalam mode non-interaktif, kandidat unik yang valid dapat digunakan, kecuali pengguna menggantinya melalui flag. +3. Default khusus framework hanya jika didukung oleh indikator yang memadai dan sesuai dengan template yang dipilih. +4. Jika tidak ada pilihan yang dapat dipastikan, minta nilai eksplisit atau hentikan proses dengan pesan kesalahan yang jelas. + +Keberadaan script tidak menjamin script itu benar untuk produksi. DevOpsGen tidak boleh mengklaim telah membuktikan perilaku aplikasi hanya dari isi `package.json`. + +#### 3.2.2 Heuristik Perintah + +| Indikator | Perilaku | +|---|---| +| `scripts.build` tersedia | Menjadi kandidat build command. | +| `scripts.test` tersedia | Menjadi kandidat test command. | +| `scripts.start` tersedia | Menjadi kandidat startup command. | +| Framework dapat diidentifikasi dengan meyakinkan | Sarankan template framework yang sesuai. | +| Framework atau runtime ambigu | Minta klarifikasi atau hentikan mode non-interaktif. | +| Port tidak memiliki bukti yang cukup | Minta port eksplisit. | +| Perintah wajib tidak tersedia | Minta konfigurasi eksplisit atau hentikan proses. | +| Lockfile dikenali | Pilih prosedur instalasi dependency yang sesuai. | +| Beberapa lockfile bertentangan | Laporkan ambiguitas; jangan memilih package manager secara diam-diam. | + +Package manager ditentukan dari lockfile yang terdeteksi (`package-lock.json` atau `npm-shrinkwrap.json` untuk npm, `pnpm-lock.yaml` untuk pnpm, `yarn.lock` untuk yarn) dan disimpan sebagai nilai turunan `package-manager`; nilai ini bukan flag. Tanpa lockfile, npm digunakan dan peringatan ditampilkan. Dockerfile dan workflow yang dihasilkan harus memakai package manager yang sama untuk instalasi dependency dan untuk perintah build/start default. + +### 3.3 Jenis Aplikasi (AppType) + +| Stack | Nilai | Perilaku template | +|---|---|---| +| Go | `binary` | Menghasilkan satu binary aplikasi dan menjalankan binary tersebut secara langsung. | +| Node.js | `express` | Aplikasi server HTTP Express. Startup harus sesuai script/perintah yang tervalidasi. | +| Node.js | `nextjs` | Build Next.js dan runtime `next start` pada port konfigurasi. Mode standalone tidak didukung pada MVP. | +| Node.js | `vite` | Build aset statis ke `dist/`. Runtime memakai server preview bawaan Vite (` run preview -- --port `) kecuali `start-cmd` diberikan. Server preview ditujukan untuk pratinjau, bukan server produksi; dokumentasi harus menyarankan static server khusus untuk produksi. | +| Node.js | `nestjs` | Build NestJS dan jalankan output proyek. Kandidat startup default adalah `node dist/main.js`; pengguna harus mengonfirmasinya atau menggantinya melalui `start-cmd` bila output proyek berbeda. | +| Node.js | `generic` | Aplikasi Node.js tanpa framework teridentifikasi; perintah build dan startup harus diberikan secara eksplisit jika dibutuhkan. | + +Nilai di luar daftar ditolak dengan exit code `2`. Jika deteksi memberikan lebih dari satu kandidat yang masuk akal, mode interaktif meminta pilihan; mode non-interaktif mengembalikan exit code `5`. + +### 3.4 Deteksi Ambigu + +Jika beberapa stack terdeteksi atau indikator bertentangan, sistem harus melaporkan file dan indikator yang menyebabkan ambiguitas. Mode interaktif meminta pilihan pengguna. Mode non-interaktif berhenti dengan exit code `5` dan menyebut flag yang diperlukan untuk menyelesaikan ambiguitas. + +Deteksi bersifat read-only dan tidak boleh mengubah file proyek. + +--- + +## 4. Kebutuhan Fungsional + +### 4.1 Matriks Parameter Konfigurasi + +Parameter berikut menjadi kontrak konfigurasi MVP. “Wajib” berarti proses tidak dapat dilanjutkan tanpa nilai yang valid. Nilai dapat berasal dari flag eksplisit atau deteksi yang memenuhi aturan pada bagian 3. Nilai yang tidak dapat dipastikan harus diminta secara eksplisit. + +| Parameter | Go | Node.js | Keterangan | +|---|---|---|---| +| `stack` | Wajib | Wajib | Enum `go` atau `nodejs`; dapat berasal dari deteksi jika hasilnya tunggal dan jelas. | +| `ci` | Wajib | Wajib | MVP: `github-actions`. | +| `registry` | Wajib | Wajib | `dockerhub` atau `ghcr`. | +| `image` | Wajib | Wajib | Repository image dalam bentuk `namespace/repository`, tanpa hostname registry dan tanpa tag. Contoh: `bangkah/my-app`. | +| `port` | Wajib | Wajib | Rentang 1–65535. Harus sesuai dengan port listener aplikasi. Jangan menebak dari port umum framework. | +| `build-cmd` | Opsional | Opsional bersyarat | Go: default template yang teruji. Node.js: gunakan kandidat yang terdeteksi atau default framework yang tervalidasi; bila tidak tersedia, wajib eksplisit. | +| `build-path` | Opsional | Tidak berlaku | Path paket Go yang dibangun di dalam container (`go build `). Default `.`. Dipakai jika `main` berada di subdirektori, misalnya `./cmd/server`. | +| `package-manager` | Tidak berlaku | Turunan | Bukan flag. Ditentukan dari lockfile oleh deteksi (npm, pnpm, atau yarn); default npm. Lihat 3.2.2. | +| `test-cmd` | Opsional | Opsional | Perintah shell lengkap yang ditulis apa adanya ke workflow (contoh: `go test ./...`, `npm test`); DevOpsGen tidak menambahkan awalan package manager. Jika tidak tersedia, test stage dilewati secara transparan dan bukan dilaporkan sebagai berhasil. | +| `start-cmd` | Tidak berlaku | Wajib bersyarat | Go menjalankan binary. Node.js memerlukan perintah untuk server runtime; default template (misalnya `vite preview`) hanya menjadi kandidat yang ditampilkan untuk dikonfirmasi dan tidak boleh dipakai secara diam-diam. | +| `app-type` | Opsional | Opsional | Enum pada bagian 3.3; nilai default hanya jika deteksi jelas. | +| `tag` | Opsional | Opsional | Tag alias tambahan; default alias adalah `latest`. Workflow wajib menerbitkan tag commit SHA dan alias ini. Tag `latest` tidak boleh menjadi satu-satunya tag. Nilai tag harus memenuhi aturan validasi tag pada FR-09. | +| `k8s` | Opsional | Opsional | Default: menghasilkan Deployment dan Service. Dapat dinonaktifkan secara eksplisit. | +| `ingress` | Opsional | Opsional | Default nonaktif. Jika aktif, wajib diikuti `ingress-host`. | +| `ingress-host` | Kondisional | Kondisional | Wajib jika Ingress diaktifkan; harus memenuhi validasi hostname RFC 1123. | +| `health-path` | Opsional bersyarat | Opsional bersyarat | Endpoint HTTP pemeriksaan kesehatan yang diberikan pengguna. Tidak boleh ditebak. Jika tidak diberikan, HTTP readiness/liveness probe tidak dibuat. | +| `output-dir` | Opsional | Opsional | Default: direktori kerja saat ini. Path harus divalidasi. | +| `force` | Flag | Flag | Persetujuan eksplisit untuk mengganti file reguler yang konflik. Tidak mengizinkan penggantian symlink atau direktori. | +| `dry-run` | Flag | Flag | Simulasi tanpa menulis atau membuat file/direktori sementara pada target. | + +`image` harus menyertakan namespace agar referensi image untuk registry dan Kubernetes tidak bergantung pada tebakan. Referensi image lengkap dibentuk sebagai `docker.io//` untuk Docker Hub atau `ghcr.io//` untuk GHCR. Nama dan komponen path harus sesuai aturan registry. + +Pada mode non-interaktif, jika parameter wajib yang tidak dapat dideteksi belum disediakan, proses harus gagal dengan exit code `2`. Stack yang ambigu atau tidak didukung menggunakan exit code `5` sesuai aturan pada FR-16. + +### 4.2 Resolusi Nilai dan Default + +Sistem harus menggunakan urutan prioritas yang konsisten: + +1. Nilai valid yang diberikan secara eksplisit melalui flags. +2. Nilai hasil deteksi yang tunggal, relevan, dan didukung indikator proyek. +3. Default template yang terdokumentasi dan sesuai dengan `stack` serta `app-type`. +4. Jika tetap tidak dapat ditentukan, hentikan proses dan minta nilai eksplisit. + +Tidak ada default yang boleh menutupi ambiguitas stack, menciptakan port aplikasi, atau mengarang endpoint kesehatan. Dalam TUI, kandidat hasil deteksi ditampilkan dan dapat diganti sebelum generasi. + +### FR-01: Inisialisasi + +Sistem menyediakan: + +`devopsgen init` + +Perintah memulai TUI apabila terminal interaktif tersedia dan konfigurasi belum lengkap. Jika konfigurasi lengkap diberikan melalui flags, sistem menjalankan mode non-interaktif. + +Jika TUI dibutuhkan tetapi stdin atau terminal tidak interaktif, proses tidak boleh menunggu input. Sistem mengembalikan kesalahan yang dapat ditindaklanjuti beserta parameter yang belum tersedia. + +### FR-02: Mode Interaktif + +Wizard harus mendukung seluruh parameter pada matriks 4.1, termasuk stack dan `app-type`, perintah build/test/start yang relevan, port, CI provider, registry, image name, tag, opsi Kubernetes, Ingress host, health path jika digunakan, serta direktori tujuan. + +Input harus divalidasi sebelum diteruskan ke generator. Wizard menampilkan kandidat hasil deteksi sebagai kandidat dan meminta konfirmasi jika perilakunya tidak dapat dibuktikan secara statis. Pengguna dapat meninjau ringkasan konfigurasi sebelum menyetujui generasi. + +### FR-03: Mode Non-interaktif + +Contoh: + +```bash +devopsgen init --stack go --ci github-actions --registry ghcr --image bangkah/my-app --port 8080 +``` + +Sistem harus: + +- Memproses konfigurasi melalui flags. +- Memastikan parameter wajib tersedia sesuai matriks 4.1. +- Tidak menunggu input stdin. +- Menolak konfigurasi yang tidak valid atau tidak lengkap. +- Menghasilkan exit code non-zero saat gagal. +- Menyebut parameter yang hilang atau tidak valid dalam pesan kesalahan. +- Tidak menjalankan perintah build, push, atau deploy sebagai bagian dari generasi. + +Nilai command yang dimasukkan pengguna dapat ditulis ke Dockerfile atau workflow yang dihasilkan. DevOpsGen tidak mengeksekusi command tersebut secara lokal. Namun, command itu akan dieksekusi ketika pengguna membangun image atau menjalankan workflow; pengguna bertanggung jawab meninjau command sebelum penggunaan. + +### FR-04: Deteksi Stack + +Perintah: + +`devopsgen detect` + +Sistem harus melaporkan stack kandidat, indikator yang ditemukan, kandidat `app-type`, build/test/start command, lockfile/package manager, serta ambiguitas yang belum terselesaikan. Jika port tidak dapat ditentukan dengan bukti eksplisit, sistem harus menyatakannya dan tidak mengisi port berdasarkan asumsi framework. + +Deteksi tidak boleh mengubah filesystem atau menjalankan script proyek. + +### FR-05: Generasi Dockerfile + +Sistem harus menghasilkan Dockerfile sesuai stack, `app-type`, dan konfigurasi. + +Dockerfile harus: + +- Menggunakan strategi build yang sesuai. +- Menghindari secret hardcoded. +- Meminimalkan komponen runtime yang tidak diperlukan. +- Menjalankan aplikasi sebagai non-root jika memungkinkan. +- Mengekspos port konfigurasi sebagai metadata, tanpa mengklaim bahwa `EXPOSE` mengubah port listener aplikasi. +- Menggunakan command build/start yang diselesaikan melalui aturan 4.2. +- Menghasilkan konten deterministik. + +Dockerfile Node.js memasang dependency sesuai lockfile (`npm ci` bila `package-lock.json` ada, `npm install` bila tidak, atau perintah package manager lain yang sesuai dengan 3.2.2). Template tidak boleh membuat berkas proyek palsu (misalnya `go.mod` atau `package.json` sementara) untuk menutupi berkas yang hilang; build harus gagal dengan jelas bila berkas wajib tidak ada. + +Pengecualian dari non-root hanya diperbolehkan jika terdapat kebutuhan kompatibilitas yang jelas dan alasan tersebut didokumentasikan. Template wajib memperhitungkan permission file, direktori kerja, CA certificates, dan kebutuhan runtime yang diketahui. + +### FR-06: Generasi GitHub Actions + +Workflow harus mencakup langkah yang relevan untuk: + +1. Checkout repository. +2. Setup runtime yang diperlukan oleh stack. +3. Instalasi dependency untuk langkah test yang berjalan di runner. +4. Menjalankan test command jika tersedia atau dikonfigurasi. +5. Build container image. +6. Login ke registry setelah prasyarat terpenuhi. +7. Publish image setelah test dan build yang diwajibkan berhasil. + +Workflow harus selalu menerbitkan tag commit SHA `${{ github.sha }}` sebagai tag immutable yang dapat ditelusuri ke commit sumber. Workflow juga menerbitkan alias `latest` secara default, atau alias kustom dari parameter `tag` jika diberikan. Tag commit SHA tetap wajib diterbitkan, sehingga alias mutable tidak pernah menjadi satu-satunya tag. + +Jika test command tidak tersedia, workflow harus menyatakan dengan jelas bahwa test stage dilewati karena belum dikonfigurasi. Workflow harus menambahkan ringkasan atau notice yang eksplisit, tidak boleh membuat langkah test palsu, dan tidak boleh melaporkan bahwa pengujian berhasil jika pengujian tidak dijalankan. Status workflow yang sukses hanya berarti langkah yang benar-benar dikonfigurasi telah berhasil. + +Pada event `pull_request`, workflow menjalankan test dan membangun image tetapi tidak login ke registry dan tidak mem-publish image. Versi runtime Go diambil dari `go.mod`. Setiap action pihak ketiga dipin ke commit SHA lengkap. + +Workflow harus memakai permission minimum yang diperlukan, menghindari token yang tidak digunakan, dan tidak menjalankan publish sebelum prasyarat test/build yang dikonfigurasi berhasil. Penggunaan action pihak ketiga harus memakai referensi versi yang eksplisit dan tidak bergantung pada branch bergerak seperti `main` atau `master`. + +### FR-07: Registry dan Autentikasi + +#### Docker Hub + +Workflow menggunakan GitHub Actions Secrets berikut: + +- `DOCKER_USERNAME` +- `DOCKER_PASSWORD` + +Image yang dihasilkan mengikuti bentuk `docker.io//`. Jika namespace image tidak sesuai dengan akun yang diizinkan untuk publish, workflow akan gagal dan pesan harus menjelaskan konfigurasi yang perlu diperiksa tanpa mencetak credential. + +#### GitHub Container Registry (GHCR) + +Workflow menggunakan `GITHUB_TOKEN` dengan permission minimum: + +- `contents: read` +- `packages: write` + +Image mengikuti bentuk `ghcr.io//`. Konfigurasi namespace harus kompatibel dengan pemilik repository dan permission workflow. + +Credential tidak boleh ditulis sebagai plaintext ke artefak, argumen log, atau pesan kesalahan. Pengujian generasi tidak memerlukan credential sungguhan. + +### FR-08: Generasi Manifest Kubernetes + +Sistem menghasilkan: + +- `deployment.yaml` +- `service.yaml` +- `ingress.yaml` jika diminta. + +Tidak ada perintah `kubectl apply`, publikasi image, atau perubahan cluster yang boleh dijalankan selama generasi. + +#### 4.8.1 Deployment + +Validator minimum harus memastikan: + +- `apiVersion` yang sesuai, `kind: Deployment`, dan `metadata.name`. +- `spec.selector.matchLabels` tersedia. +- `spec.template.metadata.labels` tersedia. +- `spec.template.spec.containers` berisi setidaknya satu container. +- Nama container valid dan unik dalam Pod. +- Image container tidak kosong. +- Port container sesuai konfigurasi apabila ditentukan. +- Selector cocok dengan label pada template Pod. + +#### 4.8.2 Service + +Validator minimum harus memastikan: + +- `apiVersion` yang sesuai, `kind: Service`, dan `metadata.name`. +- `spec.selector` tersedia. +- `spec.ports` berisi port dan target port yang valid. +- Selector cocok dengan label Pod Deployment. +- `targetPort` mengarah ke port aplikasi/container yang benar-benar dikonfigurasi. + +#### 4.8.3 Ingress + +Jika diaktifkan, Ingress menggunakan API `networking.k8s.io/v1` dan harus mempunyai: + +- `kind: Ingress`, `metadata.name`, dan aturan routing yang valid. +- Host yang lolos validasi hostname RFC 1123. +- Path dan `pathType` yang valid. +- Backend Service yang terdefinisi. +- Port backend yang cocok dengan port Service terkait. + +Dokumentasi hasil generasi harus menyatakan bahwa Ingress membutuhkan Ingress Controller dan konfigurasi cluster yang sesuai. Keberadaan manifest Ingress saja tidak membuat akses eksternal otomatis tersedia. + +#### 4.8.4 Resource dan Health Probe + +Manifest awal harus menyediakan requests dan limits resource sebagai baseline yang dapat disesuaikan. Nilai default MVP adalah `requests.cpu: 100m`, `requests.memory: 128Mi`, `limits.cpu: 500m`, dan `limits.memory: 256Mi`. Nilai ini hanya baseline awal, bukan nilai optimal universal; pengguna harus mengevaluasi dan menyesuaikannya berdasarkan beban aplikasi serta batas cluster. + +HTTP readiness/liveness probe hanya dibuat jika pengguna memberikan `health-path` atau detektor menemukan konfigurasi endpoint yang eksplisit dan dapat dipercaya. DevOpsGen tidak boleh mengarang endpoint seperti `/health` atau `/ready`. Jika endpoint pemeriksaan tidak diketahui, probe HTTP dihilangkan dan ringkasan generasi menjelaskan alasannya. + +Container sebaiknya berjalan sebagai non-root jika kompatibel. Port pada manifest harus konsisten dengan konfigurasi pengguna. Pengguna tetap bertanggung jawab memastikan aplikasi benar-benar mendengarkan di port itu. + +#### 4.8.5 Image pada Manifest + +Deployment menggunakan nama image yang berasal dari registry dan `image` pada konfigurasi. Tag manifest harus sama dengan alias yang diterbitkan workflow: `latest` secara default atau nilai parameter `tag` jika diberikan. Workflow juga harus menerbitkan tag commit SHA. Dokumentasi harus menyarankan penggunaan tag immutable atau digest untuk deployment produksi. + +#### 4.8.6 Penamaan Resource dan Label + +Nama resource Kubernetes diturunkan dari segmen terakhir `image`, diubah ke huruf kecil, dengan karakter di luar `a-z`, `0-9`, `-`, dan `.` diganti `-`, tanda `-` dan `.` di awal dan akhir dibuang, dan dipotong maksimal 253 karakter. Jika hasilnya kosong, nama `app` digunakan. Label `app: ` dipakai konsisten pada Deployment (selector dan template Pod), Service (selector), dan Ingress (backend). + +### FR-09: Validasi Input + +Sistem harus memvalidasi: + +- Field wajib dan tipe data. +- Nama repository image dan namespace sesuai aturan registry. +- Port dalam rentang 1–65535. +- CI provider dan registry terhadap nilai yang didukung. +- Path output, nama file artefak, dan path traversal. +- Nilai `app-type` terhadap enum bagian 3.3. +- Konsistensi stack, runtime, build/start command, port, dan opsi Kubernetes. +- Hostname Ingress berdasarkan aturan RFC 1123. +- Perintah kustom sebagai string satu baris tanpa karakter kontrol yang dapat merusak struktur artefak. +- Nilai `tag`: maksimal 128 karakter, hanya boleh mengandung karakter alfanumerik, `_`, `.`, dan `-`, tidak boleh mengandung newline, whitespace, atau karakter shell seperti `;`, `&`, `|`, `` ` ``, `$`, `(`, `)`, `{`, `}`, `[`, `]`, `<`, `>`, `\`, `!`, `#`, `%`, `@`, `:`, atau karakter yang dapat mengubah struktur YAML/shell. Nilai tag tidak boleh kosong jika diberikan. + +Command kustom tetap merupakan kode yang dipercaya pengguna dan dapat dieksekusi ketika Dockerfile atau workflow dijalankan. DevOpsGen harus menghindari penyisipan tidak sengaja ke struktur YAML/Dockerfile dan mendokumentasikan risiko command kustom. + +Nama resource Kubernetes mengikuti aturan nama resource terkait; aturan nama image mengikuti aturan registry, bukan aturan hostname atau resource Kubernetes. + +### FR-10: Dry-run + +Perintah: + +`devopsgen init --dry-run` + +Dry-run harus: + +- Merender dan memvalidasi semua artefak dengan jalur kode yang sama seperti generasi normal. +- Menampilkan artefak yang akan dibuat atau diganti. +- Melaporkan konflik dan aksi yang direncanakan. +- Tidak menulis atau mengubah file target. +- Tidak membuat file atau direktori sementara pada direktori target. +- Tidak mengubah permission, timestamp, atau metadata file proyek. + +Jika terdapat konflik tanpa `--force`, dry-run mengembalikan exit code `3` setelah melaporkan konflik. Jika `--force` juga diberikan, dry-run hanya melaporkan rencana penggantian dan tetap tidak menulis. + +### FR-11: No-overwrite dan Force + +Secara default, sistem tidak boleh menimpa file yang sudah ada. Dalam mode interaktif, sistem dapat meminta konfirmasi. Dalam mode non-interaktif, konflik tanpa `--force` menghasilkan exit code `3`. + +Flag `--force` merupakan persetujuan eksplisit untuk mengganti file reguler yang ditargetkan. Flag ini tidak mengizinkan penggantian symlink, direktori, atau path yang keluar dari direktori target. Sebelum commit, sistem harus menyiapkan backup seluruh file lama yang akan diganti. Jika backup tidak dapat disiapkan dengan aman, transaksi dibatalkan sebelum file target dimodifikasi. + +### FR-12: Transaksi Filesystem dan Best-effort Rollback + +Sebelum commit, sistem harus: + +1. Memvalidasi konfigurasi. +2. Merender seluruh artefak di memori. +3. Memvalidasi semua artefak. +4. Memvalidasi path dan memastikan seluruh tujuan berada dalam direktori target. +5. Mendeteksi konflik. +6. Menyiapkan rencana perubahan dan backup untuk file yang akan diganti. +7. Menyiapkan staging jika dibutuhkan, setelah dry-run dipastikan tidak aktif. + +Commit harus mencatat setiap file baru, file lama yang diganti, backup, dan direktori yang dibuat oleh transaksi. File hasil rendering harus disiapkan sebelum dipindahkan ke tujuan. Sistem sebaiknya memakai rename pada filesystem yang sama jika tersedia, tetapi tidak boleh mengklaim atomicity absolut untuk transaksi multi-file lintas platform. + +Jika terjadi kegagalan I/O, sistem harus berupaya: + +- Menghapus file baru yang dibuat transaksi. +- Memulihkan file lama yang diganti menggunakan backup. +- Menghapus direktori baru yang kosong dan dibuat oleh transaksi. +- Mempertahankan file lama yang tidak dikelola transaksi. +- Melaporkan dengan jelas setiap langkah rollback yang gagal dan file yang memerlukan pemulihan manual. + +Lokasi dan kebijakan pembersihan backup harus ditentukan oleh implementasi dan didokumentasikan. Rollback hanya boleh memengaruhi perubahan yang dapat dikaitkan dengan transaksi saat ini. Jika rollback gagal, exit code akhir adalah `1` dan pesan kesalahan harus membedakan kegagalan awal dari kegagalan pemulihan. + +Filesystem tidak menyediakan transaksi multi-file portabel yang universal. Jaminan produk adalah best-effort rollback, bukan atomicity absolut. + +### FR-13: Validasi Artefak + +Perintah: + +`devopsgen validate [--dir PATH]` + +Secara default, sistem memvalidasi artefak yang didukung pada direktori kerja. Sistem harus memeriksa: + +- Keberadaan file yang diwajibkan untuk artefak yang terdeteksi. +- Sintaks YAML dengan parser YAML struktural, bukan pencarian teks. +- Field wajib resource Kubernetes. +- Konsistensi selector dan label. +- Image container. +- Relasi Service dan Ingress. +- Struktur dasar GitHub Actions workflow. +- Konsistensi port dan health probe jika dikonfigurasi. + +Pemeriksaan harus dilakukan secara rekursif pada direktori target, termasuk subdirektori seperti `.github/workflows/` dan `kubernetes/`. Direktori tanpa artefak yang sesuai tidak dianggap sukses secara otomatis. + +Validasi internal wajib berjalan walaupun validator eksternal tidak tersedia. Validator eksternal bersifat tambahan dan tidak menggantikan validasi internal. Hasil harus membedakan status valid, tidak valid, dan pemeriksaan eksternal yang dilewati. + +Pada MVP, integrasi validator eksternal belum diimplementasikan; validasi internal yang berhasil dilaporkan bersama status `external validation skipped`. Contoh pesan: + +`Internal validation passed; external validation skipped` + +Setelah integrasi validator eksternal tersedia, pesan harus menyebut alasan pemeriksaan dilewati (misalnya `kubeconform not found`). + +Jika validasi artefak gagal, exit code adalah `4`. Jika alat eksternal tidak tersedia tetapi validasi internal berhasil, kondisi itu sendiri bukan kegagalan validasi. + +### FR-14: Daftar Dukungan + +Perintah: + +`devopsgen list` + +Sistem menampilkan stack, app type, registry, provider CI, dan template deployment yang tersedia dalam versi binary yang sedang berjalan. Daftar tersebut harus berasal dari kapabilitas aktual yang dibundel, bukan daftar roadmap masa depan. + +### FR-15: Informasi Versi + +Perintah: + +`devopsgen version` + +Sistem menampilkan versi aplikasi dan versi template. Pada MVP, versi template mengikuti versi binary. Informasi versi ditentukan saat build atau release; build development boleh menampilkan nilai seperti `dev` jika versi release tidak tersedia. + +### FR-16: Help dan Exit Code + +Setiap command harus menyediakan bantuan penggunaan yang konsisten. Kode keluar yang didukung: + +| Exit code | Arti | +|---:|---| +| `0` | Operasi berhasil. | +| `1` | Kesalahan umum atau I/O, termasuk kegagalan rollback. | +| `2` | Input, flag, atau konfigurasi tidak valid atau tidak lengkap. | +| `3` | Konflik file tanpa persetujuan overwrite. | +| `4` | Validasi artefak gagal. | +| `5` | Stack ambigu atau tidak didukung. | + +#### 4.16.1 Urutan Tahap dan Penentuan Exit Code + +Implementasi memakai alur fail-fast yang deterministik. Kesalahan pada tahap yang lebih awal menghentikan proses; tahap berikutnya tidak dijalankan. Urutan tahap adalah: + +1. Parsing flags dan validasi nilai eksplisit. Kesalahan menghasilkan `2`. +2. Deteksi stack jika diperlukan. Stack ambigu atau tidak didukung menghasilkan `5`. +3. Penyelesaian serta validasi konfigurasi lengkap. Parameter wajib yang masih hilang atau tidak konsisten menghasilkan `2`. +4. Rendering artefak dan validasi output. Kegagalan validasi artefak menghasilkan `4`. +5. Pemeriksaan path fisik, rencana filesystem, dan konflik. Path yang tidak aman menghasilkan `2`; konflik tanpa persetujuan menghasilkan `3`. +6. Backup, commit, dan operasi filesystem. Kesalahan I/O atau rollback menghasilkan `1`. + +Aturan ini adalah *precedence by processing stage*, bukan klaim bahwa aplikasi harus menemukan seluruh kemungkinan kegagalan sebelum berhenti. Sebagai contoh, input invalid menghasilkan `2` tanpa melanjutkan ke pemeriksaan konflik; validasi artefak yang gagal menghasilkan `4` sebelum konflik diperiksa. Pengujian harus membuktikan perilaku short-circuit tersebut secara eksplisit. + +--- + +## 5. Kebutuhan Nonfungsional + +### NFR-01: Performa + +Target rendering dan validasi internal adalah kurang dari satu detik pada benchmark referensi dengan ketentuan: + +- Maksimal 20 artefak. +- Total ukuran output maksimal 256 KiB. +- Tidak menjalankan proses eksternal. +- Tidak mengakses jaringan. + +Benchmark harus mencatat spesifikasi mesin, versi Go, perintah benchmark, dan kondisi pengujian. Waktu interaksi pengguna, operasi filesystem yang lambat, build image, publish, serta validasi eksternal tidak termasuk target core engine. + +### NFR-02: Portabilitas + +DevOpsGen harus dapat dibangun untuk target Linux, macOS Intel, macOS Apple Silicon, dan Windows pada arsitektur yang ditetapkan dalam pipeline release. Binary release tidak boleh membutuhkan instalasi Go pada mesin pengguna. + +Kompatibilitas dibuktikan dengan build pada target yang ditentukan dan pengujian pada runner native jika tersedia. Cross-compilation saja tidak membuktikan bahwa binary berfungsi benar pada semua platform. + +### NFR-03: Maintainability + +CLI, TUI, deteksi, konfigurasi, generator, template, validator, dan filesystem harus dipisahkan dalam modul dengan tanggung jawab jelas. Penambahan stack baru tidak boleh mengharuskan perubahan pada kebijakan dasar filesystem atau kode TUI selain integrasi yang diperlukan. + +### NFR-04: Determinisme dan Template Versioning + +Versi template mengikuti versi binary pada MVP. Template dibundel menggunakan `embed.FS`, sehingga setiap binary release membawa kumpulan template yang tetap. + +Untuk versi binary dan konfigurasi yang sama: + +- Isi setiap artefak harus identik. +- Urutan artefak yang dilaporkan harus konsisten, yaitu terurut leksikografis berdasarkan path. +- Output tidak boleh bergantung pada urutan iterasi map yang tidak deterministik. +- Nilai lingkungan runtime tidak boleh disisipkan ke file hasil generasi kecuali menjadi bagian eksplisit dari konfigurasi. + +Perubahan template yang memengaruhi output harus disertai pengujian dan catatan changelog. + +### NFR-05: Keamanan + +Sistem harus: + +- Menghindari secret plaintext. +- Menggunakan permission CI yang minimum. +- Mencegah path traversal dan menolak jalur symlink yang keluar dari target. +- Menolak overwrite tanpa persetujuan. +- Membatasi penulisan pada direktori target yang divalidasi. +- Tidak menjalankan deployment otomatis. +- Tidak membocorkan credential dalam pesan kesalahan. +- Memperlakukan command kustom sebagai input tidak tepercaya hingga ditinjau pengguna. + +Perlindungan terhadap perubahan filesystem bersamaan harus diterapkan sejauh dapat dilakukan secara portabel. Jika implementasi tidak dapat memastikan target file aman untuk ditulis, operasi harus gagal secara tertutup (*fail closed*) alih-alih melanjutkan secara spekulatif. + +### NFR-06: Usability + +CLI menyediakan help, pesan kesalahan yang dapat ditindaklanjuti, ringkasan file yang dibuat atau diganti, dan ringkasan asumsi/default penting. Semua operasi inti dapat dijalankan tanpa TUI. + +### NFR-07: Testing dan Coverage + +Paket core harus memiliki unit tests dan integration tests. Target awal statement coverage adalah minimal 80% untuk paket core: + +- `config` +- `detector` +- `generator` +- `output` +- `validator` +- `filesystem` + +Coverage bukan satu-satunya syarat rilis. Skenario keamanan, rollback, konflik, dry-run, parsing konfigurasi, serta determinisme wajib diuji secara eksplisit. + +### NFR-08: Distribusi + +Build dan release diotomatisasi menggunakan GitHub Actions dan GoReleaser. Setiap rilis harus memiliki versi yang dapat diidentifikasi dan artefak yang sesuai dengan platform yang dinyatakan didukung serta diuji. + +--- + +## 6. Arsitektur Sistem + +### 6.1 Tech Stack + +| Komponen | Teknologi | +|---|---| +| Bahasa | Go (minimal 1.24.2, sesuai `go.mod`) | +| CLI | Cobra | +| TUI | Bubble Tea | +| Template engine | `text/template` | +| Template bundling | `embed.FS` | +| Unit testing | Standard library `testing` | +| CI | GitHub Actions | +| Release | GoReleaser | +| YAML parser | `go.yaml.in/yaml/v4` (parser struktural) | + +### 6.2 Struktur Repositori + +```text +devopsgen/ +├── .github/ +│ └── workflows/ # ci.yml, release.yml +├── .goreleaser.yaml +├── cmd/ +│ └── devopsgen/ +│ └── main.go +├── internal/ +│ ├── cli/ +│ ├── wizard/ +│ ├── detector/ +│ ├── config/ +│ ├── templates/ +│ ├── generator/ +│ ├── output/ +│ ├── validator/ +│ └── filesystem/ +├── templates/ +│ ├── embed.go +│ ├── docker/ # Dockerfile.{go,express,nextjs,vite,nestjs,generic}.tmpl +│ ├── github-actions/ # ci-workflow.yaml.tmpl +│ └── kubernetes/ # {deployment,service,ingress}.yaml.tmpl +├── tests/ +├── docs/ # SRS.md (sumber tunggal), RELEASE.md +├── go.mod +├── Makefile +├── README.md +└── LICENSE +``` + +Struktur tersebut merupakan baseline. Implementasi boleh menambahkan subpaket bila diperlukan, dengan tetap mempertahankan pemisahan tanggung jawab. Berkas hasil generasi (misalnya `Dockerfile` atau `kubernetes/` di root) tidak boleh di-commit ke repositori DevOpsGen; pengujian memakai direktori sementara. + +### 6.3 Tanggung Jawab Modul + +- `cli`: registrasi command, flags, help, pemetaan hasil ke exit code. +- `wizard`: pengumpulan dan konfirmasi konfigurasi melalui TUI. +- `detector`: analisis file indikator stack tanpa mengubah proyek atau menjalankan script. +- `config`: model, enum, resolusi nilai, dan validasi konfigurasi. +- `templates`: pemuatan template bawaan dari `embed.FS`. +- `generator`: rendering template dan pembentukan artefak. +- `output`: representasi artefak di memori. +- `validator`: validasi sintaks dan konsistensi artefak. +- `filesystem`: validasi path, rencana perubahan, konflik, dry-run, backup, commit, dan rollback. + +### 6.4 Kontrak API Core Engine + +Contoh kontrak konseptual berikut menggambarkan batas tanggung jawab. Implementasi boleh menyesuaikan nama atau pembagian interface selama perilaku dan pemisahan dependensi tetap dipertahankan. + +```go +package config + +type StackType string +type AppType string + +type Config struct { + Stack StackType + AppType AppType + CI CIProvider + Registry Registry + Image string // namespace/repository, tanpa host dan tag + Port int + BuildCmd string + TestCmd string + StartCmd string + Tag string // alias tambahan; default "latest" + BuildPath string // paket Go yang dibangun; default "." + K8s K8sOptions + OutputDir string + Generation GenerationOptions + PackageManager string // turunan dari deteksi: npm, pnpm, atau yarn +} +``` + +Field wajib harus tervalidasi sebelum proses generator dijalankan. `PackageManager` diisi oleh deteksi, bukan oleh flag. Model opsi Kubernetes dan opsi operasi adalah sebagai berikut: + +```go +type K8sOptions struct { + Enabled bool + Ingress bool + IngressHost string + HealthPath string +} + +type GenerationOptions struct { + Force bool + DryRun bool +} +``` + +`Force` dan `DryRun` merupakan kebijakan operasi filesystem, bukan karakteristik aplikasi. Implementasi dapat meneruskannya melalui `GenerationOptions` ke modul filesystem. + +```go +package output + +type Artifact struct { + Path string + Content []byte +} +``` + +`Artifact.Path` harus berupa path relatif yang bersih dan tidak boleh mengarah ke luar root target. Implementasi boleh menambahkan metadata seperti permission yang diinginkan jika dibutuhkan. + +```go +package generator + +type Generator interface { + Generate(cfg config.Config) ([]output.Artifact, error) +} +``` + +```go +package validator + +type Result int + +const ( + ResultValid Result = iota + ResultInvalid + ResultExternalSkipped +) + +type Validator interface { + Validate(artifacts []output.Artifact) (Result, error) +} +``` + +Validator menjalankan pemeriksaan per artefak, lalu pemeriksaan konsistensi lintas resource (selector Service terhadap label Deployment, backend Ingress terhadap Service, dan konsistensi port). + +Modul filesystem harus memiliki tahapan terpisah untuk membuat rencana, memeriksa konflik, menjalankan dry-run, menyiapkan backup, commit, dan rollback. Operasi filesystem harus dapat diganti atau diinjeksi pada pengujian agar kegagalan I/O dapat disimulasikan. + +### 6.5 Prinsip Ketergantungan + +CLI dan TUI memakai model konfigurasi serta core engine yang sama. Core engine tidak boleh mengimpor Cobra atau Bubble Tea. Template standar harus tersedia di binary dan tidak bergantung pada folder template eksternal ketika aplikasi berjalan. + +Detektor tidak boleh menjalankan perintah proyek. Core engine tidak boleh mengakses jaringan atau credential untuk rendering dan validasi internal. + +### 6.6 Alur Pemrosesan + +```text +CLI / TUI + | + v +Parsing Flags + Validasi Nilai Eksplisit -> exit 2 + | + v +Deteksi Stack bila Dibutuhkan -> exit 5 bila ambigu/tidak didukung + | + v +Penyelesaian + Validasi Config Lengkap -> exit 2 + | + v +Rendering Seluruh Artefak di Memori + | + v +Validasi Seluruh Output -> exit 4 + | + v +Validasi Path + Rencana + Konflik -> exit 3 bila konflik tanpa izin + | + v +Backup + Commit Filesystem -> exit 1 bila I/O/rollback gagal + | + v +Ringkasan Hasil -> exit 0 +``` + +Dry-run berhenti setelah rencana dan konflik dilaporkan; tidak memasuki tahap backup atau commit. Error pada suatu tahap menghentikan alur sesuai aturan FR-16. + +--- + +## 7. Keamanan dan Keandalan + +### 7.1 Pengelolaan Secret + +Credential hanya boleh direferensikan melalui mekanisme yang sesuai, seperti GitHub Actions Secrets atau `GITHUB_TOKEN`. Secret tidak boleh ditanamkan ke file hasil generasi, default konfigurasi, log, atau pesan kesalahan. + +### 7.2 Path Traversal dan Symlink + +Semua path artefak harus relatif terhadap root target. Sistem harus menolak path absolut yang tidak diizinkan, segmen traversal yang keluar dari target, serta resolusi symlink yang memungkinkan penulisan di luar target. + +Pemeriksaan harus dilakukan saat menyusun rencana dan diulang segera sebelum operasi tulis. Implementasi harus mempertimbangkan perubahan filesystem selama transaksi sejauh dapat dilakukan secara portabel. Symlink yang menjadi target artefak harus ditolak, termasuk ketika `--force` digunakan. + +Jika keamanan jalur tidak dapat dipastikan, proses harus gagal tanpa menulis file. Dokumentasi harus menyatakan batas perlindungan terhadap kondisi filesystem yang berubah bersamaan (*time-of-check to time-of-use*). + +### 7.3 Penanganan Konflik + +Semua konflik diperiksa sebelum commit. Tanpa `--force`, file yang konflik tidak boleh diubah. Dengan `--force`, hanya file reguler yang telah dicadangkan yang boleh diganti. Direktori, symlink, dan tujuan yang tidak aman tidak boleh ditimpa. + +### 7.4 Transaksi Filesystem + +Transaksi melacak file baru, file yang diganti, backup, staging, dan direktori yang dibuat. Rollback hanya memengaruhi perubahan transaksi. Jika pemulihan gagal, aplikasi melaporkan operasi yang gagal beserta jalur file yang perlu diperiksa secara manual. + +### 7.5 Eksekusi Eksternal + +DevOpsGen tidak boleh menjalankan `docker push`, `kubectl apply`, atau perintah deployment sebagai efek samping proses generasi. Validasi eksternal bersifat opsional, harus dipisahkan dari proses penulisan, dan hasilnya dilaporkan transparan. + +--- + +## 8. Kriteria Penerimaan dan Pengujian + +### AC-01: Inisialisasi + +Perintah `init` memproses konfigurasi lengkap dan valid serta menghasilkan artefak sesuai stack dan opsi yang dipilih. Konfigurasi tidak lengkap harus menyebut parameter yang belum tersedia. + +### AC-02: Mode Non-interaktif + +Pengujian membuktikan proses tidak menunggu input stdin ketika parameter wajib belum tersedia, stack ambigu, atau konflik file ditemukan. + +### AC-03: Deteksi Stack + +Direktori dengan `go.mod` terdeteksi sebagai kandidat Go. Direktori dengan `package.json` dianalisis berdasarkan dependency, scripts, lockfile, dan indikator framework. Konflik indikator dilaporkan sebagai ambiguitas. + +### AC-04: Deterministic Output + +Eksekusi berulang dengan versi binary dan konfigurasi yang sama menghasilkan isi dan urutan artefak identik. + +### AC-05: No-overwrite + +Generasi tanpa `--force` tidak mengubah file konflik. Symlink dan direktori target ditolak meskipun `--force` diberikan. + +### AC-06: Dry-run + +Dry-run tidak membuat atau mengubah file, direktori, permission, timestamp, maupun metadata proyek pada direktori target. Dry-run tetap melakukan rendering, validasi, dan pemeriksaan konflik. + +### AC-07: Rollback + +Kegagalan I/O yang disimulasikan memicu best-effort rollback. File baru dibersihkan dan file lama dipulihkan jika memungkinkan. Kegagalan rollback harus dilaporkan terpisah dan menghasilkan exit code `1`. + +### AC-08: Validasi Kubernetes + +Validator mendeteksi YAML tidak valid, field wajib yang hilang, image kosong, selector-label tidak konsisten, relasi Service/Ingress yang salah, dan port yang tidak konsisten dengan konfigurasi. + +### AC-09: Input Safety + +Pengujian mencakup port di luar rentang, nama resource/image tidak valid, `app-type` di luar enum, path traversal, path absolut yang tidak diizinkan, symlink yang keluar dari target, command kustom berisi newline atau karakter kontrol, dan nilai `tag` yang berisi karakter shell atau YAML yang berbahaya. + +### AC-10: Credential-free Testing + +Semua unit dan integration tests berjalan tanpa credential registry atau akses cloud sungguhan. + +### AC-11: Coverage + +Coverage paket core ditargetkan minimal 80%, disertai pengujian khusus untuk skenario keamanan, kegagalan, konflik, dan rollback. + +### AC-12: Cross-platform + +Pipeline membangun target platform yang ditetapkan dan menjalankan pengujian pada runner native jika tersedia. Status platform yang hanya di-cross-compile harus dinyatakan secara berbeda dari platform yang telah diuji native. + +### AC-13: Benchmark + +Benchmark core menggunakan maksimal 20 artefak dengan total output maksimal 256 KiB dan target kurang dari satu detik, tanpa jaringan atau proses eksternal. Metodologi serta mesin referensi didokumentasikan. + +### AC-14: Registry Workflow + +Workflow menerbitkan tag commit SHA, menggunakan autentikasi sesuai registry, menerapkan permission minimum, dan tidak menyimpan credential plaintext. + +### AC-15: Exit Code dan Short-circuit + +Pengujian membuktikan aturan FR-16. Contoh: + +- Input eksplisit tidak valid menghasilkan `2` dan tahap deteksi/generasi tidak dijalankan. +- Stack yang benar-benar ambigu menghasilkan `5` ketika tidak ada input eksplisit yang lebih awal invalid. +- Output yang tidak valid menghasilkan `4` sebelum pemeriksaan konflik. +- Konflik tanpa `--force` menghasilkan `3` setelah input serta artefak lolos validasi. +- Kegagalan I/O atau rollback menghasilkan `1`. + +### AC-16: Test Stage Transparan + +Jika test command tidak dikonfigurasi, workflow menampilkan notice atau summary bahwa test dilewati dan tidak mengeluarkan pesan bahwa tes berhasil. Jika test command tersedia dan gagal, proses publikasi image tidak boleh dijalankan. + +### AC-17: Port dan Health Probe + +`EXPOSE`, `containerPort`, dan Service `targetPort` harus mencerminkan konfigurasi port. Probe HTTP hanya dibuat jika health path yang valid dikonfigurasi atau terdeteksi dari indikator eksplisit. + +### AC-18: Lint Artefak Hasil Generasi + +Pipeline CI menghasilkan artefak untuk konfigurasi representatif (minimal satu Go dan satu Node.js, dengan Kubernetes dan Ingress aktif) dan memeriksanya dengan `actionlint` untuk workflow, `hadolint` (ambang `warning`) untuk Dockerfile, dan `kubeconform` untuk manifest. Selain itu, `docker build` pada Dockerfile hasil generasi harus berhasil untuk proyek contoh yang valid. + +--- + +## 9. Roadmap Implementasi + +### Fase 1: Project Foundation + +- Inisialisasi module Go. +- Setup Cobra dan command root. +- Buat struktur paket dasar. +- Tambahkan Makefile. +- Setup GitHub Actions untuk pengujian dasar. + +### Fase 2: Config dan Output Models + +- Definisikan model konfigurasi dan enum AppType. +- Implementasikan parsing dan validasi input, termasuk validasi tag. +- Definisikan representasi artefak di memori. +- Tambahkan unit tests dan aturan exit code. + +### Fase 3: Template Engine + +- Implementasikan `embed.FS`. +- Buat Dockerfile Go dan Node.js. +- Buat workflow GitHub Actions untuk Docker Hub dan GHCR. +- Buat manifest Kubernetes terpisah: `deployment.yaml`, `service.yaml`, `ingress.yaml`. +- Uji determinisme dan validitas output. + +### Fase 4: Filesystem Safety + +- Implementasikan validasi path dan deteksi konflik. +- Tambahkan dry-run dan force. +- Implementasikan backup, commit, dan best-effort rollback. +- Uji kegagalan I/O, symlink, serta keamanan path. + +### Fase 5: Detect dan Validate + +- Implementasikan deteksi `go.mod`. +- Implementasikan analisis `package.json`, scripts, lockfile, dan framework. +- Buat validasi YAML struktural dan konsistensi Kubernetes/workflow. +- Tambahkan status validasi eksternal. + +### Fase 6: CLI dan TUI + +- Implementasikan `init`, `detect`, `validate`, `list`, dan `version` beserta flags. +- Hubungkan CLI ke core engine. +- Implementasikan wizard Bubble Tea dengan siklus penuh (Init, Update, View). +- Terapkan perilaku non-interaktif dan pemetaan exit code. +- Tambahkan help dan pesan kesalahan konsisten. + +### Fase 7: Release Engineering + +- Lengkapi pipeline CI. +- Integrasikan GoReleaser. +- Bangun binary lintas platform. +- Dokumentasikan instalasi, penggunaan, asumsi, serta batasan. +- Terapkan versioning dan changelog. + +### Fase 8: Ekspansi Setelah MVP + +Setelah seluruh acceptance criteria MVP terpenuhi, fitur berikut dapat dipertimbangkan: + +- Python. +- PHP/Laravel. +- Helm. +- Argo CD atau Flux. +- Provider CI/CD tambahan. +- Template eksternal. + +--- + +## 10. Risiko dan Mitigasi + +| Risiko | Mitigasi | +|---|---| +| Deteksi stack ambigu | Minta konfigurasi eksplisit dan jangan menebak. | +| Dockerfile tidak sesuai aplikasi | Validasi asumsi runtime, uji per template, dan dokumentasikan keterbatasan deteksi statis. | +| Credential terekspos | Gunakan secret references, permission minimum, dan uji artefak/log. | +| File rusak ketika proses gagal | Gunakan rencana perubahan, backup, pencatatan transaksi, dan best-effort rollback. | +| Symlink atau filesystem berubah saat operasi | Validasi path berulang, tolak target tidak aman, dan fail closed jika tidak dapat dipastikan. | +| Manifest tidak cocok dengan cluster | Lakukan validasi internal, dokumentasikan prasyarat, dan jangan menjalankan apply otomatis. | +| Port container berbeda dari listener aplikasi | Nyatakan batasan `EXPOSE`/`containerPort`, validasi konfigurasi, dan dokumentasikan bahwa aplikasi harus bind ke port tersebut. | +| Test stage tidak tersedia | Nyatakan tahap dilewati secara eksplisit; jangan melaporkan test berhasil. | +| Perbedaan platform | CI multi-platform dan pengujian native bila tersedia. | +| Scope creep | Tegakkan batas MVP dan pindahkan ekspansi ke fase berikutnya. | +| Coverage tinggi tetapi kualitas rendah | Uji skenario keamanan, regresi, determinisme, dan kegagalan secara eksplisit. | +| Template berubah tanpa pelacakan | Ikat template ke versi binary serta changelog. | +| Tag berisi karakter berbahaya | Validasi tag sesuai aturan FR-09 sebelum rendering ke workflow dan manifest. | + +--- + +## 11. Definition of Done + +MVP dinyatakan siap dirilis apabila: + +1. Seluruh command wajib tersedia dan menyediakan help. +2. Mode interaktif dan non-interaktif mengikuti spesifikasi. +3. Stack Go dan Node.js yang didukung menghasilkan artefak sesuai konfigurasi. +4. No-overwrite, dry-run, dan force telah diuji. +5. Backup dan best-effort rollback diuji melalui skenario kegagalan I/O. +6. Validasi input, YAML, struktur workflow, dan konsistensi Kubernetes berjalan. +7. Target coverage core dievaluasi dan skenario kritis diuji eksplisit. +8. Pipeline CI lulus pada target yang ditetapkan. +9. Dokumentasi penggunaan, instalasi, keamanan, asumsi, serta batasan tersedia. +10. Binary release memiliki versi yang dapat diidentifikasi. +11. Benchmark core dijalankan dan hasilnya dicatat. +12. Tidak ada credential plaintext dalam artefak yang dihasilkan. +13. Exit code dan aturan short-circuit diuji sesuai AC-15. +14. Test stage yang tidak dikonfigurasi dilaporkan sebagai dilewati, bukan berhasil. +15. Template tidak membuat klaim bahwa port, health endpoint, atau nilai resource telah diverifikasi jika bukti tidak tersedia. +16. Artefak hasil generasi lolos lint dan build sesuai AC-18. +17. Seluruh butir Lampiran A berstatus selesai atau diterima secara eksplisit oleh maintainer. +16. Nilai `tag` divalidasi sebelum digunakan dalam workflow dan manifest Kubernetes. + +--- + +## 12. Persetujuan Spesifikasi + +Dokumen ini menjadi baseline teknis DevOpsGen versi **1.2.0** dengan status **Approved for Implementation**. + +Perubahan pada scope MVP, kontrak CLI, kebijakan keamanan, arsitektur core, model konfigurasi, atau acceptance criteria harus dicatat sebagai revisi spesifikasi atau keputusan desain. Implementasi harus mengutamakan ketepatan output, keamanan filesystem, determinisme, dan kemudahan pengujian sebelum ekspansi fitur. + +**Status akhir: Approved for Implementation (revisi 1.2.1 menunggu peninjauan maintainer).** + +--- + +## Lampiran A. Catatan Kesesuaian Implementasi (per 17 Oktober 2026) + +Lampiran ini mencatat selisih antara spesifikasi dan implementasi pada branch `impl-srs-v1.2.0-completion` yang ditemukan saat revisi 1.2.1. Spesifikasi di atas tetap menjadi acuan; butir berikut adalah pekerjaan implementasi atau keputusan yang belum selesai. + +| ID | Selisih | Acuan | Tindak lanjut | +|---|---|---|---| +| A1 | Dockerfile Node.js selalu memasang dependency dengan npm dan menjalankan `pnpm run build` atau `yarn run build` sesuai package manager terdeteksi. pnpm tidak dipasang di image, sehingga build pnpm gagal; lockfile pnpm dan yarn diabaikan. Workflow juga mengasumsikan pnpm tersedia di runner. | 3.2.2, FR-05 | Dukung pnpm/yarn penuh (misalnya `corepack enable` dan perintah install/prune yang sesuai), atau batasi MVP ke npm dan tolak package manager lain secara eksplisit. | +| A2 | `TestCmdForWorkflow` membungkus `test-cmd` Node.js menjadi ` run `, sehingga `--test-cmd "npm test"` menjadi `npm run npm test`. `build-cmd` dan `start-cmd` tidak dibungkus. | 4.1 (`test-cmd`), FR-03 | Tulis `test-cmd` apa adanya dan perbarui pengujian terkait. | +| A3 | Detektor memilih satu lockfile menurut urutan tetap (npm, pnpm, yarn) dan tidak melaporkan ambiguitas bila ada beberapa lockfile. | 3.2.2 | Laporkan ambiguitas (exit code `5` pada mode non-interaktif). | +| A4 | `RequiredFieldsMissing` mewajibkan `--start-cmd` untuk semua stack Node.js, padahal spesifikasi menyatakan wajib bersyarat dan template memiliki kandidat default. | 4.1 (`start-cmd`) | Putuskan: wajib selalu (ubah spesifikasi) atau bersyarat (ubah implementasi). | +| A5 | Template Dockerfile Go dan Node.js membuat `go.mod` atau `package.json` sementara bila berkas tidak ada (pada Node.js, pengecekan berjalan sebelum `COPY` sehingga tidak berguna). | FR-05 | Hapus; biarkan build gagal dengan jelas. | +| A6 | Validator hanya menolak pola `{{.` dan tidak mendeteksi ekspresi bocor berbentuk `${{ .Field }}` (ada spasi). | FR-13 | Gunakan pola `\{\{\s*\.` dan tambahkan pengujian regresi. | +| A7 | `Dockerfile` dan `kubernetes/` hasil generasi ter-commit di root repositori, tidak tercakup `.gitignore`. | 6.2 | Hapus dari repositori dan tambahkan ke `.gitignore`. | +| A8 | Integrasi validator eksternal (misalnya `kubeconform`) pada `validate` belum ada; hanya pesan `external validation skipped`. | FR-13 | Diterima untuk MVP (sudah dicatat pada FR-13); jadwalkan pascarilis. | +| A9 | Langkah hadolint pada `ci.yml` memakai path di luar workspace (`/tmp/...`) yang tidak terlihat oleh container action, dan memakai ambang `info`. | AC-18 | Jalankan hadolint melalui stdin atau di dalam workspace, ambang `warning`. | \ No newline at end of file diff --git a/go.mod b/go.mod index 07f35f8..d3a2963 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,15 @@ go 1.24.2 require ( github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.2 + go.yaml.in/yaml/v4 v4.0.0-rc.3 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect diff --git a/go.sum b/go.sum index e7e811f..6bfc5ab 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,10 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= +go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= +go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/cli/coverage_test.go b/internal/cli/coverage_test.go new file mode 100644 index 0000000..a96fe6b --- /dev/null +++ b/internal/cli/coverage_test.go @@ -0,0 +1,160 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "devopsgen.dev/devopsgen/internal/config" +) + +func TestRunInit_OutputDirInvalid(t *testing.T) { + flags := &InitFlags{ + Stack: "go", + AppType: "binary", + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 8080, + OutputDir: "../escape", + } + err := RunInit(flags) + if err == nil { + t.Fatal("expected error for invalid output dir") + } +} + +func TestRunInit_TagInvalid(t *testing.T) { + dir := t.TempDir() + flags := &InitFlags{ + Stack: "go", + AppType: "binary", + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 8080, + Tag: "v1;rm", + OutputDir: dir, + } + err := RunInit(flags) + if err == nil { + t.Fatal("expected error for invalid tag") + } +} + +func TestRunInit_GoBuildCmdUsed(t *testing.T) { + dir := t.TempDir() + flags := &InitFlags{ + Stack: "go", + AppType: "binary", + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 8080, + BuildCmd: "go build -o custom .", + OutputDir: dir, + } + if err := RunInit(flags); err != nil { + t.Fatalf("runInit: %v", err) + } + df, err := os.ReadFile(filepath.Join(dir, "Dockerfile")) + if err != nil { + t.Fatal(err) + } + if !contains(string(df), "go build -o custom") { + t.Fatalf("Dockerfile should use custom build cmd:\n%s", string(df)) + } +} + +func TestRunInit_NodeStartCmdDefault(t *testing.T) { + dir := t.TempDir() + flags := &InitFlags{ + Stack: "nodejs", + AppType: "express", + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 3000, + OutputDir: dir, + } + if err := RunInit(flags); err != nil { + t.Fatalf("runInit: %v", err) + } + df, err := os.ReadFile(filepath.Join(dir, "Dockerfile")) + if err != nil { + t.Fatal(err) + } + if !contains(string(df), "npm run start") { + t.Fatalf("Dockerfile should default to npm run start:\n%s", string(df)) + } +} + +func TestRunInit_ViteStartCmdDefault(t *testing.T) { + dir := t.TempDir() + flags := &InitFlags{ + Stack: "nodejs", + AppType: "vite", + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 8080, + OutputDir: dir, + } + if err := RunInit(flags); err != nil { + t.Fatalf("runInit: %v", err) + } + df, err := os.ReadFile(filepath.Join(dir, "Dockerfile")) + if err != nil { + t.Fatal(err) + } + if !contains(string(df), "npm run preview -- --port 8080") { + t.Fatalf("Dockerfile should use npm run preview:\n%s", string(df)) + } +} + +func TestRunInit_NextJSDefaults(t *testing.T) { + dir := t.TempDir() + flags := &InitFlags{ + Stack: "nodejs", + AppType: "nextjs", + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 3000, + OutputDir: dir, + } + if err := RunInit(flags); err != nil { + t.Fatalf("runInit: %v", err) + } + df, err := os.ReadFile(filepath.Join(dir, "Dockerfile")) + if err != nil { + t.Fatal(err) + } + if !contains(string(df), "npm run build") || !contains(string(df), "npx next start -p 3000") { + t.Fatalf("Dockerfile should use Next.js defaults:\n%s", string(df)) + } +} + +func TestValidateConfig_StartCmdForGo(t *testing.T) { + // Go ignores start-cmd + cfg := config.Config{ + Stack: config.StackGo, + CI: config.CIGitHubActions, + Registry: config.RegistryDockerHub, + Image: "a/b", + Port: 8080, + StartCmd: "echo hello", + } + if err := config.ValidateConfig(cfg); err != nil { + t.Fatalf("Go should accept start-cmd: %v", err) + } +} + +func contains(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/cli/help_test.go b/internal/cli/help_test.go new file mode 100644 index 0000000..e8822bf --- /dev/null +++ b/internal/cli/help_test.go @@ -0,0 +1,55 @@ +package cli + +import ( + "testing" +) + +func TestNewRootCommand_Help(t *testing.T) { + root := NewRootCommand() + root.SetArgs([]string{"--help"}) + err := root.Execute() + if err != nil { + t.Fatalf("help: %v", err) + } +} + +func TestInitCommand_Help(t *testing.T) { + cmd := newInitCommand() + cmd.SetArgs([]string{"--help"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("init help: %v", err) + } +} + +func TestDetectCommand_Help(t *testing.T) { + cmd := newDetectCommand() + cmd.SetArgs([]string{"--help"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("detect help: %v", err) + } +} + +func TestValidateCommand_Help(t *testing.T) { + cmd := newValidateCommand() + cmd.SetArgs([]string{"--help"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("validate help: %v", err) + } +} + +func TestListCommand_Executes(t *testing.T) { + cmd := newListCommand() + if err := cmd.Flags().Parse(nil); err != nil { + t.Fatal(err) + } +} + +func TestVersionCommand_Executes(t *testing.T) { + cmd := newVersionCommand() + if err := cmd.Flags().Parse(nil); err != nil { + t.Fatal(err) + } +} diff --git a/internal/cli/init_flags.go b/internal/cli/init_flags.go index 21c202e..3cad526 100644 --- a/internal/cli/init_flags.go +++ b/internal/cli/init_flags.go @@ -8,6 +8,7 @@ package cli import ( "fmt" "strconv" + "strings" "github.com/spf13/cobra" @@ -23,6 +24,7 @@ type InitFlags struct { Image string Port int BuildCmd string + BuildPath string TestCmd string StartCmd string Tag string @@ -46,6 +48,7 @@ func AddInitFlags(cmd *cobra.Command) *InitFlags { f.StringVar(&flags.Image, "image", "", "image repository as namespace/repository") f.IntVar(&flags.Port, "port", 0, "application port (1-65535)") f.StringVar(&flags.BuildCmd, "build-cmd", "", "build command") + f.StringVar(&flags.BuildPath, "build-path", ".", "Go package path to build inside the container (default .)") f.StringVar(&flags.TestCmd, "test-cmd", "", "test command") f.StringVar(&flags.StartCmd, "start-cmd", "", "startup command") f.StringVar(&flags.Tag, "tag", "", "additional image tag alias (default latest)") @@ -59,8 +62,8 @@ func AddInitFlags(cmd *cobra.Command) *InitFlags { return flags } -// ToConfig converts parsed flags into a validated Config. -// Returns an error when a required parameter is missing or invalid (exit 2). +// ToConfig converts parsed flags into a Config without requiring stack. +// Stack resolution (from flags or detection) happens in RunInit per SRS 4.2. func (f *InitFlags) ToConfig() (config.Config, error) { cfg := config.Config{ Stack: config.StackType(f.Stack), @@ -70,6 +73,7 @@ func (f *InitFlags) ToConfig() (config.Config, error) { Image: f.Image, Port: f.Port, BuildCmd: f.BuildCmd, + BuildPath: f.BuildPath, TestCmd: f.TestCmd, StartCmd: f.StartCmd, Tag: f.Tag, @@ -83,11 +87,51 @@ func (f *InitFlags) ToConfig() (config.Config, error) { HealthPath: f.HealthPath, } - if cfg.Stack == "" { - return cfg, fmt.Errorf("--stack is required (go or nodejs)") + // Validate CI, registry, image, port, tag, app-type, k8s options. + // Stack is allowed to be empty here; it is resolved later. + if cfg.Stack != "" && cfg.Stack != config.StackGo && cfg.Stack != config.StackNode { + return cfg, fmt.Errorf("stack must be 'go' or 'nodejs'") } - if err := config.ValidateConfig(cfg); err != nil { - return cfg, err + if cfg.CI != config.CIGitHubActions { + return cfg, fmt.Errorf("ci must be 'github-actions'") + } + if cfg.Registry != config.RegistryDockerHub && cfg.Registry != config.RegistryGHCR { + return cfg, fmt.Errorf("registry must be 'dockerhub' or 'ghcr'") + } + if strings.TrimSpace(cfg.Image) == "" { + return cfg, fmt.Errorf("image is required") + } + if !config.ImageRe.MatchString(cfg.Image) { + return cfg, fmt.Errorf("image must be 'namespace/repository' without host or tag") + } + if cfg.Port < 1 || cfg.Port > 65535 { + return cfg, fmt.Errorf("port must be in range 1-65535") + } + if cfg.AppType != "" && !config.ValidAppTypeForStack(cfg.Stack, cfg.AppType) { + return cfg, fmt.Errorf("app-type is not supported for the selected stack") + } + if err := config.ValidateTag(cfg.Tag); err != nil { + return cfg, fmt.Errorf("tag: %w", err) + } + if cfg.K8s.Ingress && cfg.K8s.IngressHost == "" { + return cfg, fmt.Errorf("ingress-host is required when ingress is enabled") + } + if cfg.K8s.IngressHost != "" { + if err := config.ValidateHostname(cfg.K8s.IngressHost); err != nil { + return cfg, err + } + } + if cfg.K8s.HealthPath != "" && !config.HealthPathRe.MatchString(cfg.K8s.HealthPath) { + return cfg, fmt.Errorf("health-path must be a valid HTTP path") + } + if err := config.ValidateCommand(cfg.BuildCmd); err != nil { + return cfg, fmt.Errorf("build-cmd: %w", err) + } + if err := config.ValidateCommand(cfg.TestCmd); err != nil { + return cfg, fmt.Errorf("test-cmd: %w", err) + } + if err := config.ValidateCommand(cfg.StartCmd); err != nil { + return cfg, fmt.Errorf("start-cmd: %w", err) } if err := config.ValidateOutputDir(cfg.OutputDir); err != nil { return cfg, err @@ -114,6 +158,7 @@ func readInitFlags(cmd *cobra.Command) (*InitFlags, error) { flags.Image, _ = f.GetString("image") flags.Port, _ = f.GetInt("port") flags.BuildCmd, _ = f.GetString("build-cmd") + flags.BuildPath, _ = f.GetString("build-path") flags.TestCmd, _ = f.GetString("test-cmd") flags.StartCmd, _ = f.GetString("start-cmd") flags.Tag, _ = f.GetString("tag") diff --git a/internal/cli/init_flags_test.go b/internal/cli/init_flags_test.go index 4529d5f..c60bbb3 100644 --- a/internal/cli/init_flags_test.go +++ b/internal/cli/init_flags_test.go @@ -29,13 +29,18 @@ func TestAddInitFlags_ToConfig(t *testing.T) { } } -func TestToConfig_MissingStack(t *testing.T) { +func TestToConfig_EmptyStackAllowed(t *testing.T) { cmd := &cobra.Command{Use: "init"} flags := AddInitFlags(cmd) flags.Image = "a/b" flags.Port = 8080 - if _, err := flags.ToConfig(); err == nil { - t.Fatal("expected error for missing stack") + // Stack is allowed to be empty here; resolved later by detection. + cfg, err := flags.ToConfig() + if err != nil { + t.Fatalf("unexpected error for empty stack: %v", err) + } + if cfg.Image != "a/b" { + t.Fatalf("image: %s", cfg.Image) } } diff --git a/internal/cli/regression_test.go b/internal/cli/regression_test.go index 52eb93b..25001f7 100644 --- a/internal/cli/regression_test.go +++ b/internal/cli/regression_test.go @@ -1,67 +1,70 @@ package cli import ( - "os" - "path/filepath" "strings" "testing" + + "devopsgen.dev/devopsgen/internal/config" + "devopsgen.dev/devopsgen/internal/generator" ) -// TestRunInit_DryRunWithConflict ensures --dry-run does not require --force -// when target files already exist. Dry-run only reports the plan and never -// writes, so conflicts must not block it. -func TestRunInit_DryRunWithConflict(t *testing.T) { - dir := t.TempDir() - // Pre-create Dockerfile to force a conflict. - if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("old"), 0o644); err != nil { - t.Fatal(err) - } - flags := &InitFlags{ - Stack: "go", - AppType: "binary", - CI: "github-actions", - Registry: "ghcr", - Image: "bangkah/my-app", - Port: 8080, - OutputDir: dir, - DryRun: true, +func goDockerfile(t *testing.T, appType config.AppType) string { + t.Helper() + arts, err := generator.New().Generate(config.Config{ + Stack: config.StackGo, + AppType: appType, + CI: config.CIGitHubActions, + Registry: config.RegistryGHCR, + Image: "bangkah/my-app", + Port: 8080, + }) + if err != nil { + t.Fatalf("generate: %v", err) } - if err := RunInit(flags); err != nil { - t.Fatalf("dry-run with conflict should not error: %v", err) + for _, a := range arts { + if a.Path == "Dockerfile" { + return string(a.Content) + } } - // Dry-run must not write anything. - if c, _ := os.ReadFile(filepath.Join(dir, "Dockerfile")); string(c) != "old" { - t.Fatal("dry-run must not overwrite existing files") + t.Fatal("no Dockerfile artifact") + return "" +} + +// Regression: with --stack go and no --app-type, the build step rendered with +// no `go build` command at all, so the Dockerfile compiled nothing. +func TestGoDockerfile_EmptyAppTypeStillBuilds(t *testing.T) { + for _, at := range []config.AppType{"", config.AppTypeBinary} { + df := goDockerfile(t, at) + found := false + for _, line := range strings.Split(df, "\n") { + if strings.HasPrefix(line, "RUN ") && strings.Contains(line, "go build") { + found = true + } + } + if !found { + t.Errorf("app-type %q: no RUN instruction runs `go build`:\n%s", at, df) + } } } -// TestRunInit_PythonStack ensures Python stack generates Dockerfile + workflow. -func TestRunInit_PythonStack(t *testing.T) { - dir := t.TempDir() - flags := &InitFlags{ - Stack: "python", - AppType: "fastapi", - CI: "github-actions", - Registry: "ghcr", - Image: "bangkah/my-app", - Port: 8000, - StartCmd: "uvicorn app:app --host 0.0.0.0 --port 8000", - OutputDir: dir, +// Regression: the runtime stage copied /src/devopsgen (the name of this very +// project) instead of the binary written by the build step. +func TestGoDockerfile_CopiesTheBinaryItBuilds(t *testing.T) { + df := goDockerfile(t, "") + if strings.Contains(df, "devopsgen") { + t.Errorf("Dockerfile must not reference a hard-coded project name:\n%s", df) } - if err := RunInit(flags); err != nil { - t.Fatalf("runInit python: %v", err) + if !strings.Contains(df, "-o /out/server") || !strings.Contains(df, "COPY --from=builder /out/server /server") { + t.Errorf("build output path and COPY source must both be /out/server:\n%s", df) } - for _, p := range []string{"Dockerfile", ".github/workflows/ci.yml"} { - if _, err := os.Stat(filepath.Join(dir, p)); err != nil { - t.Fatalf("expected artifact %s: %v", p, err) +} + +// A Dockerfile RUN must never end in bare variable assignments (shellcheck SC2034). +func TestGoDockerfile_NoRunEndsWithAssignment(t *testing.T) { + df := goDockerfile(t, "") + for _, line := range strings.Split(df, "\n") { + if strings.HasPrefix(line, "RUN ") && strings.HasSuffix(strings.TrimSpace(line), "GOARCH=${TARGETARCH}") { + t.Errorf("RUN line ends with an assignment and no command: %q", line) } } - df, _ := os.ReadFile(filepath.Join(dir, "Dockerfile")) - if !strings.Contains(string(df), "/opt/venv") { - t.Fatalf("python Dockerfile should use /opt/venv, got %q", string(df)[:200]) - } - wf, _ := os.ReadFile(filepath.Join(dir, ".github/workflows/ci.yml")) - if !strings.Contains(string(wf), "setup-python") { - t.Fatalf("python workflow should have setup-python step, got %q", string(wf)[:200]) - } } diff --git a/internal/cli/root.go b/internal/cli/root.go index d9395e3..3a11a3f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -16,6 +16,7 @@ import ( "github.com/spf13/cobra" + "devopsgen.dev/devopsgen/internal/config" "devopsgen.dev/devopsgen/internal/detector" "devopsgen.dev/devopsgen/internal/output" "devopsgen.dev/devopsgen/internal/templates" @@ -94,8 +95,7 @@ var ( errAmbiguousStack = errors.New("ambiguous or unsupported stack") ) -// newInitCommand wires the init command. The implementation lives in a -// separate file so this file stays focused on the command tree. +// newInitCommand wires the init command. func newInitCommand() *cobra.Command { cmd := &cobra.Command{ Use: "init", @@ -113,7 +113,7 @@ process runs headless and never waits for stdin.`, } // In an interactive terminal with incomplete configuration, // launch the TUI wizard before generation. - if flags.Stack == "" && isInteractive() { + if flags.Stack == "" && isInteractiveFn() { wcfg, werr := wizard.Run() if werr != nil { return newExitError(2, werr) @@ -123,6 +123,33 @@ process runs headless and never waits for stdin.`, flags.BuildCmd = wcfg.BuildCmd flags.TestCmd = wcfg.TestCmd flags.StartCmd = wcfg.StartCmd + if wcfg.Image != "" { + flags.Image = wcfg.Image + } + if wcfg.Port != 0 { + flags.Port = wcfg.Port + } + if wcfg.Tag != "" { + flags.Tag = wcfg.Tag + } + if wcfg.Registry != "" { + flags.Registry = string(wcfg.Registry) + } + if wcfg.CI != "" { + flags.CI = string(wcfg.CI) + } + // Merge flag-based k8s settings into wizard config. + wcfg.K8s.Enabled = flags.K8s + wcfg.K8s.Ingress = flags.Ingress + wcfg.K8s.IngressHost = flags.IngressHost + wcfg.K8s.HealthPath = flags.HealthPath + wcfg.OutputDir = flags.OutputDir + wcfg.Generation = config.GenerationOptions{Force: flags.Force, DryRun: flags.DryRun} + // Read back merged k8s options into flags for RunInit. + flags.K8s = wcfg.K8s.Enabled + flags.Ingress = wcfg.K8s.Ingress + flags.IngressHost = wcfg.K8s.IngressHost + flags.HealthPath = wcfg.K8s.HealthPath } return RunInit(flags) }, @@ -131,6 +158,10 @@ process runs headless and never waits for stdin.`, return cmd } +// isInteractiveFn reports whether stdin is a terminal. It is a package-level +// variable so tests can override it. +var isInteractiveFn = isInteractive + // isInteractive reports whether stdin is a terminal. func isInteractive() bool { info, err := os.Stdin.Stat() @@ -165,6 +196,9 @@ func runDetect(out io.Writer) error { } fmt.Fprintf(out, "stack: %s\n", r.Stack) fmt.Fprintf(out, "indicators: %s\n", strings.Join(r.Indicators, ", ")) + if len(r.Warnings) > 0 { + fmt.Fprintf(out, "warnings: %s\n", strings.Join(r.Warnings, "; ")) + } if len(r.AppCandidates) > 0 { fmt.Fprintf(out, "app-type candidates: %s\n", strings.Join(r.AppCandidates, ", ")) } @@ -209,40 +243,82 @@ GitHub Actions structure, and port/health probe consistency.`, } func runValidate(dir string, out io.Writer) error { - entries, err := os.ReadDir(dir) + arts, err := collectArtifacts(dir) if err != nil { return newExitError(1, fmt.Errorf("read dir: %w", err)) } - var arts []output.Artifact - for _, e := range entries { - if e.IsDir() { - continue + if len(arts) == 0 { + return newExitError(4, fmt.Errorf("no artifacts found in %s", dir)) + } + // Check for required MVP artifacts. + required := map[string]bool{"Dockerfile": false, ".github/workflows/ci.yml": false} + for _, a := range arts { + if a.Path == "Dockerfile" { + required["Dockerfile"] = true } - name := e.Name() - if !isArtifact(name) { - continue + if a.Path == ".github/workflows/ci.yml" { + required[".github/workflows/ci.yml"] = true } - b, err := os.ReadFile(filepath.Join(dir, name)) - if err != nil { - return newExitError(1, fmt.Errorf("read %s: %w", name, err)) + } + var missing []string + for k, v := range required { + if !v { + missing = append(missing, k) } - arts = append(arts, output.Artifact{Path: name, Content: b}) } - if len(arts) == 0 { - fmt.Fprintf(out, "no artifacts found in %s\n", dir) - return nil + if len(missing) > 0 { + return newExitError(4, fmt.Errorf("missing required artifacts: %s", strings.Join(missing, ", "))) } r, err := validator.New().Validate(arts) if err != nil { return newExitError(4, fmt.Errorf("validate: %w", err)) } - if r != validator.ResultValid { + switch r { + case validator.ResultValid: + fmt.Fprintf(out, "Internal validation passed\n") + case validator.ResultExternalSkipped: + fmt.Fprintf(out, "Internal validation passed; external validation skipped\n") + default: return newExitError(4, fmt.Errorf("validation failed: %s", r)) } - fmt.Fprintf(out, "Internal validation passed\n") return nil } +// collectArtifacts recursively finds supported artifact files in dir. +func collectArtifacts(dir string) ([]output.Artifact, error) { + var arts []output.Artifact + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + // Skip hidden directories and common non-artifact dirs. + name := d.Name() + if name == ".git" || name == "node_modules" || name == ".devopsgen" { + return filepath.SkipDir + } + return nil + } + if !isArtifact(d.Name()) { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + b, err := os.ReadFile(path) + if err != nil { + return err + } + arts = append(arts, output.Artifact{Path: filepath.ToSlash(rel), Content: b}) + return nil + }) + if err != nil { + return nil, err + } + return arts, nil +} + func newListCommand() *cobra.Command { return &cobra.Command{ Use: "list", @@ -252,8 +328,8 @@ deployment templates bundled in the running binary. The list reflects actual bundled capability, not a future roadmap.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintf(cmd.OutOrStdout(), "Stacks:\n go, nodejs, python\n") - fmt.Fprintf(cmd.OutOrStdout(), "App types:\n binary (Go), express, nextjs, vite, nestjs, generic (Node.js), fastapi, django, flask, generic (Python)\n") + fmt.Fprintf(cmd.OutOrStdout(), "Stacks:\n go, nodejs\n") + fmt.Fprintf(cmd.OutOrStdout(), "App types:\n binary (Go), express, nextjs, vite, nestjs, generic (Node.js)\n") fmt.Fprintf(cmd.OutOrStdout(), "CI providers:\n github-actions\n") fmt.Fprintf(cmd.OutOrStdout(), "Registries:\n dockerhub, ghcr\n") fmt.Fprintf(cmd.OutOrStdout(), "Templates: docker, github-actions, kubernetes\n") diff --git a/internal/cli/root_extra_test.go b/internal/cli/root_extra_test.go new file mode 100644 index 0000000..e1ed02e --- /dev/null +++ b/internal/cli/root_extra_test.go @@ -0,0 +1,122 @@ +package cli + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "devopsgen.dev/devopsgen/internal/output" +) + +func TestRunValidate_Recursive(t *testing.T) { + dir := t.TempDir() + // Create artifacts in subdirectories. + if err := os.MkdirAll(filepath.Join(dir, ".github/workflows"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "kubernetes"), 0o755); err != nil { + t.Fatal(err) + } + arts := []struct { + path string + content string + }{ + {"Dockerfile", "FROM golang:1.22\nEXPOSE 8080"}, + {".github/workflows/ci.yml", "name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: docker push x:${{ github.sha }}"}, + {"kubernetes/deployment.yaml", "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n selector:\n matchLabels:\n app: app\n template:\n metadata:\n labels:\n app: app\n spec:\n containers:\n - name: app\n image: ghcr.io/x/app:latest"}, + {"kubernetes/service.yaml", "apiVersion: v1\nkind: Service\nmetadata:\n name: app\nspec:\n selector:\n app: app\n ports:\n - port: 8080\n targetPort: 8080"}, + } + for _, a := range arts { + fullPath := filepath.Join(dir, a.path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(fullPath, []byte(a.content), 0o644); err != nil { + t.Fatal(err) + } + } + if err := runValidate(dir, os.Stdout); err != nil { + t.Fatalf("runValidate recursive: %v", err) + } +} + +func TestRunValidate_MissingRequiredArtifact(t *testing.T) { + dir := t.TempDir() + // Only create a Dockerfile, missing workflow. + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM golang:1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + err := runValidate(dir, os.Stdout) + if err == nil { + t.Fatal("expected error for missing required artifact") + } + var ee *exitError + if !errors.As(err, &ee) || ee.ExitCode() != 4 { + t.Fatalf("expected exit 4, got %v", err) + } +} + +func TestCollectArtifacts_Recursive(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "kubernetes"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "kubernetes", "deployment.yaml"), []byte("kind: Deployment"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# hi"), 0o644); err != nil { + t.Fatal(err) + } + arts, err := collectArtifacts(dir) + if err != nil { + t.Fatalf("collectArtifacts: %v", err) + } + if len(arts) != 2 { + t.Fatalf("expected 2 artifacts, got %d", len(arts)) + } + paths := map[string]bool{} + for _, a := range arts { + paths[a.Path] = true + } + if !paths["Dockerfile"] { + t.Fatal("expected Dockerfile artifact") + } + if !paths["kubernetes/deployment.yaml"] { + t.Fatal("expected kubernetes/deployment.yaml artifact") + } +} + +func TestCollectArtifacts_SkipsHiddenDirs(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".git", "config"), []byte("[core]"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch"), 0o644); err != nil { + t.Fatal(err) + } + arts, err := collectArtifacts(dir) + if err != nil { + t.Fatalf("collectArtifacts: %v", err) + } + for _, a := range arts { + if strings.Contains(a.Path, ".git") { + t.Fatal("should not include .git directory artifacts") + } + } +} + +func TestMapCommandError_ExitError(t *testing.T) { + if got := MapExitCode(newExitError(4, errors.New("validate"))); got != 4 { + t.Fatalf("expected 4, got %d", got) + } +} + +var _ = output.Artifact{} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 1192fa8..8d91f42 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -6,6 +6,11 @@ import ( "path/filepath" "strings" "testing" + + "github.com/spf13/cobra" + + "devopsgen.dev/devopsgen/internal/config" + "devopsgen.dev/devopsgen/internal/detector" ) func TestRunDetect(t *testing.T) { @@ -52,9 +57,13 @@ func TestRunValidate_Pass(t *testing.T) { dir := t.TempDir() arts := []string{ "Dockerfile:FROM golang:1.22\nEXPOSE 8080", + ".github/workflows/ci.yml:name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: docker push x:${{ github.sha }}", } for _, a := range arts { parts := strings.SplitN(a, ":", 2) + if err := os.MkdirAll(filepath.Join(dir, filepath.Dir(parts[0])), 0o755); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(dir, parts[0]), []byte(parts[1]), 0o644); err != nil { t.Fatal(err) } @@ -81,8 +90,13 @@ func TestRunValidate_Fail(t *testing.T) { func TestRunValidate_Empty(t *testing.T) { dir := t.TempDir() - if err := runValidate(dir, os.Stdout); err != nil { - t.Fatalf("empty dir should pass: %v", err) + err := runValidate(dir, os.Stdout) + if err == nil { + t.Fatal("expected error for no artifacts") + } + var ee *exitError + if !errors.As(err, &ee) || ee.ExitCode() != 4 { + t.Fatalf("expected exit 4, got %v", err) } } @@ -204,7 +218,7 @@ func TestNewRootCommand(t *testing.T) { if err := root.Execute(); err != nil { t.Fatalf("root execute list: %v", err) } - if !strings.Contains(buf.String(), "Go") { + if !strings.Contains(buf.String(), "go") { t.Fatalf("expected list output, got %q", buf.String()) } } @@ -218,3 +232,273 @@ func TestIsInteractive(t *testing.T) { // Exercise isInteractive helper _ = isInteractive() } + +func TestIsInteractiveFnOverride(t *testing.T) { + orig := isInteractiveFn + defer func() { isInteractiveFn = orig }() + isInteractiveFn = func() bool { return true } + if !isInteractiveFn() { + t.Fatal("expected isInteractiveFn to return true") + } +} + +func TestIsInteractive_Direct(t *testing.T) { + // Just exercise the function; result depends on environment. + _ = isInteractive() +} + +func TestNewDetectCommand(t *testing.T) { + cmd := newDetectCommand() + if cmd == nil { + t.Fatal("newDetectCommand returned nil") + } + if cmd.Name() != "detect" { + t.Fatalf("expected name detect, got %s", cmd.Name()) + } +} + +func TestNewValidateCommand(t *testing.T) { + cmd := newValidateCommand() + if cmd == nil { + t.Fatal("newValidateCommand returned nil") + } + if cmd.Name() != "validate" { + t.Fatalf("expected name validate, got %s", cmd.Name()) + } +} + +func TestRunInit_AmbiguousStack(t *testing.T) { + orig := isInteractiveFn + defer func() { isInteractiveFn = orig }() + isInteractiveFn = func() bool { return true } + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + origWd, _ := os.Getwd() + defer os.Chdir(origWd) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + flags := &InitFlags{ + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 8080, + OutputDir: t.TempDir(), + } + err := RunInit(flags) + if err == nil { + t.Fatal("expected error for ambiguous stack") + } + if !strings.Contains(err.Error(), "ambiguous") && !strings.Contains(err.Error(), "multiple stacks") { + t.Fatalf("expected ambiguous stack error, got: %v", err) + } +} + +func TestRunInit_EmptyStack_Interactive(t *testing.T) { + orig := isInteractiveFn + defer func() { isInteractiveFn = orig }() + isInteractiveFn = func() bool { return true } + + dir := t.TempDir() + // Create go.mod so detection succeeds. + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + origWd, _ := os.Getwd() + defer os.Chdir(origWd) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + flags := &InitFlags{ + CI: "github-actions", + Registry: "ghcr", + OutputDir: t.TempDir(), + } + err := RunInit(flags) + if err == nil { + t.Fatal("expected error when stack empty and image/port missing") + } + // Detection succeeds (go.mod found), but image is required and missing. + if !strings.Contains(err.Error(), "image is required") { + t.Fatalf("expected image required error, got: %v", err) + } +} + +func TestRunInit_NodeJS_Express(t *testing.T) { + dir := t.TempDir() + flags := &InitFlags{ + Stack: "nodejs", + AppType: "express", + CI: "github-actions", + Registry: "ghcr", + Image: "bangkah/api", + Port: 3000, + StartCmd: "node server.js", + OutputDir: dir, + } + if err := RunInit(flags); err != nil { + t.Fatalf("runInit: %v", err) + } + for _, p := range []string{"Dockerfile", ".github/workflows/ci.yml"} { + if _, err := os.Stat(filepath.Join(dir, p)); err != nil { + t.Fatalf("expected artifact %s: %v", p, err) + } + } +} + +func TestRunInit_NodeJS_WithK8sIngress(t *testing.T) { + dir := t.TempDir() + flags := &InitFlags{ + Stack: "nodejs", + AppType: "nextjs", + CI: "github-actions", + Registry: "ghcr", + Image: "bangkah/web", + Port: 3000, + StartCmd: "npx next start -p 3000", + K8s: true, + Ingress: true, + IngressHost: "app.example.com", + HealthPath: "/health", + OutputDir: dir, + } + if err := RunInit(flags); err != nil { + t.Fatalf("runInit: %v", err) + } + for _, p := range []string{ + "Dockerfile", + ".github/workflows/ci.yml", + "kubernetes/deployment.yaml", + "kubernetes/service.yaml", + "kubernetes/ingress.yaml", + } { + if _, err := os.Stat(filepath.Join(dir, p)); err != nil { + t.Fatalf("expected artifact %s: %v", p, err) + } + } +} + +func TestToConfig_ErrorPaths(t *testing.T) { + cmd := &cobra.Command{Use: "init"} + flags := AddInitFlags(cmd) + + // Bad stack. + flags.Stack = "python" + if _, err := flags.ToConfig(); err == nil { + t.Fatal("expected error for bad stack") + } + + // Bad CI. + flags.Stack = "go" + flags.CI = "jenkins" + if _, err := flags.ToConfig(); err == nil { + t.Fatal("expected error for bad CI") + } + + // Bad registry. + flags.CI = "github-actions" + flags.Registry = "gitlab" + if _, err := flags.ToConfig(); err == nil { + t.Fatal("expected error for bad registry") + } + + // Bad port. + flags.Registry = "dockerhub" + flags.Port = 0 + if _, err := flags.ToConfig(); err == nil { + t.Fatal("expected error for port 0") + } + + // Bad tag. + flags.Port = 8080 + flags.Tag = "bad;tag" + if _, err := flags.ToConfig(); err == nil { + t.Fatal("expected error for bad tag") + } +} + +func TestResolveConfig_ErrorPaths(t *testing.T) { + // Empty stack with detection that fails. + cfg := config.Config{ + CI: config.CIGitHubActions, + Registry: config.RegistryDockerHub, + Image: "a/b", + Port: 8080, + OutputDir: t.TempDir(), + } + _, err := ResolveConfig(cfg, detector.New()) + if err == nil { + t.Fatal("expected error for empty stack with no indicators") + } + + // Ambiguous stack: both go.mod and package.json. + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + origWd, _ := os.Getwd() + defer os.Chdir(origWd) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + cfg2 := config.Config{ + CI: config.CIGitHubActions, + Registry: config.RegistryDockerHub, + Image: "a/b", + Port: 8080, + OutputDir: t.TempDir(), + } + _, err = ResolveConfig(cfg2, detector.New()) + if err == nil { + t.Fatal("expected error for ambiguous stack") + } +} + +func TestRunDetect_AmbiguousStack(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"scripts":{"start":"node s.js"}}`), 0o644); err != nil { + t.Fatal(err) + } + orig, _ := os.Getwd() + defer os.Chdir(orig) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + err := runDetect(os.Stdout) + if err == nil { + t.Fatal("expected ambiguity error") + } + var ee *exitError + if !errors.As(err, &ee) || ee.ExitCode() != 5 { + t.Fatalf("expected exit 5, got %v", err) + } +} + +func TestRunDetect_NoIndicators(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + defer os.Chdir(orig) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + err := runDetect(os.Stdout) + if err == nil { + t.Fatal("expected error for no indicators") + } + var ee *exitError + if !errors.As(err, &ee) || ee.ExitCode() != 5 { + t.Fatalf("expected exit 5, got %v", err) + } +} diff --git a/internal/cli/run.go b/internal/cli/run.go index 77d406e..d11dfc6 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -5,9 +5,13 @@ package cli import ( + "errors" "fmt" "os" + "strings" + "devopsgen.dev/devopsgen/internal/config" + "devopsgen.dev/devopsgen/internal/detector" "devopsgen.dev/devopsgen/internal/filesystem" "devopsgen.dev/devopsgen/internal/generator" "devopsgen.dev/devopsgen/internal/validator" @@ -27,38 +31,78 @@ func (e *exitError) Error() string { return e.err.Error() } func (e *exitError) ExitCode() int { return e.code } // RunInit executes the full generation flow for the init command. +// +// Stage order (SRS 6.6, FR-16): +// 1. Parsing flags and validasi nilai eksplisit -> exit 2 +// 2. Deteksi stack bila diperlukan -> exit 5 +// 3. Penyelesaian + validasi config lengkap -> exit 2 +// 4. Rendering seluruh artefak di memori -> (error -> 4) +// 5. Validasi seluruh output -> exit 4 +// 6. Validasi path + rencana + konflik -> exit 2/3 +// 7. Backup + commit filesystem -> exit 1 func RunInit(flags *InitFlags) error { + // Stage 1: flag-based config (stack may be empty; resolved later). cfg, err := flags.ToConfig() if err != nil { return newExitError(2, fmt.Errorf("config: %w", err)) } + // Stage 2: stack detection when not provided via flags. + if cfg.Stack == "" { + if !isInteractiveFn() { + // Non-interactive: cannot prompt, fail with actionable error. + return newExitError(2, errors.New("stack is required; provide --stack or run in an interactive terminal")) + } + d := detector.New() + result, err := d.Detect(".") + if err != nil { + return newExitError(2, fmt.Errorf("stack detection failed: %w", err)) + } + if result.IsAmbiguous() { + return newExitError(5, fmt.Errorf("ambiguous stack: %s", strings.Join(result.Ambiguities, "; "))) + } + cfg.Stack = config.StackType(result.Stack) + if len(result.AppCandidates) == 1 { + cfg.AppType = config.AppType(result.AppCandidates[0]) + } + cfg.BuildCmd = result.BuildCmd + cfg.TestCmd = result.TestCmd + cfg.StartCmd = result.StartCmd + cfg.PackageManager = result.PackageManager + } + + // Stage 3: full configuration validation after detection. + if err := config.ValidateConfig(cfg); err != nil { + return newExitError(2, fmt.Errorf("config: %w", err)) + } + // Stage 4: render artifacts in memory. arts, err := generator.New().Generate(cfg) if err != nil { return newExitError(4, fmt.Errorf("generate: %w", err)) } - if result, err := validator.New().Validate(arts); err != nil || result != validator.ResultValid { - if err != nil { - return newExitError(4, fmt.Errorf("validate: %w", err)) - } + + // Stage 5: validate all output artifacts. + result, err := validator.New().Validate(arts) + if err != nil { + return newExitError(4, fmt.Errorf("validate: %w", err)) + } + if result != validator.ResultValid { return newExitError(4, fmt.Errorf("validate: result %s", result)) } - // Stage 5: filesystem plan and conflict check. + // Stage 6: filesystem plan and conflict check. fs := &filesystem.RealFS{Root: cfg.OutputDir} - artifacts := map[string][]byte{} + artifactMap := map[string][]byte{} for _, a := range arts { - artifacts[a.Path] = a.Content + artifactMap[a.Path] = a.Content } - plan, err := fs.PlanBuild(artifacts) + plan, err := fs.PlanBuild(artifactMap) if err != nil { return newExitError(2, fmt.Errorf("plan: %w", err)) } - // Dry-run stops here and reports the plan, including conflicts as - // "would replace" entries. Dry-run never writes, so conflicts must not - // block it even without --force. + // Dry-run stops here and reports the plan. Never writes. if cfg.Generation.DryRun { fmt.Fprintf(os.Stderr, "dry-run: would create %d file(s), replace %d file(s)\n", len(plan.Create), len(plan.Replace)) @@ -74,13 +118,12 @@ func RunInit(flags *InitFlags) error { return nil } - // Conflict without --force -> exit 3. Only enforced when actually - // committing, never during dry-run. + // Conflict without --force -> exit 3. if len(plan.Conflicts) > 0 && !cfg.Generation.Force { return newExitError(3, fmt.Errorf("conflict: %d file(s) would be overwritten without --force", len(plan.Conflicts))) } - // Stage 6: backup and commit. + // Stage 7: backup and commit. if err := fs.Commit(plan); err != nil { return newExitError(1, fmt.Errorf("commit: %w", err)) } @@ -89,3 +132,29 @@ func RunInit(flags *InitFlags) error { len(plan.Create), len(plan.Replace), cfg.OutputDir) return nil } + +// ResolveConfig applies the SRS 4.2 resolution priority: +// flags -> detection -> template defaults -> error. +func ResolveConfig(cfg config.Config, det detector.Detector) (config.Config, error) { + if cfg.Stack == "" { + result, err := det.Detect(".") + if err != nil { + return cfg, err + } + if result.IsAmbiguous() { + return cfg, errors.New("ambiguous stack: " + strings.Join(result.Ambiguities, "; ")) + } + cfg.Stack = config.StackType(result.Stack) + if len(result.AppCandidates) == 1 { + cfg.AppType = config.AppType(result.AppCandidates[0]) + } + cfg.BuildCmd = result.BuildCmd + cfg.TestCmd = result.TestCmd + cfg.StartCmd = result.StartCmd + cfg.PackageManager = result.PackageManager + } + if err := config.ValidateConfig(cfg); err != nil { + return cfg, err + } + return cfg, nil +} diff --git a/internal/cli/run_extra_test.go b/internal/cli/run_extra_test.go new file mode 100644 index 0000000..55648aa --- /dev/null +++ b/internal/cli/run_extra_test.go @@ -0,0 +1,175 @@ +package cli + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "devopsgen.dev/devopsgen/internal/config" + "devopsgen.dev/devopsgen/internal/detector" +) + +func TestRunInit_StackFromDetection(t *testing.T) { + // Create a temp dir with go.mod so detection finds Go. + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + orig, _ := os.Getwd() + defer os.Chdir(orig) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + // Non-interactive: detection should work and stack should be resolved. + flags := &InitFlags{ + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 8080, + OutputDir: t.TempDir(), + } + err := RunInit(flags) + if err != nil { + t.Fatalf("expected success with detected stack, got: %v", err) + } +} + +func TestRunInit_StackDetectionFailsNonInteractive(t *testing.T) { + // Empty temp dir with no indicators. + flags := &InitFlags{ + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 8080, + OutputDir: t.TempDir(), + } + err := RunInit(flags) + if err == nil { + t.Fatal("expected error when stack cannot be detected") + } + var ee *exitError + if !errors.As(err, &ee) || ee.ExitCode() != 2 { + t.Fatalf("expected exit 2, got %v", err) + } +} + +func TestRunInit_AmbiguousDetectionExit5(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"scripts":{"start":"node s.js"}}`), 0o644); err != nil { + t.Fatal(err) + } + orig, _ := os.Getwd() + defer os.Chdir(orig) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + flags := &InitFlags{ + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 8080, + OutputDir: t.TempDir(), + } + err := RunInit(flags) + if err == nil { + t.Fatal("expected ambiguity error") + } + var ee *exitError + if !errors.As(err, &ee) || ee.ExitCode() != 5 { + t.Fatalf("expected exit 5, got %v", err) + } +} + +func TestRunInit_ValidateTag(t *testing.T) { + flags := &InitFlags{ + Stack: "go", + AppType: "binary", + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 8080, + Tag: "v1;rm -rf /", + OutputDir: t.TempDir(), + } + err := RunInit(flags) + if err == nil { + t.Fatal("expected error for invalid tag") + } +} + +func TestRunInit_ValidTag(t *testing.T) { + dir := t.TempDir() + flags := &InitFlags{ + Stack: "go", + AppType: "binary", + CI: "github-actions", + Registry: "ghcr", + Image: "a/b", + Port: 8080, + Tag: "v1.2.3", + OutputDir: dir, + } + if err := RunInit(flags); err != nil { + t.Fatalf("expected success with valid tag, got: %v", err) + } +} + +func TestResolveConfig_DetectStack(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + orig, _ := os.Getwd() + defer os.Chdir(orig) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + cfg := config.Config{ + CI: config.CIGitHubActions, + Registry: config.RegistryDockerHub, + Image: "a/b", + Port: 8080, + } + d := detector.New() + result, err := ResolveConfig(cfg, d) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if result.Stack != config.StackGo { + t.Fatalf("expected stack go, got %s", result.Stack) + } +} + +func TestResolveConfig_AmbiguousExit(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module x\n\ngo 1.22\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"scripts":{"start":"node s.js"}}`), 0o644); err != nil { + t.Fatal(err) + } + orig, _ := os.Getwd() + defer os.Chdir(orig) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + cfg := config.Config{ + CI: config.CIGitHubActions, + Registry: config.RegistryDockerHub, + Image: "a/b", + Port: 8080, + } + d := detector.New() + _, err := ResolveConfig(cfg, d) + if err == nil { + t.Fatal("expected ambiguity error") + } + if !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("expected ambiguous error, got: %v", err) + } +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 19bd7fa..bdfc70e 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -33,32 +33,6 @@ func TestRunInit_EndToEnd(t *testing.T) { } } -func TestRunInit_PythonFastAPI(t *testing.T) { - dir := t.TempDir() - flags := &InitFlags{ - Stack: "python", - AppType: "fastapi", - CI: "github-actions", - Registry: "ghcr", - Image: "bangkah/api", - Port: 8000, - StartCmd: "uvicorn main:app", - Tag: "v1", - K8s: true, - OutputDir: dir, - } - if err := RunInit(flags); err != nil { - t.Fatalf("runInit: %v", err) - } - df, err := os.ReadFile(filepath.Join(dir, "Dockerfile")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(df), "python:3.12-slim") { - t.Fatal("Dockerfile missing python:3.12-slim") - } -} - func TestRunInit_ConflictExit3(t *testing.T) { dir := t.TempDir() // Pre-create Dockerfile to force a conflict. diff --git a/internal/config/config.go b/internal/config/config.go index b79089c..5a6f26e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,9 +8,8 @@ package config type StackType string const ( - StackGo StackType = "go" - StackNode StackType = "nodejs" - StackPython StackType = "python" + StackGo StackType = "go" + StackNode StackType = "nodejs" ) // AppType identifies the application shape within a stack. @@ -22,10 +21,7 @@ const ( AppTypeNextJS AppType = "nextjs" // Node.js AppTypeVite AppType = "vite" // Node.js AppTypeNestJS AppType = "nestjs" // Node.js - AppTypeGeneric AppType = "generic" // Node.js / Python - AppTypeFastAPI AppType = "fastapi" // Python - AppTypeDjango AppType = "django" // Python - AppTypeFlask AppType = "flask" // Python + AppTypeGeneric AppType = "generic" // Node.js ) // CIProvider identifies a supported CI/CD provider. @@ -62,20 +58,29 @@ type GenerationOptions struct { // Field validation happens before rendering; required fields are enforced // per the SRS 4.1 parameter matrix. type Config struct { - Stack StackType - AppType AppType - CI CIProvider - Registry Registry - Image string // namespace/repository, no registry host, no tag - Port int - BuildCmd string - TestCmd string - StartCmd string - Tag string // additional alias; default "latest" + Stack StackType + AppType AppType + CI CIProvider + Registry Registry + Image string // namespace/repository, no registry host, no tag + Port int + BuildCmd string + TestCmd string + StartCmd string + Tag string // additional alias; default "latest" + // BuildPath is the Go package path passed to `go build` in the Dockerfile. + // Defaults to "." (project root). Set to "./cmd/..." when main lives in a + // subdirectory. + BuildPath string K8s K8sOptions OutputDir string Generation GenerationOptions + // PackageManager is the detected or configured package manager (npm, pnpm, yarn). + PackageManager string } // DefaultTag is the default mutable alias published alongside the commit SHA. const DefaultTag = "latest" + +// MaxTagLen is the maximum allowed length for a tag value. +const MaxTagLen = 128 diff --git a/internal/config/validate.go b/internal/config/validate.go index 5f70f3e..a33ea7a 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -14,7 +14,7 @@ import ( var ( errEmptyStack = errors.New("stack is required") - errInvalidStack = errors.New("stack must be 'go', 'nodejs', or 'python'") + errInvalidStack = errors.New("stack must be 'go' or 'nodejs'") errInvalidCI = errors.New("ci must be 'github-actions'") errInvalidRegistry = errors.New("registry must be 'dockerhub' or 'ghcr'") errInvalidImage = errors.New("image must be 'namespace/repository' without host or tag") @@ -24,23 +24,36 @@ var ( errInvalidHostname = errors.New("ingress-host must be a valid RFC 1123 hostname") errInvalidHealthPath = errors.New("health-path must be a valid HTTP path") errInvalidCommand = errors.New("command must be a single line without control characters") + errInvalidTag = errors.New("tag is invalid: max 128 chars, no whitespace, newline, or shell/YAML metacharacters") + errInvalidBuildPath = errors.New("build-path must be a safe relative path without traversal") ) // RFC 1123 hostname: lowercase labels of 1-63 chars, alnum and '-'. -// Length is enforced separately in validateHostname. var hostnameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`) // imageRe enforces "namespace/repository" with no host and no tag. var imageRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9._-]{0,61}[a-z0-9])?/[a-z0-9]([a-z0-9._-]{0,61}[a-z0-9])?$`) +// ImageRe is the exported image validation regex. +var ImageRe = imageRe + // healthPathRe enforces a rooted HTTP path. var healthPathRe = regexp.MustCompile(`^/([a-zA-Z0-9._~/-]+)?$`) -// validAppTypes maps each stack to its allowed app types. +// buildPathRe enforces a safe relative filesystem path: no leading slash, +// no traversal segments, only safe characters. +var buildPathRe = regexp.MustCompile(`^([A-Za-z0-9._~-]+([/\\][A-Za-z0-9._~-]+)*)?$`) + +// HealthPathRe is the exported health path validation regex. +var HealthPathRe = healthPathRe + +// tagRe enforces a safe Docker tag: alphanumeric, underscore, dot, hyphen only. +var tagRe = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) + +// validAppTypes maps each stack to its allowed app types (MVP: go and nodejs only). var validAppTypes = map[StackType][]AppType{ - StackGo: {AppTypeBinary}, - StackNode: {AppTypeExpress, AppTypeNextJS, AppTypeVite, AppTypeNestJS, AppTypeGeneric}, - StackPython: {AppTypeFastAPI, AppTypeDjango, AppTypeFlask, AppTypeGeneric}, + StackGo: {AppTypeBinary}, + StackNode: {AppTypeExpress, AppTypeNextJS, AppTypeVite, AppTypeNestJS, AppTypeGeneric}, } // ValidateConfig checks the full configuration contract. @@ -48,7 +61,7 @@ func ValidateConfig(cfg Config) error { if cfg.Stack == "" { return errEmptyStack } - if cfg.Stack != StackGo && cfg.Stack != StackNode && cfg.Stack != StackPython { + if cfg.Stack != StackGo && cfg.Stack != StackNode { return errInvalidStack } if cfg.CI != CIGitHubActions { @@ -78,6 +91,9 @@ func ValidateConfig(cfg Config) error { if err := validateCommand(cfg.StartCmd); err != nil { return fmt.Errorf("start-cmd: %w", err) } + if err := ValidateTag(cfg.Tag); err != nil { + return fmt.Errorf("tag: %w", err) + } if cfg.K8s.Ingress && cfg.K8s.IngressHost == "" { return errInvalidHostname } @@ -89,6 +105,62 @@ func ValidateConfig(cfg Config) error { if cfg.K8s.HealthPath != "" && !healthPathRe.MatchString(cfg.K8s.HealthPath) { return errInvalidHealthPath } + if err := validateBuildPath(cfg.BuildPath); err != nil { + return fmt.Errorf("build-path: %w", err) + } + return nil +} + +// validateBuildPath ensures BuildPath is a safe relative path (default "."). +func validateBuildPath(path string) error { + if path == "" { + return nil + } + clean := filepath.Clean(path) + if clean == "." { + return nil + } + if !buildPathRe.MatchString(path) { + return errInvalidBuildPath + } + // Reject traversal segments. + for _, seg := range strings.Split(path, string(filepath.Separator)) { + if seg == ".." { + return errInvalidBuildPath + } + } + return nil +} + +// ValidateBuildPath is the exported form of validateBuildPath. +func ValidateBuildPath(path string) error { + return validateBuildPath(path) +} + +// ValidateTag checks that a tag value is safe for use in shell commands and +// YAML manifests. Empty tag is allowed (defaults to "latest"). +func ValidateTag(tag string) error { + if tag == "" { + return nil + } + if len(tag) > MaxTagLen { + return errInvalidTag + } + if strings.ContainsAny(tag, "\x00\r\n\t ") { + return errInvalidTag + } + if !tagRe.MatchString(tag) { + return errInvalidTag + } + // Reject shell metacharacters and YAML special characters that could + // alter workflow commands or manifest structure. + for _, r := range tag { + switch r { + case ';', '&', '|', '`', '$', '(', ')', '{', '}', '[', ']', + '<', '>', '\\', '!', '#', '%', '@', ':': + return errInvalidTag + } + } return nil } @@ -141,6 +213,11 @@ func validAppTypeForStack(stack StackType, appType AppType) bool { return false } +// ValidAppTypeForStack is the exported form of validAppTypeForStack. +func ValidAppTypeForStack(stack StackType, appType AppType) bool { + return validAppTypeForStack(stack, appType) +} + // validateCommand enforces single-line, control-character-free commands. func validateCommand(cmd string) error { cmd = strings.TrimSpace(cmd) @@ -159,6 +236,16 @@ func validateCommand(cmd string) error { return nil } +// ValidateCommand is the exported form of validateCommand. +func ValidateCommand(cmd string) error { + return validateCommand(cmd) +} + +// ValidateHostname is the exported form of validateHostname. +func ValidateHostname(host string) error { + return validateHostname(host) +} + // ImageRef returns the fully-qualified image reference for the registry. func (cfg Config) ImageRef() string { switch cfg.Registry { @@ -177,3 +264,22 @@ func (cfg Config) TagList() []string { } return []string{alias, "${{ github.sha }}"} } + +// RequiredFieldsMissing reports which required fields are still empty. +// Used by the resolver to give actionable headless errors. +func RequiredFieldsMissing(cfg Config) []string { + var missing []string + if cfg.Stack == "" { + missing = append(missing, "--stack") + } + if cfg.Image == "" { + missing = append(missing, "--image") + } + if cfg.Port == 0 { + missing = append(missing, "--port") + } + if cfg.Stack == StackNode && cfg.StartCmd == "" { + missing = append(missing, "--start-cmd") + } + return missing +} diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 7fbdd1c..d76ea81 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -1,6 +1,7 @@ package config import ( + "strings" "testing" ) @@ -20,12 +21,15 @@ func TestValidateConfig_RequiredFields(t *testing.T) { {"port too high", Config{Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 70000}}, {"bad app type for go", Config{Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, AppType: AppTypeExpress}}, {"bad app type for node", Config{Stack: StackNode, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, AppType: AppTypeBinary}}, - {"bad app type for python", Config{Stack: StackPython, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, AppType: AppTypeBinary}}, {"ingress host missing", Config{Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, K8s: K8sOptions{Ingress: true}}}, {"bad ingress host", Config{Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, K8s: K8sOptions{Ingress: true, IngressHost: "Bad_Host!"}}}, {"bad health path", Config{Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, K8s: K8sOptions{HealthPath: "health"}}}, {"command with newline", Config{Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, BuildCmd: "make\nbuild"}}, {"command with control char", Config{Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, StartCmd: "run\x01"}}, + {"tag too long", Config{Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, Tag: strings.Repeat("a", 129)}}, + {"tag with newline", Config{Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, Tag: "v1\nrm -rf /"}}, + {"tag with shell metachar", Config{Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, Tag: "v1; rm -rf /"}}, + {"tag with space", Config{Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, Tag: "v1 tag"}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -42,8 +46,8 @@ func TestValidateConfig_Valid(t *testing.T) { {Stack: StackGo, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "bangkah/my-app", Port: 8080, AppType: AppTypeBinary}, {Stack: StackNode, CI: CIGitHubActions, Registry: RegistryGHCR, Image: "bangkah/api", Port: 3000, AppType: AppTypeExpress, StartCmd: "npm start"}, {Stack: StackNode, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "bangkah/site", Port: 8080, AppType: AppTypeVite, K8s: K8sOptions{Ingress: true, IngressHost: "example.com", HealthPath: "/health"}}, - {Stack: StackPython, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "bangkah/api", Port: 8000, AppType: AppTypeFastAPI, StartCmd: "uvicorn main:app"}, - {Stack: StackPython, CI: CIGitHubActions, Registry: RegistryGHCR, Image: "bangkah/django", Port: 8000, AppType: AppTypeDjango}, + {Stack: StackNode, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, Tag: "v1.2.3"}, + {Stack: StackNode, CI: CIGitHubActions, Registry: RegistryDockerHub, Image: "a/b", Port: 8080, Tag: "latest"}, } for i, cfg := range cases { t.Run("ok", func(t *testing.T) { @@ -54,6 +58,24 @@ func TestValidateConfig_Valid(t *testing.T) { } } +func TestValidateTag(t *testing.T) { + good := []string{"", "latest", "v1", "v1.2.3", "my-tag", "my_tag", "my.tag", "sha-abc123"} + for _, tag := range good { + if err := ValidateTag(tag); err != nil { + t.Fatalf("ValidateTag(%q) unexpected error: %v", tag, err) + } + } + bad := []string{"v1\n", "v1;rm", "v1 tag", "v1$()", "v1`x`", "v1|x", "v1&x", "v1x", "v1!x", "v1#x", "v1%x", "v1@x", "v1:x", "v1\\x", strings.Repeat("a", 129)} + for _, tag := range bad { + if err := ValidateTag(tag); err == nil { + t.Fatalf("expected error for tag %q", tag) + } + } + if len(bad[0]) > 0 { + _ = bad[0] + } +} + func TestValidateOutputDir(t *testing.T) { good := []string{"out", "deploy/k8s", "."} for _, d := range good { @@ -88,3 +110,41 @@ func TestTagList(t *testing.T) { t.Fatalf("default tag list: %v", tags) } } + +func TestRequiredFieldsMissing(t *testing.T) { + cfg := Config{} + missing := RequiredFieldsMissing(cfg) + if len(missing) == 0 { + t.Fatal("expected missing fields for empty config") + } + foundStack := false + foundImage := false + foundPort := false + for _, m := range missing { + if m == "--stack" { + foundStack = true + } + if m == "--image" { + foundImage = true + } + if m == "--port" { + foundPort = true + } + } + if !foundStack || !foundImage || !foundPort { + t.Fatalf("missing fields incomplete: %v", missing) + } + + // Node.js without start-cmd + cfgNode := Config{Stack: StackNode, Image: "a/b", Port: 8080} + missing = RequiredFieldsMissing(cfgNode) + foundStart := false + for _, m := range missing { + if m == "--start-cmd" { + foundStart = true + } + } + if !foundStart { + t.Fatalf("expected --start-cmd missing for nodejs, got %v", missing) + } +} diff --git a/internal/detector/detector.go b/internal/detector/detector.go index f0ad638..7d63d45 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -1,7 +1,7 @@ // Package detector performs read-only stack detection from project indicators. // // Detection must never modify the project or execute project scripts. It -// reports candidates, indicators, and unresolved ambiguities. +// reports candidates, indicators, warnings, and unresolved ambiguities. package detector import ( @@ -23,6 +23,7 @@ type DetectionResult struct { StartCmd string Lockfile string PackageManager string + Warnings []string Ambiguities []string } @@ -69,7 +70,7 @@ func detectNode(root string) (DetectionResult, bool, error) { Scripts map[string]string `json:"scripts"` } if err := json.Unmarshal(b, &pkg); err != nil { - r.Ambiguities = append(r.Ambiguities, "package.json is not valid JSON") + r.Warnings = append(r.Warnings, "package.json is not valid JSON") return r, true, nil } @@ -117,7 +118,7 @@ func detectNode(root string) (DetectionResult, bool, error) { r.AppCandidates = append(r.AppCandidates, "generic") } - // Lockfile / package manager. + // Lockfile / package manager. Absence is a warning, not ambiguity. for _, f := range []string{"package-lock.json", "pnpm-lock.yaml", "yarn.lock", "npm-shrinkwrap.json"} { if _, err := os.Stat(filepath.Join(root, f)); err == nil { r.Lockfile = f @@ -134,7 +135,7 @@ func detectNode(root string) (DetectionResult, bool, error) { } } if r.Lockfile == "" { - r.Ambiguities = append(r.Ambiguities, "no lockfile detected") + r.Warnings = append(r.Warnings, "no lockfile detected; dependency installation may not be reproducible") } return r, true, nil } @@ -147,79 +148,8 @@ func New() Detector { return &engine{} } -// detectPython checks for a Python project via pyproject.toml, requirements.txt, setup.py, Pipfile, etc. -func detectPython(root string) (DetectionResult, bool, error) { - r := DetectionResult{Stack: "python"} - var indicators []string - var appCandidates []string - - pythonFiles := []string{ - "pyproject.toml", "setup.py", "requirements.txt", - "Pipfile", "poetry.lock", "uv.lock", "manage.py", "app.py", - } - - for _, f := range pythonFiles { - if _, err := os.Stat(filepath.Join(root, f)); err == nil { - indicators = append(indicators, f) - if f == "manage.py" { - appCandidates = append(appCandidates, "django") - } - } - } - - if len(indicators) == 0 { - return r, false, nil - } - - r.Indicators = indicators - - // Check lockfile and package manager. - if _, err := os.Stat(filepath.Join(root, "poetry.lock")); err == nil { - r.Lockfile = "poetry.lock" - r.PackageManager = "poetry" - } else if _, err := os.Stat(filepath.Join(root, "Pipfile.lock")); err == nil { - r.Lockfile = "Pipfile.lock" - r.PackageManager = "pipenv" - } else if _, err := os.Stat(filepath.Join(root, "uv.lock")); err == nil { - r.Lockfile = "uv.lock" - r.PackageManager = "uv" - } else if _, err := os.Stat(filepath.Join(root, "requirements.txt")); err == nil { - r.PackageManager = "pip" - } - - // Inspect pyproject.toml and requirements.txt for framework dependencies. - for _, reqFile := range []string{"requirements.txt", "pyproject.toml", "setup.py"} { - if b, err := os.ReadFile(filepath.Join(root, reqFile)); err == nil { - s := strings.ToLower(string(b)) - if strings.Contains(s, "fastapi") || strings.Contains(s, "uvicorn") { - appCandidates = append(appCandidates, "fastapi") - } - if strings.Contains(s, "django") { - appCandidates = append(appCandidates, "django") - } - if strings.Contains(s, "flask") { - appCandidates = append(appCandidates, "flask") - } - } - } - - // Deduplicate candidates. - seen := map[string]bool{} - for _, c := range appCandidates { - if !seen[c] { - seen[c] = true - r.AppCandidates = append(r.AppCandidates, c) - } - } - - if len(r.AppCandidates) == 0 { - r.AppCandidates = append(r.AppCandidates, "generic") - } - - return r, true, nil -} - // Detect runs all stack detectors against root and reports the result. +// Only Go and Node.js are in MVP scope. func (e *engine) Detect(root string) (DetectionResult, error) { var results []DetectionResult goRes, goOk, err := detectGo(root) @@ -236,13 +166,6 @@ func (e *engine) Detect(root string) (DetectionResult, error) { if nodeOk { results = append(results, nodeRes) } - pyRes, pyOk, err := detectPython(root) - if err != nil { - return DetectionResult{}, err - } - if pyOk { - results = append(results, pyRes) - } if len(results) == 0 { return DetectionResult{}, errors.New("detector: no supported stack indicators found") } @@ -266,7 +189,7 @@ func (r DetectionResult) IsAmbiguous() bool { // HasStart reports whether a startup command was detected. func (r DetectionResult) HasStart() bool { - return (r.Stack == "nodejs" || r.Stack == "python") && r.StartCmd != "" + return (r.Stack == "nodejs") && r.StartCmd != "" } // HasBuild reports whether a build command was detected. diff --git a/internal/detector/detector_python_test.go b/internal/detector/detector_python_test.go deleted file mode 100644 index b86c42a..0000000 --- a/internal/detector/detector_python_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package detector - -import ( - "testing" -) - -func TestDetect_Python(t *testing.T) { - dir := t.TempDir() - write(t, dir, "pyproject.toml", "[project]\nname = 'api'\ndependencies = ['fastapi']\n") - write(t, dir, "requirements.txt", "fastapi\n") - r, err := New().Detect(dir) - if err != nil { - t.Fatalf("detect: %v", err) - } - if r.Stack != "python" { - t.Fatalf("stack: %s", r.Stack) - } - if !contains(r.Indicators, "pyproject.toml") && !contains(r.Indicators, "requirements.txt") { - t.Fatalf("indicators: %v", r.Indicators) - } - if !contains(r.AppCandidates, "fastapi") { - t.Fatalf("app candidates: %v", r.AppCandidates) - } -} - -func TestDetect_PythonDjango(t *testing.T) { - dir := t.TempDir() - write(t, dir, "manage.py", "print('ok')") - write(t, dir, "requirements.txt", "django\n") - r, err := New().Detect(dir) - if err != nil { - t.Fatalf("detect: %v", err) - } - if !contains(r.AppCandidates, "django") { - t.Fatalf("app candidates: %v", r.AppCandidates) - } -} - -func TestDetect_PythonFlask(t *testing.T) { - dir := t.TempDir() - write(t, dir, "requirements.txt", "flask\n") - r, err := New().Detect(dir) - if err != nil { - t.Fatalf("detect: %v", err) - } - if !contains(r.AppCandidates, "flask") { - t.Fatalf("app candidates: %v", r.AppCandidates) - } -} - -func TestDetect_PythonNoIndicators(t *testing.T) { - dir := t.TempDir() - write(t, dir, "README.md", "# hi") - _, err := New().Detect(dir) - if err == nil { - t.Fatal("expected error for non-python project") - } -} - -func TestDetect_PythonAndGoAmbiguous(t *testing.T) { - dir := t.TempDir() - write(t, dir, "go.mod", "module x\n\ngo 1.22\n") - write(t, dir, "pyproject.toml", "[project]\nname='x'\n") - r, err := New().Detect(dir) - if err != nil { - t.Fatalf("detect: %v", err) - } - if !r.IsAmbiguous() { - t.Fatal("expected ambiguity for go+python") - } -} - -func TestDetect_PythonLockfiles(t *testing.T) { - // poetry.lock - dir1 := t.TempDir() - write(t, dir1, "poetry.lock", "") - write(t, dir1, "pyproject.toml", "") - r1, err := New().Detect(dir1) - if err != nil || r1.PackageManager != "poetry" || r1.Lockfile != "poetry.lock" { - t.Fatalf("expected poetry, got %v %v", r1, err) - } - - // Pipfile.lock - dir2 := t.TempDir() - write(t, dir2, "Pipfile", "") - write(t, dir2, "Pipfile.lock", "") - r2, err := New().Detect(dir2) - if err != nil || r2.PackageManager != "pipenv" || r2.Lockfile != "Pipfile.lock" { - t.Fatalf("expected pipenv, got %v %v", r2, err) - } - - // uv.lock - dir3 := t.TempDir() - write(t, dir3, "uv.lock", "") - write(t, dir3, "pyproject.toml", "") - r3, err := New().Detect(dir3) - if err != nil || r3.PackageManager != "uv" || r3.Lockfile != "uv.lock" { - t.Fatalf("expected uv, got %v %v", r3, err) - } -} diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index 295ab10..e44f9d0 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -94,7 +94,7 @@ func TestDetect_NoIndicators(t *testing.T) { } } -func TestDetect_NodeNoLockfile(t *testing.T) { +func TestDetect_NodeNoLockfileIsWarning(t *testing.T) { dir := t.TempDir() write(t, dir, "package.json", `{"scripts":{"start":"node s.js"}}`) r, err := New().Detect(dir) @@ -104,8 +104,18 @@ func TestDetect_NodeNoLockfile(t *testing.T) { if r.Lockfile != "" { t.Fatalf("expected no lockfile, got %q", r.Lockfile) } - if !contains(r.Ambiguities, "no lockfile detected") { - t.Fatalf("ambiguities should mention lockfile: %v", r.Ambiguities) + // No lockfile is a warning, not ambiguity. + hasLockfileWarning := false + for _, w := range r.Warnings { + if strings.Contains(w, "no lockfile detected") { + hasLockfileWarning = true + } + } + if !hasLockfileWarning { + t.Fatalf("expected no-lockfile warning: %v", r.Warnings) + } + if r.IsAmbiguous() { + t.Fatalf("no lockfile should not be ambiguous: %v", r.Ambiguities) } } diff --git a/internal/filesystem/commit_rollback_test.go b/internal/filesystem/commit_rollback_test.go new file mode 100644 index 0000000..aad3116 --- /dev/null +++ b/internal/filesystem/commit_rollback_test.go @@ -0,0 +1,135 @@ +package filesystem + +import ( + "os" + "path/filepath" + "testing" +) + +// These tests drive RealFS.Commit through real I/O failures that behave the same +// on Linux, macOS, and Windows: a regular file standing where a directory is needed. + +func setupBlocker(t *testing.T) (root, blocker string, f *RealFS) { + t.Helper() + root = t.TempDir() + blocker = filepath.Join(root, "blocker") + if err := os.WriteFile(blocker, []byte("i am a file"), 0o644); err != nil { + t.Fatal(err) + } + return root, blocker, &RealFS{Root: root} +} + +func mustRead(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} + +func exists(path string) bool { + _, err := os.Lstat(path) + return err == nil +} + +func TestRealFS_Commit_WriteFailureRemovesCreatedFiles(t *testing.T) { + root, blocker, f := setupBlocker(t) + a := filepath.Join(root, "a.txt") + bad := filepath.Join(blocker, "b.txt") // parent is a regular file -> write fails + p := Plan{ + Create: []string{a, bad}, + Files: map[string][]byte{a: []byte("A"), bad: []byte("B")}, + } + if err := f.Commit(p); err == nil { + t.Fatal("expected commit to fail") + } + if exists(a) { + t.Error("file created before the failure must be rolled back") + } +} + +func TestRealFS_Commit_ReplaceWriteFailureRestoresBackup(t *testing.T) { + root, blocker, f := setupBlocker(t) + old := filepath.Join(root, "old.txt") + bak := filepath.Join(root, "old.txt.bak") + if err := os.WriteFile(old, []byte("OLD"), 0o644); err != nil { + t.Fatal(err) + } + bad := filepath.Join(blocker, "x.txt") + p := Plan{ + Replace: []string{bad}, + Backup: map[string]string{old: bak}, + Files: map[string][]byte{bad: []byte("NEW")}, + } + if err := f.Commit(p); err == nil { + t.Fatal("expected commit to fail") + } + if got := mustRead(t, old); got != "OLD" { + t.Errorf("old file not restored, got %q", got) + } + if exists(bak) { + t.Error("backup file must be consumed by the restore") + } +} + +func TestRealFS_Commit_MkdirFailureRemovesCreatedDirs(t *testing.T) { + root, blocker, f := setupBlocker(t) + newDir := filepath.Join(root, "newdir") + p := Plan{Dirs: []string{newDir, filepath.Join(blocker, "sub")}} + if err := f.Commit(p); err == nil { + t.Fatal("expected commit to fail") + } + if exists(newDir) { + t.Error("directory created before the failure must be removed") + } +} + +// Regression: when the second backup rename failed, the backup that had already +// succeeded was never restored, leaving the user's original file renamed to *.bak. +func TestRealFS_Commit_BackupFailureRestoresEarlierBackups(t *testing.T) { + root, _, f := setupBlocker(t) + a := filepath.Join(root, "a.txt") + if err := os.WriteFile(a, []byte("A"), 0o644); err != nil { + t.Fatal(err) + } + missing := filepath.Join(root, "b.txt") // does not exist -> rename fails + p := Plan{ + Backup: map[string]string{a: a + ".bak", missing: missing + ".bak"}, + } + if err := f.Commit(p); err == nil { + t.Fatal("expected commit to fail") + } + if !exists(a) { + t.Fatal("original a.txt was lost: earlier backup was not rolled back") + } + if got := mustRead(t, a); got != "A" { + t.Errorf("a.txt content changed: %q", got) + } + if exists(a + ".bak") { + t.Error("stray backup left behind") + } +} + +func TestRealFS_Commit_SuccessReplacesAndDiscardsBackup(t *testing.T) { + root, _, f := setupBlocker(t) + old := filepath.Join(root, "old.txt") + bak := old + ".bak" + if err := os.WriteFile(old, []byte("OLD"), 0o644); err != nil { + t.Fatal(err) + } + p := Plan{ + Replace: []string{old}, + Backup: map[string]string{old: bak}, + Files: map[string][]byte{old: []byte("NEW")}, + } + if err := f.Commit(p); err != nil { + t.Fatalf("commit: %v", err) + } + if got := mustRead(t, old); got != "NEW" { + t.Errorf("content = %q", got) + } + if exists(bak) { + t.Error("backup must be discarded after success") + } +} diff --git a/internal/filesystem/coverage2_test.go b/internal/filesystem/coverage2_test.go new file mode 100644 index 0000000..edadf03 --- /dev/null +++ b/internal/filesystem/coverage2_test.go @@ -0,0 +1,80 @@ +package filesystem + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRealFS_Commit_BackupFailure(t *testing.T) { + dir := t.TempDir() + fs := &RealFS{Root: dir} + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "Dockerfile.devopsgen.bak"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + _, err := fs.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + if err == nil || !strings.Contains(err.Error(), "backup") { + t.Fatalf("expected backup collision: %v", err) + } +} + +func TestMemFS_Commit_MultipleCreate(t *testing.T) { + m := NewMemFS() + p, _ := m.PlanBuild(map[string][]byte{ + "a/Dockerfile": []byte("FROM scratch"), + "b/ci.yml": []byte("name: CI"), + }) + if err := m.Commit(p); err != nil { + t.Fatalf("commit: %v", err) + } + if _, ok := m.Get("a/Dockerfile"); !ok { + t.Fatal("a/Dockerfile not written") + } + if _, ok := m.Get("b/ci.yml"); !ok { + t.Fatal("b/ci.yml not written") + } +} + +func TestMemFS_Commit_ReplaceThenRollback(t *testing.T) { + m := NewMemFS() + m.Put("Dockerfile", []byte("old")) + p, _ := m.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + m.WriteErr = errors.New("write failed") + err := m.Commit(p) + if err == nil || !strings.Contains(err.Error(), "rollback") { + t.Fatalf("expected rollback error: %v", err) + } + c, ok := m.Get("Dockerfile") + if !ok || string(c) != "old" { + t.Fatalf("old file should be restored after rollback: %v %q", ok, c) + } +} + +func TestRealFS_ResolveArtifactPath_Absolute(t *testing.T) { + fs := &RealFS{Root: "/tmp"} + _, err := fs.resolveArtifactPath("/etc/passwd") + if err == nil || !strings.Contains(err.Error(), "escapes") { + t.Fatalf("expected escape error: %v", err) + } +} + +func TestRealFS_ResolveArtifactPath_DotDot(t *testing.T) { + fs := &RealFS{Root: "/tmp"} + _, err := fs.resolveArtifactPath("../escape") + if err == nil || !strings.Contains(err.Error(), "escapes") { + t.Fatalf("expected escape error: %v", err) + } +} + +func TestMemFS_Stat_NotFound(t *testing.T) { + m := NewMemFS() + _, _, err := m.stat("nonexistent") + if !errors.Is(err, errNotFound) { + t.Fatalf("expected errNotFound: %v", err) + } +} diff --git a/internal/filesystem/coverage3_test.go b/internal/filesystem/coverage3_test.go new file mode 100644 index 0000000..e908461 --- /dev/null +++ b/internal/filesystem/coverage3_test.go @@ -0,0 +1,66 @@ +package filesystem + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRealFS_CheckSymlinkParent(t *testing.T) { + dir := t.TempDir() + realDir := filepath.Join(dir, "real") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatal(err) + } + linkDir := filepath.Join(dir, "link") + if err := os.Symlink(realDir, linkDir); err != nil { + t.Fatal(err) + } + fs := &RealFS{Root: dir} + _, err := fs.PlanBuild(map[string][]byte{"link/Dockerfile": []byte("x")}) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected symlink parent error: %v", err) + } +} + +func TestRealFS_RollbackCreate_Error(t *testing.T) { + dir := t.TempDir() + // Use a read-only root to force write failure. + readOnly := filepath.Join(dir, "readonly") + if err := os.MkdirAll(readOnly, 0o555); err != nil { + t.Fatal(err) + } + defer os.Chmod(readOnly, 0o755) + fs := &RealFS{Root: readOnly} + p, err := fs.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + if err != nil { + t.Fatalf("plan: %v", err) + } + err = fs.Commit(p) + // On Windows, chmod 0555 may not prevent writes. Just verify no panic. + _ = err +} + +func TestMemFS_Commit_CleanupError(t *testing.T) { + m := NewMemFS() + m.Put("Dockerfile", []byte("old")) + p, _ := m.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + if err := m.Commit(p); err != nil { + t.Fatalf("commit: %v", err) + } + if _, ok := m.Get("Dockerfile.devopsgen.bak"); ok { + t.Fatal("backup should be cleaned up") + } +} + +func TestMemFS_PlanBuild_Traversal(t *testing.T) { + m := NewMemFS() + bad := []string{"../escape", "/etc/passwd", "..", "."} + for _, b := range bad { + _, err := m.PlanBuild(map[string][]byte{b: []byte("x")}) + if err == nil { + t.Fatalf("expected traversal error for %q", b) + } + } +} diff --git a/internal/filesystem/coverage4_test.go b/internal/filesystem/coverage4_test.go new file mode 100644 index 0000000..be5061f --- /dev/null +++ b/internal/filesystem/coverage4_test.go @@ -0,0 +1,59 @@ +package filesystem + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRealFS_Commit_WriteFailureRollbackCreate(t *testing.T) { + dir := t.TempDir() + // Create a file where a directory is needed, so MkdirAll fails. + blocker := filepath.Join(dir, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + fs := &RealFS{Root: dir} + p, err := fs.PlanBuild(map[string][]byte{"blocker/sub/Dockerfile": []byte("new")}) + if err != nil { + t.Fatalf("plan: %v", err) + } + err = fs.Commit(p) + if err == nil { + t.Fatal("expected commit error when parent is a file") + } +} + +func TestRealFS_Commit_WriteFailureRollbackReplace(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + fs := &RealFS{Root: dir} + // Replace Dockerfile but target a path under a file blocker. + blocker := filepath.Join(dir, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + p, err := fs.PlanBuild(map[string][]byte{"blocker/Dockerfile": []byte("new")}) + if err != nil { + t.Fatalf("plan: %v", err) + } + err = fs.Commit(p) + if err == nil { + t.Fatal("expected commit error") + } +} + +func TestRealFS_WriteFile_MkdirFailure(t *testing.T) { + dir := t.TempDir() + blocker := filepath.Join(dir, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + fs := &RealFS{Root: dir} + err := fs.writeFile(filepath.Join(dir, "blocker", "Dockerfile"), []byte("x")) + if err == nil { + t.Fatal("expected writeFile error when parent is a file") + } +} diff --git a/internal/filesystem/coverage5_test.go b/internal/filesystem/coverage5_test.go new file mode 100644 index 0000000..6e58816 --- /dev/null +++ b/internal/filesystem/coverage5_test.go @@ -0,0 +1,105 @@ +package filesystem + +import ( + "errors" + "strings" + "testing" +) + +func TestMemFS_Commit_RollbackReplaceError(t *testing.T) { + m := NewMemFS() + m.Put("Dockerfile", []byte("old")) + p, _ := m.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + m.RestoreErr = errors.New("restore failed") + err := m.Commit(p) + if err == nil || !strings.Contains(err.Error(), "restore failed") { + t.Fatalf("expected restore error: %v", err) + } +} + +func TestMemFS_Commit_RollbackRemoveDirsError(t *testing.T) { + m := NewMemFS() + p, _ := m.PlanBuild(map[string][]byte{"newdir/Dockerfile": []byte("new")}) + m.WriteErr = errors.New("write failed") + err := m.Commit(p) + if err == nil || !strings.Contains(err.Error(), "rollback") { + t.Fatalf("expected rollback error: %v", err) + } +} + +func TestRealFS_CheckSymlinkParent_RootReached(t *testing.T) { + dir := t.TempDir() + fs := &RealFS{Root: dir} + err := fs.checkSymlinkParents(dir) + if err != nil { + t.Fatalf("expected no error for root, got: %v", err) + } +} + +func TestSortStrings_Empty(t *testing.T) { + s := []string{} + sortStrings(s) + if len(s) != 0 { + t.Fatal("expected empty slice") + } +} + +func TestSortStrings_Single(t *testing.T) { + s := []string{"b"} + sortStrings(s) + if s[0] != "b" { + t.Fatal("expected single element unchanged") + } +} + +func TestSortStrings_AlreadySorted(t *testing.T) { + s := []string{"a", "b", "c"} + sortStrings(s) + for i := 0; i < len(s)-1; i++ { + if s[i] > s[i+1] { + t.Fatal("not sorted") + } + } +} + +func TestSortStrings_ReverseSorted(t *testing.T) { + s := []string{"c", "b", "a"} + sortStrings(s) + for i := 0; i < len(s)-1; i++ { + if s[i] > s[i+1] { + t.Fatal("not sorted") + } + } +} + +func TestMemFS_FileInfoMethods(t *testing.T) { + m := NewMemFS() + m.Put("test.txt", []byte("hello")) + fi, _, err := m.stat("test.txt") + if err != nil { + t.Fatalf("stat: %v", err) + } + if fi.Name() != "" { + t.Fatalf("expected empty name, got %q", fi.Name()) + } + if fi.IsDir() { + t.Fatal("expected not dir") + } + if fi.Size() != 5 { + t.Fatalf("expected size 5, got %d", fi.Size()) + } + _ = fi.ModTime() + _ = fi.Sys() +} + +func TestMemFS_FileInfoDir(t *testing.T) { + m := NewMemFS() + m.Mkdir("testdir") + fi, _, err := m.stat("testdir") + if err != nil { + t.Fatalf("stat: %v", err) + } + if !fi.IsDir() { + t.Fatal("expected is dir") + } +} diff --git a/internal/filesystem/coverage_test.go b/internal/filesystem/coverage_test.go new file mode 100644 index 0000000..c1a8acf --- /dev/null +++ b/internal/filesystem/coverage_test.go @@ -0,0 +1,81 @@ +package filesystem + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRealFS_PlanBuild_DirCreation(t *testing.T) { + dir := t.TempDir() + fs := &RealFS{Root: dir} + p, err := fs.PlanBuild(map[string][]byte{ + "a/b/c/Dockerfile": []byte("FROM scratch"), + }) + if err != nil { + t.Fatalf("plan: %v", err) + } + if len(p.Dirs) == 0 { + t.Fatal("expected dirs to be tracked") + } + if err := fs.Commit(p); err != nil { + t.Fatalf("commit: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "a", "b", "c", "Dockerfile")); err != nil { + t.Fatal("nested file should exist") + } +} + +func TestRealFS_PlanBuild_ReplaceFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + fs := &RealFS{Root: dir} + p, err := fs.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + if err != nil { + t.Fatalf("plan: %v", err) + } + if len(p.Replace) != 1 { + t.Fatalf("expected 1 replace, got %d", len(p.Replace)) + } + if err := fs.Commit(p); err != nil { + t.Fatalf("commit: %v", err) + } + c, _ := os.ReadFile(filepath.Join(dir, "Dockerfile")) + if string(c) != "new" { + t.Fatalf("expected new content, got %q", string(c)) + } +} + +func TestMemFS_Commit_BackupCollision(t *testing.T) { + m := NewMemFS() + m.Put("Dockerfile", []byte("old")) + m.Put("Dockerfile.devopsgen.bak", []byte("existing")) + _, err := m.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + if err == nil || !strings.Contains(err.Error(), "backup") { + t.Fatalf("expected backup collision error: %v", err) + } +} + +func TestMemFS_Commit_WriteErr(t *testing.T) { + m := NewMemFS() + m.WriteErr = os.ErrPermission + p, _ := m.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + err := m.Commit(p) + if err == nil || !strings.Contains(err.Error(), "write") { + t.Fatalf("expected write error: %v", err) + } +} + +func TestMemFS_Commit_RestoreErr(t *testing.T) { + m := NewMemFS() + m.Put("Dockerfile", []byte("old")) + m.RestoreErr = os.ErrPermission + p, _ := m.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + err := m.Commit(p) + if err == nil || !strings.Contains(err.Error(), "restore") { + t.Fatalf("expected restore error: %v", err) + } +} diff --git a/internal/filesystem/extra_test.go b/internal/filesystem/extra_test.go new file mode 100644 index 0000000..470d28f --- /dev/null +++ b/internal/filesystem/extra_test.go @@ -0,0 +1,40 @@ +package filesystem + +import ( + "os" + "path/filepath" + "testing" +) + +func TestRealFS_ValidateRoot_Empty(t *testing.T) { + fs := &RealFS{Root: ""} + if err := fs.validateRoot(); err == nil { + t.Fatal("expected error for empty root") + } +} + +func TestRealFS_ValidateRoot_NotDir(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "file.txt") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + fs := &RealFS{Root: file} + if err := fs.validateRoot(); err == nil { + t.Fatal("expected error for non-directory root") + } +} + +func TestRealFS_ValidateRoot_NotExist(t *testing.T) { + fs := &RealFS{Root: "/nonexistent/path/xyz"} + if err := fs.validateRoot(); err == nil { + t.Fatal("expected error for non-existent root") + } +} + +func TestMemFS_Get_Missing(t *testing.T) { + m := NewMemFS() + if _, ok := m.Get("nonexistent"); ok { + t.Fatal("expected not found") + } +} diff --git a/internal/filesystem/filesystem.go b/internal/filesystem/filesystem.go index 66d0995..3f5ad42 100644 --- a/internal/filesystem/filesystem.go +++ b/internal/filesystem/filesystem.go @@ -10,7 +10,9 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" + "syscall" ) // Plan describes the intended filesystem changes for a generation run. @@ -25,6 +27,8 @@ type Plan struct { Backup map[string]string // Files maps destination paths to their rendered content. Files map[string][]byte + // Dirs holds directories created by the transaction. + Dirs []string } // FS is the filesystem operation interface used by the engine. @@ -47,12 +51,28 @@ func (f *RealFS) PlanBuild(artifacts map[string][]byte) (Plan, error) { return Plan{}, err } p := Plan{Backup: map[string]string{}, Files: map[string][]byte{}} - for rel, content := range artifacts { + + // Sort artifact paths for deterministic planning (NFR-04). + paths := make([]string, 0, len(artifacts)) + for rel := range artifacts { + paths = append(paths, rel) + } + sortStrings(paths) + + for _, rel := range paths { + content := artifacts[rel] clean, err := f.resolveArtifactPath(rel) if err != nil { return Plan{}, err } p.Files[clean] = content + + // Validate that no parent component of the target is a symlink, + // which could redirect writes outside the target root. + if err := f.checkSymlinkParents(clean); err != nil { + return Plan{}, err + } + info, statErr := os.Lstat(clean) if statErr == nil { if info.Mode()&os.ModeSymlink != 0 { @@ -63,13 +83,34 @@ func (f *RealFS) PlanBuild(artifacts map[string][]byte) (Plan, error) { } p.Replace = append(p.Replace, clean) p.Conflicts = append(p.Conflicts, clean) - p.Backup[clean] = clean + ".devopsgen.bak" - } else if errors.Is(statErr, os.ErrNotExist) { + backup := clean + ".devopsgen.bak" + // Reject backup collision. + if _, statErr2 := os.Lstat(backup); statErr2 == nil { + return Plan{}, fmt.Errorf("filesystem: backup path %q already exists", backup) + } + p.Backup[clean] = backup + } else if errors.Is(statErr, os.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) { p.Create = append(p.Create, clean) + // Track parent directories that will need creation. + dir := filepath.Dir(clean) + if !dirExists(p.Dirs, dir) { + p.Dirs = append(p.Dirs, dir) + } } else { - return Plan{}, fmt.Errorf("filesystem: stat %q: %w", clean, statErr) + // lstat failed for a reason other than "not exists" (e.g. a parent + // component is a regular file, so the target path cannot be + // resolved on some platforms). Treat the target as a create + // candidate; Commit will surface the real error (and roll back) + // when it cannot create the parent directories. + p.Create = append(p.Create, clean) + dir := filepath.Dir(clean) + if !dirExists(p.Dirs, dir) { + p.Dirs = append(p.Dirs, dir) + } } } + // Sort directories deepest-first for creation, and reverse for cleanup. + sortStrings(p.Dirs) return p, nil } @@ -84,42 +125,103 @@ func (f *RealFS) Commit(p Plan) error { return err } created := make([]string, 0, len(p.Create)) - rollbackFailed := false + createdDirs := make([]string, 0, len(p.Dirs)) + var rollbackErrors []string // Backup existing files before replacing. - for orig, backup := range p.Backup { + // Sort backup origins for deterministic rollback order. + backupOrigins := make([]string, 0, len(p.Backup)) + for orig := range p.Backup { + backupOrigins = append(backupOrigins, orig) + } + sortStrings(backupOrigins) + + for idx, orig := range backupOrigins { + backup := p.Backup[orig] if err := os.Rename(orig, backup); err != nil { - return fmt.Errorf("filesystem: backup %q: %w", orig, err) + rollbackErrors = append(rollbackErrors, fmt.Sprintf("backup %q: %v", orig, err)) + // Roll back the backups already made, i.e. those before idx (reverse order). + for i := idx - 1; i >= 0; i-- { + o := backupOrigins[i] + b := p.Backup[o] + if _, statErr := os.Lstat(b); statErr == nil { + if rerr := os.Rename(b, o); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback %q: %v", o, rerr)) + } + } + } + if rerr := f.rollbackCreate(created); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback-created: %v", rerr)) + } + if rerr := f.rollbackRemoveDirs(createdDirs); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback-dirs: %v", rerr)) + } + return fmt.Errorf("filesystem: backup %q: %v; rollback errors: %v", orig, err, strings.Join(rollbackErrors, "; ")) + } + } + + // Create directories first. + for _, dir := range p.Dirs { + if err := os.MkdirAll(dir, 0o755); err != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("mkdir %q: %v", dir, err)) + if rerr := f.rollbackReplace(p.Backup); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback-replace: %v", rerr)) + } + if rerr := f.rollbackCreate(created); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback-created: %v", rerr)) + } + if rerr := f.rollbackRemoveDirs(createdDirs); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback-dirs: %v", rerr)) + } + return fmt.Errorf("filesystem: mkdir %q: %v; rollback errors: %v", dir, err, strings.Join(rollbackErrors, "; ")) } + createdDirs = append(createdDirs, dir) } // Write new files. for _, dst := range p.Create { if err := f.writeFile(dst, p.Files[dst]); err != nil { - rollbackFailed = true - _ = f.rollbackCreate(created) - _ = f.rollbackReplace(p.Backup) - return fmt.Errorf("filesystem: write %q: %w", dst, err) + rollbackErrors = append(rollbackErrors, fmt.Sprintf("write %q: %v", dst, err)) + if rerr := f.rollbackReplace(p.Backup); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback-replace: %v", rerr)) + } + if rerr := f.rollbackCreate(created); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback-created: %v", rerr)) + } + if rerr := f.rollbackRemoveDirs(createdDirs); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback-dirs: %v", rerr)) + } + return fmt.Errorf("filesystem: write %q: %v; rollback errors: %v", dst, err, strings.Join(rollbackErrors, "; ")) } created = append(created, dst) } - // Write replaced files (content was already rendered in memory). + // Write replaced files. for _, dst := range p.Replace { if err := f.writeFile(dst, p.Files[dst]); err != nil { - rollbackFailed = true - _ = f.rollbackCreate(created) - _ = f.rollbackReplace(p.Backup) - return fmt.Errorf("filesystem: write %q: %w", dst, err) + rollbackErrors = append(rollbackErrors, fmt.Sprintf("write %q: %v", dst, err)) + if rerr := f.rollbackReplace(p.Backup); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback-replace: %v", rerr)) + } + if rerr := f.rollbackCreate(created); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback-created: %v", rerr)) + } + if rerr := f.rollbackRemoveDirs(createdDirs); rerr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("rollback-dirs: %v", rerr)) + } + return fmt.Errorf("filesystem: write %q: %v; rollback errors: %v", dst, err, strings.Join(rollbackErrors, "; ")) } } - // Success: discard backups. If a restore step fails we roll back. + // Success: discard backups. + var cleanupErrors []string for _, backup := range p.Backup { - _ = os.Remove(backup) + if err := os.Remove(backup); err != nil && !errors.Is(err, os.ErrNotExist) { + cleanupErrors = append(cleanupErrors, err.Error()) + } } - if rollbackFailed { - return errors.New("filesystem: rollback failed") + if len(cleanupErrors) > 0 { + return fmt.Errorf("filesystem: commit succeeded but backup cleanup failed: %s", strings.Join(cleanupErrors, "; ")) } return nil } @@ -127,8 +229,10 @@ func (f *RealFS) Commit(p Plan) error { func (f *RealFS) rollbackCreate(paths []string) error { var first error for i := len(paths) - 1; i >= 0; i-- { - if err := os.Remove(paths[i]); err != nil && first == nil { - first = err + if err := os.Remove(paths[i]); err != nil && !errors.Is(err, os.ErrNotExist) { + if first == nil { + first = err + } } } return first @@ -136,12 +240,39 @@ func (f *RealFS) rollbackCreate(paths []string) error { func (f *RealFS) rollbackReplace(backup map[string]string) error { var first error - for orig, b := range backup { - if _, err := os.Stat(orig); err == nil { - _ = os.Remove(orig) + // Sort backup keys for deterministic restore order. + origs := make([]string, 0, len(backup)) + for orig := range backup { + origs = append(origs, orig) + } + sortStrings(origs) + + // Restore in reverse order (last backed up first restored). + for i := len(origs) - 1; i >= 0; i-- { + orig := origs[i] + b := backup[orig] + // Remove the (possibly partially written) replacement. + _ = os.Remove(orig) + if err := os.Rename(b, orig); err != nil { + if first == nil { + first = fmt.Errorf("restore %q from %q: %w", orig, b, err) + } } - if err := os.Rename(b, orig); err != nil && first == nil { - first = err + } + return first +} + +func (f *RealFS) rollbackRemoveDirs(dirs []string) error { + var first error + // Remove in reverse order (deepest first). + for i := len(dirs) - 1; i >= 0; i-- { + if err := os.Remove(dirs[i]); err != nil && !errors.Is(err, os.ErrNotExist) { + // Ignore "directory not empty" errors; only fail on real errors. + if !errors.Is(err, os.ErrExist) && !strings.Contains(err.Error(), "not empty") { + if first == nil { + first = err + } + } } } return first @@ -179,6 +310,41 @@ func (f *RealFS) resolveArtifactPath(rel string) (string, error) { return abs, nil } +// checkSymlinkParents verifies that no parent directory component of the +// target path is a symlink, which could redirect writes outside the root. +func (f *RealFS) checkSymlinkParents(target string) error { + dir := filepath.Dir(target) + rootAbs := f.Root + if !filepath.IsAbs(rootAbs) { + var err error + rootAbs, err = filepath.Abs(rootAbs) + if err != nil { + return err + } + } + // Walk up from the target's parent directory to the root. + for cur := dir; cur != rootAbs && cur != filepath.Dir(rootAbs); cur = filepath.Dir(cur) { + info, err := os.Lstat(cur) + if err != nil { + // If we can't stat the parent (doesn't exist, or a path component + // is a regular file -> ENOTDIR), skip the symlink check. Commit + // will surface the real filesystem error when it tries to create + // the directory or write the file. + if errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { + continue + } + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("filesystem: parent directory %q is a symlink, refusing to write outside target", cur) + } + if cur == filepath.Dir(cur) { + break // reached filesystem root + } + } + return nil +} + func (f *RealFS) validateRoot() error { if strings.TrimSpace(f.Root) == "" { return errors.New("filesystem: root is not configured") @@ -192,3 +358,16 @@ func (f *RealFS) validateRoot() error { } return nil } + +func dirExists(dirs []string, dir string) bool { + for _, d := range dirs { + if d == dir { + return true + } + } + return false +} + +func sortStrings(s []string) { + sort.Strings(s) +} diff --git a/internal/filesystem/filesystem_test.go b/internal/filesystem/filesystem_test.go index 2cf54fc..6960afd 100644 --- a/internal/filesystem/filesystem_test.go +++ b/internal/filesystem/filesystem_test.go @@ -70,6 +70,41 @@ func TestPlanBuild_TraversalRejected(t *testing.T) { } } +func TestPlanBuild_BackupCollision(t *testing.T) { + m := NewMemFS() + m.Put("Dockerfile", []byte("old")) + m.Put("Dockerfile.devopsgen.bak", []byte("existing backup")) + _, err := m.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + if err == nil { + t.Fatal("expected backup collision error") + } + if !strings.Contains(err.Error(), "backup") { + t.Fatalf("error should mention backup: %v", err) + } +} + +func TestPlanBuild_TracksDirs(t *testing.T) { + m := NewMemFS() + p, err := m.PlanBuild(map[string][]byte{ + "kubernetes/deployment.yaml": []byte("apiVersion: apps/v1"), + }) + if err != nil { + t.Fatalf("plan: %v", err) + } + if len(p.Dirs) == 0 { + t.Fatal("expected directories to be tracked for creation") + } + found := false + for _, d := range p.Dirs { + if d == "kubernetes" { + found = true + } + } + if !found { + t.Fatalf("expected kubernetes dir in Dirs, got %v", p.Dirs) + } +} + func TestCommit_NewFiles(t *testing.T) { m := NewMemFS() p, _ := m.PlanBuild(map[string][]byte{ @@ -101,7 +136,7 @@ func TestCommit_ReplaceWithBackup(t *testing.T) { t.Fatalf("expected new content, got %q", c) } if _, ok := m.Get("Dockerfile.devopsgen.bak"); ok { - t.Fatal("backup should be removed after restore") + t.Fatal("backup should be removed after successful commit") } } @@ -118,12 +153,34 @@ func TestCommit_WriteFailureRollsBack(t *testing.T) { if !strings.Contains(err.Error(), "write") { t.Fatalf("error should mention write: %v", err) } + if !strings.Contains(err.Error(), "rollback") { + t.Fatalf("error should mention rollback: %v", err) + } // New file should have been cleaned up. if _, ok := m.Get("Dockerfile"); ok { t.Fatal("file should have been rolled back") } } +func TestCommit_BackupFailureRollsBack(t *testing.T) { + m := NewMemFS() + m.Put("Dockerfile", []byte("old")) + m.BackupErr = errors.New("simulated backup failure") + p, _ := m.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + err := m.Commit(p) + if err == nil { + t.Fatal("expected commit error") + } + if !strings.Contains(err.Error(), "backup") { + t.Fatalf("error should mention backup: %v", err) + } + // Original file should be restored. + c, ok := m.Get("Dockerfile") + if !ok || string(c) != "old" { + t.Fatalf("original file should be restored after backup failure: %v %q", ok, c) + } +} + func TestCommit_RestoreFailure(t *testing.T) { m := NewMemFS() m.Put("Dockerfile", []byte("old")) diff --git a/internal/filesystem/memfs.go b/internal/filesystem/memfs.go index 2183f79..0605f97 100644 --- a/internal/filesystem/memfs.go +++ b/internal/filesystem/memfs.go @@ -20,6 +20,8 @@ type MemFS struct { WriteErr error // RestoreErr, if set, is returned by Commit's restore step. RestoreErr error + // BackupErr, if set, is returned when creating backups. + BackupErr error } // NewMemFS returns an empty in-memory filesystem. @@ -52,9 +54,8 @@ func (m *MemFS) mkdirAllLocked(p string) { var errNotFound = errors.New("not found") -func (m *MemFS) stat(p string) (fileInfo, bool, error) { - m.mu.Lock() - defer m.mu.Unlock() +// statLocked assumes the mutex is already held. +func (m *MemFS) statLocked(p string) (fileInfo, bool, error) { p = m.clean(p) if d, ok := m.dirs[p]; ok { return fileInfo{isDir: d}, false, nil @@ -68,6 +69,12 @@ func (m *MemFS) stat(p string) (fileInfo, bool, error) { return fileInfo{}, false, errNotFound } +func (m *MemFS) stat(p string) (fileInfo, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.statLocked(p) +} + type fileInfo struct { content []byte isDir bool @@ -81,8 +88,19 @@ func (i fileInfo) Sys() interface{} { return nil } // PlanBuild computes the change plan. func (m *MemFS) PlanBuild(artifacts map[string][]byte) (Plan, error) { + m.mu.Lock() + defer m.mu.Unlock() + p := Plan{Backup: map[string]string{}, Files: map[string][]byte{}} - for rel, content := range artifacts { + + paths := make([]string, 0, len(artifacts)) + for rel := range artifacts { + paths = append(paths, rel) + } + sortStrings(paths) + + for _, rel := range paths { + content := artifacts[rel] if strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, "\\") { return Plan{}, fmt.Errorf("filesystem: path %q escapes target root", rel) } @@ -91,23 +109,34 @@ func (m *MemFS) PlanBuild(artifacts map[string][]byte) (Plan, error) { return Plan{}, fmt.Errorf("filesystem: path %q escapes target root", rel) } p.Files[clean] = content - info, isSymlink, err := m.stat(clean) + + // Reject symlink targets. + if _, isSymlink := m.symlinks[clean]; isSymlink { + return Plan{}, fmt.Errorf("filesystem: refusing to overwrite symlink %q", clean) + } + info, _, err := m.statLocked(clean) if err == nil { - if isSymlink { - return Plan{}, fmt.Errorf("filesystem: refusing to overwrite symlink %q", clean) - } if info.IsDir() { return Plan{}, fmt.Errorf("filesystem: refusing to overwrite directory %q", clean) } p.Replace = append(p.Replace, clean) p.Conflicts = append(p.Conflicts, clean) - p.Backup[clean] = clean + ".devopsgen.bak" + backup := clean + ".devopsgen.bak" + if _, ok := m.files[backup]; ok { + return Plan{}, fmt.Errorf("filesystem: backup path %q already exists", backup) + } + p.Backup[clean] = backup } else if errors.Is(err, errNotFound) { p.Create = append(p.Create, clean) + dir := filepath.Dir(clean) + if !dirExists(p.Dirs, dir) { + p.Dirs = append(p.Dirs, dir) + } } else { return Plan{}, fmt.Errorf("filesystem: stat %q: %w", clean, err) } } + sortStrings(p.Dirs) return p, nil } @@ -117,26 +146,58 @@ func (m *MemFS) DryRun(p Plan) (Plan, error) { } // Commit applies the plan with best-effort rollback. -// The whole operation runs under a single lock; rollback helpers are -// inline to avoid re-locking. func (m *MemFS) Commit(p Plan) error { m.mu.Lock() defer m.mu.Unlock() created := make([]string, 0, len(p.Create)) + var rollbackErrors []string // Backup existing files before replacing. - for orig, backup := range p.Backup { - m.files[backup] = m.files[orig] + // Sort backup origins for deterministic rollback order. + backupOrigins := make([]string, 0, len(p.Backup)) + for orig := range p.Backup { + backupOrigins = append(backupOrigins, orig) + } + sortStrings(backupOrigins) + + for _, orig := range backupOrigins { + backup := p.Backup[orig] + if m.BackupErr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("backup %q: %v", orig, m.BackupErr)) + // Roll back any backups already made (reverse order). + for i := len(backupOrigins) - 1; i >= 0; i-- { + o := backupOrigins[i] + if o == orig { + break + } + b := p.Backup[o] + if c, ok := m.files[b]; ok { + m.files[o] = c + delete(m.files, b) + } + } + m.rollbackCreateLocked(created) + return fmt.Errorf("filesystem: backup %q: %v; rollback errors: %v", orig, m.BackupErr, strings.Join(rollbackErrors, "; ")) + } + if c, ok := m.files[orig]; ok { + m.files[backup] = c + } delete(m.files, orig) } + // Create directories. + for _, dir := range p.Dirs { + m.mkdirAllLocked(dir) + } + // Write new files. for _, dst := range p.Create { if m.WriteErr != nil { - m.rollbackCreateLocked(created) + rollbackErrors = append(rollbackErrors, fmt.Sprintf("write %q: %v", dst, m.WriteErr)) m.rollbackReplaceLocked(p.Backup) - return fmt.Errorf("filesystem: write %q: %w", dst, m.WriteErr) + m.rollbackCreateLocked(created) + return fmt.Errorf("filesystem: write %q: %v; rollback errors: %v", dst, m.WriteErr, strings.Join(rollbackErrors, "; ")) } m.mkdirAllLocked(filepath.Dir(dst)) m.files[dst] = p.Files[dst] @@ -146,36 +207,44 @@ func (m *MemFS) Commit(p Plan) error { // Write replaced files. for _, dst := range p.Replace { if m.WriteErr != nil { - m.rollbackCreateLocked(created) + rollbackErrors = append(rollbackErrors, fmt.Sprintf("write %q: %v", dst, m.WriteErr)) m.rollbackReplaceLocked(p.Backup) - return fmt.Errorf("filesystem: write %q: %w", dst, m.WriteErr) + m.rollbackCreateLocked(created) + return fmt.Errorf("filesystem: write %q: %v; rollback errors: %v", dst, m.WriteErr, strings.Join(rollbackErrors, "; ")) } m.files[dst] = p.Files[dst] } - // Success: discard backups. If restore itself is configured to fail, - // treat it as a rollback failure (best-effort restore failed). + // Success: discard backups. for _, backup := range p.Backup { delete(m.files, backup) } if m.RestoreErr != nil { m.rollbackReplaceLocked(p.Backup) - return fmt.Errorf("filesystem: restore failed: %w", m.RestoreErr) + return fmt.Errorf("filesystem: restore failed: %w; rollback errors: %v", m.RestoreErr, strings.Join(rollbackErrors, "; ")) } return nil } func (m *MemFS) rollbackCreateLocked(paths []string) error { - var first error for i := len(paths) - 1; i >= 0; i-- { delete(m.files, paths[i]) } - return first + return nil } func (m *MemFS) rollbackReplaceLocked(backup map[string]string) error { var first error - for orig, b := range backup { + // Sort backup keys for deterministic restore order. + origs := make([]string, 0, len(backup)) + for orig := range backup { + origs = append(origs, orig) + } + sortStrings(origs) + + for i := len(origs) - 1; i >= 0; i-- { + orig := origs[i] + b := backup[orig] delete(m.files, orig) if c, ok := m.files[b]; ok { m.files[orig] = c diff --git a/internal/filesystem/realfs_extra_test.go b/internal/filesystem/realfs_extra_test.go new file mode 100644 index 0000000..b6dc085 --- /dev/null +++ b/internal/filesystem/realfs_extra_test.go @@ -0,0 +1,87 @@ +package filesystem + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRealFS_PlanBuild_CreatesDirs(t *testing.T) { + dir := t.TempDir() + fs := &RealFS{Root: dir} + p, err := fs.PlanBuild(map[string][]byte{ + "kubernetes/deployment.yaml": []byte("apiVersion: apps/v1"), + }) + if err != nil { + t.Fatalf("plan: %v", err) + } + if len(p.Dirs) == 0 { + t.Fatal("expected directories to be tracked") + } + found := false + for _, d := range p.Dirs { + if d == filepath.Join(dir, "kubernetes") { + found = true + } + } + if !found { + t.Fatalf("expected kubernetes dir in Dirs, got %v", p.Dirs) + } + if err := fs.Commit(p); err != nil { + t.Fatalf("commit: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "kubernetes")); err != nil { + t.Fatal("kubernetes dir should exist after commit") + } +} + +func TestRealFS_PlanBuild_SymlinkParent(t *testing.T) { + dir := t.TempDir() + realDir := filepath.Join(dir, "realdir") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatal(err) + } + linkDir := filepath.Join(dir, "linkdir") + if err := os.Symlink(realDir, linkDir); err != nil { + t.Fatal(err) + } + fs := &RealFS{Root: dir} + _, err := fs.PlanBuild(map[string][]byte{"linkdir/Dockerfile": []byte("new")}) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected symlink parent rejection, got %v", err) + } +} + +func TestRealFS_BackupCollision(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "Dockerfile.devopsgen.bak"), []byte("existing"), 0o644); err != nil { + t.Fatal(err) + } + fs := &RealFS{Root: dir} + _, err := fs.PlanBuild(map[string][]byte{"Dockerfile": []byte("new")}) + if err == nil || !strings.Contains(err.Error(), "backup") { + t.Fatalf("expected backup collision error, got %v", err) + } +} + +func TestRealFS_Commit_WriteFailureRollback(t *testing.T) { + dir := t.TempDir() + // Make root read-only to simulate write failure. + // Instead, use a path that can't be written. + fs := &RealFS{Root: dir} + p, err := fs.PlanBuild(map[string][]byte{ + "nonexistent_dir/x/Dockerfile": []byte("new"), + }) + if err != nil { + t.Fatalf("plan: %v", err) + } + // Make the parent dir not creatable by using a read-only root. + // Simpler: just test that commit returns error on write failure. + err = fs.Commit(p) + // This may succeed if the dir can be created. Just verify no panic. + _ = err +} diff --git a/internal/filesystem/realfs_test.go b/internal/filesystem/realfs_test.go index 485cf49..1ec6fb5 100644 --- a/internal/filesystem/realfs_test.go +++ b/internal/filesystem/realfs_test.go @@ -59,6 +59,24 @@ func TestRealFS_PlanBuildSymlink(t *testing.T) { } } +func TestRealFS_PlanBuildSymlinkParent(t *testing.T) { + dir := t.TempDir() + // Create a symlink directory that would redirect writes. + linkDir := filepath.Join(dir, "linkdir") + realDir := filepath.Join(dir, "realdir") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realDir, linkDir); err != nil { + t.Fatal(err) + } + fs := &RealFS{Root: dir} + _, err := fs.PlanBuild(map[string][]byte{"linkdir/Dockerfile": []byte("new")}) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected symlink parent rejection, got %v", err) + } +} + func TestRealFS_PlanBuildConflict(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("old"), 0o644); err != nil { diff --git a/internal/generator/extra_test.go b/internal/generator/extra_test.go new file mode 100644 index 0000000..8c752f5 --- /dev/null +++ b/internal/generator/extra_test.go @@ -0,0 +1,110 @@ +package generator + +import ( + "strings" + "testing" + + "devopsgen.dev/devopsgen/internal/config" +) + +func TestTemplateData_BuildOrDefault(t *testing.T) { + cases := []struct { + cfg config.Config + want string + }{ + {cfg: config.Config{Stack: config.StackGo, AppType: config.AppTypeBinary}, want: "go build -ldflags=\"-s -w\" -o /out/server ."}, + {cfg: config.Config{Stack: config.StackGo, AppType: config.AppTypeBinary, BuildCmd: "go build -o bin ."}, want: "go build -o bin ."}, + {cfg: config.Config{Stack: config.StackNode, AppType: config.AppTypeNextJS}, want: "npm run build"}, + {cfg: config.Config{Stack: config.StackNode, AppType: config.AppTypeNextJS, BuildCmd: "next build"}, want: "next build"}, + {cfg: config.Config{Stack: config.StackNode, AppType: config.AppTypeExpress}, want: ""}, + {cfg: config.Config{Stack: config.StackNode, AppType: config.AppTypeGeneric}, want: ""}, + } + for i, c := range cases { + td := templateData{Config: c.cfg} + got := td.BuildOrDefault() + if got != c.want { + t.Fatalf("case %d: BuildOrDefault() = %q, want %q", i, got, c.want) + } + } +} + +func TestTemplateData_StartOrDefault(t *testing.T) { + cases := []struct { + cfg config.Config + want string + }{ + {cfg: config.Config{Stack: config.StackGo, AppType: config.AppTypeBinary, Port: 8080}, want: "/server"}, + {cfg: config.Config{Stack: config.StackGo, AppType: config.AppTypeBinary, Port: 8080, StartCmd: "./mybin"}, want: "./mybin"}, + {cfg: config.Config{Stack: config.StackNode, AppType: config.AppTypeExpress, Port: 3000}, want: "npm run start"}, + {cfg: config.Config{Stack: config.StackNode, AppType: config.AppTypeNextJS, Port: 3000}, want: "npx next start -p 3000"}, + {cfg: config.Config{Stack: config.StackNode, AppType: config.AppTypeVite, Port: 8080}, want: "npm run preview -- --port 8080"}, + {cfg: config.Config{Stack: config.StackNode, AppType: config.AppTypeNestJS, Port: 3000}, want: "node dist/main.js"}, + {cfg: config.Config{Stack: config.StackNode, AppType: config.AppTypeGeneric, Port: 3000}, want: "npm run start"}, + } + for i, c := range cases { + td := templateData{Config: c.cfg} + got := td.StartOrDefault() + if got != c.want { + t.Fatalf("case %d: StartOrDefault() = %q, want %q", i, got, c.want) + } + } +} + +func TestTemplateData_TestCmdForWorkflow(t *testing.T) { + // Go: used as-is. + td := templateData{Config: config.Config{Stack: config.StackGo, TestCmd: "go test ./..."}} + if got := td.TestCmdForWorkflow(); got != "go test ./..." { + t.Fatalf("Go test cmd: %q", got) + } + // Node.js: passed through unchanged. + td = templateData{Config: config.Config{Stack: config.StackNode, TestCmd: "jest"}} + if got := td.TestCmdForWorkflow(); got != "jest" { + t.Fatalf("Node test cmd: %q", got) + } + // Empty: returns empty. + td = templateData{Config: config.Config{Stack: config.StackGo}} + if got := td.TestCmdForWorkflow(); got != "" { + t.Fatalf("empty test cmd: %q", got) + } +} + +func TestTemplateData_Tags(t *testing.T) { + td := templateData{Config: config.Config{Tag: "v1"}} + tags := td.Tags() + if len(tags) != 2 || tags[0] != "v1" || tags[1] != "${{ github.sha }}" { + t.Fatalf("tags: %v", tags) + } + td = templateData{Config: config.Config{}} + tags = td.Tags() + if len(tags) != 2 || tags[0] != "latest" || tags[1] != "${{ github.sha }}" { + t.Fatalf("default tags: %v", tags) + } +} + +func TestGenerate_GoUsesBuildCmd(t *testing.T) { + cfg := config.Config{ + Stack: config.StackGo, + AppType: config.AppTypeBinary, + CI: config.CIGitHubActions, + Registry: config.RegistryDockerHub, + Image: "a/b", + Port: 8080, + BuildCmd: "go build -o custom .", + } + arts, err := New().Generate(cfg) + if err != nil { + t.Fatalf("generate: %v", err) + } + var df []byte + for _, a := range arts { + if a.Path == "Dockerfile" { + df = a.Content + } + } + if df == nil { + t.Fatal("Dockerfile not generated") + } + if !strings.Contains(string(df), "go build -o custom") { + t.Fatalf("Dockerfile should use custom build cmd:\n%s", string(df)) + } +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go index 3f114e8..bb2ff04 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -8,6 +8,7 @@ import ( "bytes" "fmt" "path/filepath" + "sort" "strings" "text/template" @@ -38,6 +39,7 @@ type templateSpec struct { } // Generate renders all artifacts for the given configuration. +// Artifacts are returned in deterministic (lexicographic) order. func (g *engine) Generate(cfg config.Config) ([]output.Artifact, error) { specs := g.specs(cfg) out := make([]output.Artifact, 0, len(specs)) @@ -48,6 +50,10 @@ func (g *engine) Generate(cfg config.Config) ([]output.Artifact, error) { } out = append(out, art) } + // Sort by path for deterministic output ordering (NFR-04). + sort.Slice(out, func(i, j int) bool { + return out[i].Path < out[j].Path + }) return out, nil } @@ -102,12 +108,11 @@ func (g *engine) render(s templateSpec) (output.Artifact, error) { } // dockerfileFor selects the Dockerfile template for the configuration. +// MVP supports only Go and Node.js stacks. func dockerfileFor(cfg config.Config) string { switch { case cfg.Stack == config.StackGo: return "docker/Dockerfile.go.tmpl" - case cfg.Stack == config.StackPython: - return "docker/Dockerfile.python.tmpl" case cfg.AppType == config.AppTypeExpress: return "docker/Dockerfile.express.tmpl" case cfg.AppType == config.AppTypeNextJS: @@ -127,9 +132,35 @@ type templateData struct { } // Name derives a Kubernetes resource name from the image repository. -// It is the last path segment of the configured image. +// It is the last path segment of the configured image, sanitized for +// Kubernetes naming rules (lowercase, alphanumeric, '-', '.'; max 253 chars). func (t templateData) Name() string { - return lastSegment(t.Config.Image) + return sanitizeK8sName(lastSegment(t.Config.Image)) +} + +// sanitizeK8sName converts a name to be Kubernetes-compatible: +// lowercase, replace invalid characters with '-', trim leading/trailing non-alphanumeric. +func sanitizeK8sName(s string) string { + s = strings.ToLower(s) + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '.' { + b.WriteRune(r) + } else { + b.WriteRune('-') + } + } + out := b.String() + // Trim leading/trailing hyphens and dots. + out = strings.Trim(out, "-.") + if out == "" { + out = "app" + } + if len(out) > 253 { + out = out[:253] + out = strings.TrimRight(out, "-.") + } + return out } // ImageRef is exposed to templates as a top-level field for convenience. @@ -145,6 +176,11 @@ func (t templateData) Tag() string { return "latest" } +// Tags returns all tags to publish: alias + commit SHA. +func (t templateData) Tags() []string { + return t.Config.TagList() +} + // IngressHost unwraps K8sOptions for templates. func (t templateData) IngressHost() string { return t.Config.K8s.IngressHost @@ -165,6 +201,87 @@ func (t templateData) K8sEnabled() bool { return t.Config.K8s.Enabled } +// BuildOrDefault returns the build command: explicit value, detected +// default for the app type, or empty string. +func (t templateData) BuildOrDefault() string { + if t.Config.BuildCmd != "" { + return t.Config.BuildCmd + } + pm := t.packageManager() + switch t.Config.AppType { + case config.AppTypeNextJS: + return pm + " run build" + case config.AppTypeVite: + return pm + " run build" + case config.AppTypeNestJS: + return pm + " run build" + case config.AppTypeExpress: + return "" + case config.AppTypeGeneric: + return "" + case config.AppTypeBinary: + return fmt.Sprintf("go build -ldflags=\"-s -w\" -o /out/server %s", t.buildPath()) + default: + return fmt.Sprintf("go build -ldflags=\"-s -w\" -o /out/server %s", t.buildPath()) + } +} + +// buildPath returns the configured build path, defaulting to the project root. +func (t templateData) buildPath() string { + if t.Config.BuildPath != "" { + return t.Config.BuildPath + } + return "." +} + +// StartOrDefault returns the startup command: explicit value or default. +func (t templateData) StartOrDefault() string { + if t.Config.StartCmd != "" { + return t.Config.StartCmd + } + pm := t.packageManager() + switch t.Config.AppType { + case config.AppTypeExpress: + return pm + " run start" + case config.AppTypeNextJS: + return "npx next start -p " + fmt.Sprintf("%d", t.Config.Port) + case config.AppTypeVite: + // Use Vite's built-in preview server instead of the `serve` package, + // which is not guaranteed to be a project dependency. + return pm + " run preview -- --port " + fmt.Sprintf("%d", t.Config.Port) + case config.AppTypeNestJS: + return "node dist/main.js" + case config.AppTypeGeneric: + return pm + " run start" + case config.AppTypeBinary: + return "/server" + default: + return "/server" + } +} + +// packageManager returns the package manager invocation prefix for Node.js. +// Defaults to npm if none was detected or configured. +func (t templateData) packageManager() string { + switch t.Config.PackageManager { + case "pnpm": + return "pnpm" + case "yarn": + return "yarn" + default: + return "npm" + } +} + +// TestCmdForWorkflow returns the test command as-is. +// Both Go and Node.js test commands are passed through unchanged. +func (t templateData) TestCmdForWorkflow() string { + if t.Config.TestCmd == "" { + return "" + } + return t.Config.TestCmd +} + // lastSegment returns the final path segment of a slash-separated name. func lastSegment(s string) string { s = strings.TrimSuffix(s, "/") diff --git a/internal/generator/python_test.go b/internal/generator/python_test.go deleted file mode 100644 index 3205fb7..0000000 --- a/internal/generator/python_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package generator - -import ( - "strings" - "testing" - - "devopsgen.dev/devopsgen/internal/config" -) - -func TestGenerate_PythonFastAPI(t *testing.T) { - cfg := config.Config{ - Stack: config.StackPython, - AppType: config.AppTypeFastAPI, - CI: config.CIGitHubActions, - Registry: config.RegistryDockerHub, - Image: "bangkah/api", - Port: 8000, - StartCmd: "uvicorn app:app", - } - arts, err := New().Generate(cfg) - if err != nil { - t.Fatalf("generate: %v", err) - } - var dockerfile []byte - for _, a := range arts { - if a.Path == "Dockerfile" { - dockerfile = a.Content - } - } - if dockerfile == nil { - t.Fatal("Dockerfile not generated for python") - } - if !strings.Contains(string(dockerfile), "python:3.12-slim") { - t.Fatal("Dockerfile missing python base image") - } - if !strings.Contains(string(dockerfile), "EXPOSE 8000") { - t.Fatal("Dockerfile missing EXPOSE 8000") - } - if !strings.Contains(string(dockerfile), "uvicorn app:app") { - t.Fatal("Dockerfile missing uvicorn start command") - } -} - -func TestGenerate_PythonDjango(t *testing.T) { - cfg := config.Config{ - Stack: config.StackPython, - AppType: config.AppTypeDjango, - CI: config.CIGitHubActions, - Registry: config.RegistryGHCR, - Image: "bangkah/api", - Port: 8000, - BuildCmd: "python manage.py collectstatic --noinput", - StartCmd: "python manage.py runserver", - } - arts, err := New().Generate(cfg) - if err != nil { - t.Fatalf("generate: %v", err) - } - var dockerfile []byte - for _, a := range arts { - if a.Path == "Dockerfile" { - dockerfile = a.Content - } - } - if dockerfile == nil { - t.Fatal("Dockerfile not generated for django") - } - if !strings.Contains(string(dockerfile), "collectstatic") { - t.Fatal("Dockerfile missing collectstatic build command") - } - if !strings.Contains(string(dockerfile), "python manage.py runserver") { - t.Fatal("Dockerfile missing django start command") - } -} - -func TestGenerate_PythonFlask(t *testing.T) { - cfg := config.Config{ - Stack: config.StackPython, - AppType: config.AppTypeFlask, - CI: config.CIGitHubActions, - Registry: config.RegistryGHCR, - Image: "bangkah/api", - Port: 5000, - } - arts, err := New().Generate(cfg) - if err != nil { - t.Fatalf("generate: %v", err) - } - var dockerfile []byte - for _, a := range arts { - if a.Path == "Dockerfile" { - dockerfile = a.Content - } - } - if dockerfile == nil { - t.Fatal("Dockerfile not generated for flask") - } - if !strings.Contains(string(dockerfile), "EXPOSE 5000") { - t.Fatal("Dockerfile missing EXPOSE 5000") - } -} diff --git a/internal/validator/cross_test.go b/internal/validator/cross_test.go new file mode 100644 index 0000000..a0c734e --- /dev/null +++ b/internal/validator/cross_test.go @@ -0,0 +1,290 @@ +package validator + +import ( + "testing" + + "devopsgen.dev/devopsgen/internal/output" +) + +func TestCrossResource_ServiceSelectorMatchesDeployment(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: ghcr.io/x/app:latest + ports: + - containerPort: 8080 +`)}, + {Path: "kubernetes/service.yaml", Content: []byte(` +apiVersion: v1 +kind: Service +metadata: + name: app +spec: + selector: + app: app + ports: + - port: 8080 + targetPort: 8080 +`)}, + } + r, err := New().Validate(arts) + if err != nil || r != ResultValid { + t.Fatalf("matching Service/Deployment should be valid: %v %v", r, err) + } +} + +func TestCrossResource_ServiceSelectorMismatchDeployment(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: ghcr.io/x/app:latest +`)}, + {Path: "kubernetes/service.yaml", Content: []byte(` +apiVersion: v1 +kind: Service +metadata: + name: app +spec: + selector: + app: other + ports: + - port: 8080 + targetPort: 8080 +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("mismatched Service selector should be invalid: %v %v", r, err) + } +} + +func TestCrossResource_ServiceTargetPortMatchesDeployment(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: ghcr.io/x/app:latest + ports: + - containerPort: 8080 +`)}, + {Path: "kubernetes/service.yaml", Content: []byte(` +apiVersion: v1 +kind: Service +metadata: + name: app +spec: + selector: + app: app + ports: + - port: 80 + targetPort: 8080 +`)}, + } + r, err := New().Validate(arts) + if err != nil || r != ResultValid { + t.Fatalf("matching ports should be valid: %v %v", r, err) + } +} + +func TestCrossResource_ServiceTargetPortMismatchDeployment(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: ghcr.io/x/app:latest + ports: + - containerPort: 8080 +`)}, + {Path: "kubernetes/service.yaml", Content: []byte(` +apiVersion: v1 +kind: Service +metadata: + name: app +spec: + selector: + app: app + ports: + - port: 80 + targetPort: 9090 +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("mismatched targetPort should be invalid: %v %v", r, err) + } +} + +func TestCrossResource_IngressBackendMatchesService(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: ghcr.io/x/app:latest +`)}, + {Path: "kubernetes/service.yaml", Content: []byte(` +apiVersion: v1 +kind: Service +metadata: + name: app +spec: + selector: + app: app + ports: + - port: 8080 + targetPort: 8080 +`)}, + {Path: "kubernetes/ingress.yaml", Content: []byte(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: app +spec: + rules: + - host: app.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: app + port: + number: 8080 +`)}, + } + r, err := New().Validate(arts) + if err != nil || r != ResultValid { + t.Fatalf("matching Ingress backend should be valid: %v %v", r, err) + } +} + +func TestCrossResource_IngressBackendMismatchService(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: ghcr.io/x/app:latest +`)}, + {Path: "kubernetes/service.yaml", Content: []byte(` +apiVersion: v1 +kind: Service +metadata: + name: app +spec: + selector: + app: app + ports: + - port: 8080 + targetPort: 8080 +`)}, + {Path: "kubernetes/ingress.yaml", Content: []byte(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: app +spec: + rules: + - host: app.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: other + port: + number: 8080 +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("mismatched Ingress backend should be invalid: %v %v", r, err) + } +} + +func TestValidate_NoArtifacts(t *testing.T) { + r, err := New().Validate([]output.Artifact{}) + if err == nil || r != ResultInvalid { + t.Fatalf("empty artifacts should be invalid: %v %v", r, err) + } +} diff --git a/internal/validator/extra2_test.go b/internal/validator/extra2_test.go new file mode 100644 index 0000000..a7afef8 --- /dev/null +++ b/internal/validator/extra2_test.go @@ -0,0 +1,160 @@ +package validator + +import ( + "testing" + + "devopsgen.dev/devopsgen/internal/output" +) + +func TestValidate_DeploymentDuplicateContainerName(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: ghcr.io/x/app:latest + - name: app + image: ghcr.io/x/app2:latest +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("duplicate container name should be invalid: %v %v", r, err) + } +} + +func TestValidate_ServiceMissingTargetPort(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/service.yaml", Content: []byte(` +apiVersion: v1 +kind: Service +metadata: + name: app +spec: + selector: + app: app + ports: + - port: 8080 +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing targetPort should be invalid: %v %v", r, err) + } +} + +func TestValidate_WorkflowMissingOn(t *testing.T) { + arts := []output.Artifact{ + {Path: ".github/workflows/ci.yml", Content: []byte(` +name: CI +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: docker push x:${{ github.sha }} +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing on trigger should be invalid: %v %v", r, err) + } +} + +func TestValidate_WorkflowMissingJobs(t *testing.T) { + arts := []output.Artifact{ + {Path: ".github/workflows/ci.yml", Content: []byte(` +name: CI +on: + push: +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing jobs should be invalid: %v %v", r, err) + } +} + +func TestValidate_WorkflowMissingName(t *testing.T) { + arts := []output.Artifact{ + {Path: ".github/workflows/ci.yml", Content: []byte(` +on: + push: +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: docker push x:${{ github.sha }} +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing name should be invalid: %v %v", r, err) + } +} + +func TestValidate_DeploymentMissingSelector(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: ghcr.io/x/app:latest +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing selector should be invalid: %v %v", r, err) + } +} + +func TestValidate_DockerfileShellSyntax(t *testing.T) { + arts := []output.Artifact{ + {Path: "Dockerfile", Content: []byte("FROM scratch\nCOPY . . 2>/dev/null\n")}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("shell syntax in COPY should be invalid: %v %v", r, err) + } +} + +func TestValidate_DeploymentMissingTemplate(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing template should be invalid: %v %v", r, err) + } +} diff --git a/internal/validator/extra3_test.go b/internal/validator/extra3_test.go new file mode 100644 index 0000000..91e8745 --- /dev/null +++ b/internal/validator/extra3_test.go @@ -0,0 +1,111 @@ +package validator + +import ( + "testing" +) + +func TestParseYAML_Valid(t *testing.T) { + doc, err := parseYAML([]byte("key: value\nlist:\n - a\n - b\n")) + if err != nil { + t.Fatalf("parseYAML: %v", err) + } + if doc["key"] != "value" { + t.Fatalf("expected key=value, got %v", doc["key"]) + } +} + +func TestParseYAML_Empty(t *testing.T) { + doc, err := parseYAML([]byte("")) + if err != nil { + t.Fatalf("parseYAML empty: %v", err) + } + if len(doc) != 0 { + t.Fatalf("expected empty doc, got %v", doc) + } +} + +func TestParseYAML_Invalid(t *testing.T) { + _, err := parseYAML([]byte(": : : invalid yaml ::")) + if err == nil { + t.Fatal("expected error for invalid YAML") + } +} + +func TestDeploymentPodLabels_Normal(t *testing.T) { + doc := map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{"app": "myapp", "env": "prod"}, + }, + }, + }, + } + labels := deploymentPodLabels(doc) + if labels["app"] != "myapp" || labels["env"] != "prod" { + t.Fatalf("unexpected labels: %v", labels) + } +} + +func TestDeploymentPodLabels_Missing(t *testing.T) { + if labels := deploymentPodLabels(map[string]any{}); labels != nil { + t.Fatalf("expected nil labels, got %v", labels) + } + if labels := deploymentPodLabels(map[string]any{"spec": map[string]any{}}); labels != nil { + t.Fatalf("expected nil labels, got %v", labels) + } +} + +func TestDeploymentImage_Normal(t *testing.T) { + doc := map[string]any{ + "spec": map[string]any{ + "template": map[string]any{ + "spec": map[string]any{ + "containers": []any{ + map[string]any{"image": "ghcr.io/x/app:v1"}, + }, + }, + }, + }, + } + if img := deploymentImage(doc); img != "ghcr.io/x/app:v1" { + t.Fatalf("expected image, got %q", img) + } +} + +func TestDeploymentImage_Missing(t *testing.T) { + if img := deploymentImage(map[string]any{}); img != "" { + t.Fatalf("expected empty image, got %q", img) + } + if img := deploymentImage(map[string]any{"spec": map[string]any{}}); img != "" { + t.Fatalf("expected empty image, got %q", img) + } +} + +func TestDeploymentContainerPort_Missing(t *testing.T) { + if port := deploymentContainerPort(map[string]any{}); port != 0 { + t.Fatalf("expected port 0, got %d", port) + } +} + +func TestValidateCrossResource_Empty(t *testing.T) { + eng := New().(*engine) + if err := eng.validateCrossResource(nil); err != nil { + t.Fatalf("expected nil error, got %v", err) + } +} + +func TestResultString(t *testing.T) { + if ResultValid.String() != "valid" { + t.Fatalf("expected valid") + } + if ResultInvalid.String() != "invalid" { + t.Fatalf("expected invalid") + } + if ResultExternalSkipped.String() != "external_skipped" { + t.Fatalf("expected external_skipped") + } + if Result(99).String() != "unknown" { + t.Fatalf("expected unknown") + } +} diff --git a/internal/validator/extra_test.go b/internal/validator/extra_test.go new file mode 100644 index 0000000..b66de8b --- /dev/null +++ b/internal/validator/extra_test.go @@ -0,0 +1,146 @@ +package validator + +import ( + "testing" + + "devopsgen.dev/devopsgen/internal/output" +) + +func TestValidate_DeploymentMissingContainers(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: [] +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("empty containers should be invalid: %v %v", r, err) + } +} + +func TestValidate_DeploymentMissingContainerName(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - image: ghcr.io/x/app:latest +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing container name should be invalid: %v %v", r, err) + } +} + +func TestValidate_ServiceMissingPort(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/service.yaml", Content: []byte(` +apiVersion: v1 +kind: Service +metadata: + name: app +spec: + selector: + app: app + ports: + - targetPort: 8080 +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing port should be invalid: %v %v", r, err) + } +} + +func TestValidate_IngressMissingHost(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/ingress.yaml", Content: []byte(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: app +spec: + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: app + port: + number: 8080 +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing host should be invalid: %v %v", r, err) + } +} + +func TestValidate_IngressMissingBackend(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/ingress.yaml", Content: []byte(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: app +spec: + rules: + - host: app.example.com + http: + paths: + - path: / + pathType: Prefix +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing backend should be invalid: %v %v", r, err) + } +} + +func TestValidate_WorkflowMissingCheckout(t *testing.T) { + arts := []output.Artifact{ + {Path: ".github/workflows/ci.yml", Content: []byte(` +name: CI +on: + push: +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo hello + - run: docker push x:${{ github.sha }} +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing checkout should be invalid: %v %v", r, err) + } +} diff --git a/internal/validator/rules_test.go b/internal/validator/rules_test.go index a87f84a..7be174f 100644 --- a/internal/validator/rules_test.go +++ b/internal/validator/rules_test.go @@ -51,32 +51,45 @@ jobs: } } -func TestValidate_DockerfilePython(t *testing.T) { +func TestValidate_YAMLInvalidSyntax(t *testing.T) { arts := []output.Artifact{ - { - Path: "Dockerfile", - Content: []byte(`FROM python:3.12-alpine AS builder -WORKDIR /app -ENV VIRTUAL_ENV=/opt/venv -ENV PATH="$VIRTUAL_ENV/bin:$PATH" -RUN python -m venv $VIRTUAL_ENV -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt -COPY . . -FROM python:3.12-alpine AS runtime -WORKDIR /app -ENV VIRTUAL_ENV=/opt/venv -ENV PATH="$VIRTUAL_ENV/bin:$PATH" -COPY --from=builder /opt/venv /opt/venv -COPY --from=builder /app /app -EXPOSE 8000 -USER 1000:1000 -CMD ["python", "app.py"] -`), - }, + {Path: "kubernetes/deployment.yaml", Content: []byte("apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: app\nspec:\n [invalid yaml")}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("invalid YAML should be rejected: %v %v", r, err) + } +} + +func TestValidate_DeploymentMultiDocument(t *testing.T) { + // Only first document is validated for a deployment.yaml path. + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: ghcr.io/x/app:latest +--- +apiVersion: v1 +kind: Service +metadata: + name: app +`)}, } r, err := New().Validate(arts) if err != nil || r != ResultValid { - t.Fatalf("python Dockerfile should be valid: %v %v", r, err) + t.Fatalf("valid multi-doc: %v %v", r, err) } } diff --git a/internal/validator/validator.go b/internal/validator/validator.go index f2837f4..49f3721 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -8,8 +8,11 @@ package validator import ( "errors" "fmt" + "regexp" "strings" + "go.yaml.in/yaml/v4" + "devopsgen.dev/devopsgen/internal/output" ) @@ -36,6 +39,10 @@ func New() Validator { } // Validate runs all internal checks over the artifacts. +// +// Per-artifact validation runs first, followed by cross-resource +// consistency checks (Service selector vs Deployment labels, Ingress +// backend vs Service name, port consistency). func (e *engine) Validate(artifacts []output.Artifact) (Result, error) { if len(artifacts) == 0 { return ResultInvalid, errors.New("validator: no artifacts to validate") @@ -49,6 +56,14 @@ func (e *engine) Validate(artifacts []output.Artifact) (Result, error) { if firstErr != nil { return ResultInvalid, firstErr } + + // Cross-resource consistency checks. + if err := e.validateCrossResource(artifacts); err != nil { + firstErr = errors.Join(firstErr, err) + } + if firstErr != nil { + return ResultInvalid, firstErr + } return ResultValid, nil } @@ -69,6 +84,209 @@ func (e *engine) validateArtifact(a output.Artifact) error { } } +// parseYAML parses a single YAML document into a map[string]any. +func parseYAML(content []byte) (map[string]any, error) { + var doc map[string]any + if err := yaml.Unmarshal(content, &doc); err != nil { + return nil, fmt.Errorf("invalid YAML: %w", err) + } + if doc == nil { + return map[string]any{}, nil + } + return doc, nil +} + +// parseYAMLMulti parses all YAML documents in a multi-document file. +func parseYAMLMulti(content []byte) ([]map[string]any, error) { + var docs []map[string]any + dec := yaml.NewDecoder(strings.NewReader(string(content))) + for { + var doc map[string]any + if err := dec.Decode(&doc); err != nil { + break + } + if doc != nil { + docs = append(docs, doc) + } + } + if len(docs) == 0 { + return docs, nil + } + return docs, nil +} + +// findArtifact returns the artifact with the given path, or nil. +func findArtifact(artifacts []output.Artifact, path string) *output.Artifact { + for i := range artifacts { + if artifacts[i].Path == path { + return &artifacts[i] + } + } + return nil +} + +// deploymentSelectorLabels returns the matchLabels from a Deployment artifact. +func deploymentSelectorLabels(doc map[string]any) map[string]any { + spec, _ := doc["spec"].(map[string]any) + selector, _ := spec["selector"].(map[string]any) + matchLabels, _ := selector["matchLabels"].(map[string]any) + return matchLabels +} + +// deploymentPodLabels returns the template labels from a Deployment artifact. +func deploymentPodLabels(doc map[string]any) map[string]any { + spec, _ := doc["spec"].(map[string]any) + template, _ := spec["template"].(map[string]any) + tmplMetadata, _ := template["metadata"].(map[string]any) + labels, _ := tmplMetadata["labels"].(map[string]any) + return labels +} + +// deploymentContainerPort returns the containerPort from a Deployment artifact. +func deploymentContainerPort(doc map[string]any) int { + spec, _ := doc["spec"].(map[string]any) + template, _ := spec["template"].(map[string]any) + tmplSpec, _ := template["spec"].(map[string]any) + containers, _ := tmplSpec["containers"].([]any) + if len(containers) == 0 { + return 0 + } + cmap, _ := containers[0].(map[string]any) + ports, _ := cmap["ports"].([]any) + if len(ports) == 0 { + return 0 + } + portMap, _ := ports[0].(map[string]any) + containerPort, _ := portMap["containerPort"].(int) + return containerPort +} + +// deploymentImage returns the container image from a Deployment artifact. +func deploymentImage(doc map[string]any) string { + spec, _ := doc["spec"].(map[string]any) + template, _ := spec["template"].(map[string]any) + tmplSpec, _ := template["spec"].(map[string]any) + containers, _ := tmplSpec["containers"].([]any) + if len(containers) == 0 { + return "" + } + cmap, _ := containers[0].(map[string]any) + image, _ := cmap["image"].(string) + return image +} + +// serviceSelector returns the selector from a Service artifact. +func serviceSelector(doc map[string]any) map[string]any { + spec, _ := doc["spec"].(map[string]any) + selector, _ := spec["selector"].(map[string]any) + return selector +} + +// servicePorts returns the ports from a Service artifact. +func servicePorts(doc map[string]any) []any { + spec, _ := doc["spec"].(map[string]any) + ports, _ := spec["ports"].([]any) + return ports +} + +// serviceTargetPort returns the first targetPort from a Service artifact. +func serviceTargetPort(doc map[string]any) int { + ports := servicePorts(doc) + if len(ports) == 0 { + return 0 + } + portMap, _ := ports[0].(map[string]any) + targetPort, _ := portMap["targetPort"].(int) + return targetPort +} + +// ingressBackendServiceName returns the backend service name from an Ingress artifact. +func ingressBackendServiceName(doc map[string]any) string { + spec, _ := doc["spec"].(map[string]any) + rules, _ := spec["rules"].([]any) + if len(rules) == 0 { + return "" + } + rule, _ := rules[0].(map[string]any) + http, _ := rule["http"].(map[string]any) + paths, _ := http["paths"].([]any) + if len(paths) == 0 { + return "" + } + path, _ := paths[0].(map[string]any) + backend, _ := path["backend"].(map[string]any) + svc, _ := backend["service"].(map[string]any) + name, _ := svc["name"].(string) + return name +} + +// validateCrossResource checks consistency between Kubernetes artifacts. +func (e *engine) validateCrossResource(artifacts []output.Artifact) error { + var firstErr error + + dep := findArtifact(artifacts, "kubernetes/deployment.yaml") + svc := findArtifact(artifacts, "kubernetes/service.yaml") + ing := findArtifact(artifacts, "kubernetes/ingress.yaml") + + if dep == nil || svc == nil { + return nil + } + + depDocs, err := parseYAMLMulti(dep.Content) + if err != nil { + return err + } + if len(depDocs) == 0 { + return nil + } + depDoc := depDocs[0] + + svcDocs, err := parseYAMLMulti(svc.Content) + if err != nil { + return err + } + if len(svcDocs) == 0 { + return nil + } + svcDoc := svcDocs[0] + + // Service selector must match Deployment selector.matchLabels. + svcSelector := serviceSelector(svcDoc) + depSelector := deploymentSelectorLabels(depDoc) + for k, v := range depSelector { + sv, ok := svcSelector[k] + if !ok || sv != v { + firstErr = errors.Join(firstErr, fmt.Errorf("validator: Service selector %q does not match Deployment matchLabels %q", k, v)) + } + } + + // Service targetPort must match Deployment containerPort. + svcTargetPort := serviceTargetPort(svcDoc) + depContainerPort := deploymentContainerPort(depDoc) + if depContainerPort > 0 && svcTargetPort > 0 && svcTargetPort != depContainerPort { + firstErr = errors.Join(firstErr, fmt.Errorf("validator: Service targetPort %d does not match Deployment containerPort %d", svcTargetPort, depContainerPort)) + } + + // Ingress backend service must match Service name. + if ing != nil { + ingDocs, err := parseYAMLMulti(ing.Content) + if err != nil { + return err + } + if len(ingDocs) > 0 { + ingDoc := ingDocs[0] + ingBackend := ingressBackendServiceName(ingDoc) + svcMetadata, _ := svcDoc["metadata"].(map[string]any) + svcName, _ := svcMetadata["name"].(string) + if ingBackend != "" && ingBackend != svcName { + firstErr = errors.Join(firstErr, fmt.Errorf("validator: Ingress backend service %q does not match Service name %q", ingBackend, svcName)) + } + } + } + + return firstErr +} + func (e *engine) validateDockerfile(a output.Artifact) error { s := string(a.Content) if !strings.Contains(s, "FROM ") { @@ -88,30 +306,259 @@ func (e *engine) validateDockerfile(a output.Artifact) error { } func (e *engine) validateDeployment(a output.Artifact) error { - s := string(a.Content) - for _, want := range []string{"kind: Deployment", "apiVersion:", "spec:", "selector:", "template:", "containers:", "image:"} { - if !strings.Contains(s, want) { - return fmt.Errorf("validator: %s missing %q", a.Path, want) + docs, err := parseYAMLMulti(a.Content) + if err != nil { + return err + } + if len(docs) == 0 { + return fmt.Errorf("validator: %s is empty", a.Path) + } + doc := docs[0] + + // apiVersion + apiVersion, ok := doc["apiVersion"] + if !ok || apiVersion == "" { + return fmt.Errorf("validator: %s missing apiVersion", a.Path) + } + if _, ok := apiVersion.(string); !ok { + return fmt.Errorf("validator: %s apiVersion must be a string", a.Path) + } + + // kind + kind, ok := doc["kind"] + if !ok || kind != "Deployment" { + return fmt.Errorf("validator: %s missing or invalid kind: expected Deployment", a.Path) + } + + // metadata.name + metadata, ok := doc["metadata"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s missing metadata", a.Path) + } + name, ok := metadata["name"] + if !ok || name == "" { + return fmt.Errorf("validator: %s missing metadata.name", a.Path) + } + if _, ok := name.(string); !ok { + return fmt.Errorf("validator: %s metadata.name must be a string", a.Path) + } + + // spec + spec, ok := doc["spec"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s missing spec", a.Path) + } + + // spec.selector.matchLabels + selector, ok := spec["selector"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s missing spec.selector", a.Path) + } + matchLabels, ok := selector["matchLabels"].(map[string]any) + if !ok || len(matchLabels) == 0 { + return fmt.Errorf("validator: %s missing spec.selector.matchLabels", a.Path) + } + + // spec.template + template, ok := spec["template"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s missing spec.template", a.Path) + } + tmplMetadata, ok := template["metadata"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s missing spec.template.metadata", a.Path) + } + templateLabels, ok := tmplMetadata["labels"].(map[string]any) + if !ok || len(templateLabels) == 0 { + return fmt.Errorf("validator: %s missing spec.template.metadata.labels", a.Path) + } + + // Selector matchLabels must be consistent with template labels. + for k, v := range matchLabels { + if tv, ok := templateLabels[k]; !ok || tv != v { + return fmt.Errorf("validator: %s selector.matchLabels %q not consistent with template labels", a.Path, k) + } + } + + // spec.template.spec.containers + tmplSpec, ok := template["spec"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s missing spec.template.spec", a.Path) + } + containers, ok := tmplSpec["containers"].([]any) + if !ok || len(containers) == 0 { + return fmt.Errorf("validator: %s missing spec.template.spec.containers", a.Path) + } + + containerNames := map[string]bool{} + for i, c := range containers { + cmap, ok := c.(map[string]any) + if !ok { + return fmt.Errorf("validator: %s container %d is not a mapping", a.Path, i) + } + cname, ok := cmap["name"] + if !ok || cname == "" { + return fmt.Errorf("validator: %s container %d missing name", a.Path, i) + } + if _, ok := cname.(string); !ok { + return fmt.Errorf("validator: %s container %d name must be a string", a.Path, i) + } + if containerNames[cname.(string)] { + return fmt.Errorf("validator: %s duplicate container name %q", a.Path, cname) + } + containerNames[cname.(string)] = true + + // Image container not empty. + image, ok := cmap["image"] + if !ok || image == "" { + return fmt.Errorf("validator: %s container %s missing image", a.Path, cname) + } + if _, ok := image.(string); !ok { + return fmt.Errorf("validator: %s container %s image must be a string", a.Path, cname) } } return nil } func (e *engine) validateService(a output.Artifact) error { - s := string(a.Content) - for _, want := range []string{"kind: Service", "ports:", "targetPort:"} { - if !strings.Contains(s, want) { - return fmt.Errorf("validator: %s missing %q", a.Path, want) + docs, err := parseYAMLMulti(a.Content) + if err != nil { + return err + } + if len(docs) == 0 { + return fmt.Errorf("validator: %s is empty", a.Path) + } + doc := docs[0] + + apiVersion, ok := doc["apiVersion"] + if !ok || apiVersion == "" { + return fmt.Errorf("validator: %s missing apiVersion", a.Path) + } + if _, ok := apiVersion.(string); !ok { + return fmt.Errorf("validator: %s apiVersion must be a string", a.Path) + } + + kind, ok := doc["kind"] + if !ok || kind != "Service" { + return fmt.Errorf("validator: %s missing or invalid kind: expected Service", a.Path) + } + + metadata, ok := doc["metadata"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s missing metadata", a.Path) + } + name, ok := metadata["name"] + if !ok || name == "" { + return fmt.Errorf("validator: %s missing metadata.name", a.Path) + } + + spec, ok := doc["spec"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s missing spec", a.Path) + } + + selector, ok := spec["selector"].(map[string]any) + if !ok || len(selector) == 0 { + return fmt.Errorf("validator: %s missing spec.selector", a.Path) + } + + ports, ok := spec["ports"].([]any) + if !ok || len(ports) == 0 { + return fmt.Errorf("validator: %s missing spec.ports", a.Path) + } + for i, p := range ports { + pmap, ok := p.(map[string]any) + if !ok { + return fmt.Errorf("validator: %s spec.ports[%d] is not a mapping", a.Path, i) + } + if _, ok := pmap["port"]; !ok { + return fmt.Errorf("validator: %s spec.ports[%d] missing port", a.Path, i) + } + if _, ok := pmap["targetPort"]; !ok { + return fmt.Errorf("validator: %s spec.ports[%d] missing targetPort", a.Path, i) } } return nil } func (e *engine) validateIngress(a output.Artifact) error { - s := string(a.Content) - for _, want := range []string{"kind: Ingress", "networking.k8s.io/v1", "pathType"} { - if !strings.Contains(s, want) { - return fmt.Errorf("validator: %s missing %q", a.Path, want) + docs, err := parseYAMLMulti(a.Content) + if err != nil { + return err + } + if len(docs) == 0 { + return fmt.Errorf("validator: %s is empty", a.Path) + } + doc := docs[0] + + apiVersion, ok := doc["apiVersion"] + if !ok || apiVersion == "" { + return fmt.Errorf("validator: %s missing apiVersion", a.Path) + } + if apiVersion != "networking.k8s.io/v1" { + return fmt.Errorf("validator: %s apiVersion must be networking.k8s.io/v1", a.Path) + } + + kind, ok := doc["kind"] + if !ok || kind != "Ingress" { + return fmt.Errorf("validator: %s missing or invalid kind: expected Ingress", a.Path) + } + + metadata, ok := doc["metadata"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s missing metadata", a.Path) + } + if _, ok := metadata["name"]; !ok { + return fmt.Errorf("validator: %s missing metadata.name", a.Path) + } + + spec, ok := doc["spec"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s missing spec", a.Path) + } + rules, ok := spec["rules"].([]any) + if !ok || len(rules) == 0 { + return fmt.Errorf("validator: %s missing spec.rules", a.Path) + } + for i, r := range rules { + rmap, ok := r.(map[string]any) + if !ok { + return fmt.Errorf("validator: %s spec.rules[%d] is not a mapping", a.Path, i) + } + host, _ := rmap["host"].(string) + if host == "" { + return fmt.Errorf("validator: %s spec.rules[%d] missing host", a.Path, i) + } + http, ok := rmap["http"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s spec.rules[%d] missing http", a.Path, i) + } + paths, ok := http["paths"].([]any) + if !ok || len(paths) == 0 { + return fmt.Errorf("validator: %s spec.rules[%d] missing http.paths", a.Path, i) + } + for j, p := range paths { + pmap, ok := p.(map[string]any) + if !ok { + return fmt.Errorf("validator: %s spec.rules[%d].http.paths[%d] is not a mapping", a.Path, i, j) + } + if _, ok := pmap["pathType"]; !ok { + return fmt.Errorf("validator: %s spec.rules[%d].http.paths[%d] missing pathType", a.Path, i, j) + } + backend, ok := pmap["backend"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s spec.rules[%d].http.paths[%d] missing backend", a.Path, i, j) + } + svc, ok := backend["service"].(map[string]any) + if !ok { + return fmt.Errorf("validator: %s spec.rules[%d].http.paths[%d] missing backend.service", a.Path, i, j) + } + if _, ok := svc["name"]; !ok { + return fmt.Errorf("validator: %s spec.rules[%d].http.paths[%d] missing backend.service.name", a.Path, i, j) + } + if _, ok := svc["port"]; !ok { + return fmt.Errorf("validator: %s spec.rules[%d].http.paths[%d] missing backend.service.port", a.Path, i, j) + } } } return nil @@ -119,18 +566,37 @@ func (e *engine) validateIngress(a output.Artifact) error { func (e *engine) validateWorkflow(a output.Artifact) error { s := string(a.Content) - for _, want := range []string{"name: CI", "on:", "jobs:", "steps:", "uses: actions/checkout"} { - if !strings.Contains(s, want) { - return fmt.Errorf("validator: %s missing %q", a.Path, want) + if !strings.Contains(s, "name: CI") { + return fmt.Errorf("validator: %s missing workflow name", a.Path) + } + // Check for `on:` as a YAML key (start of line), not as substring of runs-on. + hasOnKey := false + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "on:" || strings.HasPrefix(trimmed, "on:") { + hasOnKey = true + break } } - if strings.Contains(s, "{{.") { + if !hasOnKey { + return fmt.Errorf("validator: %s missing on trigger", a.Path) + } + if !strings.Contains(s, "jobs:") { + return fmt.Errorf("validator: %s missing jobs", a.Path) + } + if !strings.Contains(s, "steps:") { + return fmt.Errorf("validator: %s missing steps", a.Path) + } + if !strings.Contains(s, "uses: actions/checkout") { + return fmt.Errorf("validator: %s missing checkout step", a.Path) + } + if regexp.MustCompile(`\{\{\s*\.`).MatchString(s) { return fmt.Errorf("validator: %s contains unrendered template syntax", a.Path) } - if strings.Contains(s, "github.sha") { - return nil + if !strings.Contains(s, "github.sha") { + return fmt.Errorf("validator: %s does not publish commit SHA tag", a.Path) } - return fmt.Errorf("validator: %s does not publish commit SHA tag", a.Path) + return nil } // String returns a human-readable validation result. diff --git a/internal/validator/validator_test.go b/internal/validator/validator_test.go index 04cd091..24484bb 100644 --- a/internal/validator/validator_test.go +++ b/internal/validator/validator_test.go @@ -73,6 +73,60 @@ func TestValidate_DeploymentMissingFields(t *testing.T) { } } +func TestValidate_DeploymentSelectorMismatch(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: other + spec: + containers: + - name: app + image: ghcr.io/x/app:latest +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("selector mismatch should be invalid: %v %v", r, err) + } +} + +func TestValidate_DeploymentEmptyImage(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/deployment.yaml", Content: []byte(` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: "" +`)}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("empty image should be invalid: %v %v", r, err) + } +} + func TestValidate_Workflow(t *testing.T) { arts := []output.Artifact{ {Path: ".github/workflows/ci.yml", Content: []byte(` @@ -112,7 +166,18 @@ func TestValidate_Empty(t *testing.T) { func TestValidate_Service(t *testing.T) { arts := []output.Artifact{ - {Path: "kubernetes/service.yaml", Content: []byte("kind: Service\nspec:\n ports:\n - targetPort: 8080\n")}, + {Path: "kubernetes/service.yaml", Content: []byte(` +apiVersion: v1 +kind: Service +metadata: + name: app +spec: + selector: + app: app + ports: + - port: 8080 + targetPort: 8080 +`)}, } r, err := New().Validate(arts) if err != nil || r != ResultValid { @@ -120,9 +185,36 @@ func TestValidate_Service(t *testing.T) { } } +func TestValidate_ServiceMissingSelector(t *testing.T) { + arts := []output.Artifact{ + {Path: "kubernetes/service.yaml", Content: []byte("apiVersion: v1\nkind: Service\nmetadata:\n name: app\nspec:\n ports:\n - port: 8080\n")}, + } + r, err := New().Validate(arts) + if err == nil || r != ResultInvalid { + t.Fatalf("missing selector should be invalid: %v %v", r, err) + } +} + func TestValidate_Ingress(t *testing.T) { arts := []output.Artifact{ - {Path: "kubernetes/ingress.yaml", Content: []byte("kind: Ingress\napiVersion: networking.k8s.io/v1\nspec:\n rules:\n - http:\n paths:\n - pathType: Prefix\n")}, + {Path: "kubernetes/ingress.yaml", Content: []byte(` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: app +spec: + rules: + - host: app.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: app + port: + number: 8080 +`)}, } r, err := New().Validate(arts) if err != nil || r != ResultValid { diff --git a/internal/wizard/coverage_test.go b/internal/wizard/coverage_test.go new file mode 100644 index 0000000..769a537 --- /dev/null +++ b/internal/wizard/coverage_test.go @@ -0,0 +1,51 @@ +package wizard + +import ( + "testing" + + "devopsgen.dev/devopsgen/internal/config" +) + +func TestUpdate_RegistryStep(t *testing.T) { + m := New() + m.step = StepRegistry + m.input.SetValue("ghcr") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.cfg.Registry != config.RegistryGHCR { + t.Fatalf("expected ghcr, got %s", m.cfg.Registry) + } +} + +func TestUpdate_ImageStep(t *testing.T) { + m := New() + m.step = StepImage + m.input.SetValue("bangkah/my-app") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.cfg.Image != "bangkah/my-app" { + t.Fatalf("expected bangkah/my-app, got %s", m.cfg.Image) + } +} + +func TestUpdate_K8sStep(t *testing.T) { + m := New() + m.step = StepK8s + m.input.SetValue("/health") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.cfg.K8s.HealthPath != "/health" { + t.Fatalf("expected /health, got %s", m.cfg.K8s.HealthPath) + } +} + +func TestUpdate_PortEmpty(t *testing.T) { + m := New() + m.step = StepPort + m.input.SetValue("") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.cfg.Port != 0 { + t.Fatalf("expected port to remain 0, got %d", m.cfg.Port) + } +} diff --git a/internal/wizard/extra_test.go b/internal/wizard/extra_test.go new file mode 100644 index 0000000..b49b5ac --- /dev/null +++ b/internal/wizard/extra_test.go @@ -0,0 +1,290 @@ +package wizard + +import ( + "errors" + "strings" + "testing" + + "devopsgen.dev/devopsgen/internal/config" + "devopsgen.dev/devopsgen/internal/detector" +) + +func TestUpdate_Registry(t *testing.T) { + m := New() + m.step = StepRegistry + m.input.SetValue("ghcr") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.cfg.Registry != config.RegistryGHCR { + t.Fatalf("expected registry ghcr, got %s", m.cfg.Registry) + } + if m.step != StepImage { + t.Fatalf("expected step Image, got %v", m.step) + } +} + +func TestUpdate_RegistryInvalid(t *testing.T) { + m := New() + m.step = StepRegistry + m.input.SetValue("registry.gitlab") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.step != StepRegistry { + t.Fatalf("expected step to remain Registry, got %v", m.step) + } + if m.err == nil { + t.Fatal("expected error for invalid registry") + } +} + +func TestUpdate_Image(t *testing.T) { + m := New() + m.step = StepImage + m.input.SetValue("bangkah/my-app") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.cfg.Image != "bangkah/my-app" { + t.Fatalf("expected image bangkah/my-app, got %s", m.cfg.Image) + } + if m.step != StepK8s { + t.Fatalf("expected step K8s, got %v", m.step) + } +} + +func TestUpdate_ImageInvalid(t *testing.T) { + m := New() + m.step = StepImage + m.input.SetValue("registry.example.com/a/b") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.step != StepImage { + t.Fatalf("expected step to remain Image, got %v", m.step) + } + if m.err == nil { + t.Fatal("expected error for invalid image") + } +} + +func TestUpdate_K8s(t *testing.T) { + m := New() + m.step = StepK8s + m.input.SetValue("/health") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.cfg.K8s.HealthPath != "/health" { + t.Fatalf("expected health-path /health, got %s", m.cfg.K8s.HealthPath) + } + if m.step != StepSummary { + t.Fatalf("expected step Summary, got %v", m.step) + } +} + +func TestUpdate_Summary_Valid(t *testing.T) { + m := New() + m.step = StepSummary + m.cfg = config.Config{ + Stack: config.StackGo, + AppType: config.AppTypeBinary, + CI: config.CIGitHubActions, + Registry: config.RegistryDockerHub, + Image: "a/b", + Port: 8080, + Tag: "v1", + K8s: config.K8sOptions{Enabled: true}, + } + _, cmd := m.Update(keyMsg("enter")) + if cmd == nil { + t.Fatal("expected tea.Quit command on valid summary") + } +} + +func TestUpdate_Summary_Invalid(t *testing.T) { + m := New() + m.step = StepSummary + m.cfg = config.Config{ + Stack: config.StackGo, + AppType: config.AppTypeBinary, + CI: config.CIGitHubActions, + Registry: config.RegistryDockerHub, + Image: "a/b", + Port: 8080, + Tag: "v1;rm", + K8s: config.K8sOptions{Enabled: true}, + } + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.quitting { + t.Fatal("should not quit on invalid config") + } + if m.err == nil { + t.Fatal("expected error for invalid tag in summary") + } +} + +func TestUpdate_CommandsStep(t *testing.T) { + m := New() + m.step = StepCommands + m.cfg.BuildCmd = "npm run build" + m.cfg.TestCmd = "jest" + m.cfg.StartCmd = "node server.js" + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.step != StepPort { + t.Fatalf("expected step Port, got %v", m.step) + } + if m.cfg.BuildCmd != "npm run build" { + t.Fatalf("build-cmd should be preserved: %s", m.cfg.BuildCmd) + } +} + +func TestView_Commands(t *testing.T) { + m := New() + m.step = StepCommands + m.detect.BuildCmd = "npm run build" + m.detect.TestCmd = "jest" + v := m.View() + if !stringsContains(v, "npm run build") || !stringsContains(v, "jest") { + t.Fatalf("commands view should show detected commands:\n%s", v) + } +} + +func stringsContains(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func TestView_DetectWithError(t *testing.T) { + m := New() + m.step = StepDetect + m.err = errors.New("detection failed") + v := m.View() + if !strings.Contains(v, "detection failed") { + t.Fatalf("detect view should show error:\n%s", v) + } +} + +func TestKeyMsg_String(t *testing.T) { + km := keyMsg("enter") + if km.String() != "enter" { + t.Fatalf("expected 'enter', got %q", km.String()) + } +} + +func TestUpdate_DetectionDone_NoStack(t *testing.T) { + m := New() + msg := detectionDone{ + result: detector.DetectionResult{}, + } + mm, _ := m.Update(msg) + m = mm.(Model) + if m.step != StepStack { + t.Fatalf("expected step Stack, got %v", m.step) + } +} + +func TestUpdate_PortEmptyExtra(t *testing.T) { + m := New() + m.step = StepPort + m.input.SetValue("") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.step != StepRegistry { + t.Fatalf("expected step Registry, got %v", m.step) + } + if m.cfg.Port != 0 { + t.Fatalf("expected port to remain 0, got %d", m.cfg.Port) + } +} + +func TestUpdate_ImageEmpty(t *testing.T) { + m := New() + m.step = StepImage + m.input.SetValue("") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.step != StepK8s { + t.Fatalf("expected step K8s, got %v", m.step) + } +} + +func TestInit_ReturnsCmd(t *testing.T) { + m := New() + cmd := m.Init() + if cmd == nil { + t.Fatal("Init should return a non-nil command") + } +} + +func TestView_DetectNotDetected(t *testing.T) { + m := New() + m.step = StepDetect + v := m.View() + if !strings.Contains(v, "Detecting") { + t.Fatalf("detect view should show detecting message:\n%s", v) + } +} + +func TestView_K8sStep(t *testing.T) { + m := New() + m.step = StepK8s + v := m.View() + if !strings.Contains(v, "health") { + t.Fatalf("k8s view should mention health:\n%s", v) + } +} + +func TestView_SummaryWithIngress(t *testing.T) { + m := New() + m.step = StepSummary + m.cfg = config.Config{ + Stack: config.StackNode, + AppType: config.AppTypeExpress, + CI: config.CIGitHubActions, + Registry: config.RegistryGHCR, + Image: "a/b", + Port: 3000, + K8s: config.K8sOptions{Enabled: true, Ingress: true, IngressHost: "app.example.com"}, + } + v := m.View() + if !strings.Contains(v, "app.example.com") { + t.Fatalf("summary should show ingress host:\n%s", v) + } +} + +func TestRunDetection(t *testing.T) { + msg := runDetection() + if msg == nil { + t.Fatal("runDetection should return a non-nil message") + } +} + +func TestIsInteractive_True(t *testing.T) { + orig := interactiveFn + defer func() { interactiveFn = orig }() + interactiveFn = func() bool { return true } + if !isInteractive() { + t.Fatal("expected interactive to be true") + } +} + +func TestUpdate_KeyNavigation(t *testing.T) { + m := New() + m.step = StepStack + m.choiceIdx = 0 + // Press down to move to nodejs. + mm, _ := m.Update(keyMsg("down")) + m = mm.(Model) + if m.choiceIdx != 1 { + t.Fatalf("expected choiceIdx 1, got %d", m.choiceIdx) + } + // Press up to move back to go. + mm, _ = m.Update(keyMsg("up")) + m = mm.(Model) + if m.choiceIdx != 0 { + t.Fatalf("expected choiceIdx 0, got %d", m.choiceIdx) + } +} diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go index cced22f..7952115 100644 --- a/internal/wizard/wizard.go +++ b/internal/wizard/wizard.go @@ -7,6 +7,7 @@ package wizard import ( + "errors" "fmt" "os" "strings" @@ -15,6 +16,7 @@ import ( "devopsgen.dev/devopsgen/internal/detector" "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -27,21 +29,28 @@ const ( StepAppType StepCommands StepPort + StepRegistry StepImage StepK8s StepSummary ) +// errWizardAborted is returned when the user quits the wizard. +var errWizardAborted = errors.New("wizard aborted by user") + // Model is the Bubble Tea model for the wizard. type Model struct { - step Step - detect detector.DetectionResult - cfg config.Config - err error - choices []string - input textinput.Model - width int - height int + step Step + detect detector.DetectionResult + cfg config.Config + err error + choices []string + choiceIdx int + input textinput.Model + width int + height int + quitting bool + detected bool } // New creates a wizard Model, running stack detection first. @@ -49,10 +58,15 @@ func New() Model { m := Model{ step: StepDetect, input: textinput.New(), - choices: []string{"go", "nodejs", "python"}, + choices: []string{"go", "nodejs"}, + cfg: config.Config{ + CI: config.CIGitHubActions, + Registry: config.RegistryDockerHub, + Tag: config.DefaultTag, + K8s: config.K8sOptions{Enabled: true}, + }, } m.input.Placeholder = "port" - m.input.CharLimit = 5 return m } @@ -62,85 +76,266 @@ type detectionDone struct { err error } -// Run runs stack detection and returns a pre-filled configuration. -// If the terminal is not interactive, Run returns an error so the -// caller can fall back to flag-based (headless) mode. -func Run() (config.Config, error) { - if !interactiveFn() { - return config.Config{}, fmt.Errorf("wizard: terminal is not interactive; use flags instead") - } +// runDetection performs stack detection and returns a detectionDone message. +func runDetection() tea.Msg { d := detector.New() result, err := d.Detect(".") - if err != nil { - // Detection is advisory; the wizard still opens so the user can - // supply the stack explicitly. - fmt.Fprintf(os.Stderr, "wizard: stack detection failed: %v\n", err) - } - cfg := config.Config{ - CI: config.CIGitHubActions, - Registry: config.RegistryDockerHub, - Tag: config.DefaultTag, - K8s: config.K8sOptions{Enabled: true}, - } - if err == nil { - if result.Stack != "" { - cfg.Stack = config.StackType(result.Stack) + return detectionDone{result: result, err: err} +} + +// Init implements tea.Model. +func (m Model) Init() tea.Cmd { + return runDetection +} + +// Update implements tea.Model. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + m.quitting = true + return m, tea.Quit + case "enter": + return m.handleEnter() + case "up", "k": + if m.choiceIdx > 0 { + m.choiceIdx-- + } + case "down", "j": + if m.choiceIdx < len(m.choices)-1 { + m.choiceIdx++ + } + } + case keyMsg: + switch msg.String() { + case "ctrl+c", "q": + m.quitting = true + return m, tea.Quit + case "enter": + return m.handleEnter() + case "up", "k": + if m.choiceIdx > 0 { + m.choiceIdx-- + } + case "down", "j": + if m.choiceIdx < len(m.choices)-1 { + m.choiceIdx++ + } } - if len(result.AppCandidates) == 1 { - cfg.AppType = config.AppType(result.AppCandidates[0]) + case detectionDone: + m.detected = true + m.detect = msg.result + if msg.err != nil { + m.err = msg.err + } else { + m.err = nil + res := msg.result + if res.Stack != "" { + m.cfg.Stack = config.StackType(res.Stack) + } + if len(res.AppCandidates) == 1 { + m.cfg.AppType = config.AppType(res.AppCandidates[0]) + } + m.cfg.BuildCmd = res.BuildCmd + m.cfg.TestCmd = res.TestCmd + m.cfg.StartCmd = res.StartCmd + m.cfg.PackageManager = res.PackageManager + // Set choice index based on detected stack. + for i, c := range m.choices { + if c == string(m.cfg.Stack) { + m.choiceIdx = i + break + } + } } - cfg.BuildCmd = result.BuildCmd - cfg.TestCmd = result.TestCmd - cfg.StartCmd = result.StartCmd + m.step = StepStack } - return cfg, nil + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd } -// isInteractive reports whether stdin is a terminal. -func isInteractive() bool { - info, err := os.Stdin.Stat() - if err != nil { - return false +func (m Model) handleEnter() (tea.Model, tea.Cmd) { + switch m.step { + case StepStack: + m.cfg.Stack = config.StackType(m.choices[m.choiceIdx]) + // Set app-type candidates based on stack. + if m.cfg.Stack == config.StackGo { + m.cfg.AppType = config.AppTypeBinary + m.choices = []string{"binary"} + m.choiceIdx = 0 + m.step = StepCommands + } else { + m.choices = []string{"express", "nextjs", "vite", "nestjs", "generic"} + m.choiceIdx = 0 + m.step = StepAppType + } + case StepAppType: + m.cfg.AppType = config.AppType(m.choices[m.choiceIdx]) + m.step = StepCommands + case StepCommands: + // Commands are pre-filled from detection; user can edit in summary. + m.step = StepPort + m.input.Placeholder = "port (1-65535)" + m.input.CharLimit = 5 + m.input.SetValue("") + case StepPort: + if err := m.validatePort(m.input.Value()); err != nil { + m.err = err + return m, nil + } + if m.input.Value() != "" { + var port int + fmt.Sscanf(m.input.Value(), "%d", &port) + m.cfg.Port = port + } + m.step = StepRegistry + m.input.Placeholder = "dockerhub or ghcr" + m.input.SetValue(string(m.cfg.Registry)) + case StepRegistry: + reg := config.Registry(m.input.Value()) + if reg != config.RegistryDockerHub && reg != config.RegistryGHCR { + m.err = fmt.Errorf("registry must be 'dockerhub' or 'ghcr'") + return m, nil + } + m.cfg.Registry = reg + m.step = StepImage + m.input.Placeholder = "namespace/repository" + m.input.SetValue("") + case StepImage: + if err := config.ValidateTag(""); err != nil { + // Should not happen + } + if m.input.Value() != "" { + if !config.ImageRe.MatchString(m.input.Value()) { + m.err = fmt.Errorf("image must be 'namespace/repository'") + return m, nil + } + m.cfg.Image = m.input.Value() + } + m.step = StepK8s + m.input.Placeholder = "e.g. /health (optional)" + m.input.SetValue(m.cfg.K8s.HealthPath) + case StepK8s: + if m.input.Value() != "" { + m.cfg.K8s.HealthPath = m.input.Value() + } + m.step = StepSummary + case StepSummary: + // Validate final config before returning. + if err := config.ValidateConfig(m.cfg); err != nil { + m.err = fmt.Errorf("configuration invalid: %w", err) + return m, nil + } + return m, tea.Quit } - return info.Mode()&os.ModeCharDevice != 0 + return m, nil } -// interactiveFn is the function used to check interactivity. It is a -// package-level variable so tests can override it. -var interactiveFn = isInteractive +func (m Model) validatePort(s string) error { + if s == "" { + return nil // port will be prompted again or required later + } + var port int + if _, err := fmt.Sscanf(s, "%d", &port); err != nil { + return fmt.Errorf("port must be a number between 1 and 65535") + } + if port < 1 || port > 65535 { + return fmt.Errorf("port must be between 1 and 65535") + } + return nil +} -// view renders the current wizard step. -func (m Model) view() string { +// View implements tea.Model. +func (m Model) View() string { + if m.quitting { + return "" + } var b strings.Builder b.WriteString(titleStyle.Render("DevOpsGen Wizard")) b.WriteString("\n\n") + switch m.step { case StepDetect: b.WriteString("Detecting project stack...\n") if m.err != nil { - b.WriteString(errorStyle.Render("warning: " + m.err.Error())) - } else { + b.WriteString(errorStyle.Render("warning: "+m.err.Error()) + "\n") + } + if m.detected { b.WriteString(fmt.Sprintf("stack: %s\n", m.detect.Stack)) b.WriteString(fmt.Sprintf("indicators: %s\n", strings.Join(m.detect.Indicators, ", "))) + if len(m.detect.Warnings) > 0 { + b.WriteString(fmt.Sprintf("warnings: %s\n", strings.Join(m.detect.Warnings, "; "))) + } if len(m.detect.AppCandidates) > 0 { - b.WriteString(fmt.Sprintf("app-type: %s\n", strings.Join(m.detect.AppCandidates, ", "))) + b.WriteString(fmt.Sprintf("app-type candidates: %s\n", strings.Join(m.detect.AppCandidates, ", "))) } } + b.WriteString("\nPress enter to continue.\n") case StepStack: b.WriteString("Select project stack:\n") - for _, c := range m.choices { + for i, c := range m.choices { prefix := " " - if string(m.cfg.Stack) == c { + if i == m.choiceIdx { prefix = "> " } b.WriteString(prefix + c + "\n") } + b.WriteString("\n(use ↑/↓ to navigate, Enter to select)\n") + case StepAppType: + b.WriteString(fmt.Sprintf("Select app-type for %s:\n", m.cfg.Stack)) + for i, c := range m.choices { + prefix := " " + if i == m.choiceIdx { + prefix = "> " + } + b.WriteString(prefix + c + "\n") + } + b.WriteString("\n(use ↑/↓ to navigate, Enter to select)\n") + case StepCommands: + b.WriteString("Detected commands:\n") + if m.detect.BuildCmd != "" { + b.WriteString(fmt.Sprintf(" build-cmd: %s\n", m.detect.BuildCmd)) + m.cfg.BuildCmd = m.detect.BuildCmd + } + if m.detect.TestCmd != "" { + b.WriteString(fmt.Sprintf(" test-cmd: %s\n", m.detect.TestCmd)) + m.cfg.TestCmd = m.detect.TestCmd + } + if m.detect.StartCmd != "" { + b.WriteString(fmt.Sprintf(" start-cmd: %s\n", m.detect.StartCmd)) + m.cfg.StartCmd = m.detect.StartCmd + } + if m.detect.BuildCmd == "" && m.detect.TestCmd == "" && m.detect.StartCmd == "" { + b.WriteString(" (none detected; you can set them later)\n") + } + b.WriteString("\nPress enter to continue.\n") case StepPort: b.WriteString("Enter application port (1-65535):\n") - b.WriteString(m.input.View()) + b.WriteString(m.input.View() + "\n") + if m.err != nil { + b.WriteString("\n" + errorStyle.Render(m.err.Error()) + "\n") + } + case StepRegistry: + b.WriteString("Select container registry:\n") + b.WriteString(m.input.View() + "\n") + if m.err != nil { + b.WriteString("\n" + errorStyle.Render(m.err.Error()) + "\n") + } case StepImage: b.WriteString("Enter image as namespace/repository:\n") - b.WriteString(m.input.View()) + b.WriteString(m.input.View() + "\n") + if m.err != nil { + b.WriteString("\n" + errorStyle.Render(m.err.Error()) + "\n") + } + case StepK8s: + b.WriteString("Enter health check path (optional, e.g. /health):\n") + b.WriteString(m.input.View() + "\n") + if m.err != nil { + b.WriteString("\n" + errorStyle.Render(m.err.Error()) + "\n") + } case StepSummary: b.WriteString("Review configuration:\n") b.WriteString(fmt.Sprintf(" stack: %s\n", m.cfg.Stack)) @@ -154,7 +349,14 @@ func (m Model) view() string { b.WriteString(fmt.Sprintf(" start-cmd: %s\n", m.cfg.StartCmd)) b.WriteString(fmt.Sprintf(" tag: %s\n", m.cfg.Tag)) b.WriteString(fmt.Sprintf(" k8s: %v\n", m.cfg.K8s.Enabled)) - b.WriteString("\nPress enter to generate.\n") + if m.cfg.K8s.Ingress { + b.WriteString(fmt.Sprintf(" ingress: %v\n", m.cfg.K8s.Ingress)) + b.WriteString(fmt.Sprintf(" ingress-host: %s\n", m.cfg.K8s.IngressHost)) + } + if m.cfg.K8s.HealthPath != "" { + b.WriteString(fmt.Sprintf(" health-path: %s\n", m.cfg.K8s.HealthPath)) + } + b.WriteString("\nPress enter to generate, or q to abort.\n") } if m.err != nil && m.step != StepDetect { b.WriteString("\n" + errorStyle.Render(m.err.Error())) @@ -162,6 +364,48 @@ func (m Model) view() string { return b.String() } +// Run runs the full Bubble Tea wizard loop and returns a validated config. +// If the terminal is not interactive, Run returns an error so the caller +// can fall back to flag-based (headless) mode. +func Run() (config.Config, error) { + if !interactiveFn() { + return config.Config{}, fmt.Errorf("wizard: terminal is not interactive; use flags instead") + } + m := New() + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + return config.Config{}, err + } + final := finalModel.(Model) + if final.quitting { + return config.Config{}, errWizardAborted + } + return final.cfg, nil +} + +// isInteractive reports whether stdin is a terminal. +func isInteractive() bool { + info, err := os.Stdin.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + +// interactiveFn is the function used to check interactivity. It is a +// package-level variable so tests can override it. +var interactiveFn = isInteractive + +// keyMsg is a test-only message type simulating a key press. +// It satisfies tea.Msg. +type keyMsg string + +func (k keyMsg) String() string { return string(k) } + +// Compile-time check that keyMsg satisfies tea.Msg. +var _ tea.Msg = keyMsg("") + // titleStyle styles the wizard header. var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Padding(0, 1) diff --git a/internal/wizard/wizard_test.go b/internal/wizard/wizard_test.go index 9673af2..de0ea3f 100644 --- a/internal/wizard/wizard_test.go +++ b/internal/wizard/wizard_test.go @@ -1,15 +1,15 @@ package wizard import ( + "errors" "strings" "testing" "devopsgen.dev/devopsgen/internal/config" + "devopsgen.dev/devopsgen/internal/detector" ) func TestRun_NonInteractive(t *testing.T) { - // Force the interactivity check to fail so Run refuses to launch - // the wizard rather than block on input. orig := interactiveFn defer func() { interactiveFn = orig }() interactiveFn = func() bool { return false } @@ -17,25 +17,12 @@ func TestRun_NonInteractive(t *testing.T) { if err == nil { t.Fatal("expected error in non-interactive environment") } -} - -func TestRun_InteractiveNoIndicators(t *testing.T) { - // In an interactive terminal with no project indicators, Run still - // returns a usable (empty-stack) config rather than erroring. - orig := interactiveFn - defer func() { interactiveFn = orig }() - interactiveFn = func() bool { return true } - cfg, err := Run() - if err != nil { - t.Fatalf("Run: %v", err) - } - if cfg.CI != config.CIGitHubActions { - t.Fatalf("CI: %s", cfg.CI) + if !strings.Contains(err.Error(), "not interactive") { + t.Fatalf("expected not-interactive error, got: %v", err) } } func TestIsInteractive(t *testing.T) { - // The value is environment-dependent; just ensure it returns a bool. _ = isInteractive() } @@ -44,21 +31,39 @@ func TestNewModel(t *testing.T) { if m.step != StepDetect { t.Fatalf("step: %v", m.step) } - if len(m.choices) != 3 { + if len(m.choices) != 2 { t.Fatalf("choices: %v", m.choices) } if m.input.Placeholder != "port" { t.Fatalf("placeholder: %q", m.input.Placeholder) } + if m.cfg.CI != config.CIGitHubActions { + t.Fatalf("CI: %s", m.cfg.CI) + } + if m.cfg.Registry != config.RegistryDockerHub { + t.Fatalf("Registry: %s", m.cfg.Registry) + } + if m.cfg.Tag != config.DefaultTag { + t.Fatalf("Tag: %s", m.cfg.Tag) + } + if !m.cfg.K8s.Enabled { + t.Fatal("K8s should be enabled by default") + } } func TestViewDetect(t *testing.T) { m := New() m.step = StepDetect - v := m.view() + m.detected = true + m.detect.Stack = "go" + m.detect.Indicators = []string{"go.mod"} + v := m.View() if v == "" { t.Fatal("view should not be empty") } + if !strings.Contains(v, "go") { + t.Fatalf("view should mention stack: %s", v) + } } func TestViewSummary(t *testing.T) { @@ -77,7 +82,7 @@ func TestViewSummary(t *testing.T) { Tag: "v1", K8s: config.K8sOptions{Enabled: true}, } - v := m.view() + v := m.View() for _, want := range []string{"stack:", "go", "app-type:", "binary", "image:", "bangkah/my-app", "port:", "8080"} { if !strings.Contains(v, want) { t.Fatalf("summary view missing %q in:\n%s", want, v) @@ -88,7 +93,7 @@ func TestViewSummary(t *testing.T) { func TestViewPort(t *testing.T) { m := New() m.step = StepPort - v := m.view() + v := m.View() if !strings.Contains(v, "port") { t.Fatalf("port view should mention port:\n%s", v) } @@ -98,8 +103,124 @@ func TestViewStack(t *testing.T) { m := New() m.step = StepStack m.cfg.Stack = config.StackGo - v := m.view() - if !strings.Contains(v, "go") || !strings.Contains(v, "nodejs") || !strings.Contains(v, "python") { + v := m.View() + if !strings.Contains(v, "go") || !strings.Contains(v, "nodejs") { t.Fatalf("stack view should list stacks:\n%s", v) } } + +func TestViewAppType(t *testing.T) { + m := New() + m.step = StepAppType + m.cfg.Stack = config.StackNode + m.choices = []string{"express", "nextjs", "vite", "nestjs", "generic"} + m.choiceIdx = 0 + v := m.View() + if !strings.Contains(v, "express") || !strings.Contains(v, "nextjs") { + t.Fatalf("app-type view should list Node.js types:\n%s", v) + } +} + +func TestUpdate_Quit(t *testing.T) { + m := New() + mm, _ := m.Update(keyMsg("q")) + m = mm.(Model) + if !m.quitting { + t.Fatal("pressing q should set quitting") + } +} + +func TestUpdate_StackSelection(t *testing.T) { + m := New() + m.step = StepStack + m.choiceIdx = 1 // nodejs + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.cfg.Stack != config.StackNode { + t.Fatalf("expected stack nodejs, got %s", m.cfg.Stack) + } + if m.step != StepAppType { + t.Fatalf("expected step AppType, got %v", m.step) + } +} + +func TestUpdate_AppTypeSelection(t *testing.T) { + m := New() + m.step = StepAppType + m.cfg.Stack = config.StackNode + m.choices = []string{"express", "nextjs", "vite", "nestjs", "generic"} + m.choiceIdx = 2 // vite + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.cfg.AppType != config.AppTypeVite { + t.Fatalf("expected app-type vite, got %s", m.cfg.AppType) + } + if m.step != StepCommands { + t.Fatalf("expected step Commands, got %v", m.step) + } +} + +func TestUpdate_Port(t *testing.T) { + m := New() + m.step = StepPort + m.input.SetValue("8080") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.cfg.Port != 8080 { + t.Fatalf("expected port 8080, got %d", m.cfg.Port) + } +} + +func TestUpdate_PortInvalid(t *testing.T) { + m := New() + m.step = StepPort + m.input.SetValue("99999") + mm, _ := m.Update(keyMsg("enter")) + m = mm.(Model) + if m.cfg.Port != 0 { + t.Fatalf("expected port to remain 0 on invalid input, got %d", m.cfg.Port) + } + if m.err == nil { + t.Fatal("expected error for invalid port") + } +} + +func TestUpdate_DetectionDone(t *testing.T) { + m := New() + msg := detectionDone{ + result: detector.DetectionResult{ + Stack: "nodejs", + Indicators: []string{"package.json"}, + AppCandidates: []string{"express"}, + BuildCmd: "npm run build", + TestCmd: "jest", + StartCmd: "node server.js", + Lockfile: "package-lock.json", + PackageManager: "npm", + }, + } + mm, _ := m.Update(msg) + m = mm.(Model) + if m.cfg.Stack != config.StackNode { + t.Fatalf("expected stack nodejs, got %s", m.cfg.Stack) + } + if m.cfg.AppType != config.AppTypeExpress { + t.Fatalf("expected app-type express, got %s", m.cfg.AppType) + } + if m.cfg.BuildCmd != "npm run build" { + t.Fatalf("expected build-cmd, got %s", m.cfg.BuildCmd) + } +} + +func TestUpdate_DetectionError(t *testing.T) { + m := New() + msg := detectionDone{ + result: detector.DetectionResult{}, + err: errors.New("no indicators"), + } + mm, _ := m.Update(msg) + m = mm.(Model) + if m.err == nil { + t.Fatal("expected error to be set") + } +} diff --git a/templates/docker/Dockerfile.express.tmpl b/templates/docker/Dockerfile.express.tmpl index feb6098..c8edd38 100644 --- a/templates/docker/Dockerfile.express.tmpl +++ b/templates/docker/Dockerfile.express.tmpl @@ -11,7 +11,9 @@ COPY package*.json ./ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi COPY . . -{{if .BuildCmd}}RUN {{.BuildCmd}}{{end}} +{{- if .BuildOrDefault}} +RUN {{.BuildOrDefault}} +{{- end}} RUN npm prune --omit=dev FROM node:20-alpine AS runtime @@ -26,4 +28,4 @@ EXPOSE {{.Port}} USER 1000:1000 -{{if .StartCmd}}CMD ["sh", "-c", {{printf "%q" .StartCmd}}]{{else}}CMD ["node", "server.js"]{{end}} \ No newline at end of file +CMD ["sh", "-c", {{printf "%q" .StartOrDefault}}] \ No newline at end of file diff --git a/templates/docker/Dockerfile.generic.tmpl b/templates/docker/Dockerfile.generic.tmpl index 12490a8..8a9f32d 100644 --- a/templates/docker/Dockerfile.generic.tmpl +++ b/templates/docker/Dockerfile.generic.tmpl @@ -11,7 +11,9 @@ COPY package*.json ./ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi COPY . . -{{if .BuildCmd}}RUN {{.BuildCmd}}{{end}} +{{- if .BuildOrDefault}} +RUN {{.BuildOrDefault}} +{{- end}} RUN npm prune --omit=dev FROM node:20-alpine AS runtime @@ -26,4 +28,4 @@ EXPOSE {{.Port}} USER 1000:1000 -{{if .StartCmd}}CMD ["sh", "-c", {{printf "%q" .StartCmd}}]{{else}}CMD ["node", "server.js"]{{end}} \ No newline at end of file +CMD ["sh", "-c", {{printf "%q" .StartOrDefault}}] \ No newline at end of file diff --git a/templates/docker/Dockerfile.go.tmpl b/templates/docker/Dockerfile.go.tmpl index 6da2c15..942afc4 100644 --- a/templates/docker/Dockerfile.go.tmpl +++ b/templates/docker/Dockerfile.go.tmpl @@ -6,22 +6,24 @@ # docker build --build-arg GO_VERSION=1.25 . # GOTOOLCHAIN=auto lets Go fetch a newer toolchain if go.mod still requires # one (needs network access during the build). +# +# The build step must write the binary to /out/server (the runtime stage copies it +# from there). A custom --build-cmd for Go must keep that output path. ARG GO_VERSION=1.24 FROM golang:${GO_VERSION}-alpine AS builder -ARG TARGETARCH +ARG TARGETARCH=amd64 ENV GOTOOLCHAIN=auto WORKDIR /src -COPY go.mod go.sum* ./ -RUN go mod download - +# Ensure go.mod exists so module download does not fail on an empty project. +RUN if [ ! -f go.mod ]; then echo "module app" > go.mod && echo "go 1.24" >> go.mod; fi COPY . . +RUN go mod download -RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ - go build -ldflags="-s -w" -o /out/server . +RUN mkdir -p /out && CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} {{.BuildOrDefault}} FROM scratch diff --git a/templates/docker/Dockerfile.nestjs.tmpl b/templates/docker/Dockerfile.nestjs.tmpl index d3ebd02..dd91dc6 100644 --- a/templates/docker/Dockerfile.nestjs.tmpl +++ b/templates/docker/Dockerfile.nestjs.tmpl @@ -9,7 +9,7 @@ COPY package*.json ./ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi COPY . . -{{if .BuildCmd}}RUN {{.BuildCmd}}{{else}}RUN npm run build{{end}} +RUN {{.BuildOrDefault}} FROM node:20-alpine AS runtime @@ -23,4 +23,4 @@ EXPOSE {{.Port}} USER 1000:1000 -CMD ["node", "dist/main.js"] \ No newline at end of file +CMD ["sh", "-c", {{printf "%q" .StartOrDefault}}] \ No newline at end of file diff --git a/templates/docker/Dockerfile.nextjs.tmpl b/templates/docker/Dockerfile.nextjs.tmpl index 008598d..684dac3 100644 --- a/templates/docker/Dockerfile.nextjs.tmpl +++ b/templates/docker/Dockerfile.nextjs.tmpl @@ -10,7 +10,7 @@ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi COPY . . RUN mkdir -p public -{{if .BuildCmd}}RUN {{.BuildCmd}}{{else}}RUN npm run build{{end}} +RUN {{.BuildOrDefault}} RUN npm prune --omit=dev FROM node:20-alpine AS runtime @@ -28,4 +28,4 @@ EXPOSE {{.Port}} USER 1000:1000 -{{if .StartCmd}}CMD ["sh", "-c", {{printf "%q" .StartCmd}}]{{else}}CMD ["npx", "next", "start", "-p", "{{.Port}}"]{{end}} \ No newline at end of file +CMD ["sh", "-c", {{printf "%q" .StartOrDefault}}] \ No newline at end of file diff --git a/templates/docker/Dockerfile.python.tmpl b/templates/docker/Dockerfile.python.tmpl deleted file mode 100644 index a422a64..0000000 --- a/templates/docker/Dockerfile.python.tmpl +++ /dev/null @@ -1,37 +0,0 @@ -# Dockerfile for Python applications. -# Supports FastAPI, Django, Flask, and generic Python apps. -# Uses a virtualenv at /opt/venv on a slim base image so the runtime user -# (1000) can read installed packages without relying on /root/.local. -# Build and startup commands are resolved per SRS 4.2. - -FROM python:3.12-slim AS builder - -WORKDIR /app - -ENV VIRTUAL_ENV=/opt/venv -ENV PATH="$VIRTUAL_ENV/bin:$PATH" - -RUN python -m venv $VIRTUAL_ENV - -COPY requirements*.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -COPY . . - -{{if .BuildCmd}}RUN {{.BuildCmd}}{{end}} - -FROM python:3.12-slim AS runtime - -WORKDIR /app - -ENV VIRTUAL_ENV=/opt/venv -ENV PATH="$VIRTUAL_ENV/bin:$PATH" - -COPY --from=builder /opt/venv /opt/venv -COPY --from=builder /app /app - -EXPOSE {{.Port}} - -USER 1000:1000 - -{{if .StartCmd}}CMD ["sh", "-c", {{printf "%q" .StartCmd}}]{{else}}CMD ["python", "app.py"]{{end}} diff --git a/templates/docker/Dockerfile.vite.tmpl b/templates/docker/Dockerfile.vite.tmpl index db00a82..7d0f756 100644 --- a/templates/docker/Dockerfile.vite.tmpl +++ b/templates/docker/Dockerfile.vite.tmpl @@ -9,7 +9,7 @@ COPY package*.json ./ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi COPY . . -{{if .BuildCmd}}RUN {{.BuildCmd}}{{else}}RUN npm run build{{end}} +RUN {{.BuildOrDefault}} FROM node:20-alpine AS runtime @@ -23,4 +23,4 @@ EXPOSE {{.Port}} USER 1000:1000 -CMD ["node", "node_modules/.bin/serve", "-s", "dist", "-l", "{{.Port}}"] \ No newline at end of file +CMD ["sh", "-c", {{printf "%q" .StartOrDefault}}] \ No newline at end of file diff --git a/templates/github-actions/ci-workflow.yaml.tmpl b/templates/github-actions/ci-workflow.yaml.tmpl index 8b2f9e4..5c544a1 100644 --- a/templates/github-actions/ci-workflow.yaml.tmpl +++ b/templates/github-actions/ci-workflow.yaml.tmpl @@ -37,29 +37,21 @@ jobs: with: node-version: '20' - - name: Install dependencies - run: if [ -f package-lock.json ]; then npm ci; else npm install; fi -[[- end]] - -[[if eq .Stack "python"]] - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Install dependencies run: | - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - pip install pytest -[[end]] + if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; \ + elif [ -f yarn.lock ]; then yarn install --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + else npm install; fi +[[- end]] -[[if .TestCmd]] +[[- if .TestCmd]] - name: Test - run: [[.TestCmd]] -[[else]] + run: [[.TestCmdForWorkflow]] +[[- else]] - name: Test stage skipped run: echo "Test stage is not configured for this project. No test command was provided, so tests are intentionally not run." -[[end]] +[[- end]] - name: Build image run: |