diff --git a/.commitlintrc.yml b/.commitlintrc.yml new file mode 100644 index 0000000..8bc6ddb --- /dev/null +++ b/.commitlintrc.yml @@ -0,0 +1,5 @@ +extends: + - '@commitlint/config-conventional' + +rules: + header-max-length: [0, 'always', 100] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a5ba463 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +.idea +.scratch +dist +Dockerfile +.dockerignore +devctl +sx +devctl-skills diff --git a/.github/scripts/compute_release_version.py b/.github/scripts/compute_release_version.py new file mode 100644 index 0000000..a849f6f --- /dev/null +++ b/.github/scripts/compute_release_version.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Literal + +REPO_ROOT = Path(__file__).resolve().parents[2] +RC_IGNORE_TAGS = r"^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$" +RC_VERSION_RE = re.compile(r"^(\d+\.\d+\.\d+)-rc\.(\d+)$") +ReleaseKind = Literal["stable", "rc"] + + +def bumped_version(release_kind: ReleaseKind) -> str: + try: + raw_version = run_command(*build_git_cliff_args(release_kind)) + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or "").strip() + if "No releases found" in stderr: + return "0.1.0" + raise RuntimeError(stderr or "git-cliff failed while computing the next version.") from exc + + return raw_version + + +def build_git_cliff_args(release_kind: ReleaseKind) -> list[str]: + args = ["git-cliff"] + if release_kind == "stable": + args.extend(["--ignore-tags", RC_IGNORE_TAGS]) + args.append("--bumped-version") + return args + + +def build_git_cliff_context_args(release_kind: ReleaseKind) -> list[str]: + args = ["git-cliff", "--unreleased", "--bump", "--context"] + if release_kind == "stable": + args.extend(["--ignore-tags", RC_IGNORE_TAGS]) + return args + + +def run_command(*args: str) -> str: + completed = subprocess.run( + args, + cwd=REPO_ROOT, + check=True, + text=True, + capture_output=True, + ) + return completed.stdout.strip() + + +def normalize_version(raw_version: str) -> str: + version = raw_version.strip() + if version.startswith("v"): + version = version[1:] + return version + + +def compute_next_tag(raw_version: str, release_kind: ReleaseKind) -> str: + version = normalize_version(raw_version) + if not version: + raise RuntimeError("git-cliff did not return a version.") + + if release_kind == "stable": + if RC_VERSION_RE.fullmatch(version): + raise RuntimeError("git-cliff returned a prerelease version for a stable release.") + return f"v{version}" + + if RC_VERSION_RE.fullmatch(version): + return f"v{version}" + return f"v{version}-rc.1" + + +def release_commit_count(release_kind: ReleaseKind) -> int: + try: + raw_context = run_command(*build_git_cliff_context_args(release_kind)) + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or "").strip() + raise RuntimeError(stderr or "git-cliff failed while checking for new commits.") from exc + + try: + context = json.loads(raw_context) + except json.JSONDecodeError as exc: + raise RuntimeError("git-cliff returned invalid JSON while checking for new commits.") from exc + + if not context: + return 0 + + statistics = context[0].get("statistics", {}) + return int(statistics.get("commit_count", 0)) + + +def ensure_new_commits(release_kind: ReleaseKind) -> None: + if release_commit_count(release_kind) == 0: + raise RuntimeError("nothing to release") + + +def tag_exists(tag: str) -> bool: + completed = subprocess.run( + ["git", "ls-remote", "--exit-code", "--tags", "origin", f"refs/tags/{tag}"], + cwd=REPO_ROOT, + text=True, + capture_output=True, + ) + if completed.returncode == 0: + return True + if completed.returncode == 2: + return False + + stderr = (completed.stderr or completed.stdout or "").strip() + raise RuntimeError(stderr or f"git failed while checking whether {tag} exists in origin.") + + +def ensure_tag_absent(tag: str) -> None: + if tag_exists(tag): + raise RuntimeError(f"tag {tag} already exists in origin") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="compute_release_version.py", + description="Compute the next release tag for stable or rc workflows.", + ) + parser.add_argument( + "--release-kind", + required=True, + choices=("stable", "rc"), + help="Release line to compute the next tag for.", + ) + parser.add_argument( + "--require-new-commits", + action="store_true", + help="Fail if there are no unreleased commits for the selected release line.", + ) + parser.add_argument( + "--require-absent-tag", + action="store_true", + help="Fail if the computed release tag already exists in origin.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.require_new_commits: + ensure_new_commits(args.release_kind) + next_tag = compute_next_tag(bumped_version(args.release_kind), args.release_kind) + if args.require_absent_tag: + ensure_tag_absent(next_tag) + print(next_tag) + except (RuntimeError, subprocess.CalledProcessError) as exc: + print(str(exc), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/tests/__init__.py b/.github/scripts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/.github/scripts/tests/test_compute_release_version.py b/.github/scripts/tests/test_compute_release_version.py new file mode 100644 index 0000000..167696e --- /dev/null +++ b/.github/scripts/tests/test_compute_release_version.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import io +import subprocess +import sys +import unittest +from contextlib import redirect_stderr +from pathlib import Path +from unittest.mock import patch + + +SCRIPT_ROOT = Path(__file__).resolve().parents[1] +if str(SCRIPT_ROOT) not in sys.path: + sys.path.insert(0, str(SCRIPT_ROOT)) + +import compute_release_version as release_version + + +class ComputeReleaseVersionTests(unittest.TestCase): + def test_normalize_version_strips_leading_v(self) -> None: + self.assertEqual(release_version.normalize_version("v1.2.3"), "1.2.3") + self.assertEqual(release_version.normalize_version("1.2.3"), "1.2.3") + + def test_build_git_cliff_args_for_stable_adds_ignore_tags(self) -> None: + self.assertEqual( + release_version.build_git_cliff_args("stable"), + ["git-cliff", "--ignore-tags", release_version.RC_IGNORE_TAGS, "--bumped-version"], + ) + + def test_build_git_cliff_args_for_rc_omits_ignore_tags(self) -> None: + self.assertEqual(release_version.build_git_cliff_args("rc"), ["git-cliff", "--bumped-version"]) + + def test_build_git_cliff_context_args_for_stable_adds_ignore_tags(self) -> None: + self.assertEqual( + release_version.build_git_cliff_context_args("stable"), + [ + "git-cliff", + "--unreleased", + "--bump", + "--context", + "--ignore-tags", + release_version.RC_IGNORE_TAGS, + ], + ) + + def test_build_git_cliff_context_args_for_rc_omits_ignore_tags(self) -> None: + self.assertEqual( + release_version.build_git_cliff_context_args("rc"), + ["git-cliff", "--unreleased", "--bump", "--context"], + ) + + def test_bumped_version_returns_raw_git_cliff_output(self) -> None: + with patch.object(release_version, "run_command", return_value="v1.2.4"): + self.assertEqual(release_version.bumped_version("stable"), "v1.2.4") + + def test_bumped_version_falls_back_without_releases(self) -> None: + error = subprocess.CalledProcessError( + returncode=1, + cmd=("git-cliff", "--bumped-version"), + stderr="No releases found, using 0.1.0 as the next version.", + ) + + with patch.object(release_version, "run_command", side_effect=error): + self.assertEqual(release_version.bumped_version("stable"), "0.1.0") + self.assertEqual(release_version.bumped_version("rc"), "0.1.0") + + def test_release_commit_count_parses_json_context(self) -> None: + with patch.object( + release_version, + "run_command", + return_value='[{"statistics":{"commit_count":2}}]', + ): + self.assertEqual(release_version.release_commit_count("stable"), 2) + + def test_release_commit_count_handles_empty_context(self) -> None: + with patch.object(release_version, "run_command", return_value="[]"): + self.assertEqual(release_version.release_commit_count("rc"), 0) + + def test_ensure_new_commits_raises_for_empty_release(self) -> None: + with patch.object(release_version, "release_commit_count", return_value=0): + with self.assertRaisesRegex(RuntimeError, "nothing to release"): + release_version.ensure_new_commits("stable") + + def test_compute_next_tag_for_stable_prefixes_version(self) -> None: + self.assertEqual(release_version.compute_next_tag("v1.2.4", "stable"), "v1.2.4") + + def test_compute_next_tag_for_rc_keeps_existing_rc_suffix(self) -> None: + self.assertEqual(release_version.compute_next_tag("v1.2.4-rc.2", "rc"), "v1.2.4-rc.2") + + def test_compute_next_tag_for_rc_adds_first_suffix_for_plain_version(self) -> None: + self.assertEqual(release_version.compute_next_tag("v1.2.4", "rc"), "v1.2.4-rc.1") + + def test_compute_next_tag_for_stable_rejects_prerelease_version(self) -> None: + with self.assertRaisesRegex(RuntimeError, "prerelease version"): + release_version.compute_next_tag("v1.2.4-rc.2", "stable") + + def test_compute_next_tag_for_empty_version_rejects_empty_output(self) -> None: + with self.assertRaisesRegex(RuntimeError, "did not return a version"): + release_version.compute_next_tag(" ", "stable") + + def test_tag_exists_returns_true_when_origin_has_tag(self) -> None: + completed = subprocess.CompletedProcess(args=(), returncode=0, stdout="ref", stderr="") + with patch.object(release_version.subprocess, "run", return_value=completed): + self.assertTrue(release_version.tag_exists("v1.2.3")) + + def test_tag_exists_returns_false_when_origin_has_no_tag(self) -> None: + completed = subprocess.CompletedProcess(args=(), returncode=2, stdout="", stderr="") + with patch.object(release_version.subprocess, "run", return_value=completed): + self.assertFalse(release_version.tag_exists("v1.2.3")) + + def test_tag_exists_raises_for_git_errors(self) -> None: + completed = subprocess.CompletedProcess(args=(), returncode=128, stdout="", stderr="network failed") + with patch.object(release_version.subprocess, "run", return_value=completed): + with self.assertRaisesRegex(RuntimeError, "network failed"): + release_version.tag_exists("v1.2.3") + + def test_ensure_tag_absent_raises_for_existing_tag(self) -> None: + with patch.object(release_version, "tag_exists", return_value=True): + with self.assertRaisesRegex(RuntimeError, "tag v1.2.3 already exists in origin"): + release_version.ensure_tag_absent("v1.2.3") + + def test_main_fails_on_empty_release_when_required(self) -> None: + stderr = io.StringIO() + with ( + patch.object(release_version, "ensure_new_commits", side_effect=RuntimeError("nothing to release")), + patch.object(release_version, "bumped_version") as bumped_version, + redirect_stderr(stderr), + ): + self.assertEqual( + release_version.main(["--release-kind", "stable", "--require-new-commits"]), + 1, + ) + bumped_version.assert_not_called() + self.assertEqual(stderr.getvalue().strip(), "nothing to release") + + def test_main_fails_on_existing_tag_when_required(self) -> None: + stderr = io.StringIO() + with ( + patch.object(release_version, "bumped_version", return_value="v1.2.4"), + patch.object(release_version, "ensure_tag_absent", side_effect=RuntimeError("tag v1.2.4 already exists in origin")), + redirect_stderr(stderr), + ): + self.assertEqual( + release_version.main(["--release-kind", "stable", "--require-absent-tag"]), + 1, + ) + self.assertEqual(stderr.getvalue().strip(), "tag v1.2.4 already exists in origin") + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..40a5222 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,152 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + GOWORK: "off" + +jobs: + changes: + runs-on: ubuntu-latest + outputs: + quality_ci: ${{ steps.filter.outputs.quality_ci }} + docker_ci: ${{ steps.filter.outputs.docker_ci }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Detect changed paths + id: filter + uses: dorny/paths-filter@v4 + with: + filters: | + quality_ci: + - '**/*.go' + - 'go.mod' + - 'go.sum' + - '.golangci.yml' + - '.mise.toml' + - 'README.md' + - 'CONTEXT.md' + - 'docs/**' + - 'examples/**' + - 'e2e/**' + - 'internal/service/scaffold/templates/**' + - '.github/workflows/**' + - '.github/scripts/**' + docker_ci: + - 'go.mod' + - 'go.sum' + - 'Dockerfile' + - '.dockerignore' + - '.github/workflows/ci.yml' + - '.github/workflows/publish-image.yml' + + linux: + name: quality (Linux) + needs: changes + if: needs.changes.outputs.quality_ci == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + - name: Set up project tools + uses: jdx/mise-action@v4 + with: + cache: true + install: true + github_token: ${{ github.token }} + - name: Cache golangci-lint analysis + uses: actions/cache@v5 + with: + path: ~/.cache/golangci-lint + key: golangci-lint-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.mise.toml', '.golangci.yml', 'go.mod', 'go.sum') }} + restore-keys: | + golangci-lint-${{ runner.os }}-${{ runner.arch }}- + - name: Run full check + run: mise run check + + - name: Build macOS binaries + env: + CGO_ENABLED: "0" + GOOS: darwin + run: | + set -euo pipefail + for goarch in amd64 arm64; do + GOARCH="${goarch}" GOWORK=off go build \ + -trimpath \ + -o "${RUNNER_TEMP}/devctl-darwin-${goarch}" \ + ./cmd/devctl + done + + docker: + name: docker (linux/amd64) + needs: changes + if: needs.changes.outputs.docker_ci == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Build Docker image + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64 + load: true + push: false + tags: devctl:ci + build-args: | + VERSION=ci + COMMIT=${{ github.sha }} + cache-from: type=gha,scope=docker-amd64 + cache-to: type=gha,scope=docker-amd64,mode=max + provenance: false + - name: Verify image metadata + env: + EXPECTED_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + version_output="$(docker run --rm --platform linux/amd64 devctl:ci --version)" + grep -Fqx "devctl version ci" <<< "${version_output}" + grep -Fqx "commit: ${EXPECTED_COMMIT}" <<< "${version_output}" + - name: Verify project runner tools + run: | + set -euo pipefail + docker run --rm --platform linux/amd64 --entrypoint sh devctl:ci -ceu ' + test -x /devctl + command -v devctl + go version + git --version + node --version | grep -E "^v24\\." + quicktype --version + ' + + release-helper: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Run release helper tests + run: python3 -B -m unittest discover -s .github/scripts/tests -t .github/scripts diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..039a8b2 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,51 @@ +name: Commit checks + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: commitlint-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + commitlint: + if: >- + (github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]') + || (github.event_name == 'push' && github.event.head_commit.author.name != 'dependabot[bot]') + runs-on: ubuntu-latest + + steps: + - name: Enforce single-commit PRs + if: github.event_name == 'pull_request' + env: + PR_COMMIT_COUNT: ${{ github.event.pull_request.commits }} + run: test "$PR_COMMIT_COUNT" -eq 1 + + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Install commitlint + run: npm install --no-save --no-package-lock @commitlint/cli @commitlint/config-conventional + + - name: Validate PR title + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: printf '%s\n' "$PR_TITLE" | npx commitlint --verbose + + - name: Validate last commit + run: npx commitlint --last --verbose + diff --git a/.github/workflows/preview-release.yml b/.github/workflows/preview-release.yml new file mode 100644 index 0000000..9f6996c --- /dev/null +++ b/.github/workflows/preview-release.yml @@ -0,0 +1,68 @@ +name: preview-release + +on: + workflow_dispatch: + inputs: + release_kind: + description: "Release line to preview." + required: true + default: stable + type: choice + options: + - stable + - rc + +permissions: + contents: read + +jobs: + preview: + runs-on: ubuntu-latest + env: + RELEASE_KIND: ${{ inputs.release_kind }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 100 + fetch-tags: true + + - name: Install git-cliff + uses: taiki-e/install-action@git-cliff + + - name: Compute release tag + id: compute_release_version + shell: bash + run: | + set -euo pipefail + + next_tag="$(python3 .github/scripts/compute_release_version.py --release-kind "${RELEASE_KIND}")" + echo "next_tag=${next_tag}" >> "$GITHUB_OUTPUT" + + - name: Generate release preview + shell: bash + env: + NEXT_TAG: ${{ steps.compute_release_version.outputs.next_tag }} + run: | + set -euo pipefail + + ignore_rc_tags='^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' + notes_path="${RUNNER_TEMP}/release-notes.md" + if [[ "${RELEASE_KIND}" == "stable" ]]; then + git-cliff --ignore-tags "${ignore_rc_tags}" --unreleased --bump --tag "${NEXT_TAG}" --output "${notes_path}" + else + git-cliff --unreleased --bump --tag "${NEXT_TAG}" --output "${notes_path}" + fi + + cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_STEP_SUMMARY" + diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..7e7192c --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,315 @@ +name: publish-image + +on: + workflow_dispatch: + inputs: + release_tag: + description: "Published release tag to publish to GHCR." + required: true + type: string + +permissions: {} + +concurrency: + group: publish-image-${{ inputs.release_tag }} + cancel-in-progress: false + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + attestations: write + id-token: write + artifact-metadata: write + defaults: + run: + shell: bash + + steps: + - name: Validate published release + id: release + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ inputs.release_tag }} + WORKFLOW_REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + + if [[ "${WORKFLOW_REF_NAME}" != "${DEFAULT_BRANCH}" ]]; then + echo "run this workflow from the default branch: ${DEFAULT_BRANCH}" >&2 + exit 1 + fi + + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "unsupported release tag: ${RELEASE_TAG}" >&2 + exit 1 + fi + + release_json="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}")" + draft="$(jq -r '.draft' <<< "${release_json}")" + prerelease="$(jq -r '.prerelease' <<< "${release_json}")" + published_at="$(jq -r '.published_at' <<< "${release_json}")" + + if [[ "${draft}" != "false" || -z "${published_at}" || "${published_at}" == "null" ]]; then + echo "release ${RELEASE_TAG} is not published" >&2 + exit 1 + fi + + expected_prerelease=false + if [[ "${RELEASE_TAG}" =~ -rc\.[0-9]+$ ]]; then + expected_prerelease=true + fi + if [[ "${prerelease}" != "${expected_prerelease}" ]]; then + echo "release prerelease flag does not match tag ${RELEASE_TAG}" >&2 + exit 1 + fi + + echo "version=${RELEASE_TAG#v}" >> "${GITHUB_OUTPUT}" + echo "prerelease=${prerelease}" >> "${GITHUB_OUTPUT}" + + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ inputs.release_tag }} + fetch-depth: 1 + + - name: Validate release source + id: source + run: | + set -euo pipefail + + if ! grep -Eq '^ARG[[:space:]]+VERSION(=|[[:space:]]|$)' Dockerfile || \ + ! grep -Eq '^ARG[[:space:]]+COMMIT(=|[[:space:]]|$)' Dockerfile; then + echo "release Dockerfile does not support VERSION and COMMIT build arguments" >&2 + exit 1 + fi + + echo "commit=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate Docker metadata + id: meta + uses: docker/metadata-action@v6 + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: latest=false + tags: | + type=semver,pattern={{version}},value=${{ inputs.release_tag }} + type=raw,value=latest,enable=${{ steps.release.outputs.prerelease == 'false' }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.description=Manifest-driven Go project control plane + org.opencontainers.image.licenses=MIT + org.opencontainers.image.version=${{ steps.release.outputs.version }} + org.opencontainers.image.revision=${{ steps.source.outputs.commit }} + annotations: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.description=Manifest-driven Go project control plane + org.opencontainers.image.licenses=MIT + org.opencontainers.image.version=${{ steps.release.outputs.version }} + org.opencontainers.image.revision=${{ steps.source.outputs.commit }} + + - name: Inspect existing version tag + id: existing + env: + EXPECTED_COMMIT: ${{ steps.source.outputs.commit }} + EXPECTED_VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + + image="${REGISTRY}/${IMAGE_NAME}:${EXPECTED_VERSION}" + set +e + manifest_json="$(docker buildx imagetools inspect "${image}" --format '{{json .Manifest}}' 2>&1)" + inspect_status=$? + set -e + + if (( inspect_status != 0 )); then + if grep -Eiq 'manifest unknown|name unknown|not found|does not exist' <<< "${manifest_json}"; then + echo "exists=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + echo "${manifest_json}" >&2 + exit "${inspect_status}" + fi + + digest="$(jq -r '.digest // ""' <<< "${manifest_json}")" + actual_version="$(jq -r '.annotations["org.opencontainers.image.version"] // ""' <<< "${manifest_json}")" + actual_commit="$(jq -r '.annotations["org.opencontainers.image.revision"] // ""' <<< "${manifest_json}")" + + if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "existing image has an invalid digest: ${digest}" >&2 + exit 1 + fi + if [[ "${actual_version}" != "${EXPECTED_VERSION}" || "${actual_commit}" != "${EXPECTED_COMMIT}" ]]; then + echo "existing image metadata does not match release source" >&2 + exit 1 + fi + if ! jq -e 'any(.manifests[]?; .platform.os == "linux" and .platform.architecture == "amd64")' \ + <<< "${manifest_json}" >/dev/null; then + echo "existing image is missing linux/amd64" >&2 + exit 1 + fi + if ! jq -e 'any(.manifests[]?; .platform.os == "linux" and .platform.architecture == "arm64")' \ + <<< "${manifest_json}" >/dev/null; then + echo "existing image is missing linux/arm64" >&2 + exit 1 + fi + + echo "exists=true" >> "${GITHUB_OUTPUT}" + echo "digest=${digest}" >> "${GITHUB_OUTPUT}" + + - name: Build and push Docker image + if: steps.existing.outputs.exists != 'true' + id: push + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + build-args: | + VERSION=${{ steps.release.outputs.version }} + COMMIT=${{ steps.source.outputs.commit }} + cache-from: type=gha,scope=docker-release + cache-to: type=gha,scope=docker-release,mode=max + provenance: mode=max + + - name: Resolve image digest + id: image + env: + BUILT_DIGEST: ${{ steps.push.outputs.digest }} + EXISTING_DIGEST: ${{ steps.existing.outputs.digest }} + IMAGE_EXISTS: ${{ steps.existing.outputs.exists }} + run: | + set -euo pipefail + + if [[ "${IMAGE_EXISTS}" == "true" ]]; then + digest="${EXISTING_DIGEST}" + reused=true + else + digest="${BUILT_DIGEST}" + reused=false + fi + if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "image digest is invalid: ${digest}" >&2 + exit 1 + fi + + echo "digest=${digest}" >> "${GITHUB_OUTPUT}" + echo "reused=${reused}" >> "${GITHUB_OUTPUT}" + + - name: Update latest tag + if: steps.release.outputs.prerelease == 'false' && steps.image.outputs.reused == 'true' + env: + DIGEST: ${{ steps.image.outputs.digest }} + run: | + set -euo pipefail + + docker buildx imagetools create \ + --tag "${REGISTRY}/${IMAGE_NAME}:latest" \ + "${REGISTRY}/${IMAGE_NAME}@${DIGEST}" + + - name: Verify latest tag + if: steps.release.outputs.prerelease == 'false' + env: + EXPECTED_DIGEST: ${{ steps.image.outputs.digest }} + run: | + set -euo pipefail + + latest_manifest="$(docker buildx imagetools inspect \ + "${REGISTRY}/${IMAGE_NAME}:latest" --format '{{json .Manifest}}')" + latest_digest="$(jq -r '.digest // ""' <<< "${latest_manifest}")" + if [[ "${latest_digest}" != "${EXPECTED_DIGEST}" ]]; then + echo "latest points to ${latest_digest}, expected ${EXPECTED_DIGEST}" >&2 + exit 1 + fi + + - name: Verify published image + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.release.outputs.version }} + EXPECTED_VERSION: ${{ steps.release.outputs.version }} + EXPECTED_COMMIT: ${{ steps.source.outputs.commit }} + run: | + set -euo pipefail + + version_output="$(docker run --rm --pull always --platform linux/amd64 "${IMAGE}" --version)" + grep -Fqx "devctl version ${EXPECTED_VERSION}" <<< "${version_output}" + grep -Fqx "commit: ${EXPECTED_COMMIT}" <<< "${version_output}" + + - name: Check GitHub attestation + id: attestation + env: + DIGEST: ${{ steps.image.outputs.digest }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + count="$(gh api \ + "repos/${GITHUB_REPOSITORY}/attestations/${DIGEST}?predicate_type=provenance&per_page=1" \ + --jq '.attestations | length')" + if [[ "${count}" == "0" ]]; then + echo "exists=false" >> "${GITHUB_OUTPUT}" + else + echo "exists=true" >> "${GITHUB_OUTPUT}" + fi + + - name: Attest Docker image + if: steps.attestation.outputs.exists != 'true' + uses: actions/attest@v4 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.image.outputs.digest }} + push-to-registry: true + + - name: Verify GitHub attestation + env: + DIGEST: ${{ steps.image.outputs.digest }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + gh attestation verify \ + "oci://${REGISTRY}/${IMAGE_NAME}@${DIGEST}" \ + --repo "${GITHUB_REPOSITORY}" + + - name: Summarize Docker image + env: + DIGEST: ${{ steps.image.outputs.digest }} + REUSED: ${{ steps.image.outputs.reused }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + + { + echo "# Docker Image" + echo + echo "- Digest: \`${DIGEST}\`" + echo "- Existing digest reused: \`${REUSED}\`" + echo "- Tags:" + while IFS= read -r tag; do + echo " - \`${tag}\`" + done <<< "${TAGS}" + } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b1eb459 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,246 @@ +name: release + +on: + workflow_dispatch: + inputs: + release_kind: + description: "Release line to publish." + required: true + default: stable + type: choice + options: + - stable + - rc + +permissions: + contents: write + +concurrency: + group: release-pipeline + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + outputs: + next_tag: ${{ steps.compute_release_version.outputs.next_tag }} + release_name: ${{ steps.compute_release_version.outputs.release_name }} + prerelease: ${{ steps.release_flags.outputs.prerelease }} + make_latest: ${{ steps.release_flags.outputs.make_latest }} + env: + RELEASE_KIND: ${{ inputs.release_kind }} + defaults: + run: + shell: bash + steps: + - name: Checkout default branch + uses: actions/checkout@v6 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 100 + fetch-tags: true + - name: Install git-cliff + uses: taiki-e/install-action@git-cliff + - name: Compute release tag + id: compute_release_version + run: | + set -euo pipefail + next_tag="$( + python3 .github/scripts/compute_release_version.py \ + --release-kind "${RELEASE_KIND}" \ + --require-new-commits \ + --require-absent-tag + )" + echo "next_tag=${next_tag}" >> "$GITHUB_OUTPUT" + echo "release_name=${next_tag#v}" >> "$GITHUB_OUTPUT" + - name: Create annotated tag + env: + NEXT_TAG: ${{ steps.compute_release_version.outputs.next_tag }} + RELEASE_NAME: ${{ steps.compute_release_version.outputs.release_name }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${NEXT_TAG}" -m "Release ${RELEASE_NAME}" + git push origin "${NEXT_TAG}" + commit_sha="$(git rev-parse HEAD)" + cat >> "$GITHUB_STEP_SUMMARY" <> "$GITHUB_OUTPUT" + echo "make_latest=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + echo "make_latest=true" >> "$GITHUB_OUTPUT" + fi + + linux-artifacts: + name: Linux artifact + needs: release + runs-on: ubuntu-latest + container: golang:1.26-alpine + env: + GOARCH: amd64 + GOFLAGS: -buildvcs=false + GOWORK: off + NEXT_TAG: ${{ needs.release.outputs.next_tag }} + RELEASE_NAME: ${{ needs.release.outputs.release_name }} + defaults: + run: + shell: bash + steps: + - name: Install shell dependencies + shell: sh + run: apk add --no-cache bash ca-certificates git tar + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: ${{ needs.release.outputs.next_tag }} + fetch-depth: 1 + - name: Mark repository as safe for git + shell: sh + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Build Linux archive + id: package + run: | + set -euo pipefail + commit_sha="$(git rev-parse HEAD)" + archive="devctl_${RELEASE_NAME}_linux_${GOARCH}.tar.gz" + mkdir -p dist/linux + CGO_ENABLED=0 go build \ + -trimpath \ + -ldflags "-s -w -X main.version=${RELEASE_NAME} -X main.commit=${commit_sha}" \ + -o dist/linux/devctl ./cmd/devctl + cp README.md LICENSE dist/linux/ + tar -C dist/linux -czf "${archive}" devctl README.md LICENSE + echo "archive=${archive}" >> "$GITHUB_OUTPUT" + - name: Upload Linux archive + uses: actions/upload-artifact@v7 + with: + name: release-linux + path: ${{ steps.package.outputs.archive }} + retention-days: 1 + if-no-files-found: error + + macos-artifacts: + name: macOS artifacts (${{ matrix.goarch }}) + needs: release + runs-on: ${{ matrix.runner }} + env: + GOARCH: ${{ matrix.goarch }} + GOFLAGS: -buildvcs=false + GOWORK: off + NEXT_TAG: ${{ needs.release.outputs.next_tag }} + RELEASE_NAME: ${{ needs.release.outputs.release_name }} + strategy: + fail-fast: false + matrix: + include: + - runner: macos-26-intel + goarch: amd64 + - runner: macos-26 + goarch: arm64 + steps: + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: ${{ needs.release.outputs.next_tag }} + fetch-depth: 1 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + - name: Build macOS archive + id: package + run: | + set -euo pipefail + commit_sha="$(git rev-parse HEAD)" + archive="devctl_${RELEASE_NAME}_darwin_${GOARCH}.tar.gz" + mkdir -p dist/macos + CGO_ENABLED=0 go build \ + -trimpath \ + -ldflags "-s -w -X main.version=${RELEASE_NAME} -X main.commit=${commit_sha}" \ + -o dist/macos/devctl ./cmd/devctl + cp README.md LICENSE dist/macos/ + tar -C dist/macos -czf "${archive}" devctl README.md LICENSE + echo "archive=${archive}" >> "$GITHUB_OUTPUT" + - name: Upload macOS archive + uses: actions/upload-artifact@v7 + with: + name: release-macos-${{ matrix.goarch }} + path: ${{ steps.package.outputs.archive }} + retention-days: 1 + if-no-files-found: error + + publish-release: + name: Publish release + needs: + - release + - linux-artifacts + - macos-artifacts + runs-on: ubuntu-latest + env: + NEXT_TAG: ${{ needs.release.outputs.next_tag }} + RELEASE_NAME: ${{ needs.release.outputs.release_name }} + PRERELEASE: ${{ needs.release.outputs.prerelease }} + MAKE_LATEST: ${{ needs.release.outputs.make_latest }} + NOTES_PATH: ${{ github.workspace }}/release-notes.md + defaults: + run: + shell: bash + steps: + - name: Checkout release tag + uses: actions/checkout@v6 + with: + ref: ${{ needs.release.outputs.next_tag }} + fetch-depth: 100 + fetch-tags: true + - name: Install git-cliff + uses: taiki-e/install-action@git-cliff + - name: Generate release notes + run: | + set -euo pipefail + ignore_rc_tags='^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' + if [[ "${NEXT_TAG}" =~ -rc\.[0-9]+$ ]]; then + git-cliff --current --output "${NOTES_PATH}" + else + git-cliff --current --ignore-tags "${ignore_rc_tags}" --output "${NOTES_PATH}" + fi + { + echo "# Release Notes" + echo + cat "${NOTES_PATH}" + } >> "$GITHUB_STEP_SUMMARY" + - name: Download release artifacts + uses: actions/download-artifact@v8 + with: + pattern: release-* + path: release-assets + merge-multiple: true + - name: Publish GitHub Release + uses: softprops/action-gh-release@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ env.NEXT_TAG }} + name: ${{ env.RELEASE_NAME }} + body_path: ${{ env.NOTES_PATH }} + prerelease: ${{ env.PRERELEASE }} + make_latest: ${{ env.MAKE_LATEST }} + working_directory: release-assets + draft: true + files: | + *.tar.gz diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e7e200 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.devctl-tmp/ +.scratch/ +dist/ +.idea/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..8f9457e --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,79 @@ +version: "2" + +run: + relative-path-mode: gomod + tests: true + modules-download-mode: readonly + +linters: + default: none + enable: + - asasalint + - bidichk + - bodyclose + - containedctx + - contextcheck + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - fatcontext + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - gocognit + - govet + - inamedparam + - ineffassign + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - predeclared + - reassign + - recvcheck + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - testifylint + - thelper + - tparallel + - unconvert + - unused + - usetesting + - wastedassign + - wrapcheck + settings: + gocognit: + min-complexity: 20 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + paralleltest: + ignore-missing: false + ignore-missing-subtests: false + check-cleanup: true + revive: + rules: + - name: argument-limit + arguments: [4] + - name: function-result-limit + arguments: [3] + wrapcheck: + ignore-sig-regexps: + # ReportError already returns a delivery-owned terminal error. Wrapping it + # would make the error visible to urfave/cli and duplicate the log event. + - 'ErrorReporter\)\.ReportError\(' + +formatters: + enable: + - gofmt diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..3c8fa1d --- /dev/null +++ b/.mise.toml @@ -0,0 +1,42 @@ +[tools] +go = "1.26.0" +golangci-lint = "2.12.2" +node = "24" +"npm:quicktype" = "26.0.0" + +[tasks.fmt] +run = "golangci-lint fmt" + +[tasks."fmt:check"] +run = "golangci-lint fmt --diff" + +[tasks.lint] +run = "golangci-lint run" + +[tasks.test] +run = "GOWORK=off go test ./..." + +[tasks."test:race"] +run = "GOWORK=off go test -race ./..." + +[tasks.build] +run = "GOWORK=off go build -trimpath -o \"${TMPDIR:-/tmp}/devctl-check\" ./cmd/devctl" + +[tasks."mod:check"] +run = "GOWORK=off go mod tidy -diff" + +[tasks.e2e] +run = "GOWORK=off go test -tags=e2e ./e2e -v" + +[tasks.check] +depends = ["fmt:check", "lint", "test:race", "build", "mod:check", "docs:check", "example:orders:check", "e2e"] + +[tasks."docs:generate"] +run = "GOWORK=off go run ./cmd/devctl/internal/docgen" + +[tasks."docs:check"] +run = "GOWORK=off go run ./cmd/devctl/internal/docgen --check" + +[tasks."example:orders:check"] +dir = "examples/orders-api" +run = "GOWORK=off go test ./..." diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bad2f35 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +## Agent skills + +### Issue tracker + +Issues and specs are tracked as local Markdown files under `.scratch/`. See `docs/agents/issue-tracker.md`. + +### Domain docs + +This repo uses a single-context domain docs layout. See `docs/agents/domain.md`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..c73643b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,134 @@ +# Devctl ubiquitous language + +Devctl is a non-interactive control plane for defining, validating, and +materializing reproducible Go projects. These are the canonical terms used by +manifests, commands, diagnostics, and architecture documents. + +## Project model + +**Project**: +A repository rooted at a selected Manifest and managed through Devctl +workflows. + +**Manifest**: +The canonical desired-state document that names a Project and declares its +Components, Resources, paths, and generator policy. +_Avoid_: configuration file, when referring to the complete Project model + +**Component**: +An application-facing area such as HTTP, gRPC, Kafka, logging, or telemetry. +A Component can contain Capabilities and related Resources. + +**Capability**: +Enableable runtime behavior such as an HTTP server, health endpoint, or +telemetry. A Capability may have a Runtime Start Policy. + +**Resource**: +A named infrastructure dependency declared by a Project, such as a database, +Redis connection, S3 bucket, or client. + +**Connection**: +A named access configuration for an external system. A Connection can expose +one or more Variants. + +**Variant**: +One concrete backend form of a logical Resource, such as SQLite, PostgreSQL, +or ClickHouse for a database connection. + +## Effective project model + +**Target**: +A stable, addressable effective unit on which `sync`, `lint`, or `gen` can +operate, such as `http-client:payments`. A Target ID is its CLI address. + +**Target Catalog**: +The immutable, deterministically ordered projection of one Manifest into +effective Targets. It is the canonical source of Target identities, inputs, +outputs, references, and operation capabilities. + +**Logical Input**: +The Manifest-derived Contract selection recorded by a Target before external +state is materialized. + +**Resolved Input**: +The concrete Contract entrypoint or module root selected from valid committed +Snapshot Metadata. + +**Readiness**: +The Project state required to execute its declared toolchain, including local +files, native generator configs, module tools, and task declarations. + +## Contracts + +**Contract**: +A stable description of an API or event surface: OpenAPI, Proto, JSON Schema, +or a raw Kafka message convention. + +**Source**: +A named origin and containment root for Contracts. A Source is local, +URL-based, Git-based, or another Devctl Project. + +**Contract Reference**: +A Component's selection of a Contract from a Source, either by a relative +Entrypoint or by a named Export. + +**Export**: +A named Contract surface published by one Devctl Project for consumption by +another. + +**Contract Snapshot**: +An exact, self-contained Contract closure selected from a Source, including +its Entrypoint, Module Root, files, and Snapshot Metadata when applicable. + +**Entrypoint**: +The contained file through which a file-based Contract is interpreted. +_Avoid_: first file, main file + +**Module Root**: +The contained directory that defines a multi-file Contract module, especially +a Proto module. +_Avoid_: entrypoint, when the Contract is selected as a module + +**Snapshot Metadata**: +Committed, machine-readable facts required to interpret a Managed External +Contract without rediscovering its structure or contacting its supplier. + +**Managed External Contract**: +A Contract Snapshot published by `sync` into a Project-owned namespace and +committed for review. Offline workflows consume this committed state. + +## Ownership and runtime + +**Managed Output**: +A file or complete tree whose contents belong entirely to Devctl and may be +atomically replaced or pruned by its owning workflow. + +**Scaffold Seed**: +A file created once by scaffold and owned by the user afterwards, such as an +application entrypoint, provider binding, or handler. +_Avoid_: create-only artifact, in user-facing language + +**Provider Binding**: +User-owned composition code that selects concrete application behavior or +adapts generated infrastructure to application types. + +**Canonical DI Key**: +A stable, namespaced identifier for one named runtime dependency or runner. +_Avoid_: ad hoc string key + +**Scenario**: +An executable application mode that owns a composed dependency graph, its +selected runtime work, and orderly shutdown. + +**Runtime Config**: +The immutable, typed catalog of effective environment-backed settings derived +from the Manifest. Migration-only environment is not application Runtime +Config. + +**Runtime Start Policy**: +An optional environment-backed gate for a Capability. It distinguishes +construction of a runtime dependency from starting its background work. + +**Migration Target**: +The migration path and migration-only environment belonging to one SQLite, +PostgreSQL, or ClickHouse database Variant. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a9ef96 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.26-alpine AS builder + +ARG VERSION=dev +ARG COMMIT + +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 go build \ + -buildvcs=false \ + -trimpath \ + -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \ + -o /devctl ./cmd/devctl + +FROM node:24-alpine + +RUN apk add --no-cache ca-certificates git \ + && npm install --global --no-update-notifier --no-fund quicktype@26.0.0 \ + && npm cache clean --force + +COPY --from=builder /usr/local/go /usr/local/go +COPY --from=builder /devctl /devctl +RUN ln -s /devctl /usr/local/bin/devctl + +ENV PATH="/usr/local/go/bin:/go/bin:/usr/local/bin:${PATH}" + +ENTRYPOINT ["/devctl"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c45a44 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Devctl + +`devctl` is a non-interactive CLI for defining, validating, and materializing +reproducible Go projects. A checked-in `devctl.yaml` Manifest describes the +Project, its runtime Capabilities and Resources, its API and event Contracts, +and the project-owned tools used to generate code. + +Devctl keeps each workflow explicit: changing the Manifest does not install +tools or rewrite application code, and `sync`, `lint`, and `gen` never invoke +one another implicitly. + +## Requirements + +- Go 1.26 +- Git for `git` and `devctl` sources +- [Mise](https://mise.jdx.dev/) for scaffolded toolchains and quality tasks +- Docker for the optional local PostgreSQL walkthrough or the published Devctl image + +## Install + +Install the latest published Go module: + +```sh +go install github.com/devctllabs/devctl/cmd/devctl@latest +devctl --version +``` + +## Start a Project + +```sh +mkdir orders-api +cd orders-api + +devctl init manifest \ + --lang go \ + --preset http-service \ + --name orders-api \ + --module example.com/orders-api + +devctl add db primary --kind postgres +devctl init scaffold +mise install +go mod tidy +devctl validate +``` + +The published container image is available at +`ghcr.io/devctllabs/devctl`. Stable releases provide both a SemVer tag and +`latest`; release candidates provide only their version tag: + +```sh +docker run --rm ghcr.io/devctllabs/devctl:latest --version +``` + +The image includes Devctl, Go, Git, Node, and quicktype so a mounted Project +can use Devctl's `sync`, `lint`, and `gen` workflows. Project-declared Go tools +remain owned by that Project and are resolved from its `go.mod`. + +Continue with the [HTTP and PostgreSQL getting started +guide](docs/user-guide/getting-started.md) to define an Orders API, apply a +migration, implement the generated server interface, and make a real database +round trip. + +The normal Project lifecycle is: + +```text +init or mutate Manifest -> scaffold -> validate -> sync -> lint -> gen +``` + +Only run the steps required by a change. For example, a local server Contract +does not need `sync`, while a changed external Contract does. + +## Documentation + +- [User guide](docs/user-guide/README.md) +- [Command reference](docs/user-guide/reference/commands.md) +- [Manifest reference](docs/user-guide/reference/manifest/README.md) +- [Output and error contract](docs/user-guide/reference/output-and-errors.md) +- [Development guide](docs/development.md) + +Release artifacts are published as `devctl___.tar.gz` for +Linux amd64 and macOS amd64/arm64. Releases use stable and RC lines and are +created as draft GitHub Releases for manual publication. + +The documentation on `main` describes the current source tree. For a released +binary, read the documentation at the matching Git tag. + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..6e436d3 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,40 @@ +# https://git-cliff.org/docs/configuration + +[changelog] + +body = """ +{% for group, commits in commits | group_by(attribute="group") %} +## {{ group | striptags | trim }} + +{% for commit in commits -%} +- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} +{% endfor -%} + +{% endfor %} +""" + +trim = true + +[bump] +initial_tag = "v0.1.0" +breaking_always_bump_major = false + +[git] +conventional_commits = true +tag_pattern = "^v[0-9]+\.[0-9]+\.[0-9]+(?:-rc\.[0-9]+)?$" + +commit_parsers = [ + { message = "^feat", group = "New Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^perf", group = "Performance" }, + { message = "^ci", group = "CI" }, + { message = "^test", group = "Tests" }, + { message = "^(build|chore)", group = "Chores" }, + { message = ".*", group = "Other" }, +] + +filter_unconventional = true +sort_commits = "oldest" +topo_order = false diff --git a/cmd/devctl/internal/add/add.go b/cmd/devctl/internal/add/add.go new file mode 100644 index 0000000..73023ec --- /dev/null +++ b/cmd/devctl/internal/add/add.go @@ -0,0 +1,23 @@ +package add + +import "github.com/urfave/cli/v3" + +// NewCmd constructs the namespace for project resource additions. +func NewCmd() *cli.Command { + return &cli.Command{ + Name: "add", + Usage: "Add a named Project resource", + Description: "Add or update a named Source, client, Kafka endpoint, database Variant, Redis Connection, S3 Connection, or S3 bucket in the Manifest. This command changes only devctl.yaml.", + Commands: []*cli.Command{ + newDBCmd(dbCmdOpts{}, buildDB), + newSourceCmd(sourceCmdOpts{}, buildSource), + newHTTPClientCmd(httpClientCmdOpts{}, buildHTTPClient), + newGRPCClientCmd(grpcClientCmdOpts{}, buildGRPCClient), + newKafkaConsumerCmd(kafkaCmdOpts{}, buildKafka), + newKafkaProducerCmd(kafkaCmdOpts{}, buildKafka), + newRedisCmd(storageCmdOpts{}, buildStorage), + newS3ConnectionCmd(storageCmdOpts{}, buildStorage), + newS3Cmd(storageCmdOpts{}, buildStorage), + }, + } +} diff --git a/cmd/devctl/internal/add/add_test.go b/cmd/devctl/internal/add/add_test.go new file mode 100644 index 0000000..7b99e34 --- /dev/null +++ b/cmd/devctl/internal/add/add_test.go @@ -0,0 +1,44 @@ +package add + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAddSourceWritesManifestChangeJSON(t *testing.T) { + t.Parallel() + manifestPath := writeManifest(t) + command := newSourceCmd(sourceCmdOpts{}, buildSource) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{ + "source", "catalog", "--file", manifestPath, "--json", "--type", "local", "--path", "api/catalog.yaml", + }) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"project resource addition completed"`) + require.Contains(t, stdout.String(), `"command":"source"`) + require.Contains(t, stdout.String(), `"data":{"manifest":"`+manifestPath+`","change":"updated"}`) +} + +func writeManifest(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + return path +} diff --git a/cmd/devctl/internal/add/db.go b/cmd/devctl/internal/add/db.go new file mode 100644 index 0000000..5833830 --- /dev/null +++ b/cmd/devctl/internal/add/db.go @@ -0,0 +1,115 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/db.go -package mocks -typed . databaseAdder + +type databaseAdder interface { + // AddDB adds or updates one named database variant. + AddDB(ctx context.Context, command projectdomain.AddDBCommand) (projectdomain.ManifestResult, error) +} + +// dbRuntime contains the application port and cleanup hook used by Action. +type dbRuntime struct { + adder databaseAdder + shutdown func(context.Context) error +} + +// dbBuilder isolates dependency construction from database mutation behavior. +type dbBuilder func(context.Context, *zap.Logger) (dbRuntime, error) + +// dbCmd owns parsed database options and the runtime factory. +type dbCmd struct { + opts dbCmdOpts + buildRuntime dbBuilder +} + +// dbCmdOpts receives the positional name, common flags, and database settings. +type dbCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + Kind string + Default bool + NoMigrations bool + MigrationsPath string + Force bool +} + +// newDBCmd constructs the executable add db leaf. +func newDBCmd(opts dbCmdOpts, build dbBuilder) *cli.Command { + cmd := &dbCmd{opts: opts, buildRuntime: build} + noMigrationsFlag := &cli.BoolFlag{Name: "no-migrations", Usage: "do not declare a migration target for this Variant", Destination: &cmd.opts.NoMigrations} + migrationsPathFlag := &cli.StringFlag{Name: "migrations-path", Usage: "override the project-relative migration `path`", Destination: &cmd.opts.MigrationsPath} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "kind", Usage: "select `sqlite`, `postgres`, or `clickhouse`", Destination: &cmd.opts.Kind}, + &cli.BoolFlag{Name: "default", Usage: "make this Variant the Connection default", Destination: &cmd.opts.Default}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing Variant with the same identity", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: "db", + Usage: "Add a database variant", + Description: "Add a SQLite, PostgreSQL, or ClickHouse Variant to a named database Connection. A migration target is declared by default; Devctl never writes SQL or applies migrations.", + UsageText: "devctl add db --kind ", + UseShortOptionHandling: true, + Arguments: []cli.Argument{&cli.StringArg{ + Name: "database-name", UsageText: "", Destination: &cmd.opts.Name, + }}, + Flags: flags, + MutuallyExclusiveFlags: []cli.MutuallyExclusiveFlags{{Flags: [][]cli.Flag{{noMigrationsFlag}, {migrationsPathFlag}}}}, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action adds one named database variant to the selected manifest. +func (cmd *dbCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit("database name is required", 2)) + } + if cmd.opts.Kind == "" { + return reporter.ReportError(cli.Exit("--kind is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.adder.AddDB(ctx, projectdomain.AddDBCommand{ + ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Kind: cmd.opts.Kind, + Default: cmd.opts.Default, NoMigrations: cmd.opts.NoMigrations, + MigrationsPath: cmd.opts.MigrationsPath, Force: cmd.opts.Force, + }) + return finishManifestAddition(ctx, manifestAddition{ + stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, + result: result, operationErr: operationErr, + }) +} + +// buildDB constructs and resolves the lazy dependencies owned by add db. +func buildDB(ctx context.Context, logger *zap.Logger) (dbRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return dbRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return dbRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return dbRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/add/grpc_client.go b/cmd/devctl/internal/add/grpc_client.go new file mode 100644 index 0000000..3f3e0bc --- /dev/null +++ b/cmd/devctl/internal/add/grpc_client.go @@ -0,0 +1,112 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +type grpcClientAdder interface { + // AddGRPCClient adds or updates the gRPC client described by command. + AddGRPCClient(ctx context.Context, command projectdomain.AddGRPCClientCommand) (projectdomain.ManifestResult, error) +} + +type grpcClientRuntime struct { + adder grpcClientAdder + shutdown func(context.Context) error +} + +type grpcClientBuilder func(context.Context, *zap.Logger) (grpcClientRuntime, error) + +type grpcClientCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + Source string + Export string + Path string + ProtoRoot string + BufGenConfig string + AddrEnv string + Force bool +} + +type grpcClientCmd struct { + opts grpcClientCmdOpts + buildRuntime grpcClientBuilder +} + +func newGRPCClientCmd(opts grpcClientCmdOpts, build grpcClientBuilder) *cli.Command { + cmd := &grpcClientCmd{opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "source", Usage: "select the named contract `source`", Destination: &cmd.opts.Source}, + &cli.StringFlag{Name: "export", Usage: "select a named Export from a Devctl Source", Destination: &cmd.opts.Export}, + &cli.StringFlag{Name: "path", Usage: "select the Contract path from a non-Devctl Source", Destination: &cmd.opts.Path}, + &cli.StringFlag{Name: "proto-root", Usage: "set the Source-relative Proto `module-root`", Destination: &cmd.opts.ProtoRoot}, + &cli.StringFlag{Name: "buf-gen-config", Usage: "use the project-owned generator `config-path`", Destination: &cmd.opts.BufGenConfig}, + &cli.StringFlag{Name: "addr-env", Usage: "override the generated runtime address environment `key`", Destination: &cmd.opts.AddrEnv}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing gRPC client with the same name", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: "grpc-client", + Usage: "Add a gRPC client", + Description: "Declare a named Proto client Target. Use --path with ordinary Sources or --export with a Devctl Source; custom generator configs remain user-owned.", + UsageText: "devctl add grpc-client --source (--path | --export )", + Arguments: []cli.Argument{&cli.StringArg{ + Name: "grpc-client-name", UsageText: "", Destination: &cmd.opts.Name, + }}, + Flags: flags, + Action: cmd.Action, + } +} + +func (cmd *grpcClientCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit("gRPC client name is required", 2)) + } + if cmd.opts.Source == "" { + return reporter.ReportError(cli.Exit("--source is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.adder.AddGRPCClient(ctx, projectdomain.AddGRPCClientCommand{ + ManifestPath: cmd.opts.ManifestPath, + Name: cmd.opts.Name, + Source: cmd.opts.Source, + Export: cmd.opts.Export, + Path: cmd.opts.Path, + ProtoRoot: cmd.opts.ProtoRoot, + BufGenConfig: cmd.opts.BufGenConfig, + AddrEnv: cmd.opts.AddrEnv, + Force: cmd.opts.Force, + }) + return finishManifestAddition(ctx, manifestAddition{ + stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, + result: result, operationErr: operationErr, + }) +} + +func buildGRPCClient(ctx context.Context, logger *zap.Logger) (grpcClientRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return grpcClientRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + return grpcClientRuntime{}, errors.Join( + fmt.Errorf("container.ProjectService: %w", err), + commandruntime.Shutdown(ctx, container), + ) + } + return grpcClientRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/add/http_client.go b/cmd/devctl/internal/add/http_client.go new file mode 100644 index 0000000..c370679 --- /dev/null +++ b/cmd/devctl/internal/add/http_client.go @@ -0,0 +1,113 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/http_client.go -package mocks -typed . httpClientAdder + +type httpClientAdder interface { + // AddHTTPClient adds or updates one named generated HTTP client. + AddHTTPClient(ctx context.Context, command projectdomain.AddHTTPClientCommand) (projectdomain.ManifestResult, error) +} + +// httpClientRuntime contains the application port and cleanup hook used by Action. +type httpClientRuntime struct { + adder httpClientAdder + shutdown func(context.Context) error +} + +// httpClientBuilder isolates dependency construction from HTTP client mutation behavior. +type httpClientBuilder func(context.Context, *zap.Logger) (httpClientRuntime, error) + +// httpClientCmd owns parsed client options and the runtime factory. +type httpClientCmd struct { + opts httpClientCmdOpts + buildRuntime httpClientBuilder +} + +// httpClientCmdOpts receives the positional name, common flags, and contract selection. +type httpClientCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + Source string + Export string + Path string + BaseURLEnv string + Force bool +} + +// newHTTPClientCmd constructs the executable add http-client leaf. +func newHTTPClientCmd(opts httpClientCmdOpts, build httpClientBuilder) *cli.Command { + cmd := &httpClientCmd{opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "source", Usage: "select the named contract `source`", Destination: &cmd.opts.Source}, + &cli.StringFlag{Name: "export", Usage: "select a named Export from a Devctl Source", Destination: &cmd.opts.Export}, + &cli.StringFlag{Name: "path", Usage: "select an OpenAPI Entrypoint from a non-Devctl Source", Destination: &cmd.opts.Path}, + &cli.StringFlag{Name: "base-url-env", Usage: "override the generated runtime base URL environment `key`", Destination: &cmd.opts.BaseURLEnv}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing HTTP client with the same name", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: "http-client", + Usage: "Add an HTTP client", + Description: "Declare a named OpenAPI client Target. Use --path with ordinary Sources or --export with a Devctl Source.", + UsageText: "devctl add http-client --source (--path | --export )", + UseShortOptionHandling: true, + Arguments: []cli.Argument{&cli.StringArg{ + Name: "http-client-name", UsageText: "", Destination: &cmd.opts.Name, + }}, + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action adds one named HTTP client to the selected manifest. +func (cmd *httpClientCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit("HTTP client name is required", 2)) + } + if cmd.opts.Source == "" { + return reporter.ReportError(cli.Exit("--source is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.adder.AddHTTPClient(ctx, projectdomain.AddHTTPClientCommand{ + ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Source: cmd.opts.Source, + Export: cmd.opts.Export, Path: cmd.opts.Path, BaseURLEnv: cmd.opts.BaseURLEnv, Force: cmd.opts.Force, + }) + return finishManifestAddition(ctx, manifestAddition{ + stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, + result: result, operationErr: operationErr, + }) +} + +// buildHTTPClient constructs and resolves the lazy dependencies owned by add http-client. +func buildHTTPClient(ctx context.Context, logger *zap.Logger) (httpClientRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return httpClientRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return httpClientRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return httpClientRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/add/kafka.go b/cmd/devctl/internal/add/kafka.go new file mode 100644 index 0000000..d216e94 --- /dev/null +++ b/cmd/devctl/internal/add/kafka.go @@ -0,0 +1,150 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +type kafkaAdder interface { + // AddKafkaConsumer adds or updates the Kafka consumer described by command. + AddKafkaConsumer(ctx context.Context, command projectdomain.AddKafkaConsumerCommand) (projectdomain.ManifestResult, error) + // AddKafkaProducer adds or updates the Kafka producer described by command. + AddKafkaProducer(ctx context.Context, command projectdomain.AddKafkaProducerCommand) (projectdomain.ManifestResult, error) +} + +type kafkaRuntime struct { + adder kafkaAdder + shutdown func(context.Context) error +} + +type kafkaBuilder func(context.Context, *zap.Logger) (kafkaRuntime, error) + +type kafkaCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + Topic string + Source string + Export string + Path string + Format string + ProtoRoot string + Message string + Encoding string + GroupEnv string + TopicEnv string + Always bool + Force bool +} + +type kafkaCmd struct { + opts kafkaCmdOpts + consumer bool + buildRuntime kafkaBuilder +} + +func newKafkaConsumerCmd(opts kafkaCmdOpts, build kafkaBuilder) *cli.Command { + return newKafkaCmd("kafka-consumer", true, opts, build) +} +func newKafkaProducerCmd(opts kafkaCmdOpts, build kafkaBuilder) *cli.Command { + return newKafkaCmd("kafka-producer", false, opts, build) +} + +func newKafkaCmd(name string, consumer bool, opts kafkaCmdOpts, build kafkaBuilder) *cli.Command { + cmd := &kafkaCmd{opts: opts, consumer: consumer, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "topic", Usage: "set the Kafka `topic`", Destination: &cmd.opts.Topic}, + &cli.StringFlag{Name: "source", Usage: "select the named contract `source`", Destination: &cmd.opts.Source}, + &cli.StringFlag{Name: "export", Usage: "select a named Export from a Devctl Source", Destination: &cmd.opts.Export}, + &cli.StringFlag{Name: "path", Usage: "select the schema Entrypoint from a non-Devctl Source", Destination: &cmd.opts.Path}, + &cli.StringFlag{Name: "format", Usage: "select `raw`, `json`, or `proto`", Destination: &cmd.opts.Format}, + &cli.StringFlag{Name: "proto-root", Usage: "set the Source-relative Proto `module-root`", Destination: &cmd.opts.ProtoRoot}, + &cli.StringFlag{Name: "message", Usage: "select the fully-qualified Proto `message`", Destination: &cmd.opts.Message}, + &cli.StringFlag{Name: "encoding", Usage: "select Proto `binary` or `json` encoding", Destination: &cmd.opts.Encoding}, + ) + if consumer { + flags = append(flags, + &cli.StringFlag{Name: "group-env", Usage: "override the consumer group environment `key`", Destination: &cmd.opts.GroupEnv}, + &cli.BoolFlag{Name: "always", Usage: "omit the Runtime Start Policy so the consumer is always enabled", Destination: &cmd.opts.Always}, + ) + } else { + flags = append(flags, &cli.StringFlag{Name: "topic-env", Usage: "override the producer topic environment `key`", Destination: &cmd.opts.TopicEnv}) + } + flags = append(flags, &cli.BoolFlag{Name: "force", Usage: "replace an existing Kafka endpoint with the same name", Destination: &cmd.opts.Force}) + return &cli.Command{ + Name: name, + Usage: "Add a Kafka endpoint", + Description: "Declare a named Kafka endpoint and its raw, JSON Schema, or Proto Contract. Schema-backed endpoints use --path for ordinary Sources or --export for Devctl Sources.", + UsageText: "devctl add " + name + " <" + name + "-name> --topic --format [contract flags]", + Arguments: []cli.Argument{&cli.StringArg{ + Name: name + "-name", UsageText: "<" + name + "-name>", Destination: &cmd.opts.Name, + }}, + Flags: flags, + Action: cmd.Action, + } +} + +func (cmd *kafkaCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit("Kafka endpoint name is required", 2)) + } + if cmd.opts.Topic == "" { + return reporter.ReportError(cli.Exit("--topic is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := cmd.add(ctx, runtime.adder) + return finishManifestAddition(ctx, manifestAddition{stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, result: result, operationErr: operationErr}) +} + +func (cmd *kafkaCmd) add(ctx context.Context, adder kafkaAdder) (projectdomain.ManifestResult, error) { + if cmd.consumer { + result, err := adder.AddKafkaConsumer(ctx, projectdomain.AddKafkaConsumerCommand{ + ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Topic: cmd.opts.Topic, + Source: cmd.opts.Source, Export: cmd.opts.Export, Path: cmd.opts.Path, + Format: cmd.opts.Format, ProtoRoot: cmd.opts.ProtoRoot, Message: cmd.opts.Message, + Encoding: cmd.opts.Encoding, GroupEnv: cmd.opts.GroupEnv, + Always: cmd.opts.Always, Force: cmd.opts.Force, + }) + if err != nil { + return result, fmt.Errorf("adder.AddKafkaConsumer: %w", err) + } + return result, nil + } + result, err := adder.AddKafkaProducer(ctx, projectdomain.AddKafkaProducerCommand{ + ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Topic: cmd.opts.Topic, + Source: cmd.opts.Source, Export: cmd.opts.Export, Path: cmd.opts.Path, + Format: cmd.opts.Format, ProtoRoot: cmd.opts.ProtoRoot, Message: cmd.opts.Message, + Encoding: cmd.opts.Encoding, TopicEnv: cmd.opts.TopicEnv, Force: cmd.opts.Force, + }) + if err != nil { + return result, fmt.Errorf("adder.AddKafkaProducer: %w", err) + } + return result, nil +} + +func buildKafka(ctx context.Context, logger *zap.Logger) (kafkaRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return kafkaRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + return kafkaRuntime{}, errors.Join( + fmt.Errorf("container.ProjectService: %w", err), + commandruntime.Shutdown(ctx, container), + ) + } + return kafkaRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/add/mocks/db.go b/cmd/devctl/internal/add/mocks/db.go new file mode 100644 index 0000000..e6d4d2f --- /dev/null +++ b/cmd/devctl/internal/add/mocks/db.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/add (interfaces: databaseAdder) +// +// Generated by this command: +// +// mockgen -destination mocks/db.go -package mocks -typed . databaseAdder +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockdatabaseAdder is a mock of databaseAdder interface. +type MockdatabaseAdder struct { + ctrl *gomock.Controller + recorder *MockdatabaseAdderMockRecorder + isgomock struct{} +} + +// MockdatabaseAdderMockRecorder is the mock recorder for MockdatabaseAdder. +type MockdatabaseAdderMockRecorder struct { + mock *MockdatabaseAdder +} + +// NewMockdatabaseAdder creates a new mock instance. +func NewMockdatabaseAdder(ctrl *gomock.Controller) *MockdatabaseAdder { + mock := &MockdatabaseAdder{ctrl: ctrl} + mock.recorder = &MockdatabaseAdderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockdatabaseAdder) EXPECT() *MockdatabaseAdderMockRecorder { + return m.recorder +} + +// AddDB mocks base method. +func (m *MockdatabaseAdder) AddDB(ctx context.Context, command project.AddDBCommand) (project.ManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddDB", ctx, command) + ret0, _ := ret[0].(project.ManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddDB indicates an expected call of AddDB. +func (mr *MockdatabaseAdderMockRecorder) AddDB(ctx, command any) *MockdatabaseAdderAddDBCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddDB", reflect.TypeOf((*MockdatabaseAdder)(nil).AddDB), ctx, command) + return &MockdatabaseAdderAddDBCall{Call: call} +} + +// MockdatabaseAdderAddDBCall wrap *gomock.Call +type MockdatabaseAdderAddDBCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockdatabaseAdderAddDBCall) Return(arg0 project.ManifestResult, arg1 error) *MockdatabaseAdderAddDBCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockdatabaseAdderAddDBCall) Do(f func(context.Context, project.AddDBCommand) (project.ManifestResult, error)) *MockdatabaseAdderAddDBCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockdatabaseAdderAddDBCall) DoAndReturn(f func(context.Context, project.AddDBCommand) (project.ManifestResult, error)) *MockdatabaseAdderAddDBCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/add/mocks/http_client.go b/cmd/devctl/internal/add/mocks/http_client.go new file mode 100644 index 0000000..135d797 --- /dev/null +++ b/cmd/devctl/internal/add/mocks/http_client.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/add (interfaces: httpClientAdder) +// +// Generated by this command: +// +// mockgen -destination mocks/http_client.go -package mocks -typed . httpClientAdder +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockhttpClientAdder is a mock of httpClientAdder interface. +type MockhttpClientAdder struct { + ctrl *gomock.Controller + recorder *MockhttpClientAdderMockRecorder + isgomock struct{} +} + +// MockhttpClientAdderMockRecorder is the mock recorder for MockhttpClientAdder. +type MockhttpClientAdderMockRecorder struct { + mock *MockhttpClientAdder +} + +// NewMockhttpClientAdder creates a new mock instance. +func NewMockhttpClientAdder(ctrl *gomock.Controller) *MockhttpClientAdder { + mock := &MockhttpClientAdder{ctrl: ctrl} + mock.recorder = &MockhttpClientAdderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockhttpClientAdder) EXPECT() *MockhttpClientAdderMockRecorder { + return m.recorder +} + +// AddHTTPClient mocks base method. +func (m *MockhttpClientAdder) AddHTTPClient(ctx context.Context, command project.AddHTTPClientCommand) (project.ManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddHTTPClient", ctx, command) + ret0, _ := ret[0].(project.ManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddHTTPClient indicates an expected call of AddHTTPClient. +func (mr *MockhttpClientAdderMockRecorder) AddHTTPClient(ctx, command any) *MockhttpClientAdderAddHTTPClientCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddHTTPClient", reflect.TypeOf((*MockhttpClientAdder)(nil).AddHTTPClient), ctx, command) + return &MockhttpClientAdderAddHTTPClientCall{Call: call} +} + +// MockhttpClientAdderAddHTTPClientCall wrap *gomock.Call +type MockhttpClientAdderAddHTTPClientCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockhttpClientAdderAddHTTPClientCall) Return(arg0 project.ManifestResult, arg1 error) *MockhttpClientAdderAddHTTPClientCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockhttpClientAdderAddHTTPClientCall) Do(f func(context.Context, project.AddHTTPClientCommand) (project.ManifestResult, error)) *MockhttpClientAdderAddHTTPClientCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockhttpClientAdderAddHTTPClientCall) DoAndReturn(f func(context.Context, project.AddHTTPClientCommand) (project.ManifestResult, error)) *MockhttpClientAdderAddHTTPClientCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/add/mocks/source.go b/cmd/devctl/internal/add/mocks/source.go new file mode 100644 index 0000000..512ce26 --- /dev/null +++ b/cmd/devctl/internal/add/mocks/source.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/add (interfaces: sourceAdder) +// +// Generated by this command: +// +// mockgen -destination mocks/source.go -package mocks -typed . sourceAdder +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MocksourceAdder is a mock of sourceAdder interface. +type MocksourceAdder struct { + ctrl *gomock.Controller + recorder *MocksourceAdderMockRecorder + isgomock struct{} +} + +// MocksourceAdderMockRecorder is the mock recorder for MocksourceAdder. +type MocksourceAdderMockRecorder struct { + mock *MocksourceAdder +} + +// NewMocksourceAdder creates a new mock instance. +func NewMocksourceAdder(ctrl *gomock.Controller) *MocksourceAdder { + mock := &MocksourceAdder{ctrl: ctrl} + mock.recorder = &MocksourceAdderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MocksourceAdder) EXPECT() *MocksourceAdderMockRecorder { + return m.recorder +} + +// AddSource mocks base method. +func (m *MocksourceAdder) AddSource(ctx context.Context, command project.AddSourceCommand) (project.ManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AddSource", ctx, command) + ret0, _ := ret[0].(project.ManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AddSource indicates an expected call of AddSource. +func (mr *MocksourceAdderMockRecorder) AddSource(ctx, command any) *MocksourceAdderAddSourceCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddSource", reflect.TypeOf((*MocksourceAdder)(nil).AddSource), ctx, command) + return &MocksourceAdderAddSourceCall{Call: call} +} + +// MocksourceAdderAddSourceCall wrap *gomock.Call +type MocksourceAdderAddSourceCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksourceAdderAddSourceCall) Return(arg0 project.ManifestResult, arg1 error) *MocksourceAdderAddSourceCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksourceAdderAddSourceCall) Do(f func(context.Context, project.AddSourceCommand) (project.ManifestResult, error)) *MocksourceAdderAddSourceCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksourceAdderAddSourceCall) DoAndReturn(f func(context.Context, project.AddSourceCommand) (project.ManifestResult, error)) *MocksourceAdderAddSourceCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/add/result.go b/cmd/devctl/internal/add/result.go new file mode 100644 index 0000000..131deef --- /dev/null +++ b/cmd/devctl/internal/add/result.go @@ -0,0 +1,42 @@ +package add + +import ( + "context" + "errors" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/go-libs/lifecycle" + "go.uber.org/zap" +) + +// manifestResultDTO is the stable payload shared by manifest resource additions. +type manifestResultDTO struct { + Manifest string `json:"manifest"` + Change string `json:"change"` +} + +// manifestAddition carries one mutation outcome through the shared shutdown boundary. +type manifestAddition struct { + stdout *zap.Logger + reporter *commandruntime.ErrorReporter + shutdown func(context.Context) error + result projectdomain.ManifestResult + operationErr error +} + +// finishManifestAddition joins operation and shutdown outcomes before emitting one final event. +func finishManifestAddition(ctx context.Context, addition manifestAddition) error { + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, addition.shutdown) + dto := manifestResultDTO{Manifest: addition.result.Manifest, Change: string(addition.result.Change)} + var options []commandruntime.ErrorOption + if addition.result.Change != "" { + options = append(options, commandruntime.WithPartialResult(dto)) + } + if finalErr := errors.Join(addition.operationErr, shutdownErr); finalErr != nil { + return addition.reporter.ReportError(finalErr, options...) + } + addition.stdout.Info("project resource addition completed", zap.Any("data", dto)) + return nil +} diff --git a/cmd/devctl/internal/add/result_test.go b/cmd/devctl/internal/add/result_test.go new file mode 100644 index 0000000..0896cd7 --- /dev/null +++ b/cmd/devctl/internal/add/result_test.go @@ -0,0 +1,68 @@ +package add + +import ( + "context" + "errors" + "testing" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestFinishManifestAdditionReportsJoinedFailuresWithKnownDataOnly(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result projectdomain.ManifestResult + operationErr error + shutdownErr error + expectPartial bool + }{ + { + name: "operation and shutdown with known manifest", + result: projectdomain.ManifestResult{Manifest: "/project/devctl.yaml", Change: projectdomain.ChangeUpdated}, + operationErr: errors.New("operation failed"), shutdownErr: errors.New("shutdown failed"), + expectPartial: true, + }, + { + name: "operation with unknown manifest", + operationErr: errors.New("operation failed"), + expectPartial: false, + }, + { + name: "shutdown with unknown manifest", + shutdownErr: errors.New("shutdown failed"), + expectPartial: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + core, observed := observer.New(zap.DebugLevel) + reporter := commandruntime.NewErrorReporter(zap.New(core), false) + err := finishManifestAddition(context.Background(), manifestAddition{ + stdout: zap.NewNop(), reporter: reporter, result: test.result, + operationErr: test.operationErr, + shutdown: func(context.Context) error { return test.shutdownErr }, + }) + + if test.operationErr != nil { + require.ErrorIs(t, err, test.operationErr) + } + if test.shutdownErr != nil { + require.ErrorIs(t, err, test.shutdownErr) + } + require.Len(t, observed.All(), 1) + context := observed.All()[0].ContextMap() + require.NotContains(t, context, "data") + details, hasDetails := context["details"].(map[string]any) + _, hasPartial := details["partial_result"] + require.Equal(t, test.expectPartial, hasDetails && hasPartial) + }) + } +} diff --git a/cmd/devctl/internal/add/source.go b/cmd/devctl/internal/add/source.go new file mode 100644 index 0000000..ccff89b --- /dev/null +++ b/cmd/devctl/internal/add/source.go @@ -0,0 +1,122 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/source.go -package mocks -typed . sourceAdder + +type sourceAdder interface { + // AddSource adds or updates one named contract source. + AddSource(ctx context.Context, command projectdomain.AddSourceCommand) (projectdomain.ManifestResult, error) +} + +// sourceRuntime contains the application port and cleanup hook used by Action. +type sourceRuntime struct { + adder sourceAdder + shutdown func(context.Context) error +} + +// sourceBuilder isolates dependency construction from source mutation behavior. +type sourceBuilder func(context.Context, *zap.Logger) (sourceRuntime, error) + +// sourceCmd owns parsed source options and the runtime factory. +type sourceCmd struct { + opts sourceCmdOpts + buildRuntime sourceBuilder +} + +// sourceCmdOpts receives the positional name, common flags, and source location settings. +type sourceCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + Type string + Path string + URL string + Filename string + AllowInsecureHTTP bool + Repo string + Ref string + BufConfig string + Force bool +} + +// newSourceCmd constructs the executable add source leaf. +func newSourceCmd(opts sourceCmdOpts, build sourceBuilder) *cli.Command { + cmd := &sourceCmd{opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "type", Usage: "select `local`, `url`, `git`, or `devctl`", Destination: &cmd.opts.Type}, + &cli.StringFlag{Name: "path", Usage: "set the project-relative local or Git containment `path`", Destination: &cmd.opts.Path}, + &cli.StringFlag{Name: "url", Usage: "set the base `URL` for a URL Source", Destination: &cmd.opts.URL}, + &cli.StringFlag{Name: "filename", Usage: "store a single URL document under `filename`", Destination: &cmd.opts.Filename}, + &cli.BoolFlag{Name: "allow-insecure-http", Usage: "allow an http URL instead of requiring https", Destination: &cmd.opts.AllowInsecureHTTP}, + &cli.StringFlag{Name: "repo", Usage: "set the Git or Devctl repository `location`", Destination: &cmd.opts.Repo}, + &cli.StringFlag{Name: "ref", Usage: "select the immutable or reviewable repository `ref`", Destination: &cmd.opts.Ref}, + &cli.StringFlag{Name: "buf-config", Usage: "select the Source-relative supplier `buf-config`", Destination: &cmd.opts.BufConfig}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing Source with the same name", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: "source", + Usage: "Add a contract source", + Description: "Declare a bounded origin for Contracts. Type-specific flags select a local directory, URL closure, Git checkout, or another Devctl Project.", + UsageText: "devctl add source --type [type-specific flags]", + UseShortOptionHandling: true, + Arguments: []cli.Argument{&cli.StringArg{ + Name: "source-name", UsageText: "", Destination: &cmd.opts.Name, + }}, + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action adds one named contract source to the selected manifest. +func (cmd *sourceCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit("source name is required", 2)) + } + if cmd.opts.Type == "" { + return reporter.ReportError(cli.Exit("--type is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.adder.AddSource(ctx, projectdomain.AddSourceCommand{ + ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Type: cmd.opts.Type, + Path: cmd.opts.Path, URL: cmd.opts.URL, Filename: cmd.opts.Filename, + AllowInsecureHTTP: cmd.opts.AllowInsecureHTTP, Repo: cmd.opts.Repo, Ref: cmd.opts.Ref, BufConfig: cmd.opts.BufConfig, Force: cmd.opts.Force, + }) + return finishManifestAddition(ctx, manifestAddition{ + stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, + result: result, operationErr: operationErr, + }) +} + +// buildSource constructs and resolves the lazy dependencies owned by add source. +func buildSource(ctx context.Context, logger *zap.Logger) (sourceRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return sourceRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return sourceRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return sourceRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/add/storage.go b/cmd/devctl/internal/add/storage.go new file mode 100644 index 0000000..c8a7e70 --- /dev/null +++ b/cmd/devctl/internal/add/storage.go @@ -0,0 +1,146 @@ +package add + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +type storageAdder interface { + // AddRedis adds or updates the Redis connection described by command. + AddRedis(ctx context.Context, command projectdomain.AddRedisCommand) (projectdomain.ManifestResult, error) + // AddS3Connection adds or updates the S3 connection described by command. + AddS3Connection(ctx context.Context, command projectdomain.AddS3ConnectionCommand) (projectdomain.ManifestResult, error) + // AddS3 adds or updates the S3 bucket described by command. + AddS3(ctx context.Context, command projectdomain.AddS3Command) (projectdomain.ManifestResult, error) +} + +type storageRuntime struct { + adder storageAdder + shutdown func(context.Context) error +} + +type storageBuilder func(context.Context, *zap.Logger) (storageRuntime, error) + +type storageCmdOpts struct { + commandruntime.CommonCmdOpts + Name string + AddrEnv string + AddrDefault string + Connection string + Credentials string + Force bool +} + +type storageCmd struct { + kind string + opts storageCmdOpts + buildRuntime storageBuilder +} + +func newRedisCmd(opts storageCmdOpts, build storageBuilder) *cli.Command { + cmd := &storageCmd{kind: "redis", opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "addr-env", Usage: "override the generated Redis address environment `key`", Destination: &cmd.opts.AddrEnv}, + &cli.StringFlag{Name: "addr-default", Usage: "override the local Redis `address` default", Destination: &cmd.opts.AddrDefault}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing Redis Connection with the same name", Destination: &cmd.opts.Force}, + ) + return newStorageLeaf(cmd, flags) +} + +func newS3ConnectionCmd(opts storageCmdOpts, build storageBuilder) *cli.Command { + cmd := &storageCmd{kind: "s3-connection", opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "credentials", Usage: "select `ambient` or `static` credentials", Destination: &cmd.opts.Credentials}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing S3 Connection with the same name", Destination: &cmd.opts.Force}, + ) + return newStorageLeaf(cmd, flags) +} + +func newS3Cmd(opts storageCmdOpts, build storageBuilder) *cli.Command { + cmd := &storageCmd{kind: "s3", opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "connection", Usage: "attach the bucket to the named S3 `connection`", Destination: &cmd.opts.Connection}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing S3 bucket with the same name", Destination: &cmd.opts.Force}, + ) + return newStorageLeaf(cmd, flags) +} + +func newStorageLeaf(cmd *storageCmd, flags []cli.Flag) *cli.Command { + descriptions := map[string]string{ + "redis": "Declare a named Redis Connection with an environment-backed address and a local default.", + "s3-connection": "Declare a named S3 Connection and choose ambient or static credentials.", + "s3": "Declare a named S3 bucket attached to an existing Connection, or create the canonical local Connection when omitted.", + } + return &cli.Command{ + Name: cmd.kind, + Usage: "Add a " + cmd.kind + " resource", + Description: descriptions[cmd.kind], + UsageText: "devctl add " + cmd.kind + " <" + cmd.kind + "-name> [options]", + Arguments: []cli.Argument{&cli.StringArg{ + Name: cmd.kind + "-name", UsageText: "<" + cmd.kind + "-name>", Destination: &cmd.opts.Name, + }}, + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +func (cmd *storageCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if cmd.opts.Name == "" { + return reporter.ReportError(cli.Exit(cmd.kind+" name is required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := cmd.add(ctx, runtime.adder) + return finishManifestAddition(ctx, manifestAddition{ + stdout: stdout, reporter: reporter, shutdown: runtime.shutdown, + result: result, operationErr: operationErr, + }) +} + +func (cmd *storageCmd) add(ctx context.Context, adder storageAdder) (projectdomain.ManifestResult, error) { + var result projectdomain.ManifestResult + var err error + operation := "AddS3" + switch cmd.kind { + case "redis": + operation = "AddRedis" + result, err = adder.AddRedis(ctx, projectdomain.AddRedisCommand{ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, AddrEnv: cmd.opts.AddrEnv, AddrDefault: cmd.opts.AddrDefault, Force: cmd.opts.Force}) + case "s3-connection": + operation = "AddS3Connection" + result, err = adder.AddS3Connection(ctx, projectdomain.AddS3ConnectionCommand{ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Credentials: cmd.opts.Credentials, Force: cmd.opts.Force}) + default: + result, err = adder.AddS3(ctx, projectdomain.AddS3Command{ManifestPath: cmd.opts.ManifestPath, Name: cmd.opts.Name, Connection: cmd.opts.Connection, Force: cmd.opts.Force}) + } + if err != nil { + return result, fmt.Errorf("adder.%s: %w", operation, err) + } + return result, nil +} + +func buildStorage(ctx context.Context, logger *zap.Logger) (storageRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return storageRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + return storageRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), commandruntime.Shutdown(ctx, container)) + } + return storageRuntime{adder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/app/app.go b/cmd/devctl/internal/app/app.go new file mode 100644 index 0000000..baf2ba5 --- /dev/null +++ b/cmd/devctl/internal/app/app.go @@ -0,0 +1,47 @@ +// Package app constructs the complete devctl command tree. +package app + +import ( + "context" + + addcmd "github.com/devctllabs/devctl/cmd/devctl/internal/add" + enablecmd "github.com/devctllabs/devctl/cmd/devctl/internal/enable" + gencmd "github.com/devctllabs/devctl/cmd/devctl/internal/gen" + initcmd "github.com/devctllabs/devctl/cmd/devctl/internal/init" + inspectcmd "github.com/devctllabs/devctl/cmd/devctl/internal/inspect" + lintcmd "github.com/devctllabs/devctl/cmd/devctl/internal/lint" + synccmd "github.com/devctllabs/devctl/cmd/devctl/internal/sync" + validatecmd "github.com/devctllabs/devctl/cmd/devctl/internal/validate" + "github.com/urfave/cli/v3" +) + +// New constructs the root command shared by the executable and documentation generator. +func New(releaseVersion, releaseCommit string) *cli.Command { + return &cli.Command{ + Name: "devctl", + Usage: "Manage Devctl Go projects", + Description: "Devctl defines, validates, and materializes reproducible Go projects from a devctl.yaml manifest. Commands are non-interactive and keep manifest mutation, synchronization, linting, scaffolding, and generation explicit.", + Version: buildVersion(releaseVersion, releaseCommit), + Commands: []*cli.Command{ + initcmd.NewCmd(), + validatecmd.NewCmd(), + inspectcmd.NewCmd(), + enablecmd.NewCmd(), + addcmd.NewCmd(), + synccmd.NewCmd(), + gencmd.NewCmd(), + lintcmd.NewCmd(), + }, + ExitErrHandler: func(context.Context, *cli.Command, error) {}, + OnUsageError: func(_ context.Context, _ *cli.Command, err error, _ bool) error { + return cli.Exit(err, 2) + }, + } +} + +func buildVersion(releaseVersion, releaseCommit string) string { + if releaseCommit == "" { + return releaseVersion + } + return releaseVersion + "\ncommit: " + releaseCommit +} diff --git a/cmd/devctl/internal/app/app_test.go b/cmd/devctl/internal/app/app_test.go new file mode 100644 index 0000000..9e4022b --- /dev/null +++ b/cmd/devctl/internal/app/app_test.go @@ -0,0 +1,37 @@ +package app_test + +import ( + "testing" + + devctlapp "github.com/devctllabs/devctl/cmd/devctl/internal/app" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestCommandTreeHasDocumentationMetadata(t *testing.T) { + t.Parallel() + + root := devctlapp.New("test", "") + walkCommands(t, root, func(command *cli.Command) { + require.NotEmpty(t, command.Usage, "%s has no usage", command.Path()) + require.NotEmpty(t, command.Description, "%s has no description", command.Path()) + + for _, flag := range command.VisibleFlags() { + documented, ok := flag.(cli.DocGenerationFlag) + require.True(t, ok, "%s flag %v cannot be documented", command.Path(), flag.Names()) + require.NotEmpty(t, documented.GetUsage(), "%s flag %v has no usage", command.Path(), flag.Names()) + } + + for _, argument := range command.Arguments { + require.NotEmpty(t, argument.Usage(), "%s has an undocumented argument", command.Path()) + } + }) +} + +func walkCommands(t *testing.T, command *cli.Command, check func(*cli.Command)) { + t.Helper() + check(command) + for _, child := range command.Commands { + walkCommands(t, child, check) + } +} diff --git a/cmd/devctl/internal/command/options.go b/cmd/devctl/internal/command/options.go new file mode 100644 index 0000000..b4499ee --- /dev/null +++ b/cmd/devctl/internal/command/options.go @@ -0,0 +1,79 @@ +package command + +import ( + "io" + "os" + "strings" + + loglib "github.com/devctllabs/go-libs/log" + "github.com/urfave/cli/v3" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// CommonCmdOpts contains the delivery options shared by every executable leaf. +type CommonCmdOpts struct { + // ManifestPath selects a manifest explicitly; an empty value enables project discovery. + ManifestPath string + // JSON selects JSON events instead of the default console encoding. + JSON bool + // Verbose enables debug diagnostics and raw error causes. + Verbose bool +} + +// CommonFlags binds shared flags directly to the owning leaf's options. +func (o *CommonCmdOpts) CommonFlags() []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{Name: "file", Usage: "use `path` as the Manifest instead of discovering devctl.yaml", Destination: &o.ManifestPath}, + &cli.BoolFlag{Name: "json", Usage: "emit compact JSONL events instead of text", Destination: &o.JSON}, + &cli.BoolFlag{Name: "verbose", Usage: "include debug diagnostics and raw causes on stderr", Destination: &o.Verbose}, + } +} + +// NewStdoutLogger creates the leaf result logger using the selected encoding. +func (o *CommonCmdOpts) NewStdoutLogger(command *cli.Command) *zap.Logger { + return o.newLogger(command, outputWriter(command), zapcore.InfoLevel) +} + +// NewStderrLogger creates the leaf diagnostic logger using the selected encoding and verbosity. +func (o *CommonCmdOpts) NewStderrLogger(command *cli.Command) *zap.Logger { + level := zapcore.WarnLevel + if o.Verbose { + level = zapcore.DebugLevel + } + return o.newLogger(command, errorWriter(command), level) +} + +func (o *CommonCmdOpts) newLogger(command *cli.Command, writer io.Writer, level zapcore.Level) *zap.Logger { + encoding := loglib.EncodingConsole + if o.JSON { + encoding = loglib.EncodingJSON + } + logger := loglib.New(level, false, loglib.WithEncoding(encoding), loglib.WithOutput(writer)) + if name := leafCommandName(command); name != "" { + logger = logger.With(zap.String("command", name)) + } + return logger +} + +func leafCommandName(command *cli.Command) string { + path := command.Path() + if len(path) > 0 && path[0] == "devctl" { + path = path[1:] + } + return strings.Join(path, " ") +} + +func outputWriter(command *cli.Command) io.Writer { + if command.Writer != nil { + return command.Writer + } + return os.Stdout +} + +func errorWriter(command *cli.Command) io.Writer { + if command.ErrWriter != nil { + return command.ErrWriter + } + return os.Stderr +} diff --git a/cmd/devctl/internal/command/options_test.go b/cmd/devctl/internal/command/options_test.go new file mode 100644 index 0000000..78edd19 --- /dev/null +++ b/cmd/devctl/internal/command/options_test.go @@ -0,0 +1,52 @@ +package command + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +func TestCommonCmdOptsBuildsCommandLoggers(t *testing.T) { + t.Parallel() + + var opts CommonCmdOpts + var stdout bytes.Buffer + var stderr bytes.Buffer + leaf := &cli.Command{ + Name: "validate", + Flags: opts.CommonFlags(), + Action: func(_ context.Context, command *cli.Command) error { + opts.NewStdoutLogger(command).Info("completed", zap.String("stream", "stdout")) + opts.NewStderrLogger(command).Debug("diagnostic", zap.String("stream", "stderr")) + return nil + }, + } + root := &cli.Command{Name: "devctl", Commands: []*cli.Command{leaf}, Writer: &stdout, ErrWriter: &stderr} + + require.NoError(t, root.Run(context.Background(), []string{"devctl", "validate", "--json", "--verbose", "--file", "custom.yaml"})) + require.Equal(t, "custom.yaml", opts.ManifestPath) + require.True(t, opts.JSON) + require.True(t, opts.Verbose) + requireJSONLog(t, stdout.Bytes(), logExpectation{level: "info", command: "validate", message: "completed"}) + requireJSONLog(t, stderr.Bytes(), logExpectation{level: "debug", command: "validate", message: "diagnostic"}) +} + +type logExpectation struct { + level string + command string + message string +} + +func requireJSONLog(t *testing.T, data []byte, expected logExpectation) { + t.Helper() + var event map[string]any + require.NoError(t, json.Unmarshal(data, &event)) + require.Equal(t, expected.level, event["level"]) + require.Equal(t, expected.command, event["command"]) + require.Equal(t, expected.message, event["msg"]) +} diff --git a/cmd/devctl/internal/command/reporter.go b/cmd/devctl/internal/command/reporter.go new file mode 100644 index 0000000..9736458 --- /dev/null +++ b/cmd/devctl/internal/command/reporter.go @@ -0,0 +1,150 @@ +package command + +import ( + "context" + "errors" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +const urfaveHelpExitCode = 3 + +// ErrorOption adds caller-approved facts to an error event. +type ErrorOption func(*errorReport) + +// WithPartialResult attaches caller-approved completed work to recovery details. +func WithPartialResult(result any) ErrorOption { + return func(report *errorReport) { + report.addDetail("partial_result", result) + } +} + +// ErrorReporter owns safe CLI error presentation and process exit classification. +type ErrorReporter struct { + logger *zap.Logger + verbose bool +} + +// NewErrorReporter creates a reporter that writes final errors through logger. +func NewErrorReporter(logger *zap.Logger, verbose bool) *ErrorReporter { + return &ErrorReporter{logger: logger, verbose: verbose} +} + +// ReportError writes one final safe error event and returns a silent error with the same cause. +func (r *ErrorReporter) ReportError(err error, options ...ErrorOption) error { + report := classifyError(err) + for _, option := range options { + if option != nil { + option(&report) + } + } + if err.Error() != "" { + fields := []zap.Field{zap.String("code", report.code), zap.Int("exit_code", report.exitCode)} + if len(report.details) > 0 { + fields = append(fields, zap.Any("details", report.details)) + } + if r.verbose { + fields = append(fields, zap.Error(err)) + } + r.logger.Error(report.message, fields...) + } + return &reportedError{cause: err, exitCode: report.exitCode} +} + +type errorReport struct { + message string + code string + exitCode int + details map[string]any +} + +func (r *errorReport) addDetail(key string, value any) { + if r.details == nil { + r.details = make(map[string]any) + } + r.details[key] = value +} + +// classifyError maps domain and CLI errors into one safe presentation record. +func classifyError(err error) errorReport { + exitCode := errorExitCode(err) + if exitCode == 2 { + return errorReport{message: err.Error(), code: "usage", exitCode: exitCode} + } + if exitCode == 130 { + return errorReport{message: "operation was cancelled", code: "cancelled", exitCode: exitCode} + } + + code, message := executionErrorMessage(err) + report := errorReport{message: message, code: code, exitCode: exitCode} + var invalidManifest *projectdomain.InvalidManifestError + if errors.As(err, &invalidManifest) { + report.addDetail("path", invalidManifest.Path) + report.addDetail("issues", ValidationIssueDTOs(invalidManifest.Issues)) + } + var metadataErr *contract.SnapshotMetadataError + if errors.As(err, &metadataErr) { + report.addDetail("type", "snapshot_metadata_invalid") + report.addDetail("field", metadataErr.Field) + report.addDetail("reason", string(metadataErr.Reason)) + report.addDetail("hint", metadataErr.Hint) + } + return report +} + +// errorExitCode normalizes framework and cancellation errors to the public CLI contract. +func errorExitCode(err error) int { + if errors.Is(err, context.Canceled) { + return 130 + } + var exitCoder cli.ExitCoder + if !errors.As(err, &exitCoder) { + return 1 + } + if exitCoder.ExitCode() == urfaveHelpExitCode { + return 2 + } + return exitCoder.ExitCode() +} + +// ExitCode maps a command error to the process status used by devctl. +func ExitCode(err error) int { + return errorExitCode(err) +} + +// executionErrorMessage maps domain failure categories to stable public codes and messages. +func executionErrorMessage(err error) (string, string) { + switch failure.CategoryOf(err) { + case failure.Cancelled: + return "cancelled", "operation was cancelled" + case failure.Unavailable: + return "unavailable", "required dependency is unavailable" + case failure.InvalidInput: + return "invalid_input", "input is invalid" + case failure.NotFound: + return "not_found", "requested resource was not found" + case failure.Conflict: + return "conflict", "operation conflicts with existing state" + case failure.Unsupported: + return "unsupported", "requested operation is unsupported" + case failure.Internal: + return "internal", "internal error" + } + return "internal", "internal error" +} + +// reportedError suppresses framework rendering while retaining cause and process status. +type reportedError struct { + cause error + exitCode int +} + +func (e *reportedError) Error() string { return "" } + +func (e *reportedError) Unwrap() error { return e.cause } + +func (e *reportedError) ExitCode() int { return e.exitCode } diff --git a/cmd/devctl/internal/command/reporter_test.go b/cmd/devctl/internal/command/reporter_test.go new file mode 100644 index 0000000..ebfba4b --- /dev/null +++ b/cmd/devctl/internal/command/reporter_test.go @@ -0,0 +1,99 @@ +package command + +import ( + "bytes" + "encoding/json" + "io/fs" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func TestErrorReporterWritesSafeEventAndReturnsSilentCause(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + command := &cli.Command{Name: "validate", ErrWriter: &stderr} + opts := CommonCmdOpts{JSON: true} + logger := opts.NewStderrLogger(command) + reporter := NewErrorReporter(logger, false) + cause := &projectdomain.OperationError{ + Operation: projectdomain.OperationLoadManifest, + Path: "/private/project/devctl.yaml", + Kind: projectdomain.FailureNotFound, + Cause: fs.ErrNotExist, + } + + err := reporter.ReportError(cause, WithPartialResult(map[string]int{"completed": 2})) + + var exitCoder cli.ExitCoder + require.ErrorAs(t, err, &exitCoder) + require.Equal(t, 1, exitCoder.ExitCode()) + require.Empty(t, err.Error()) + require.ErrorIs(t, err, fs.ErrNotExist) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.Equal(t, "requested resource was not found", event["msg"]) + require.Equal(t, "not_found", event["code"]) + require.EqualValues(t, 1, event["exit_code"]) + require.NotContains(t, event, "data") + require.Equal(t, map[string]any{ + "partial_result": map[string]any{"completed": float64(2)}, + }, event["details"]) + require.NotContains(t, event, "error") + require.NotContains(t, stderr.String(), cause.Path) +} + +func TestErrorReporterExposesSafeSnapshotRefreshDetails(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + command := &cli.Command{Name: "gen", ErrWriter: &stderr} + opts := CommonCmdOpts{JSON: true} + logger := opts.NewStderrLogger(command) + reporter := NewErrorReporter(logger, false) + cause := &contract.SnapshotMetadataError{ + Field: "entrypoint", Reason: contract.MetadataRequired, Hint: "devctl sync", + } + + err := reporter.ReportError(cause, WithPartialResult(map[string]int{"completed": 1})) + + require.Error(t, err) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.Equal(t, "invalid_input", event["code"]) + require.Equal(t, map[string]any{ + "type": "snapshot_metadata_invalid", "field": "entrypoint", + "reason": "required", "hint": "devctl sync", + "partial_result": map[string]any{"completed": float64(1)}, + }, event["details"]) + require.NotContains(t, event, "data") +} + +func TestErrorReporterMergesManifestIssuesWithPartialResult(t *testing.T) { + t.Parallel() + + var stderr bytes.Buffer + command := &cli.Command{Name: "sync", ErrWriter: &stderr} + opts := CommonCmdOpts{JSON: true} + reporter := NewErrorReporter(opts.NewStderrLogger(command), false) + cause := &projectdomain.InvalidManifestError{ + Path: "devctl.yaml", + Issues: []projectdomain.Issue{{Code: projectdomain.IssueCode("source_not_found"), Field: "sources.catalog"}}, + } + + err := reporter.ReportError(cause, WithPartialResult(map[string]int{"completed": 1})) + + require.Error(t, err) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, "devctl.yaml", details["path"]) + require.Equal(t, map[string]any{"completed": float64(1)}, details["partial_result"]) + require.Equal(t, []any{map[string]any{"code": "source_not_found", "field": "sources.catalog"}}, details["issues"]) +} diff --git a/cmd/devctl/internal/command/runtime.go b/cmd/devctl/internal/command/runtime.go new file mode 100644 index 0000000..661bf60 --- /dev/null +++ b/cmd/devctl/internal/command/runtime.go @@ -0,0 +1,20 @@ +package command + +import ( + "context" + "fmt" + "time" + + "github.com/devctllabs/devctl/internal/deps" + "github.com/devctllabs/go-libs/lifecycle" +) + +const shutdownTimeout = 5 * time.Second + +// Shutdown closes resources with the common fresh bounded context convention. +func Shutdown(ctx context.Context, container *deps.Container) error { + if err := lifecycle.Shutdown(ctx, shutdownTimeout, container.Shutdown); err != nil { + return fmt.Errorf("lifecycle.Shutdown: %w", err) + } + return nil +} diff --git a/cmd/devctl/internal/command/validation_issue.go b/cmd/devctl/internal/command/validation_issue.go new file mode 100644 index 0000000..126c89b --- /dev/null +++ b/cmd/devctl/internal/command/validation_issue.go @@ -0,0 +1,45 @@ +package command + +import projectdomain "github.com/devctllabs/devctl/internal/domain/project" + +// ValidationIssueDTO is the safe CLI representation of one project validation issue. +type ValidationIssueDTO struct { + // Code identifies the stable validation rule that failed. + Code string `json:"code"` + // Path identifies the affected project file when available. + Path string `json:"path,omitempty"` + // Line and Column are one-based source coordinates; zero means unavailable. + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` + // Field identifies the affected manifest field when available. + Field string `json:"field,omitempty"` + // Parameters contains rule-specific, presentation-safe facts. + Parameters *ValidationParametersDTO `json:"parameters,omitempty"` +} + +// ValidationParametersDTO carries issue-specific facts safe for CLI output. +type ValidationParametersDTO struct { + Expected string `json:"expected,omitempty"` + Actual string `json:"actual,omitempty"` + Value string `json:"value,omitempty"` +} + +// ValidationIssueDTOs maps presentation-neutral issues to their shared CLI form. +func ValidationIssueDTOs(values []projectdomain.Issue) []ValidationIssueDTO { + issues := make([]ValidationIssueDTO, 0, len(values)) + for _, issue := range values { + var parameters *ValidationParametersDTO + if issue.Parameters != nil { + parameters = &ValidationParametersDTO{ + Expected: issue.Parameters.Expected, + Actual: issue.Parameters.Actual, + Value: issue.Parameters.Value, + } + } + issues = append(issues, ValidationIssueDTO{ + Code: string(issue.Code), Path: issue.Path, Line: issue.Line, Column: issue.Column, + Field: issue.Field, Parameters: parameters, + }) + } + return issues +} diff --git a/cmd/devctl/internal/docgen/main.go b/cmd/devctl/internal/docgen/main.go new file mode 100644 index 0000000..e47bdf8 --- /dev/null +++ b/cmd/devctl/internal/docgen/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "io/fs" + "os" + + devctlapp "github.com/devctllabs/devctl/cmd/devctl/internal/app" + devfs "github.com/devctllabs/go-libs/filesystem" + docs "github.com/urfave/cli-docs/v3" +) + +const referencePath = "docs/user-guide/reference/commands.md" + +func main() { + check := flag.Bool("check", false, "verify that the committed command reference is current") + flag.Parse() + + content, err := renderReference() + if err == nil { + if *check { + err = checkReference(".", referencePath, content) + } else { + err = writeReference(".", referencePath, content) + } + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func renderReference() ([]byte, error) { + root := devctlapp.New("", "") + markdown, err := docs.ToTabularMarkdown(root, "devctl") + if err != nil { + return nil, fmt.Errorf("generate command reference: %w", err) + } + + const header = "# Command reference\n\n\n\n" + return []byte(header + markdown), nil +} + +func checkReference(root, name string, expected []byte) error { + disk, err := devfs.Open(root) + if err != nil { + return fmt.Errorf("filesystem.Open: %w", err) + } + actual, readErr := fs.ReadFile(disk, name) + if readErr != nil { + readErr = fmt.Errorf("filesystem.ReadFile: %w", readErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + if err := errors.Join(readErr, closeErr); err != nil { + return fmt.Errorf("read command reference: %w", err) + } + if !bytes.Equal(actual, expected) { + return errors.New("command reference is out of date; run `mise run docs:generate`") + } + return nil +} + +func writeReference(root, name string, content []byte) error { + disk, err := devfs.Open(root) + if err != nil { + return fmt.Errorf("filesystem.Open: %w", err) + } + _, publishErr := disk.PublishFile(context.Background(), name, devfs.File{ + Content: content, + Mode: 0o644, + }) + if publishErr != nil { + publishErr = fmt.Errorf("filesystem.PublishFile: %w", publishErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + if err := errors.Join(publishErr, closeErr); err != nil { + return fmt.Errorf("publish command reference: %w", err) + } + return nil +} diff --git a/cmd/devctl/internal/docgen/main_test.go b/cmd/devctl/internal/docgen/main_test.go new file mode 100644 index 0000000..d56de04 --- /dev/null +++ b/cmd/devctl/internal/docgen/main_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenderReferenceIsDeterministicAndVersionless(t *testing.T) { + t.Parallel() + + first, err := renderReference() + require.NoError(t, err) + second, err := renderReference() + require.NoError(t, err) + require.Equal(t, first, second) + + text := string(first) + require.Contains(t, text, "# Command reference") + require.Contains(t, text, "### `init` command") + require.Contains(t, text, "### `init manifest` subcommand") + require.Contains(t, text, "--preset") + require.Contains(t, text, "http-service") + require.NotContains(t, text, "commit:") + require.NotContains(t, text, "Version:") +} + +func TestCheckReferenceDetectsDrift(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "commands.md"), []byte("current\n"), 0o644)) + require.NoError(t, checkReference(root, "commands.md", []byte("current\n"))) + require.ErrorContains(t, checkReference(root, "commands.md", []byte("expected\n")), "command reference is out of date") +} + +func TestWriteReferenceCreatesParentDirectories(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, writeReference(root, "docs/reference/commands.md", []byte("generated\n"))) + + actual, err := os.ReadFile(filepath.Join(root, "docs/reference/commands.md")) + require.NoError(t, err) + require.Equal(t, []byte("generated\n"), actual) +} + +func TestWriteReferenceRejectsSymlinkTarget(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("symlink policy requires symlink support") + } + + root := t.TempDir() + original := filepath.Join(t.TempDir(), "commands.md") + require.NoError(t, os.WriteFile(original, []byte("original\n"), 0o644)) + target := filepath.Join(root, "commands.md") + require.NoError(t, os.Symlink(original, target)) + + err := writeReference(root, "commands.md", []byte("replacement\n")) + + require.ErrorIs(t, err, fs.ErrInvalid) + linkTarget, readLinkErr := os.Readlink(target) + require.NoError(t, readLinkErr) + require.Equal(t, original, linkTarget) + actual, readErr := os.ReadFile(original) + require.NoError(t, readErr) + require.Equal(t, []byte("original\n"), actual) +} diff --git a/cmd/devctl/internal/enable/capability.go b/cmd/devctl/internal/enable/capability.go new file mode 100644 index 0000000..790f5b1 --- /dev/null +++ b/cmd/devctl/internal/enable/capability.go @@ -0,0 +1,117 @@ +package enable + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/capability.go -package mocks -typed . capabilityEnabler + +type capabilityEnabler interface { + // Enable adds or updates one project capability configuration. + Enable(ctx context.Context, command projectdomain.EnableCommand) (projectdomain.ManifestResult, error) +} + +// capabilityRuntime contains the application port and cleanup hook used by Action. +type capabilityRuntime struct { + enabler capabilityEnabler + shutdown func(context.Context) error +} + +// capabilityBuilder isolates dependency construction from capability mutation behavior. +type capabilityBuilder func(context.Context, *zap.Logger) (capabilityRuntime, error) + +// capabilityCmd owns parsed options, the selected capability, and its runtime factory. +type capabilityCmd struct { + opts capabilityCmdOpts + capability string + buildRuntime capabilityBuilder +} + +// capabilityCmdOpts receives common flags and capability mutation policies. +type capabilityCmdOpts struct { + commandruntime.CommonCmdOpts + Always bool + Force bool +} + +// newCapabilityCmd constructs one executable leaf for a fixed capability name. +func newCapabilityCmd(name string, opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + cmd := &capabilityCmd{opts: opts, capability: name, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.BoolFlag{Name: "always", Usage: "omit the Runtime Start Policy so the Capability always starts", Destination: &cmd.opts.Always}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing Capability declaration", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: name, + Usage: "Enable " + name, + Description: "Add the " + name + " Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed.", + UsageText: "devctl enable " + name + " [--always] [--force]", + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action enables one project capability and emits the resulting manifest change. +func (cmd *capabilityCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("enable accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.enabler.Enable(ctx, projectdomain.EnableCommand{ + ManifestPath: cmd.opts.ManifestPath, + Capability: cmd.capability, + Always: cmd.opts.Always, + Force: cmd.opts.Force, + }) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := manifestResultDTO{Manifest: result.Manifest, Change: string(result.Change)} + var options []commandruntime.ErrorOption + if result.Change != "" { + options = append(options, commandruntime.WithPartialResult(dto)) + } + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + return reporter.ReportError(finalErr, options...) + } + stdout.Info("capability enablement completed", zap.Any("data", dto)) + return nil +} + +// manifestResultDTO is the stable capability mutation payload. +type manifestResultDTO struct { + Manifest string `json:"manifest"` + Change string `json:"change"` +} + +// buildCapability constructs and resolves the lazy dependencies owned by enable leaves. +func buildCapability(ctx context.Context, logger *zap.Logger) (capabilityRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return capabilityRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return capabilityRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return capabilityRuntime{enabler: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/enable/enable.go b/cmd/devctl/internal/enable/enable.go new file mode 100644 index 0000000..40c5181 --- /dev/null +++ b/cmd/devctl/internal/enable/enable.go @@ -0,0 +1,24 @@ +package enable + +import "github.com/urfave/cli/v3" + +// NewCmd constructs the namespace for project capability commands. +func NewCmd() *cli.Command { + return &cli.Command{ + Name: "enable", + Usage: "Enable a project capability", + Description: "Add or update one supported Capability in the Manifest. This command changes only devctl.yaml and does not refresh scaffold files or generated code.", + Commands: []*cli.Command{ + newHTTPCmd(capabilityCmdOpts{}, buildCapability), + newGRPCCmd(capabilityCmdOpts{}, buildCapability), + newLoggingCmd(capabilityCmdOpts{}, buildCapability), + newHealthCmd(capabilityCmdOpts{}, buildCapability), + newTelemetryCmd(capabilityCmdOpts{}, buildCapability), + newPprofCmd(capabilityCmdOpts{}, buildCapability), + }, + } +} + +func newGRPCCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("grpc", opts, build) +} diff --git a/cmd/devctl/internal/enable/enable_test.go b/cmd/devctl/internal/enable/enable_test.go new file mode 100644 index 0000000..ed35681 --- /dev/null +++ b/cmd/devctl/internal/enable/enable_test.go @@ -0,0 +1,78 @@ +package enable + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/enable/mocks" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestEnableWritesManifestChangeJSON(t *testing.T) { + t.Parallel() + manifestPath := writeManifest(t) + command := newLoggingCmd(capabilityCmdOpts{}, buildCapability) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{"logging", "--file", manifestPath, "--json"}) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"capability enablement completed"`) + require.Contains(t, stdout.String(), `"command":"logging"`) + require.Contains(t, stdout.String(), `"data":{"manifest":"`+manifestPath+`","change":"updated"}`) +} + +func TestEnableReportsOperationAndShutdownFailuresWithManifestData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + enabler := mocks.NewMockcapabilityEnabler(ctrl) + operationErr := errors.New("enable failed") + shutdownErr := errors.New("shutdown failed") + enabler.EXPECT().Enable(gomock.Any(), projectdomain.EnableCommand{Capability: "logging"}).Return( + projectdomain.ManifestResult{Manifest: "/project/devctl.yaml", Change: projectdomain.ChangeUpdated}, operationErr, + ) + command := newLoggingCmd(capabilityCmdOpts{}, func(context.Context, *zap.Logger) (capabilityRuntime, error) { + return capabilityRuntime{enabler: enabler, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"logging", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{"manifest": "/project/devctl.yaml", "change": "updated"}, details["partial_result"]) +} + +func writeManifest(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + return path +} diff --git a/cmd/devctl/internal/enable/health.go b/cmd/devctl/internal/enable/health.go new file mode 100644 index 0000000..1291373 --- /dev/null +++ b/cmd/devctl/internal/enable/health.go @@ -0,0 +1,8 @@ +package enable + +import "github.com/urfave/cli/v3" + +// newHealthCmd constructs the health capability leaf. +func newHealthCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("health", opts, build) +} diff --git a/cmd/devctl/internal/enable/http.go b/cmd/devctl/internal/enable/http.go new file mode 100644 index 0000000..51b86ec --- /dev/null +++ b/cmd/devctl/internal/enable/http.go @@ -0,0 +1,8 @@ +package enable + +import "github.com/urfave/cli/v3" + +// newHTTPCmd constructs the HTTP capability leaf. +func newHTTPCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("http", opts, build) +} diff --git a/cmd/devctl/internal/enable/logging.go b/cmd/devctl/internal/enable/logging.go new file mode 100644 index 0000000..fdfc5de --- /dev/null +++ b/cmd/devctl/internal/enable/logging.go @@ -0,0 +1,8 @@ +package enable + +import "github.com/urfave/cli/v3" + +// newLoggingCmd constructs the logging capability leaf. +func newLoggingCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("logging", opts, build) +} diff --git a/cmd/devctl/internal/enable/mocks/capability.go b/cmd/devctl/internal/enable/mocks/capability.go new file mode 100644 index 0000000..de69704 --- /dev/null +++ b/cmd/devctl/internal/enable/mocks/capability.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/enable (interfaces: capabilityEnabler) +// +// Generated by this command: +// +// mockgen -destination mocks/capability.go -package mocks -typed . capabilityEnabler +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockcapabilityEnabler is a mock of capabilityEnabler interface. +type MockcapabilityEnabler struct { + ctrl *gomock.Controller + recorder *MockcapabilityEnablerMockRecorder + isgomock struct{} +} + +// MockcapabilityEnablerMockRecorder is the mock recorder for MockcapabilityEnabler. +type MockcapabilityEnablerMockRecorder struct { + mock *MockcapabilityEnabler +} + +// NewMockcapabilityEnabler creates a new mock instance. +func NewMockcapabilityEnabler(ctrl *gomock.Controller) *MockcapabilityEnabler { + mock := &MockcapabilityEnabler{ctrl: ctrl} + mock.recorder = &MockcapabilityEnablerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockcapabilityEnabler) EXPECT() *MockcapabilityEnablerMockRecorder { + return m.recorder +} + +// Enable mocks base method. +func (m *MockcapabilityEnabler) Enable(ctx context.Context, command project.EnableCommand) (project.ManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Enable", ctx, command) + ret0, _ := ret[0].(project.ManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Enable indicates an expected call of Enable. +func (mr *MockcapabilityEnablerMockRecorder) Enable(ctx, command any) *MockcapabilityEnablerEnableCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Enable", reflect.TypeOf((*MockcapabilityEnabler)(nil).Enable), ctx, command) + return &MockcapabilityEnablerEnableCall{Call: call} +} + +// MockcapabilityEnablerEnableCall wrap *gomock.Call +type MockcapabilityEnablerEnableCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockcapabilityEnablerEnableCall) Return(arg0 project.ManifestResult, arg1 error) *MockcapabilityEnablerEnableCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockcapabilityEnablerEnableCall) Do(f func(context.Context, project.EnableCommand) (project.ManifestResult, error)) *MockcapabilityEnablerEnableCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockcapabilityEnablerEnableCall) DoAndReturn(f func(context.Context, project.EnableCommand) (project.ManifestResult, error)) *MockcapabilityEnablerEnableCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/enable/pprof.go b/cmd/devctl/internal/enable/pprof.go new file mode 100644 index 0000000..20509b7 --- /dev/null +++ b/cmd/devctl/internal/enable/pprof.go @@ -0,0 +1,8 @@ +package enable + +import "github.com/urfave/cli/v3" + +// newPprofCmd constructs the pprof capability leaf. +func newPprofCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("pprof", opts, build) +} diff --git a/cmd/devctl/internal/enable/telemetry.go b/cmd/devctl/internal/enable/telemetry.go new file mode 100644 index 0000000..9a66cbd --- /dev/null +++ b/cmd/devctl/internal/enable/telemetry.go @@ -0,0 +1,8 @@ +package enable + +import "github.com/urfave/cli/v3" + +// newTelemetryCmd constructs the telemetry capability leaf. +func newTelemetryCmd(opts capabilityCmdOpts, build capabilityBuilder) *cli.Command { + return newCapabilityCmd("telemetry", opts, build) +} diff --git a/cmd/devctl/internal/gen/config.go b/cmd/devctl/internal/gen/config.go new file mode 100644 index 0000000..a93e128 --- /dev/null +++ b/cmd/devctl/internal/gen/config.go @@ -0,0 +1,8 @@ +package gen + +import "github.com/urfave/cli/v3" + +// newGenConfigCmd constructs the config-only generation leaf. +func newGenConfigCmd(opts genCmdOpts, build genBuilder) *cli.Command { + return newGenLeaf(genLeafSpec{name: "config", family: "config"}, opts, build) +} diff --git a/cmd/devctl/internal/gen/gen.go b/cmd/devctl/internal/gen/gen.go new file mode 100644 index 0000000..43d2f8c --- /dev/null +++ b/cmd/devctl/internal/gen/gen.go @@ -0,0 +1,169 @@ +package gen + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/gen.go -package mocks -typed . generator + +type generator interface { + // Generate returns completed target facts even when a later generation step fails. + Generate(ctx context.Context, command generatedomain.Command) (generatedomain.Result, error) +} + +// genRuntime contains the application port and cleanup hook used by Action. +type genRuntime struct { + generator generator + shutdown func(context.Context) error +} + +// genBuilder isolates dependency construction from generation behavior. +type genBuilder func(ctx context.Context, logger *zap.Logger) (genRuntime, error) + +// genCmd owns parsed options, a generation family selector, and its runtime factory. +type genCmd struct { + opts genCmdOpts + family string + buildRuntime genBuilder +} + +// genCmdOpts receives common flags plus generation target selection. +type genCmdOpts struct { + commandruntime.CommonCmdOpts + Target string + DryRun bool +} + +// genLeafSpec identifies the command node and the generation scope it selects. +type genLeafSpec struct { + name string + family string + allowTarget bool +} + +// NewCmd constructs the gen command tree. +func NewCmd() *cli.Command { + return newGenCmd(genCmdOpts{}, buildGen) +} + +func newGenCmd(opts genCmdOpts, build genBuilder) *cli.Command { + command := newGenLeaf(genLeafSpec{name: "gen", allowTarget: true}, opts, build) + command.Commands = []*cli.Command{newGenConfigCmd(genCmdOpts{}, build), newGenHTTPCmd(genCmdOpts{}, build), newGenLeaf(genLeafSpec{name: "grpc", family: "grpc", allowTarget: true}, genCmdOpts{}, build), newGenLeaf(genLeafSpec{name: "kafka", family: "kafka", allowTarget: true}, genCmdOpts{}, build)} + return command +} + +// newGenLeaf constructs one executable generation node for the selected family. +func newGenLeaf(spec genLeafSpec, opts genCmdOpts, build genBuilder) *cli.Command { + cmd := &genCmd{opts: opts, family: spec.family, buildRuntime: build} + usageText := "devctl gen" + if spec.name != "gen" { + usageText += " " + spec.name + } + if spec.allowTarget { + usageText += " [--target ]" + } + usageText += " [--dry-run]" + flags := append(cmd.opts.CommonFlags(), &cli.BoolFlag{Name: "dry-run", Destination: &cmd.opts.DryRun, Usage: "preview Managed Outputs without running generators or writing files"}) + if spec.allowTarget { + flags = append(flags, &cli.StringFlag{Name: "target", Destination: &cmd.opts.Target, Usage: "select one exact generation Target `id`"}) + } + return &cli.Command{ + Name: spec.name, + Usage: "Generate Managed Outputs", + Description: genDescription(spec.family), + UsageText: usageText, + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +func genDescription(family string) string { + if family == "" { + return "Run the Project-owned generators for every supported Target and atomically publish each Target's Managed Output. Generation never synchronizes or lints implicitly." + } + return "Run the Project-owned generators for " + family + " Targets and atomically publish their Managed Outputs without synchronizing or linting implicitly." +} + +// Action generates the selected managed outputs and emits the completed or partial result. +func (cmd *genCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("gen accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.generator.Generate(ctx, generatedomain.Command{ + ManifestPath: cmd.opts.ManifestPath, + Family: cmd.family, + Target: cmd.opts.Target, + DryRun: cmd.opts.DryRun, + }) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := generateDTO(result) + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + var options []commandruntime.ErrorOption + if !result.DryRun && (len(result.Targets) > 0 || len(result.Changes) > 0) { + options = append(options, commandruntime.WithPartialResult(dto)) + } + return reporter.ReportError(finalErr, options...) + } + stdout.Info("managed output generation completed", zap.Any("data", dto)) + return nil +} + +// changeDTO is one stable managed-file change fact. +type changeDTO struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` +} + +// resultDTO contains every completed generation target and file change. +type resultDTO struct { + Targets []string `json:"targets"` + Changes []changeDTO `json:"changes"` + DryRun bool `json:"dry_run"` +} + +// generateDTO converts the domain result into the stable CLI payload. +func generateDTO(result generatedomain.Result) resultDTO { + targets := make([]string, len(result.Targets)) + copy(targets, result.Targets) + changes := make([]changeDTO, 0, len(result.Changes)) + for _, change := range result.Changes { + changes = append(changes, changeDTO{Target: change.Target, Path: change.Path, Action: string(change.Action)}) + } + return resultDTO{Targets: targets, Changes: changes, DryRun: result.DryRun} +} + +// buildGen constructs and resolves the lazy dependencies owned by gen. +func buildGen(ctx context.Context, logger *zap.Logger) (genRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return genRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.GenService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return genRuntime{}, errors.Join(fmt.Errorf("container.GenService: %w", err), shutdownErr) + } + return genRuntime{generator: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/gen/gen_test.go b/cmd/devctl/internal/gen/gen_test.go new file mode 100644 index 0000000..e448a3a --- /dev/null +++ b/cmd/devctl/internal/gen/gen_test.go @@ -0,0 +1,78 @@ +package gen + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/gen/mocks" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestGenCommandUsesGenService(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + generator := mocks.NewMockgenerator(ctrl) + generator.EXPECT().Generate(gomock.Any(), generatedomain.Command{ + ManifestPath: "custom.yaml", + Target: "config", + DryRun: true, + }).Return(generatedomain.Result{ + Targets: []string{"config"}, + Changes: []generatedomain.Change{{Target: "config", Path: "gen/config/config.gen.go", Action: generatedomain.ChangePlannedPublish}}, + DryRun: true, + }, nil) + command := newGenCmd(genCmdOpts{}, func(_ context.Context, logger *zap.Logger) (genRuntime, error) { + require.NotNil(t, logger) + return genRuntime{generator: generator, shutdown: func(context.Context) error { return nil }}, nil + }) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{ + "gen", "--file", "custom.yaml", "--json", "--verbose", "--target", "config", "--dry-run", + }) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"managed output generation completed"`) + require.Contains(t, stdout.String(), `"command":"gen"`) + require.Contains(t, stdout.String(), `"data":{"targets":["config"],"changes":[{"target":"config","path":"gen/config/config.gen.go","action":"planned_publish"}],"dry_run":true}`) +} + +func TestGenReportsOperationAndShutdownFailuresWithPartialData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + generator := mocks.NewMockgenerator(ctrl) + operationErr := errors.New("generation failed") + shutdownErr := errors.New("shutdown failed") + generator.EXPECT().Generate(gomock.Any(), generatedomain.Command{}).Return( + generatedomain.Result{Targets: []string{"config"}}, operationErr, + ) + command := newGenCmd(genCmdOpts{}, func(context.Context, *zap.Logger) (genRuntime, error) { + return genRuntime{generator: generator, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"gen", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{ + "targets": []any{"config"}, "changes": []any{}, "dry_run": false, + }, details["partial_result"]) +} diff --git a/cmd/devctl/internal/gen/http.go b/cmd/devctl/internal/gen/http.go new file mode 100644 index 0000000..ce7d6c4 --- /dev/null +++ b/cmd/devctl/internal/gen/http.go @@ -0,0 +1,8 @@ +package gen + +import "github.com/urfave/cli/v3" + +// newGenHTTPCmd constructs the HTTP generation leaf. +func newGenHTTPCmd(opts genCmdOpts, build genBuilder) *cli.Command { + return newGenLeaf(genLeafSpec{name: "http", family: "http", allowTarget: true}, opts, build) +} diff --git a/cmd/devctl/internal/gen/mocks/gen.go b/cmd/devctl/internal/gen/mocks/gen.go new file mode 100644 index 0000000..a4fe601 --- /dev/null +++ b/cmd/devctl/internal/gen/mocks/gen.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/gen (interfaces: generator) +// +// Generated by this command: +// +// mockgen -destination mocks/gen.go -package mocks -typed . generator +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + generate "github.com/devctllabs/devctl/internal/domain/generate" + gomock "go.uber.org/mock/gomock" +) + +// Mockgenerator is a mock of generator interface. +type Mockgenerator struct { + ctrl *gomock.Controller + recorder *MockgeneratorMockRecorder + isgomock struct{} +} + +// MockgeneratorMockRecorder is the mock recorder for Mockgenerator. +type MockgeneratorMockRecorder struct { + mock *Mockgenerator +} + +// NewMockgenerator creates a new mock instance. +func NewMockgenerator(ctrl *gomock.Controller) *Mockgenerator { + mock := &Mockgenerator{ctrl: ctrl} + mock.recorder = &MockgeneratorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *Mockgenerator) EXPECT() *MockgeneratorMockRecorder { + return m.recorder +} + +// Generate mocks base method. +func (m *Mockgenerator) Generate(ctx context.Context, command generate.Command) (generate.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Generate", ctx, command) + ret0, _ := ret[0].(generate.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Generate indicates an expected call of Generate. +func (mr *MockgeneratorMockRecorder) Generate(ctx, command any) *MockgeneratorGenerateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*Mockgenerator)(nil).Generate), ctx, command) + return &MockgeneratorGenerateCall{Call: call} +} + +// MockgeneratorGenerateCall wrap *gomock.Call +type MockgeneratorGenerateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockgeneratorGenerateCall) Return(arg0 generate.Result, arg1 error) *MockgeneratorGenerateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockgeneratorGenerateCall) Do(f func(context.Context, generate.Command) (generate.Result, error)) *MockgeneratorGenerateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockgeneratorGenerateCall) DoAndReturn(f func(context.Context, generate.Command) (generate.Result, error)) *MockgeneratorGenerateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/init/init.go b/cmd/devctl/internal/init/init.go new file mode 100644 index 0000000..072a490 --- /dev/null +++ b/cmd/devctl/internal/init/init.go @@ -0,0 +1,16 @@ +package initcmd + +import "github.com/urfave/cli/v3" + +// NewCmd constructs the namespace for project initialization commands. +func NewCmd() *cli.Command { + return &cli.Command{ + Name: "init", + Usage: "Initialize a Devctl project", + Description: "Create the canonical Manifest or materialize the Go project foundation declared by an existing Manifest. Initialization steps are explicit and never run one another implicitly.", + Commands: []*cli.Command{ + newManifestCmd(manifestCmdOpts{}, buildManifest), + newScaffoldCmd(scaffoldCmdOpts{}, buildScaffold), + }, + } +} diff --git a/cmd/devctl/internal/init/init_test.go b/cmd/devctl/internal/init/init_test.go new file mode 100644 index 0000000..9468a50 --- /dev/null +++ b/cmd/devctl/internal/init/init_test.go @@ -0,0 +1,135 @@ +package initcmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/init/mocks" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestInitManifestWritesCommandSpecificJSON(t *testing.T) { + t.Parallel() + destination := filepath.Join(t.TempDir(), "devctl.yaml") + command := newManifestCmd(manifestCmdOpts{}, buildManifest) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{ + "manifest", "--file", destination, "--json", + "--lang", "go", "--preset", "cli", "--name", "sample", "--module", "example.test/sample", + }) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"manifest initialization completed"`) + require.Contains(t, stdout.String(), `"command":"manifest"`) + require.Contains(t, stdout.String(), `"data":{"manifest":"`+destination+`","change":"created"}`) + _, err = os.Stat(destination) + require.NoError(t, err) +} + +func TestInitManifestForceReplacesDifferentRegularFile(t *testing.T) { + t.Parallel() + destination := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(destination, []byte("different\n"), 0o644)) + command := newManifestCmd(manifestCmdOpts{}, buildManifest) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{ + "manifest", "--file", destination, "--json", "--force", + "--lang", "go", "--preset", "cli", "--name", "sample", "--module", "example.test/sample", + }) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"data":{"manifest":"`+destination+`","change":"updated"}`) +} + +func TestInitManifestReportsOperationAndShutdownFailuresWithManifestData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + initializer := mocks.NewMockmanifestInitializer(ctrl) + operationErr := errors.New("initialization failed") + shutdownErr := errors.New("shutdown failed") + initializer.EXPECT().InitManifest(gomock.Any(), projectdomain.InitManifestCommand{ + Language: "go", Preset: "cli", Name: "sample", Module: "example.test/sample", + }).Return(projectdomain.ManifestResult{Manifest: "/project/devctl.yaml", Change: projectdomain.ChangeUpdated}, operationErr) + command := newManifestCmd(manifestCmdOpts{}, func(context.Context, *zap.Logger) (manifestRuntime, error) { + return manifestRuntime{initializer: initializer, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{ + "manifest", "--json", "--lang", "go", "--preset", "cli", "--name", "sample", "--module", "example.test/sample", + }) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{"manifest": "/project/devctl.yaml", "change": "updated"}, details["partial_result"]) +} + +func TestInitScaffoldReportsOperationAndShutdownFailuresWithPartialData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + scaffolder := mocks.NewMockscaffolder(ctrl) + operationErr := errors.New("scaffold failed") + shutdownErr := errors.New("shutdown failed") + scaffolder.EXPECT().Scaffold(gomock.Any(), scaffolddomain.Command{}).Return(scaffolddomain.Result{ + Files: []scaffolddomain.FileChange{{Path: "go.mod", Action: scaffolddomain.FileCreated}}, + }, operationErr) + command := newScaffoldCmd(scaffoldCmdOpts{}, func(context.Context, *zap.Logger) (scaffoldRuntime, error) { + return scaffoldRuntime{scaffolder: scaffolder, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"scaffold", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{"files": []any{map[string]any{"path": "go.mod", "action": "created"}}}, details["partial_result"]) +} + +func TestInitScaffoldDoesNotExposeForceFlag(t *testing.T) { + t.Parallel() + + command := newScaffoldCmd(scaffoldCmdOpts{}, func(context.Context, *zap.Logger) (scaffoldRuntime, error) { + require.FailNow(t, "runtime must not be built for an unknown flag") + return scaffoldRuntime{}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"scaffold", "--force"}) + + require.Error(t, err) + var exitCoder cli.ExitCoder + require.ErrorAs(t, err, &exitCoder) + require.Equal(t, 2, exitCoder.ExitCode()) +} diff --git a/cmd/devctl/internal/init/manifest.go b/cmd/devctl/internal/init/manifest.go new file mode 100644 index 0000000..982726e --- /dev/null +++ b/cmd/devctl/internal/init/manifest.go @@ -0,0 +1,127 @@ +package initcmd + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/manifest.go -package mocks -typed . manifestInitializer + +type manifestInitializer interface { + // InitManifest creates or updates the canonical project manifest. + InitManifest(ctx context.Context, command projectdomain.InitManifestCommand) (projectdomain.ManifestResult, error) +} + +// manifestRuntime contains the application port and cleanup hook used by Action. +type manifestRuntime struct { + initializer manifestInitializer + shutdown func(context.Context) error +} + +// manifestBuilder isolates dependency construction from manifest initialization. +type manifestBuilder func(context.Context, *zap.Logger) (manifestRuntime, error) + +// manifestCmd owns parsed options and the runtime factory for one invocation. +type manifestCmd struct { + opts manifestCmdOpts + buildRuntime manifestBuilder +} + +// manifestCmdOpts receives common flags plus the manifest identity and overwrite policy. +type manifestCmdOpts struct { + commandruntime.CommonCmdOpts + Language string + Preset string + Name string + Module string + Force bool +} + +// newManifestCmd constructs the executable init manifest leaf. +func newManifestCmd(opts manifestCmdOpts, build manifestBuilder) *cli.Command { + cmd := &manifestCmd{opts: opts, buildRuntime: build} + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "lang", Usage: "set the project language; supported value: `go`", Destination: &cmd.opts.Language}, + &cli.StringFlag{Name: "preset", Usage: "seed the Manifest from `cli` or `http-service`", Destination: &cmd.opts.Preset}, + &cli.StringFlag{Name: "name", Usage: "set the kebab-case `project-name`", Destination: &cmd.opts.Name}, + &cli.StringFlag{Name: "module", Usage: "set the Go `module-path`", Destination: &cmd.opts.Module}, + &cli.BoolFlag{Name: "force", Usage: "replace an existing Manifest instead of returning a conflict", Destination: &cmd.opts.Force}, + ) + return &cli.Command{ + Name: "manifest", + Usage: "Create devctl.yaml", + Description: "Create a complete v1 Manifest from a supported preset. This command writes only the Manifest; it does not scaffold files, install tools, synchronize Contracts, lint, or generate code.", + UsageText: "devctl init manifest --lang go --preset --name --module ", + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action creates the selected project manifest and emits the resulting change. +func (cmd *manifestCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("init manifest accepts no positional arguments", 2)) + } + if cmd.opts.Language == "" || cmd.opts.Preset == "" || cmd.opts.Name == "" || cmd.opts.Module == "" { + return reporter.ReportError(cli.Exit("--lang, --preset, --name, and --module are required", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.initializer.InitManifest(ctx, projectdomain.InitManifestCommand{ + Destination: cmd.opts.ManifestPath, + Language: cmd.opts.Language, + Preset: cmd.opts.Preset, + Name: cmd.opts.Name, + Module: cmd.opts.Module, + Force: cmd.opts.Force, + }) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := manifestResultDTO{Manifest: result.Manifest, Change: string(result.Change)} + var options []commandruntime.ErrorOption + if result.Change != "" { + options = append(options, commandruntime.WithPartialResult(dto)) + } + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + return reporter.ReportError(finalErr, options...) + } + stdout.Info("manifest initialization completed", zap.Any("data", dto)) + return nil +} + +// manifestResultDTO is the stable manifest mutation payload. +type manifestResultDTO struct { + Manifest string `json:"manifest"` + Change string `json:"change"` +} + +// buildManifest constructs and resolves the lazy dependencies owned by init manifest. +func buildManifest(ctx context.Context, logger *zap.Logger) (manifestRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return manifestRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return manifestRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return manifestRuntime{initializer: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/init/mocks/manifest.go b/cmd/devctl/internal/init/mocks/manifest.go new file mode 100644 index 0000000..632be26 --- /dev/null +++ b/cmd/devctl/internal/init/mocks/manifest.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/init (interfaces: manifestInitializer) +// +// Generated by this command: +// +// mockgen -destination mocks/manifest.go -package mocks -typed . manifestInitializer +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockmanifestInitializer is a mock of manifestInitializer interface. +type MockmanifestInitializer struct { + ctrl *gomock.Controller + recorder *MockmanifestInitializerMockRecorder + isgomock struct{} +} + +// MockmanifestInitializerMockRecorder is the mock recorder for MockmanifestInitializer. +type MockmanifestInitializerMockRecorder struct { + mock *MockmanifestInitializer +} + +// NewMockmanifestInitializer creates a new mock instance. +func NewMockmanifestInitializer(ctrl *gomock.Controller) *MockmanifestInitializer { + mock := &MockmanifestInitializer{ctrl: ctrl} + mock.recorder = &MockmanifestInitializerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockmanifestInitializer) EXPECT() *MockmanifestInitializerMockRecorder { + return m.recorder +} + +// InitManifest mocks base method. +func (m *MockmanifestInitializer) InitManifest(ctx context.Context, command project.InitManifestCommand) (project.ManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InitManifest", ctx, command) + ret0, _ := ret[0].(project.ManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InitManifest indicates an expected call of InitManifest. +func (mr *MockmanifestInitializerMockRecorder) InitManifest(ctx, command any) *MockmanifestInitializerInitManifestCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InitManifest", reflect.TypeOf((*MockmanifestInitializer)(nil).InitManifest), ctx, command) + return &MockmanifestInitializerInitManifestCall{Call: call} +} + +// MockmanifestInitializerInitManifestCall wrap *gomock.Call +type MockmanifestInitializerInitManifestCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockmanifestInitializerInitManifestCall) Return(arg0 project.ManifestResult, arg1 error) *MockmanifestInitializerInitManifestCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockmanifestInitializerInitManifestCall) Do(f func(context.Context, project.InitManifestCommand) (project.ManifestResult, error)) *MockmanifestInitializerInitManifestCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockmanifestInitializerInitManifestCall) DoAndReturn(f func(context.Context, project.InitManifestCommand) (project.ManifestResult, error)) *MockmanifestInitializerInitManifestCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/init/mocks/scaffold.go b/cmd/devctl/internal/init/mocks/scaffold.go new file mode 100644 index 0000000..7b62c61 --- /dev/null +++ b/cmd/devctl/internal/init/mocks/scaffold.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/init (interfaces: scaffolder) +// +// Generated by this command: +// +// mockgen -destination mocks/scaffold.go -package mocks -typed . scaffolder +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + scaffold "github.com/devctllabs/devctl/internal/domain/scaffold" + gomock "go.uber.org/mock/gomock" +) + +// Mockscaffolder is a mock of scaffolder interface. +type Mockscaffolder struct { + ctrl *gomock.Controller + recorder *MockscaffolderMockRecorder + isgomock struct{} +} + +// MockscaffolderMockRecorder is the mock recorder for Mockscaffolder. +type MockscaffolderMockRecorder struct { + mock *Mockscaffolder +} + +// NewMockscaffolder creates a new mock instance. +func NewMockscaffolder(ctrl *gomock.Controller) *Mockscaffolder { + mock := &Mockscaffolder{ctrl: ctrl} + mock.recorder = &MockscaffolderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *Mockscaffolder) EXPECT() *MockscaffolderMockRecorder { + return m.recorder +} + +// Scaffold mocks base method. +func (m *Mockscaffolder) Scaffold(ctx context.Context, command scaffold.Command) (scaffold.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Scaffold", ctx, command) + ret0, _ := ret[0].(scaffold.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Scaffold indicates an expected call of Scaffold. +func (mr *MockscaffolderMockRecorder) Scaffold(ctx, command any) *MockscaffolderScaffoldCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Scaffold", reflect.TypeOf((*Mockscaffolder)(nil).Scaffold), ctx, command) + return &MockscaffolderScaffoldCall{Call: call} +} + +// MockscaffolderScaffoldCall wrap *gomock.Call +type MockscaffolderScaffoldCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockscaffolderScaffoldCall) Return(arg0 scaffold.Result, arg1 error) *MockscaffolderScaffoldCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockscaffolderScaffoldCall) Do(f func(context.Context, scaffold.Command) (scaffold.Result, error)) *MockscaffolderScaffoldCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockscaffolderScaffoldCall) DoAndReturn(f func(context.Context, scaffold.Command) (scaffold.Result, error)) *MockscaffolderScaffoldCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/init/scaffold.go b/cmd/devctl/internal/init/scaffold.go new file mode 100644 index 0000000..8cd60d0 --- /dev/null +++ b/cmd/devctl/internal/init/scaffold.go @@ -0,0 +1,119 @@ +package initcmd + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/scaffold.go -package mocks -typed . scaffolder + +type scaffolder interface { + // Scaffold creates or refreshes the generated project foundation. + Scaffold(ctx context.Context, command scaffolddomain.Command) (scaffolddomain.Result, error) +} + +// scaffoldRuntime contains the application port and cleanup hook used by Action. +type scaffoldRuntime struct { + scaffolder scaffolder + shutdown func(context.Context) error +} + +// scaffoldBuilder isolates dependency construction from scaffold behavior. +type scaffoldBuilder func(context.Context, *zap.Logger) (scaffoldRuntime, error) + +// scaffoldCmd owns parsed options and the runtime factory for one invocation. +type scaffoldCmd struct { + opts scaffoldCmdOpts + buildRuntime scaffoldBuilder +} + +// scaffoldCmdOpts receives common scaffold flags. +type scaffoldCmdOpts struct { + commandruntime.CommonCmdOpts +} + +// newScaffoldCmd constructs the executable init scaffold leaf. +func newScaffoldCmd(opts scaffoldCmdOpts, build scaffoldBuilder) *cli.Command { + cmd := &scaffoldCmd{opts: opts, buildRuntime: build} + return &cli.Command{ + Name: "scaffold", + Usage: "Create or refresh the Go project foundation", + Description: "Publish Devctl-managed project files and create missing Scaffold Seeds. Managed Outputs may be replaced; existing user-owned Seeds are never deliberately overwritten or deleted.", + UsageText: "devctl init scaffold [--file ]", + Flags: cmd.opts.CommonFlags(), + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action scaffolds the selected project and emits every resulting file change. +func (cmd *scaffoldCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("init scaffold accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.scaffolder.Scaffold(ctx, scaffolddomain.Command{ManifestPath: cmd.opts.ManifestPath}) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := scaffoldDTO(result) + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + var options []commandruntime.ErrorOption + if len(result.Files) > 0 { + options = append(options, commandruntime.WithPartialResult(dto)) + } + return reporter.ReportError(finalErr, options...) + } + stdout.Info("project scaffolding completed", zap.Any("data", dto)) + return nil +} + +// scaffoldFileDTO is one stable generated-file change fact. +type scaffoldFileDTO struct { + Path string `json:"path"` + Action string `json:"action"` +} + +// scaffoldResultDTO contains every file considered by the scaffold operation. +type scaffoldResultDTO struct { + Files []scaffoldFileDTO `json:"files"` +} + +// scaffoldDTO converts the domain result into the stable CLI payload. +func scaffoldDTO(result scaffolddomain.Result) scaffoldResultDTO { + files := make([]scaffoldFileDTO, 0, len(result.Files)) + for _, file := range result.Files { + files = append(files, scaffoldFileDTO{Path: file.Path, Action: string(file.Action)}) + } + return scaffoldResultDTO{Files: files} +} + +// buildScaffold constructs and resolves the lazy dependencies owned by init scaffold. +func buildScaffold(ctx context.Context, logger *zap.Logger) (scaffoldRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return scaffoldRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ScaffoldService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return scaffoldRuntime{}, errors.Join(fmt.Errorf("container.ScaffoldService: %w", err), shutdownErr) + } + return scaffoldRuntime{scaffolder: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/inspect/inspect.go b/cmd/devctl/internal/inspect/inspect.go new file mode 100644 index 0000000..9ace52f --- /dev/null +++ b/cmd/devctl/internal/inspect/inspect.go @@ -0,0 +1,207 @@ +package inspect + +import ( + "context" + "errors" + "fmt" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/inspect.go -package mocks -typed . projectInspector + +type projectInspector interface { + // Inspect returns the effective project view selected by query. + Inspect(ctx context.Context, query projectdomain.InspectQuery) (projectdomain.InspectResult, error) +} + +// inspectRuntime contains the application port and cleanup hook used by Action. +type inspectRuntime struct { + inspector projectInspector + shutdown func(context.Context) error +} + +// inspectBuilder isolates dependency construction from command behavior. +type inspectBuilder func(context.Context, *zap.Logger) (inspectRuntime, error) + +// inspectCmd owns parsed options and the runtime factory for one invocation. +type inspectCmd struct { + opts inspectCmdOpts + buildRuntime inspectBuilder +} + +// inspectCmdOpts receives the common leaf flags bound by urfave/cli. +type inspectCmdOpts struct { + commandruntime.CommonCmdOpts +} + +// inspectProjectDTO is the stable effective-project payload emitted by inspect. +type inspectProjectDTO struct { + Root string `json:"root"` + ManifestPath string `json:"manifest_path"` + Name string `json:"name"` + Language string `json:"language"` + Module string `json:"module"` + EnvPrefix string `json:"env_prefix"` + Paths inspectPathsDTO `json:"paths"` + Targets []inspectTargetDTO `json:"targets"` + Env []inspectEnvDTO `json:"env"` + Resources inspectResourcesDTO `json:"resources"` +} + +type inspectTargetDTO struct { + ID string `json:"id"` + Family string `json:"family"` + Format string `json:"format"` + Input string `json:"input,omitempty"` + ResolvedInput string `json:"resolved_input,omitempty"` + Config string `json:"config,omitempty"` + Output string `json:"output,omitempty"` +} + +type inspectEnvDTO struct { + Key string `json:"key"` + Type string `json:"type"` + Default any `json:"default,omitempty"` + Secret bool `json:"secret,omitempty"` +} + +type inspectResourcesDTO struct { + DBConnections []string `json:"db_connections"` + RedisConnections []string `json:"redis_connections"` + S3Connections []string `json:"s3_connections"` + S3Buckets []string `json:"s3_buckets"` + Migrations []inspectMigrationDTO `json:"migrations"` +} + +type inspectMigrationDTO struct { + Connection string `json:"connection"` + Variant string `json:"variant"` + Kind string `json:"kind"` + Path string `json:"path"` + DatabaseEnv string `json:"database_env"` +} + +// inspectPathsDTO contains effective project-relative output locations. +type inspectPathsDTO struct { + ExternalContracts string `json:"external_contracts"` + ConfigOut string `json:"config_out"` + ServerOut string `json:"server_out"` + ClientOut string `json:"client_out"` +} + +// inspectResultDTO wraps the effective project under the public data schema. +type inspectResultDTO struct { + Project inspectProjectDTO `json:"project"` +} + +// NewCmd constructs the inspect command. +func NewCmd() *cli.Command { + return newInspectCmd(inspectCmdOpts{}, buildInspect) +} + +func newInspectCmd(opts inspectCmdOpts, build inspectBuilder) *cli.Command { + cmd := &inspectCmd{opts: opts, buildRuntime: build} + return &cli.Command{ + Name: "inspect", + Usage: "Inspect effective Project configuration", + Description: "Show the selected Project root, effective paths, Target Catalog, Runtime Config, Resources, and resolved Contract inputs without requiring every external Snapshot to be ready.", + UsageText: "devctl inspect [--file ]", + Flags: cmd.opts.CommonFlags(), + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action inspects the selected project and emits its effective configuration. +func (cmd *inspectCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("inspect accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, inspectErr := runtime.inspector.Inspect(ctx, projectdomain.InspectQuery{ManifestPath: cmd.opts.ManifestPath}) + shutdownErr := runtime.shutdown(ctx) + dto := inspectDTO(result) + var options []commandruntime.ErrorOption + if inspectErr == nil { + options = append(options, commandruntime.WithPartialResult(dto)) + } + if finalErr := errors.Join(inspectErr, shutdownErr); finalErr != nil { + return reporter.ReportError(finalErr, options...) + } + stdout.Info("project inspection completed", zap.Any("data", dto)) + return nil +} + +// inspectDTO converts a domain inspection into the stable CLI payload. +func inspectDTO(result projectdomain.InspectResult) inspectResultDTO { + project := result.Project + targets := make([]inspectTargetDTO, len(project.Targets)) + for index, target := range project.Targets { + targets[index] = inspectTargetDTO{ + ID: target.ID, Family: target.Family, Format: target.Format, + Input: target.Input, ResolvedInput: target.ResolvedInput, + Config: target.Config, Output: target.Output, + } + } + env := make([]inspectEnvDTO, len(project.Env)) + for index, entry := range project.Env { + env[index] = inspectEnvDTO{Key: entry.Key, Type: entry.Type, Default: entry.Default, Secret: entry.Secret} + if entry.Secret { + env[index].Default = nil + } + } + migrations := make([]inspectMigrationDTO, len(project.Resources.Migrations)) + for index, migration := range project.Resources.Migrations { + migrations[index] = inspectMigrationDTO{ + Connection: migration.Connection, Variant: migration.Variant, Kind: migration.Kind, + Path: migration.Path, DatabaseEnv: migration.DatabaseEnv, + } + } + return inspectResultDTO{Project: inspectProjectDTO{ + Root: project.Root, ManifestPath: project.ManifestPath, Name: project.Name, + Language: project.Language, Module: project.Module, EnvPrefix: project.EnvPrefix, + Paths: inspectPathsDTO{ + ExternalContracts: project.Paths.ExternalContracts, ConfigOut: project.Paths.ConfigOut, + ServerOut: project.Paths.ServerOut, ClientOut: project.Paths.ClientOut, + }, + Targets: targets, Env: env, + Resources: inspectResourcesDTO{ + DBConnections: project.Resources.DBConnections, RedisConnections: project.Resources.RedisConnections, + S3Connections: project.Resources.S3Connections, S3Buckets: project.Resources.S3Buckets, + Migrations: migrations, + }, + }} +} + +// buildInspect constructs and resolves the lazy dependencies owned by inspect. +func buildInspect(ctx context.Context, logger *zap.Logger) (inspectRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return inspectRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return inspectRuntime{}, errors.Join(fmt.Errorf("container.ProjectService: %w", err), shutdownErr) + } + return inspectRuntime{ + inspector: service, + shutdown: func(shutdownCtx context.Context) error { + return commandruntime.Shutdown(shutdownCtx, container) + }, + }, nil +} diff --git a/cmd/devctl/internal/inspect/inspect_test.go b/cmd/devctl/internal/inspect/inspect_test.go new file mode 100644 index 0000000..1d926f8 --- /dev/null +++ b/cmd/devctl/internal/inspect/inspect_test.go @@ -0,0 +1,101 @@ +package inspect + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/inspect/mocks" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestInspectActionEmitsEffectiveProjectEvent(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + inspector := mocks.NewMockprojectInspector(ctrl) + inspector.EXPECT().Inspect(gomock.Any(), projectdomain.InspectQuery{ManifestPath: "custom.yaml"}).Return(projectdomain.InspectResult{ + Project: projectdomain.Inspection{ + Root: "/work", ManifestPath: "/work/devctl.yaml", Name: "sample", Language: "go", + Module: "example.test/sample", EnvPrefix: "SAMPLE_", Targets: []projectdomain.InspectionTarget{{ + ID: "api", Family: "http", Format: "openapi", Input: "api/external/http/client/api", + ResolvedInput: "api/external/http/client/api/openapi.yaml", Config: "tools/oapi/api.yaml", + }}, + Paths: projectdomain.Paths{ExternalContracts: "api/external", ConfigOut: "gen/config", ServerOut: "gen/serverhttp", ClientOut: "gen/clienthttp"}, + }, + }, nil) + command := newInspectCmd(inspectCmdOpts{}, func(context.Context, *zap.Logger) (inspectRuntime, error) { + return inspectRuntime{inspector: inspector, shutdown: func(context.Context) error { return nil }}, nil + }) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{"inspect", "--file", "custom.yaml", "--json"}) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"project inspection completed"`) + require.Contains(t, stdout.String(), `"command":"inspect"`) + require.Contains(t, stdout.String(), `"data":{"project":{"root":"/work"`) + require.Contains(t, stdout.String(), `"resolved_input":"api/external/http/client/api/openapi.yaml"`) + require.Contains(t, stdout.String(), `"config":"tools/oapi/api.yaml"`) + require.NotContains(t, stdout.String(), "next_steps") + require.NotContains(t, stdout.String(), `"status"`) +} + +func TestInspectActionReportsServiceErrorOnce(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + inspector := mocks.NewMockprojectInspector(ctrl) + cause := errors.New("inspect failed") + shutdownErr := errors.New("shutdown failed") + inspector.EXPECT().Inspect(gomock.Any(), projectdomain.InspectQuery{}).Return(projectdomain.InspectResult{}, cause) + command := newInspectCmd(inspectCmdOpts{}, func(context.Context, *zap.Logger) (inspectRuntime, error) { + return inspectRuntime{inspector: inspector, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"inspect", "--json"}) + + require.ErrorIs(t, err, cause) + require.ErrorIs(t, err, shutdownErr) + require.Empty(t, err.Error()) + require.Equal(t, 1, bytes.Count(stderr.Bytes(), []byte(`"level":"error"`))) + require.NotContains(t, stderr.String(), `"data"`) +} + +func TestInspectActionReportsCompletedInspectionAfterShutdownFailure(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + inspector := mocks.NewMockprojectInspector(ctrl) + shutdownErr := errors.New("shutdown failed") + inspector.EXPECT().Inspect(gomock.Any(), projectdomain.InspectQuery{}).Return(projectdomain.InspectResult{ + Project: projectdomain.Inspection{Name: "sample"}, + }, nil) + command := newInspectCmd(inspectCmdOpts{}, func(context.Context, *zap.Logger) (inspectRuntime, error) { + return inspectRuntime{inspector: inspector, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"inspect", "--json"}) + + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + partial, ok := details["partial_result"].(map[string]any) + require.True(t, ok) + project, ok := partial["project"].(map[string]any) + require.True(t, ok) + require.Equal(t, "sample", project["name"]) +} diff --git a/cmd/devctl/internal/inspect/mocks/inspect.go b/cmd/devctl/internal/inspect/mocks/inspect.go new file mode 100644 index 0000000..be96666 --- /dev/null +++ b/cmd/devctl/internal/inspect/mocks/inspect.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/inspect (interfaces: projectInspector) +// +// Generated by this command: +// +// mockgen -destination mocks/inspect.go -package mocks -typed . projectInspector +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockprojectInspector is a mock of projectInspector interface. +type MockprojectInspector struct { + ctrl *gomock.Controller + recorder *MockprojectInspectorMockRecorder + isgomock struct{} +} + +// MockprojectInspectorMockRecorder is the mock recorder for MockprojectInspector. +type MockprojectInspectorMockRecorder struct { + mock *MockprojectInspector +} + +// NewMockprojectInspector creates a new mock instance. +func NewMockprojectInspector(ctrl *gomock.Controller) *MockprojectInspector { + mock := &MockprojectInspector{ctrl: ctrl} + mock.recorder = &MockprojectInspectorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockprojectInspector) EXPECT() *MockprojectInspectorMockRecorder { + return m.recorder +} + +// Inspect mocks base method. +func (m *MockprojectInspector) Inspect(ctx context.Context, query project.InspectQuery) (project.InspectResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Inspect", ctx, query) + ret0, _ := ret[0].(project.InspectResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Inspect indicates an expected call of Inspect. +func (mr *MockprojectInspectorMockRecorder) Inspect(ctx, query any) *MockprojectInspectorInspectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Inspect", reflect.TypeOf((*MockprojectInspector)(nil).Inspect), ctx, query) + return &MockprojectInspectorInspectCall{Call: call} +} + +// MockprojectInspectorInspectCall wrap *gomock.Call +type MockprojectInspectorInspectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockprojectInspectorInspectCall) Return(arg0 project.InspectResult, arg1 error) *MockprojectInspectorInspectCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockprojectInspectorInspectCall) Do(f func(context.Context, project.InspectQuery) (project.InspectResult, error)) *MockprojectInspectorInspectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockprojectInspectorInspectCall) DoAndReturn(f func(context.Context, project.InspectQuery) (project.InspectResult, error)) *MockprojectInspectorInspectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/lint/http.go b/cmd/devctl/internal/lint/http.go new file mode 100644 index 0000000..0abb0ad --- /dev/null +++ b/cmd/devctl/internal/lint/http.go @@ -0,0 +1,8 @@ +package lint + +import "github.com/urfave/cli/v3" + +// newLintHTTPCmd constructs the HTTP-only lint leaf. +func newLintHTTPCmd(opts lintCmdOpts, build lintBuilder) *cli.Command { + return newLintLeaf("http", "http", opts, build) +} diff --git a/cmd/devctl/internal/lint/lint.go b/cmd/devctl/internal/lint/lint.go new file mode 100644 index 0000000..21c982b --- /dev/null +++ b/cmd/devctl/internal/lint/lint.go @@ -0,0 +1,173 @@ +package lint + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + lintdomain "github.com/devctllabs/devctl/internal/domain/lint" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/lint.go -package mocks -typed . linter + +type linter interface { + // Lint returns collected findings even when a later contract cannot be processed. + Lint(ctx context.Context, command lintdomain.Command) (lintdomain.Result, error) +} + +// lintRuntime contains the application port and cleanup hook used by Action. +type lintRuntime struct { + linter linter + shutdown func(context.Context) error +} + +// lintBuilder isolates dependency construction from lint behavior. +type lintBuilder func(ctx context.Context, logger *zap.Logger) (lintRuntime, error) + +// lintCmd owns parsed options, a contract family selector, and its runtime factory. +type lintCmd struct { + opts lintCmdOpts + family string + buildRuntime lintBuilder +} + +// lintCmdOpts receives the common leaf flags bound by urfave/cli. +type lintCmdOpts struct { + commandruntime.CommonCmdOpts +} + +// NewCmd constructs the lint command tree. +func NewCmd() *cli.Command { + return newLintCmd(lintCmdOpts{}, buildLint) +} + +func newLintCmd(opts lintCmdOpts, build lintBuilder) *cli.Command { + command := newLintLeaf("lint", "", opts, build) + command.Commands = []*cli.Command{newLintHTTPCmd(lintCmdOpts{}, build), newLintLeaf("grpc", "grpc", lintCmdOpts{}, build), newLintLeaf("kafka", "kafka", lintCmdOpts{}, build)} + return command +} + +// newLintLeaf constructs one executable lint node for the selected contract family. +func newLintLeaf(name, family string, opts lintCmdOpts, build lintBuilder) *cli.Command { + cmd := &lintCmd{opts: opts, family: family, buildRuntime: build} + usageText := "devctl lint" + if family != "" { + usageText += " " + name + } + return &cli.Command{ + Name: name, + Usage: "Lint Project Contracts", + Description: lintDescription(family), + UsageText: usageText + " [--file ]", + Flags: cmd.opts.CommonFlags(), + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +func lintDescription(family string) string { + if family == "" { + return "Lint every supported Contract using committed local inputs. Findings are normal results and exit with status 1 without becoming execution errors." + } + return "Lint committed " + family + " Contracts without synchronizing or generating code. Findings are normal results and exit with status 1." +} + +// Action lints selected contracts and emits all collected findings. +func (cmd *lintCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("lint accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.linter.Lint(ctx, lintdomain.Command{ManifestPath: cmd.opts.ManifestPath, Family: cmd.family}) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := lintDTO(result) + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + var options []commandruntime.ErrorOption + if len(result.Contracts) > 0 { + options = append(options, commandruntime.WithPartialResult(dto)) + } + return reporter.ReportError(finalErr, options...) + } + stdout.Info("contract lint completed", zap.Any("data", dto)) + if !result.Valid { + return cli.Exit("", 1) + } + return nil +} + +// issueDTO is one stable contract lint finding. +type issueDTO struct { + Code string `json:"code"` + Target string `json:"target,omitempty"` + Path string `json:"path,omitempty"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` + Field string `json:"field,omitempty"` + Parameters *issueParametersDTO `json:"parameters,omitempty"` +} + +// issueParametersDTO carries finding-specific facts safe for CLI output. +type issueParametersDTO struct { + OperationID string `json:"operation_id,omitempty"` + Location string `json:"location,omitempty"` + Type string `json:"type,omitempty"` + Subtype string `json:"subtype,omitempty"` + SpecPath string `json:"spec_path,omitempty"` +} + +// resultDTO contains all contracts and findings inspected by one lint run. +type resultDTO struct { + Valid bool `json:"valid"` + Contracts []string `json:"contracts"` + Issues []issueDTO `json:"issues"` +} + +// lintDTO converts the domain result into the stable CLI payload. +func lintDTO(result lintdomain.Result) resultDTO { + contracts := make([]string, len(result.Contracts)) + copy(contracts, result.Contracts) + issues := make([]issueDTO, 0, len(result.Issues)) + for _, issue := range result.Issues { + var parameters *issueParametersDTO + if issue.Parameters != nil { + parameters = &issueParametersDTO{ + OperationID: issue.Parameters.OperationID, Location: issue.Parameters.Location, + Type: issue.Parameters.Type, Subtype: issue.Parameters.Subtype, SpecPath: issue.Parameters.SpecPath, + } + } + issues = append(issues, issueDTO{ + Code: issue.Code, Target: issue.Target, Path: issue.Path, Line: issue.Line, + Column: issue.Column, Field: issue.Field, Parameters: parameters, + }) + } + return resultDTO{Valid: result.Valid, Contracts: contracts, Issues: issues} +} + +// buildLint constructs and resolves the lazy dependencies owned by lint. +func buildLint(ctx context.Context, logger *zap.Logger) (lintRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return lintRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.LintService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return lintRuntime{}, errors.Join(fmt.Errorf("container.LintService: %w", err), shutdownErr) + } + return lintRuntime{linter: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/lint/lint_test.go b/cmd/devctl/internal/lint/lint_test.go new file mode 100644 index 0000000..2e48f2c --- /dev/null +++ b/cmd/devctl/internal/lint/lint_test.go @@ -0,0 +1,93 @@ +package lint + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/lint/mocks" + lintdomain "github.com/devctllabs/devctl/internal/domain/lint" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestLintCommandUsesLintService(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + linter := mocks.NewMocklinter(ctrl) + linter.EXPECT().Lint(gomock.Any(), lintdomain.Command{ManifestPath: "custom.yaml"}).Return(lintdomain.Result{Valid: true, Contracts: []string{"http-server"}, Issues: []lintdomain.Issue{}}, nil) + command := newLintCmd(lintCmdOpts{}, func(_ context.Context, logger *zap.Logger) (lintRuntime, error) { + require.NotNil(t, logger) + return lintRuntime{linter: linter, shutdown: func(context.Context) error { return nil }}, nil + }) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{"lint", "--file", "custom.yaml", "--json", "--verbose"}) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"contract lint completed"`) + require.Contains(t, stdout.String(), `"command":"lint"`) + require.Contains(t, stdout.String(), `"data":{"valid":true,"contracts":["http-server"],"issues":[]}`) +} + +func TestLintActionEmitsInvalidResultAndExitsOneWithoutErrorEvent(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + linter := mocks.NewMocklinter(ctrl) + linter.EXPECT().Lint(gomock.Any(), lintdomain.Command{}).Return(lintdomain.Result{ + Valid: false, Contracts: []string{"http-server"}, Issues: []lintdomain.Issue{{Code: "operation_id_missing"}}, + }, nil) + command := newLintCmd(lintCmdOpts{}, func(context.Context, *zap.Logger) (lintRuntime, error) { + return lintRuntime{linter: linter, shutdown: func(context.Context) error { return nil }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Writer = &stdout + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"lint", "--json"}) + + var exitCoder cli.ExitCoder + require.ErrorAs(t, err, &exitCoder) + require.Equal(t, 1, exitCoder.ExitCode()) + require.Contains(t, stdout.String(), `"data":{"valid":false`) + require.Empty(t, stderr.String()) +} + +func TestLintReportsOperationAndShutdownFailuresWithPartialData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + linter := mocks.NewMocklinter(ctrl) + operationErr := errors.New("lint failed") + shutdownErr := errors.New("shutdown failed") + linter.EXPECT().Lint(gomock.Any(), lintdomain.Command{}).Return( + lintdomain.Result{Contracts: []string{"http-server"}}, operationErr, + ) + command := newLintCmd(lintCmdOpts{}, func(context.Context, *zap.Logger) (lintRuntime, error) { + return lintRuntime{linter: linter, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"lint", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{ + "valid": false, "contracts": []any{"http-server"}, "issues": []any{}, + }, details["partial_result"]) +} diff --git a/cmd/devctl/internal/lint/mocks/lint.go b/cmd/devctl/internal/lint/mocks/lint.go new file mode 100644 index 0000000..16a13a8 --- /dev/null +++ b/cmd/devctl/internal/lint/mocks/lint.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/lint (interfaces: linter) +// +// Generated by this command: +// +// mockgen -destination mocks/lint.go -package mocks -typed . linter +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + lint "github.com/devctllabs/devctl/internal/domain/lint" + gomock "go.uber.org/mock/gomock" +) + +// Mocklinter is a mock of linter interface. +type Mocklinter struct { + ctrl *gomock.Controller + recorder *MocklinterMockRecorder + isgomock struct{} +} + +// MocklinterMockRecorder is the mock recorder for Mocklinter. +type MocklinterMockRecorder struct { + mock *Mocklinter +} + +// NewMocklinter creates a new mock instance. +func NewMocklinter(ctrl *gomock.Controller) *Mocklinter { + mock := &Mocklinter{ctrl: ctrl} + mock.recorder = &MocklinterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *Mocklinter) EXPECT() *MocklinterMockRecorder { + return m.recorder +} + +// Lint mocks base method. +func (m *Mocklinter) Lint(ctx context.Context, command lint.Command) (lint.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Lint", ctx, command) + ret0, _ := ret[0].(lint.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Lint indicates an expected call of Lint. +func (mr *MocklinterMockRecorder) Lint(ctx, command any) *MocklinterLintCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lint", reflect.TypeOf((*Mocklinter)(nil).Lint), ctx, command) + return &MocklinterLintCall{Call: call} +} + +// MocklinterLintCall wrap *gomock.Call +type MocklinterLintCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocklinterLintCall) Return(arg0 lint.Result, arg1 error) *MocklinterLintCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocklinterLintCall) Do(f func(context.Context, lint.Command) (lint.Result, error)) *MocklinterLintCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocklinterLintCall) DoAndReturn(f func(context.Context, lint.Command) (lint.Result, error)) *MocklinterLintCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/sync/http.go b/cmd/devctl/internal/sync/http.go new file mode 100644 index 0000000..cac09c0 --- /dev/null +++ b/cmd/devctl/internal/sync/http.go @@ -0,0 +1,8 @@ +package sync + +import "github.com/urfave/cli/v3" + +// newSyncHTTPCmd constructs the HTTP-only synchronization leaf. +func newSyncHTTPCmd(opts syncCmdOpts, build syncBuilder) *cli.Command { + return newSyncLeaf("http", "http", opts, build) +} diff --git a/cmd/devctl/internal/sync/mocks/sync.go b/cmd/devctl/internal/sync/mocks/sync.go new file mode 100644 index 0000000..47d24b0 --- /dev/null +++ b/cmd/devctl/internal/sync/mocks/sync.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/sync (interfaces: syncer) +// +// Generated by this command: +// +// mockgen -destination mocks/sync.go -package mocks -typed . syncer +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + sync "github.com/devctllabs/devctl/internal/domain/sync" + gomock "go.uber.org/mock/gomock" +) + +// Mocksyncer is a mock of syncer interface. +type Mocksyncer struct { + ctrl *gomock.Controller + recorder *MocksyncerMockRecorder + isgomock struct{} +} + +// MocksyncerMockRecorder is the mock recorder for Mocksyncer. +type MocksyncerMockRecorder struct { + mock *Mocksyncer +} + +// NewMocksyncer creates a new mock instance. +func NewMocksyncer(ctrl *gomock.Controller) *Mocksyncer { + mock := &Mocksyncer{ctrl: ctrl} + mock.recorder = &MocksyncerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *Mocksyncer) EXPECT() *MocksyncerMockRecorder { + return m.recorder +} + +// Sync mocks base method. +func (m *Mocksyncer) Sync(ctx context.Context, command sync.Command) (sync.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Sync", ctx, command) + ret0, _ := ret[0].(sync.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Sync indicates an expected call of Sync. +func (mr *MocksyncerMockRecorder) Sync(ctx, command any) *MocksyncerSyncCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sync", reflect.TypeOf((*Mocksyncer)(nil).Sync), ctx, command) + return &MocksyncerSyncCall{Call: call} +} + +// MocksyncerSyncCall wrap *gomock.Call +type MocksyncerSyncCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MocksyncerSyncCall) Return(arg0 sync.Result, arg1 error) *MocksyncerSyncCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MocksyncerSyncCall) Do(f func(context.Context, sync.Command) (sync.Result, error)) *MocksyncerSyncCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MocksyncerSyncCall) DoAndReturn(f func(context.Context, sync.Command) (sync.Result, error)) *MocksyncerSyncCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/sync/sync.go b/cmd/devctl/internal/sync/sync.go new file mode 100644 index 0000000..1653cc3 --- /dev/null +++ b/cmd/devctl/internal/sync/sync.go @@ -0,0 +1,158 @@ +package sync + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/sync.go -package mocks -typed . syncer + +type syncer interface { + // Sync returns completed target facts even when a later target fails. + Sync(ctx context.Context, command syncdomain.Command) (syncdomain.Result, error) +} + +// syncRuntime contains the application port and cleanup hook used by Action. +type syncRuntime struct { + syncer syncer + shutdown func(context.Context) error +} + +// syncBuilder isolates dependency construction from synchronization behavior. +type syncBuilder func(ctx context.Context, logger *zap.Logger) (syncRuntime, error) + +// syncCmd owns parsed options, a contract family selector, and its runtime factory. +type syncCmd struct { + opts syncCmdOpts + family string + buildRuntime syncBuilder +} + +// syncCmdOpts receives common flags plus synchronization target selection. +type syncCmdOpts struct { + commandruntime.CommonCmdOpts + Target string + DryRun bool +} + +// NewCmd constructs the sync command tree. +func NewCmd() *cli.Command { + return newSyncCmd(syncCmdOpts{}, buildSync) +} + +func newSyncCmd(opts syncCmdOpts, build syncBuilder) *cli.Command { + command := newSyncLeaf("sync", "", opts, build) + command.Commands = []*cli.Command{newSyncHTTPCmd(syncCmdOpts{}, build), newSyncLeaf("grpc", "grpc", syncCmdOpts{}, build), newSyncLeaf("kafka", "kafka", syncCmdOpts{}, build)} + return command +} + +// newSyncLeaf constructs one executable sync node for the selected contract family. +func newSyncLeaf(name, family string, opts syncCmdOpts, build syncBuilder) *cli.Command { + cmd := &syncCmd{opts: opts, family: family, buildRuntime: build} + usageText := "devctl sync" + if family != "" { + usageText += " " + name + } + flags := append(cmd.opts.CommonFlags(), + &cli.StringFlag{Name: "target", Destination: &cmd.opts.Target, Usage: "select one exact Target `id`, such as http-client:billing"}, + &cli.BoolFlag{Name: "dry-run", Destination: &cmd.opts.DryRun, Usage: "preview publication and pruning without network access or writes"}, + ) + return &cli.Command{ + Name: name, + Usage: "Synchronize external Contracts", + Description: syncDescription(family), + UsageText: usageText + " [--target ] [--dry-run]", + Flags: flags, + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +func syncDescription(family string) string { + if family == "" { + return "Materialize every supported external Contract Snapshot into Project-owned paths. Full synchronization may prune stale Target directories; use --dry-run to preview changes." + } + return "Materialize external " + family + " Contract Snapshots. Family synchronization may prune stale Target directories; an explicit --target never prunes sibling Targets." +} + +// Action synchronizes the selected contracts and emits the completed or partial result. +func (cmd *syncCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("sync accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, operationErr := runtime.syncer.Sync(ctx, syncdomain.Command{ + ManifestPath: cmd.opts.ManifestPath, + Family: cmd.family, + Target: cmd.opts.Target, + DryRun: cmd.opts.DryRun, + }) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := syncDTO(result) + if finalErr := errors.Join(operationErr, shutdownErr); finalErr != nil { + var options []commandruntime.ErrorOption + if !result.DryRun && (len(result.Targets) > 0 || len(result.Changes) > 0) { + options = append(options, commandruntime.WithPartialResult(dto)) + } + return reporter.ReportError(finalErr, options...) + } + stdout.Info("contract synchronization completed", zap.Any("data", dto)) + return nil +} + +// syncChangeDTO is one stable contract publication fact. +type syncChangeDTO struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` +} + +// syncResultDTO contains all completed targets and changes, including dry runs. +type syncResultDTO struct { + Targets []string `json:"targets"` + Changes []syncChangeDTO `json:"changes"` + DryRun bool `json:"dry_run"` +} + +// syncDTO converts the domain result into the stable CLI payload. +func syncDTO(result syncdomain.Result) syncResultDTO { + targets := make([]string, len(result.Targets)) + copy(targets, result.Targets) + changes := make([]syncChangeDTO, 0, len(result.Changes)) + for _, change := range result.Changes { + changes = append(changes, syncChangeDTO{Target: change.Target, Path: change.Path, Action: string(change.Action)}) + } + return syncResultDTO{Targets: targets, Changes: changes, DryRun: result.DryRun} +} + +// buildSync constructs and resolves the lazy dependencies owned by sync. +func buildSync(ctx context.Context, logger *zap.Logger) (syncRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return syncRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.SyncService() + if err != nil { + shutdownErr := commandruntime.Shutdown(ctx, container) + return syncRuntime{}, errors.Join(fmt.Errorf("container.SyncService: %w", err), shutdownErr) + } + return syncRuntime{syncer: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/sync/sync_test.go b/cmd/devctl/internal/sync/sync_test.go new file mode 100644 index 0000000..b8bfd2d --- /dev/null +++ b/cmd/devctl/internal/sync/sync_test.go @@ -0,0 +1,144 @@ +package sync + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/sync/mocks" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestSyncCommandBuildsAndRunsOnlySyncService(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + syncer := mocks.NewMocksyncer(ctrl) + syncer.EXPECT().Sync(gomock.Any(), syncdomain.Command{ + ManifestPath: "custom.yaml", + Target: "http-client:remote", + DryRun: true, + }).Return(syncdomain.Result{ + Targets: []string{"http-client:remote"}, + Changes: []syncdomain.Change{{Target: "http-client:remote", Path: "api/external/clienthttp/remote", Action: syncdomain.ChangePlannedPublish}}, + DryRun: true, + }, nil) + command := newSyncCmd(syncCmdOpts{}, func(_ context.Context, logger *zap.Logger) (syncRuntime, error) { + require.NotNil(t, logger) + return syncRuntime{syncer: syncer, shutdown: func(context.Context) error { return nil }}, nil + }) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{ + "sync", "--file", "custom.yaml", "--json", "--verbose", "--target", "http-client:remote", "--dry-run", + }) + + require.NoError(t, err) + require.Contains(t, stdout.String(), `"msg":"contract synchronization completed"`) + require.Contains(t, stdout.String(), `"command":"sync"`) + require.Contains(t, stdout.String(), `"data":{"targets":["http-client:remote"],"changes":[{"target":"http-client:remote","path":"api/external/clienthttp/remote","action":"planned_publish"}],"dry_run":true}`) +} + +func TestSyncReportsOperationAndShutdownFailuresWithPartialData(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + syncer := mocks.NewMocksyncer(ctrl) + operationErr := errors.New("sync failed") + shutdownErr := errors.New("shutdown failed") + syncer.EXPECT().Sync(gomock.Any(), syncdomain.Command{}).Return( + syncdomain.Result{Targets: []string{"http-client:catalog"}}, operationErr, + ) + command := newSyncCmd(syncCmdOpts{}, func(context.Context, *zap.Logger) (syncRuntime, error) { + return syncRuntime{syncer: syncer, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"sync", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + require.Equal(t, map[string]any{ + "partial_result": map[string]any{ + "targets": []any{"http-client:catalog"}, "changes": []any{}, "dry_run": false, + }, + }, event["details"]) +} + +func TestSyncDryRunFailureOmitsPlannedPartialResult(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + syncer := mocks.NewMocksyncer(ctrl) + cause := errors.New("planning failed") + syncer.EXPECT().Sync(gomock.Any(), syncdomain.Command{DryRun: true}).Return( + syncdomain.Result{Targets: []string{"config"}, DryRun: true}, cause, + ) + command := newSyncCmd(syncCmdOpts{}, func(context.Context, *zap.Logger) (syncRuntime, error) { + return syncRuntime{syncer: syncer, shutdown: func(context.Context) error { return nil }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"sync", "--json", "--dry-run"}) + + require.ErrorIs(t, err, cause) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + require.NotContains(t, event, "details") +} + +func TestSyncReportsMaterializationCategoriesWithoutRawCause(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + kind materializedomain.FailureKind + code string + }{ + {name: "invalid input", kind: materializedomain.FailureInvalid, code: "invalid_input"}, + {name: "not found", kind: materializedomain.FailureNotFound, code: "not_found"}, + {name: "unsupported", kind: materializedomain.FailureUnsupported, code: "unsupported"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + syncer := mocks.NewMocksyncer(ctrl) + cause := &materializedomain.OperationError{ + Operation: materializedomain.OperationBuildSnapshot, Kind: test.kind, + } + syncer.EXPECT().Sync(gomock.Any(), syncdomain.Command{}).Return(syncdomain.Result{}, cause) + command := newSyncCmd(syncCmdOpts{}, func(context.Context, *zap.Logger) (syncRuntime, error) { + return syncRuntime{syncer: syncer, shutdown: func(context.Context) error { return nil }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"sync", "--json"}) + + require.ErrorIs(t, err, cause) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.Equal(t, test.code, event["code"]) + require.NotContains(t, event, "error") + require.NotContains(t, stderr.String(), string(materializedomain.OperationBuildSnapshot)) + }) + } +} diff --git a/cmd/devctl/internal/validate/mocks/validate.go b/cmd/devctl/internal/validate/mocks/validate.go new file mode 100644 index 0000000..2913223 --- /dev/null +++ b/cmd/devctl/internal/validate/mocks/validate.go @@ -0,0 +1,81 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/cmd/devctl/internal/validate (interfaces: projectValidator) +// +// Generated by this command: +// +// mockgen -destination mocks/validate.go -package mocks -typed . projectValidator +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockprojectValidator is a mock of projectValidator interface. +type MockprojectValidator struct { + ctrl *gomock.Controller + recorder *MockprojectValidatorMockRecorder + isgomock struct{} +} + +// MockprojectValidatorMockRecorder is the mock recorder for MockprojectValidator. +type MockprojectValidatorMockRecorder struct { + mock *MockprojectValidator +} + +// NewMockprojectValidator creates a new mock instance. +func NewMockprojectValidator(ctrl *gomock.Controller) *MockprojectValidator { + mock := &MockprojectValidator{ctrl: ctrl} + mock.recorder = &MockprojectValidatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockprojectValidator) EXPECT() *MockprojectValidatorMockRecorder { + return m.recorder +} + +// Validate mocks base method. +func (m *MockprojectValidator) Validate(ctx context.Context, query project.ValidateQuery) (project.ValidationResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Validate", ctx, query) + ret0, _ := ret[0].(project.ValidationResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Validate indicates an expected call of Validate. +func (mr *MockprojectValidatorMockRecorder) Validate(ctx, query any) *MockprojectValidatorValidateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Validate", reflect.TypeOf((*MockprojectValidator)(nil).Validate), ctx, query) + return &MockprojectValidatorValidateCall{Call: call} +} + +// MockprojectValidatorValidateCall wrap *gomock.Call +type MockprojectValidatorValidateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockprojectValidatorValidateCall) Return(arg0 project.ValidationResult, arg1 error) *MockprojectValidatorValidateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockprojectValidatorValidateCall) Do(f func(context.Context, project.ValidateQuery) (project.ValidationResult, error)) *MockprojectValidatorValidateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockprojectValidatorValidateCall) DoAndReturn(f func(context.Context, project.ValidateQuery) (project.ValidationResult, error)) *MockprojectValidatorValidateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/cmd/devctl/internal/validate/validate.go b/cmd/devctl/internal/validate/validate.go new file mode 100644 index 0000000..0b8f84d --- /dev/null +++ b/cmd/devctl/internal/validate/validate.go @@ -0,0 +1,113 @@ +package validate + +import ( + "context" + "errors" + "fmt" + "time" + + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" + "github.com/devctllabs/devctl/internal/deps" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/urfave/cli/v3" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/validate.go -package mocks -typed . projectValidator + +type projectValidator interface { + // Validate returns all available project issues; invalid project data is not an execution error. + Validate(ctx context.Context, query projectdomain.ValidateQuery) (projectdomain.ValidationResult, error) +} + +// validateRuntime contains only the behavior and cleanup hook needed after command construction. +type validateRuntime struct { + validator projectValidator + shutdown func(context.Context) error +} + +// validateBuilder owns dependency construction so Action remains testable without a container. +type validateBuilder func(ctx context.Context, logger *zap.Logger) (validateRuntime, error) + +// validateCmd owns parsed options and the runtime factory for one invocation. +type validateCmd struct { + opts validateCmdOpts + buildRuntime validateBuilder +} + +// validateCmdOpts receives the common leaf flags bound by urfave/cli. +type validateCmdOpts struct { + commandruntime.CommonCmdOpts +} + +// NewCmd constructs the validate command. +func NewCmd() *cli.Command { return newValidateCmd(validateCmdOpts{}, buildValidate) } + +func newValidateCmd(opts validateCmdOpts, build validateBuilder) *cli.Command { + cmd := &validateCmd{opts: opts, buildRuntime: build} + return &cli.Command{ + Name: "validate", + Usage: "Validate the selected Project", + Description: "Check Manifest structure, semantic validity, references, safe paths, and Project Readiness. Validation findings are normal results and exit with status 1 when any issue is present.", + UsageText: "devctl validate [--file ]", + Flags: cmd.opts.CommonFlags(), + OnUsageError: func(_ context.Context, command *cli.Command, err error, _ bool) error { + reporter := commandruntime.NewErrorReporter(cmd.opts.NewStderrLogger(command), cmd.opts.Verbose) + return reporter.ReportError(cli.Exit(err, 2)) + }, + Action: cmd.Action, + } +} + +// Action validates the selected project and emits its complete result as one event. +func (cmd *validateCmd) Action(ctx context.Context, command *cli.Command) error { + stdout := cmd.opts.NewStdoutLogger(command) + stderr := cmd.opts.NewStderrLogger(command) + reporter := commandruntime.NewErrorReporter(stderr, cmd.opts.Verbose) + if command.Args().Len() != 0 { + return reporter.ReportError(cli.Exit("validate accepts no positional arguments", 2)) + } + runtime, err := cmd.buildRuntime(ctx, stderr) + if err != nil { + return reporter.ReportError(err) + } + result, validateErr := runtime.validator.Validate(ctx, projectdomain.ValidateQuery{ManifestPath: cmd.opts.ManifestPath}) + shutdownErr := lifecycle.Shutdown(ctx, 5*time.Second, runtime.shutdown) + dto := validationDTO(result) + var options []commandruntime.ErrorOption + if validateErr == nil { + options = append(options, commandruntime.WithPartialResult(dto)) + } + if finalErr := errors.Join(validateErr, shutdownErr); finalErr != nil { + return reporter.ReportError(finalErr, options...) + } + stdout.Info("project validation completed", zap.Any("data", dto)) + if !result.IsValid() { + return cli.Exit("", 1) + } + return nil +} + +type validationResultDTO struct { + Valid bool `json:"valid"` + Issues []commandruntime.ValidationIssueDTO `json:"issues"` +} + +// validationDTO converts the domain result into the stable CLI payload. +func validationDTO(result projectdomain.ValidationResult) validationResultDTO { + return validationResultDTO{Valid: result.IsValid(), Issues: commandruntime.ValidationIssueDTOs(result.Issues)} +} + +// buildValidate constructs and resolves the lazy dependencies owned by validate. +func buildValidate(ctx context.Context, logger *zap.Logger) (validateRuntime, error) { + container, err := deps.New(logger) + if err != nil { + return validateRuntime{}, fmt.Errorf("deps.New: %w", err) + } + service, err := container.ProjectService() + if err != nil { + return validateRuntime{}, errors.Join(err, lifecycle.Shutdown(ctx, 5*time.Second, container.Shutdown)) + } + return validateRuntime{validator: service, shutdown: container.Shutdown}, nil +} diff --git a/cmd/devctl/internal/validate/validate_test.go b/cmd/devctl/internal/validate/validate_test.go new file mode 100644 index 0000000..b0323f8 --- /dev/null +++ b/cmd/devctl/internal/validate/validate_test.go @@ -0,0 +1,156 @@ +package validate + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "testing" + + "github.com/devctllabs/devctl/cmd/devctl/internal/validate/mocks" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestValidateWritesCommandSpecificJSON(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + validator := mocks.NewMockprojectValidator(ctrl) + validator.EXPECT().Validate(gomock.Any(), projectdomain.ValidateQuery{ManifestPath: "custom.yaml"}).Return(projectdomain.ValidationResult{Issues: []projectdomain.Issue{}}, nil) + built := false + command := newValidateCmd(validateCmdOpts{}, func(_ context.Context, logger *zap.Logger) (validateRuntime, error) { + built = true + require.NotNil(t, logger) + return validateRuntime{validator: validator, shutdown: func(context.Context) error { return nil }}, nil + }) + var stdout bytes.Buffer + command.Writer = &stdout + + err := command.Run(context.Background(), []string{"validate", "--file", "custom.yaml", "--json"}) + + require.NoError(t, err) + require.True(t, built) + require.Contains(t, stdout.String(), `"msg":"project validation completed"`) + require.Contains(t, stdout.String(), `"command":"validate"`) + require.Contains(t, stdout.String(), `"data":{"valid":true,"issues":[]}`) +} + +func TestValidateReturnsSafeExecutionError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + validator := mocks.NewMockprojectValidator(ctrl) + validator.EXPECT().Validate(gomock.Any(), projectdomain.ValidateQuery{}).Return( + projectdomain.ValidationResult{}, fmt.Errorf("repository.LoadDocument: %w", &projectdomain.OperationError{ + Operation: projectdomain.OperationLoadManifest, + Path: "/private/work/devctl.yaml", + Kind: projectdomain.FailureNotFound, + Cause: fs.ErrNotExist, + }), + ) + command := newValidateCmd(validateCmdOpts{}, func(context.Context, *zap.Logger) (validateRuntime, error) { + return validateRuntime{validator: validator, shutdown: func(context.Context) error { return nil }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Writer = &stdout + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"validate", "--json"}) + + var exitCoder cli.ExitCoder + require.ErrorAs(t, err, &exitCoder) + require.Equal(t, 1, exitCoder.ExitCode()) + require.Empty(t, err.Error()) + require.ErrorIs(t, err, fs.ErrNotExist) + require.Empty(t, stdout.String()) + require.Contains(t, stderr.String(), `"msg":"requested resource was not found"`) + require.Contains(t, stderr.String(), `"code":"not_found"`) + require.NotContains(t, stderr.String(), "/private/work/devctl.yaml") +} + +func TestValidateReportsOperationAndShutdownFailures(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + validator := mocks.NewMockprojectValidator(ctrl) + operationErr := errors.New("validation failed") + shutdownErr := errors.New("shutdown failed") + validator.EXPECT().Validate(gomock.Any(), projectdomain.ValidateQuery{}).Return(projectdomain.ValidationResult{}, operationErr) + command := newValidateCmd(validateCmdOpts{}, func(context.Context, *zap.Logger) (validateRuntime, error) { + return validateRuntime{validator: validator, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"validate", "--json"}) + + require.ErrorIs(t, err, operationErr) + require.ErrorIs(t, err, shutdownErr) + require.Equal(t, 1, bytes.Count(stderr.Bytes(), []byte(`"level":"error"`))) + require.NotContains(t, stderr.String(), `"data"`) +} + +func TestValidateReportsCompletedValidationAfterShutdownFailure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + validator := mocks.NewMockprojectValidator(ctrl) + shutdownErr := errors.New("shutdown failed") + validator.EXPECT().Validate(gomock.Any(), projectdomain.ValidateQuery{}).Return( + projectdomain.ValidationResult{Issues: []projectdomain.Issue{}}, nil, + ) + command := newValidateCmd(validateCmdOpts{}, func(context.Context, *zap.Logger) (validateRuntime, error) { + return validateRuntime{validator: validator, shutdown: func(context.Context) error { return shutdownErr }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stderr bytes.Buffer + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"validate", "--json"}) + + require.ErrorIs(t, err, shutdownErr) + var event map[string]any + require.NoError(t, json.Unmarshal(stderr.Bytes(), &event)) + require.NotContains(t, event, "data") + details, ok := event["details"].(map[string]any) + require.True(t, ok) + require.Equal(t, map[string]any{"valid": true, "issues": []any{}}, details["partial_result"]) +} + +func TestValidateWritesIssuesAndExitsOne(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + validator := mocks.NewMockprojectValidator(ctrl) + validator.EXPECT().Validate(gomock.Any(), projectdomain.ValidateQuery{}).Return(projectdomain.ValidationResult{ + Issues: []projectdomain.Issue{{ + Code: "name_invalid", Path: "devctl.yaml", Line: 3, Column: 9, Field: "project.name", + }}, + }, nil) + command := newValidateCmd(validateCmdOpts{}, func(context.Context, *zap.Logger) (validateRuntime, error) { + return validateRuntime{validator: validator, shutdown: func(context.Context) error { return nil }}, nil + }) + command.ExitErrHandler = func(context.Context, *cli.Command, error) {} + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Writer = &stdout + command.ErrWriter = &stderr + + err := command.Run(context.Background(), []string{"validate", "--json"}) + + var exitCoder cli.ExitCoder + require.ErrorAs(t, err, &exitCoder) + require.Equal(t, 1, exitCoder.ExitCode()) + require.Contains(t, stdout.String(), `"msg":"project validation completed"`) + require.Contains(t, stdout.String(), `"data":{"valid":false,"issues":[{"code":"name_invalid","path":"devctl.yaml","line":3,"column":9,"field":"project.name"}]}`) + require.Empty(t, stderr.String()) +} diff --git a/cmd/devctl/main.go b/cmd/devctl/main.go new file mode 100644 index 0000000..18269a9 --- /dev/null +++ b/cmd/devctl/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + devctlapp "github.com/devctllabs/devctl/cmd/devctl/internal/app" + commandruntime "github.com/devctllabs/devctl/cmd/devctl/internal/command" +) + +var ( + version = "dev" + commit = "" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + root := devctlapp.New(version, commit) + + if err := root.Run(ctx, os.Args); err != nil { + rootOpts := commandruntime.CommonCmdOpts{} + reporter := commandruntime.NewErrorReporter(rootOpts.NewStderrLogger(root), false) + os.Exit(commandruntime.ExitCode(reporter.ReportError(err))) + } +} diff --git a/cmd/devctl/main_test.go b/cmd/devctl/main_test.go new file mode 100644 index 0000000..74694c2 --- /dev/null +++ b/cmd/devctl/main_test.go @@ -0,0 +1,1658 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/BurntSushi/toml" + "github.com/devctllabs/devctl/internal/testutil/testexec" + "github.com/stretchr/testify/require" + "golang.org/x/mod/modfile" + "gopkg.in/yaml.v3" +) + +var testCLIBinary string + +func TestMain(m *testing.M) { + testRoot, err := os.MkdirTemp("", "devctl-test-") + if err != nil { + fmt.Fprintf(os.Stderr, "create test directory: %v\n", err) + os.Exit(1) + } + + binary := filepath.Join(testRoot, "devctl") + build := exec.CommandContext(context.Background(), "go", "build", "-o", binary, ".") + output, err := build.CombinedOutput() + if err != nil { + fmt.Fprintf(os.Stderr, "build test CLI: %v\n%s", err, output) + _ = os.RemoveAll(testRoot) + os.Exit(1) + } + testCLIBinary = binary + + exitCode := m.Run() + if err := os.RemoveAll(testRoot); err != nil { + fmt.Fprintf(os.Stderr, "remove test directory: %v\n", err) + if exitCode == 0 { + exitCode = 1 + } + } + os.Exit(exitCode) +} + +func TestRootHelp(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + stdout := runCLI(t, binary, "--help") + for _, commandName := range []string{"init", "validate", "inspect", "enable", "add", "sync", "gen", "lint"} { + require.Contains(t, stdout, commandName) + } + require.NotContains(t, stdout, "--file") + require.NotContains(t, stdout, "--json") + require.NotContains(t, stdout, "--verbose") + require.NotContains(t, stdout, "--format") +} + +func TestCanonicalCommandFamiliesExposePlannedLeaves(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + + tests := []struct { + args []string + leaves []string + }{ + {args: []string{"enable", "--help"}, leaves: []string{"grpc"}}, + {args: []string{"add", "--help"}, leaves: []string{"grpc-client", "kafka-consumer", "kafka-producer", "redis", "s3-connection", "s3"}}, + {args: []string{"sync", "--help"}, leaves: []string{"grpc", "kafka"}}, + {args: []string{"gen", "--help"}, leaves: []string{"grpc", "kafka"}}, + {args: []string{"lint", "--help"}, leaves: []string{"grpc", "kafka"}}, + } + + for _, test := range tests { + output := runCLI(t, binary, test.args...) + for _, leaf := range test.leaves { + require.Contains(t, output, leaf, "%v", test.args) + } + } +} + +func TestInitScaffoldCreatesPinnedBufTooling(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} +languages: + go: + module: example.test/sample + generators: + grpc: {out: gen/grpc, buf_gen_config: tools/buf/grpc.gen.yaml} +`), 0o644)) + + runCLI(t, binary, "init", "scaffold", "--file", manifestPath, "--json") + + goModBytes, err := os.ReadFile(filepath.Join(root, "go.mod")) + require.NoError(t, err) + goMod, err := modfile.Parse("go.mod", goModBytes, nil) + require.NoError(t, err) + tools := make([]string, 0, len(goMod.Tool)) + for _, tool := range goMod.Tool { + tools = append(tools, tool.Path) + } + require.ElementsMatch(t, []string{ + "github.com/bufbuild/buf/cmd/buf", + "google.golang.org/protobuf/cmd/protoc-gen-go", + "google.golang.org/grpc/cmd/protoc-gen-go-grpc", + }, tools) + requiredVersions := map[string]string{} + for _, dependency := range goMod.Require { + requiredVersions[dependency.Mod.Path] = dependency.Mod.Version + } + require.Equal(t, "v1.72.0", requiredVersions["github.com/bufbuild/buf"]) + require.Equal(t, "v1.36.12", requiredVersions["google.golang.org/protobuf"]) + require.Equal(t, "v1.6.2", requiredVersions["google.golang.org/grpc/cmd/protoc-gen-go-grpc"]) + + bufModuleBytes, err := os.ReadFile(filepath.Join(root, "buf.yaml")) + require.NoError(t, err) + var bufModule struct { + Version string `yaml:"version"` + Modules []struct { + Path string `yaml:"path"` + } `yaml:"modules"` + Lint struct { + Use []string `yaml:"use"` + Except []string `yaml:"except"` + } `yaml:"lint"` + } + require.NoError(t, yaml.Unmarshal(bufModuleBytes, &bufModule)) + require.Equal(t, "v2", bufModule.Version) + require.Equal(t, "api/proto", bufModule.Modules[0].Path) + require.Equal(t, []string{"STANDARD"}, bufModule.Lint.Use) + require.Equal(t, []string{"FILE_LOWER_SNAKE_CASE"}, bufModule.Lint.Except) + + bufGenerateBytes, err := os.ReadFile(filepath.Join(root, "tools/buf/grpc.gen.yaml")) + require.NoError(t, err) + var bufGenerate struct { + Version string `yaml:"version"` + Plugins []struct { + Local []string `yaml:"local"` + Out string `yaml:"out"` + } `yaml:"plugins"` + } + require.NoError(t, yaml.Unmarshal(bufGenerateBytes, &bufGenerate)) + require.Equal(t, "v2", bufGenerate.Version) + require.Equal(t, [][]string{ + {"go", "tool", "protoc-gen-go"}, + {"go", "tool", "protoc-gen-go-grpc"}, + }, [][]string{bufGenerate.Plugins[0].Local, bufGenerate.Plugins[1].Local}) + require.Equal(t, ".", bufGenerate.Plugins[0].Out) + require.Equal(t, ".", bufGenerate.Plugins[1].Out) +} + +func TestEnableGRPCWritesCanonicalManifest(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "enable", "grpc", "--file", manifestPath, "--json") + + manifest := readTestManifest(t, manifestPath) + require.NotNil(t, manifest.Components.GRPC) + require.Equal(t, "api/proto/grpc", manifest.Components.GRPC.Server.ProtoRoot) + require.Equal(t, "buf.yaml", manifest.Components.GRPC.Server.BufConfig) + require.Equal(t, "GRPC_SERVER_ENABLED", manifest.Components.GRPC.Server.Start.Env) + require.True(t, *manifest.Components.GRPC.Server.Start.Default) + require.Equal(t, testGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, *manifest.Languages.Go.Generators.GRPC) +} + +func TestEnableGRPCAlwaysOmitsStartPolicy(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "enable", "grpc", "--file", manifestPath, "--always") + + manifest := readTestManifest(t, manifestPath) + require.NotNil(t, manifest.Components.GRPC) + require.Nil(t, manifest.Components.GRPC.Server.Start) +} + +func TestAddGRPCClientWritesCanonicalManifest(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + contracts: {type: local, path: api/contracts} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "grpc-client", "billing", "--file", manifestPath, + "--source", "contracts", "--path", "billing", "--proto-root", "proto", + "--buf-gen-config", "tools/buf/billing.gen.yaml", "--addr-env", "BILLING_GRPC_ADDR") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, []testGRPCClient{{ + Name: "billing", Source: "contracts", Path: "billing", ProtoRoot: "proto", + BufGenConfig: "tools/buf/billing.gen.yaml", AddrEnv: "BILLING_GRPC_ADDR", + }}, manifest.Components.GRPC.Clients) + require.Equal(t, testGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, *manifest.Languages.Go.Generators.GRPC) +} + +func TestAddKafkaEndpointsWritesConsumerAndProducerPolicies(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "kafka-consumer", "billing", "--file", manifestPath, + "--topic", "billing.events", "--format", "raw", "--group-env", "BILLING_GROUP") + runCLI(t, binary, "add", "kafka-producer", "audit", "--file", manifestPath, + "--topic", "audit.events", "--format", "raw", "--topic-env", "AUDIT_TOPIC") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, []testKafkaConsumer{{ + Name: "billing", Topic: "billing.events", GroupEnv: "BILLING_GROUP", + Start: &testStart{Env: "KAFKA_BILLING_CONSUMER_ENABLED", Default: boolPointer(false)}, + Contract: testKafkaContract{Format: "raw"}, + }}, manifest.Components.Kafka.Consumers) + require.Equal(t, []testKafkaProducer{{ + Name: "audit", Topic: "audit.events", TopicEnv: "AUDIT_TOPIC", + Contract: testKafkaContract{Format: "raw"}, + }}, manifest.Components.Kafka.Producers) +} + +func TestAddSourcePersistsNestedBufConfig(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "source", "events", "--file", manifestPath, + "--type", "local", "--path", "api/events", "--buf-config", "buf.yaml") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, "buf.yaml", manifest.Sources["events"].Proto.BufConfig) +} + +func TestValidateAcceptsLocalSourceDirectory(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.MkdirAll(filepath.Join(root, "api", "events"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n"), 0o644)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + events: {type: local, path: api/events} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "validate", "--file", manifestPath) +} + +func TestAddKafkaProtoEndpointConfiguresBufGeneration(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + events: + type: local + path: api/events + proto: {buf_config: buf.yaml} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "kafka-producer", "invoice", "--file", manifestPath, + "--topic", "invoice.events", "--format", "proto", "--source", "events", + "--path", "proto/invoice.proto", "--proto-root", "proto", "--message", "acme.invoice.v1.Invoice", + "--encoding", "binary") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, testKafkaContract{ + Source: "events", Path: "proto/invoice.proto", Format: "proto", ProtoRoot: "proto", + Message: "acme.invoice.v1.Invoice", Encoding: "binary", + }, manifest.Components.Kafka.Producers[0].Contract) + require.Equal(t, testGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"}, *manifest.Languages.Go.Generators.Kafka) +} + +func TestAddStorageResourcesWritesSafeDefaults(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "redis", "cache", "--file", manifestPath) + runCLI(t, binary, "add", "s3", "media", "--file", manifestPath) + runCLI(t, binary, "add", "db", "analytics", "--file", manifestPath, "--kind", "clickhouse") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, []testRedisConnection{{Name: "cache", AddrEnv: "REDIS_CACHE_ADDR", AddrDefault: "localhost:6379"}}, manifest.Components.Redis.Connections) + require.Equal(t, []testS3Connection{{Name: "default", Credentials: "static", Endpoint: "http://localhost:9000", Region: "us-east-1", PathStyle: true}}, manifest.Components.S3.Connections) + require.Equal(t, []testS3Bucket{{Name: "media", Connection: "default", Bucket: "media-local"}}, manifest.Components.S3.Buckets) + require.Equal(t, "clickhouse", manifest.Components.DB.Connections[0].Variants[0].Kind) + require.Equal(t, "clickhouse://localhost:9000/default", manifest.Components.DB.Connections[0].Variants[0].DSNDefault) +} + +func TestAddSQLiteDatabaseConfiguresMigrations(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "db", "primary", "--file", manifestPath, "--kind", "sqlite") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, []testDBVariant{{ + Kind: "sqlite", + DSNDefault: "file:./data/primary.db?_foreign_keys=on", + Migrations: &testDBMigrations{ + Path: "migrations/primary/sqlite", + DatabaseEnv: "DB_PRIMARY_SQLITE_MIGRATIONS_URL", + DatabaseDefault: "sqlite://./data/primary.db?_pragma=foreign_keys%281%29", + }, + }}, manifest.Components.DB.Connections[0].Variants) +} + +func TestAddDatabaseSupportsMigrationOverrideAndOptOut(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "db", "archive", "--file", manifestPath, "--kind", "postgres", "--migrations-path", "db/archive") + runCLI(t, binary, "add", "db", "scratch", "--file", manifestPath, "--kind", "sqlite", "--no-migrations") + + manifest := readTestManifest(t, manifestPath) + require.Equal(t, "archive", manifest.Components.DB.Connections[0].Name) + require.Equal(t, &testDBMigrations{Path: "db/archive", DatabaseEnv: "DB_ARCHIVE_POSTGRES_MIGRATIONS_URL"}, manifest.Components.DB.Connections[0].Variants[0].Migrations) + require.Equal(t, "scratch", manifest.Components.DB.Connections[1].Name) + require.Nil(t, manifest.Components.DB.Connections[1].Variants[0].Migrations) +} + +func TestAddRejectsUnsafeMigrationAndRedisOptions(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + tests := []struct { + args []string + exitCode int + }{ + {[]string{"add", "db", "primary", "--file", manifestPath, "--kind", "sqlite", "--no-migrations", "--migrations-path", "migrations/primary/sqlite", "--json"}, 2}, + {[]string{"add", "db", "primary", "--file", manifestPath, "--kind", "sqlite", "--migrations-path", "../outside", "--json"}, 1}, + {[]string{"add", "db", "analytics", "--file", manifestPath, "--kind", "clickhouse", "--migrations-path", "../outside", "--json"}, 1}, + {[]string{"add", "redis", "cache", "--file", manifestPath, "--addr-default", "redis://user:secret@localhost:6379/0", "--json"}, 1}, + {[]string{"add", "redis", "cache", "--file", manifestPath, "--json", "--default"}, 2}, + } + for _, test := range tests { + _, exitCode := runCLIError(t, binary, test.args...) + require.Equal(t, test.exitCode, exitCode, test.args) + } +} + +func TestInitScaffoldCreatesMigrationToolingWithoutOwningSQL(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + db: + connections: + - name: primary + default: sqlite + variants: + - name: sqlite + kind: sqlite + dsn_env: DB_PRIMARY_SQLITE_DSN + dsn_default: file:./data/primary.db?_foreign_keys=on + migrations: + path: migrations/primary/sqlite + database_env: DB_PRIMARY_SQLITE_MIGRATIONS_URL + database_default: sqlite://./data/primary.db?_pragma=foreign_keys%281%29 +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "init", "scaffold", "--file", manifestPath) + + require.FileExists(t, filepath.Join(root, "migrations/primary/sqlite/.gitkeep")) + var mise struct { + Tasks map[string]struct { + Run string `toml:"run"` + } `toml:"tasks"` + } + _, err := toml.DecodeFile(filepath.Join(root, ".mise.toml"), &mise) + require.NoError(t, err) + require.Contains(t, mise.Tasks, "migrate:primary:sqlite:create") + require.Contains(t, mise.Tasks, "migrate:primary:sqlite:up") + require.Contains(t, mise.Tasks, "migrate:primary:sqlite:down") + goMod, err := os.ReadFile(filepath.Join(root, "go.mod")) + require.NoError(t, err) + require.NotContains(t, string(goMod), "golang-migrate") + + migrationPath := filepath.Join(root, "migrations/primary/sqlite/20260830010000_create_users.up.sql") + require.NoError(t, os.WriteFile(migrationPath, []byte("CREATE TABLE users (id INTEGER PRIMARY KEY);\n"), 0o644)) + runCLI(t, binary, "init", "scaffold", "--file", manifestPath) + content, err := os.ReadFile(migrationPath) + require.NoError(t, err) + require.Equal(t, "CREATE TABLE users (id INTEGER PRIMARY KEY);\n", string(content)) + runCLI(t, binary, "validate", "--file", manifestPath) +} + +func TestAddedClickHouseConnectionPassesValidation(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n"), 0o644)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + runCLI(t, binary, "add", "db", "analytics", "--kind", "clickhouse", "--file", manifestPath) + runCLI(t, binary, "init", "scaffold", "--file", manifestPath) + output := runCLI(t, binary, "validate", "--file", manifestPath, "--json") + var event struct { + Data struct { + Valid bool `json:"valid"` + Issues []any `json:"issues"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.True(t, event.Data.Valid) + require.Empty(t, event.Data.Issues) +} + +func TestValidateRejectsGRPCClientWithUnknownSource(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n"), 0o644)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + grpc: + clients: + - {name: billing, source: missing, path: proto/billing, proto_root: proto} +languages: + go: {module: example.test/sample} +`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "validate", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "source_not_found", issue["code"]) + require.Equal(t, "components.grpc.clients.billing.source", issue["field"]) +} + +func TestValidateRejectsGRPCClientWithInvalidContractSelection(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.MkdirAll(filepath.Join(root, "tools", "buf"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "tools", "buf", "grpc.gen.yaml"), []byte("version: v2\n"), 0o644)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + contracts: {type: git, repo: example/contracts, ref: v1} +exports: {} +components: + grpc: + clients: + - {name: billing, source: contracts, export: billing} +languages: + go: {module: example.test/sample} +`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "validate", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "grpc_client_invalid", issue["code"]) + require.Equal(t, "components.grpc.clients.billing", issue["field"]) +} + +func TestValidateRejectsS3BucketWithUnknownConnection(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + s3: + connections: + - {name: default, credentials: static} + buckets: + - {name: media, connection: archive, bucket: media-local} +languages: + go: {module: example.test/sample} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n"), 0o644)) + + entry, exitCode := runCLIError(t, binary, "validate", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + require.Equal(t, false, data["valid"]) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "s3_connection_not_found", issue["code"]) + require.Equal(t, "components.s3.buckets.media.connection", issue["field"]) +} + +func TestValidateRejectsKafkaContractWithUnknownSource(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + kafka: + consumers: + - name: billing + topic: billing_service.invoice.events.v1 + contract: + source: missing + path: invoice.proto + format: proto + message: acme.invoice.v1.Invoice + encoding: binary +languages: + go: {module: example.test/sample} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n"), 0o644)) + + entry, exitCode := runCLIError(t, binary, "validate", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "source_not_found", issue["code"]) + require.Equal(t, "components.kafka.consumers.billing.contract.source", issue["field"]) +} + +func TestValidateReportsMissingGRPCGeneratorConfig(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} +languages: + go: + module: example.test/sample + generators: + grpc: {out: gen/grpc, buf_gen_config: tools/buf/grpc.gen.yaml} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "buf.yaml"), []byte("version: v2\nmodules:\n - path: api/proto\n"), 0o644)) + + entry, exitCode := runCLIError(t, binary, "validate", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "tool_config_missing", issue["code"]) + require.Equal(t, "tools/buf/grpc.gen.yaml", issue["field"]) +} + +func TestInspectReportsGRPCAndKafkaTargets(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: + custom: + - group: app + vars: + - {key: API_TOKEN, type: string, default: do-not-leak, secret: true} +paths: {} +sources: + contracts: {type: local, path: api/contracts} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} + clients: + - {name: billing, source: contracts, path: proto/billing, proto_root: proto} + kafka: + producers: + - name: audit + topic: audit_service.audit.events.v1 + contract: {format: raw} +languages: + go: {module: example.test/sample} +`), 0o644)) + + output := runCLI(t, binary, "inspect", "--file", manifestPath, "--json") + var event struct { + Data struct { + Project struct { + Targets []struct { + ID string `json:"id"` + Family string `json:"family"` + Format string `json:"format"` + Input string `json:"input"` + Config string `json:"config"` + Output string `json:"output"` + } `json:"targets"` + Env []struct { + Key string `json:"key"` + Secret bool `json:"secret"` + Default *any `json:"default"` + } `json:"env"` + } `json:"project"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, "config", event.Data.Project.Targets[0].ID) + require.Equal(t, "gen/config", event.Data.Project.Targets[0].Output) + require.Equal(t, "grpc-client:billing", event.Data.Project.Targets[1].ID) + require.Equal(t, "grpc", event.Data.Project.Targets[1].Family) + require.Equal(t, "proto", event.Data.Project.Targets[1].Format) + require.Equal(t, "api/contracts/proto", event.Data.Project.Targets[1].Input) + require.Equal(t, "tools/buf/grpc.gen.yaml", event.Data.Project.Targets[1].Config) + require.Equal(t, "gen/grpc/client/billing", event.Data.Project.Targets[1].Output) + require.Equal(t, "grpc-server", event.Data.Project.Targets[2].ID) + require.Equal(t, "kafka-producer:audit", event.Data.Project.Targets[3].ID) + require.Equal(t, "raw", event.Data.Project.Targets[3].Format) + require.Equal(t, "SAMPLE_API_TOKEN", event.Data.Project.Env[0].Key) + require.True(t, event.Data.Project.Env[0].Secret) + require.Nil(t, event.Data.Project.Env[0].Default) +} + +func TestInspectAddsResolvedInputFromCommittedSnapshotMetadata(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + upstream: {type: devctl, repo: example/contracts, ref: v1} +exports: {} +components: + kafka: + consumers: + - name: audit + topic: audit_service.audit.events.v1 + contract: {format: json, source: upstream, export: audit} +languages: + go: {module: example.test/sample} +`), 0o644)) + targetRoot := filepath.Join(root, "api/external/kafka/consumer/audit") + require.NoError(t, os.MkdirAll(filepath.Join(targetRoot, "schemas"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(targetRoot, ".devctl-contract.json"), + []byte(`{"kind":"kafka","topic":"audit_service.audit.events.v1","format":"json","entrypoint":"schemas/event.json"}`), + 0o644, + )) + require.NoError(t, os.WriteFile( + filepath.Join(targetRoot, "schemas/event.json"), []byte(`{"title":"AuditEvent","type":"object"}`), 0o644, + )) + + output := runCLI(t, binary, "inspect", "--file", manifestPath, "--json") + var event struct { + Data struct { + Project struct { + Targets []struct { + ID string `json:"id"` + Input string `json:"input"` + ResolvedInput string `json:"resolved_input"` + } `json:"targets"` + } `json:"project"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, "kafka-consumer:audit", event.Data.Project.Targets[1].ID) + require.Equal(t, "api/external/kafka/consumer/audit", event.Data.Project.Targets[1].Input) + require.Equal(t, "api/external/kafka/consumer/audit/schemas/event.json", event.Data.Project.Targets[1].ResolvedInput) +} + +func TestSyncGRPCDryRunReportsPlannedClientPublication(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + billing: {type: url, url: https://example.test/billing.proto} +exports: {} +components: + grpc: + clients: + - {name: billing, source: billing, path: billing.proto, proto_root: .} +languages: + go: {module: example.test/sample} +`), 0o644)) + + output := runCLI(t, binary, "sync", "grpc", "--file", manifestPath, "--dry-run", "--json") + var event struct { + Data struct { + Targets []string `json:"targets"` + Changes []struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` + } `json:"changes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, []string{"grpc-client:billing"}, event.Data.Targets) + require.Equal(t, "grpc-client:billing", event.Data.Changes[0].Target) + require.Equal(t, "api/external/grpc/client/billing", event.Data.Changes[0].Path) + require.Equal(t, "planned_publish", event.Data.Changes[0].Action) +} + +func TestSyncKafkaDryRunReportsPlannedProducerPublication(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + events: {type: url, url: https://example.test/events.proto} +exports: {} +components: + kafka: + producers: + - name: invoice + topic: invoice.events + contract: {source: events, path: events.proto, format: proto, message: acme.Invoice, encoding: binary} +languages: + go: {module: example.test/sample} +`), 0o644)) + + output := runCLI(t, binary, "sync", "kafka", "--file", manifestPath, "--dry-run", "--json") + var event struct { + Data struct { + Targets []string `json:"targets"` + Changes []struct { + Target string `json:"target"` + Path string `json:"path"` + } `json:"changes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, []string{"kafka-producer:invoice"}, event.Data.Targets) + require.Equal(t, "api/external/kafka/producer/invoice", event.Data.Changes[0].Path) +} + +func TestGenGRPCDryRunReportsServerAndClientOutputs(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + billing: {type: local, path: api/contracts} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} + clients: + - {name: billing, source: billing, path: billing, proto_root: proto} +languages: + go: + module: example.test/sample + generators: + grpc: {out: gen/grpc, buf_gen_config: tools/buf/grpc.gen.yaml} +`), 0o644)) + + output := runCLI(t, binary, "gen", "grpc", "--file", manifestPath, "--dry-run", "--json") + var event struct { + Data struct { + Targets []string `json:"targets"` + Changes []struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` + } `json:"changes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, []string{"grpc-server", "grpc-client:billing"}, event.Data.Targets) + require.Equal(t, []struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` + }{ + {Target: "grpc-server", Path: "gen/grpc/server", Action: "planned_publish"}, + {Target: "grpc-client:billing", Path: "gen/grpc/client/billing", Action: "planned_publish"}, + }, event.Data.Changes) +} + +func TestGenKafkaDryRunReportsConsumerAndProducerOutputs(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + events: {type: local, path: api/events} +exports: {} +components: + kafka: + consumers: + - name: invoices + topic: invoice_service.invoice.events.v1 + contract: {source: events, path: invoice.proto, format: proto, proto_root: ., message: acme.Invoice, encoding: binary} + producers: + - name: audit + topic: audit_service.audit.events.v1 + contract: {source: events, path: audit.proto, format: proto, proto_root: ., message: acme.Audit, encoding: binary} +languages: + go: + module: example.test/sample + generators: + kafka: {out: gen/kafka, buf_gen_config: tools/buf/kafka.gen.yaml} +`), 0o644)) + + output := runCLI(t, binary, "gen", "kafka", "--file", manifestPath, "--dry-run", "--json") + var event struct { + Data struct { + Targets []string `json:"targets"` + Changes []struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` + } `json:"changes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, []string{"kafka-consumer:invoices", "kafka-producer:audit"}, event.Data.Targets) + require.Equal(t, []struct { + Target string `json:"target"` + Path string `json:"path"` + Action string `json:"action"` + }{ + {Target: "kafka-consumer:invoices", Path: "gen/kafka/consumer/invoices", Action: "planned_publish"}, + {Target: "kafka-producer:audit", Path: "gen/kafka/producer/audit", Action: "planned_publish"}, + }, event.Data.Changes) +} + +func TestGenGRPCPublishesBufOutput(t *testing.T) { + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} +languages: + go: + module: example.test/sample + generators: + grpc: {out: gen/grpc, buf_gen_config: tools/buf/grpc.gen.yaml} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/proto/acme/v1"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/proto/acme/v1/service.proto"), []byte("syntax = \"proto3\";\npackage acme.v1;\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "tools/buf"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "tools/buf/grpc.gen.yaml"), []byte("version: v2\nplugins: []\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "buf.yaml"), []byte("version: v2\nmodules:\n - path: api/proto\n"), 0o644)) + t.Setenv("PATH", testexec.StubPathCommand(t, "go", `#!/bin/sh +set -eu +test "$1" = tool +test "$2" = buf +test "$3" = generate +test "$4" = api/proto +test "$5" = --template +test "$6" = tools/buf/grpc.gen.yaml +test "$7" = --output +mkdir -p "$8/acme/v1" +printf '// Code generated by protoc-gen-go. DO NOT EDIT.\npackage acmev1\n' > "$8/acme/v1/service.pb.go" +`)) + + output := runCLI(t, binary, "gen", "grpc", "--target", "grpc-server", "--file", manifestPath, "--json") + var event struct { + Data struct { + Targets []string `json:"targets"` + Changes []struct { + Path string `json:"path"` + Action string `json:"action"` + } `json:"changes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal([]byte(output), &event)) + require.Equal(t, []string{"grpc-server"}, event.Data.Targets) + require.Equal(t, "gen/grpc/server/acme/v1/service.pb.go", event.Data.Changes[0].Path) + require.Equal(t, "created", event.Data.Changes[0].Action) + require.FileExists(t, filepath.Join(root, "gen/grpc/server/acme/v1/service.pb.go")) +} + +func TestGenGRPCClientUsesProtoRootAndSelectedPath(t *testing.T) { + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + contracts: {type: local, path: api/contracts} +exports: {} +components: + grpc: + clients: + - name: billing + source: contracts + path: proto/acme/billing/v1 + proto_root: proto + buf_gen_config: tools/buf/billing.gen.yaml +languages: + go: + module: example.test/sample + generators: + grpc: {out: gen/grpc, buf_gen_config: tools/buf/grpc.gen.yaml} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/contracts/proto/acme/billing/v1"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/contracts/proto/acme/billing/v1/billing.proto"), []byte("syntax = \"proto3\";\npackage acme.billing.v1;\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "tools/buf"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "tools/buf/billing.gen.yaml"), []byte("version: v2\nplugins: []\n"), 0o644)) + t.Setenv("PATH", testexec.StubPathCommand(t, "go", `#!/bin/sh +set -eu +test "$1" = tool +test "$2" = buf +test "$3" = generate +test "$4" = api/contracts/proto +test "$5" = --template +test "$6" = tools/buf/billing.gen.yaml +test "$7" = --path +test "$8" = acme/billing/v1 +test "$9" = --output +mkdir -p "${10}/acme/billing/v1" +printf '// Code generated by protoc-gen-go. DO NOT EDIT.\npackage billingv1\n' > "${10}/acme/billing/v1/billing.pb.go" +`)) + + runCLI(t, binary, "gen", "grpc", "--target", "grpc-client:billing", "--file", manifestPath, "--json") + + require.FileExists(t, filepath.Join(root, "gen/grpc/client/billing/acme/billing/v1/billing.pb.go")) +} + +func TestGenKafkaProducerUsesProtoContractSelection(t *testing.T) { + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + events: {type: local, path: api/events} +exports: {} +components: + kafka: + producers: + - name: invoice + topic: invoice_service.invoice.events.v1 + contract: + source: events + path: proto/invoice_service.invoice.events.v1.proto + format: proto + proto_root: proto + message: acme.invoice.v1.Invoice + encoding: binary +languages: + go: + module: example.test/sample + generators: + kafka: {out: gen/kafka, buf_gen_config: tools/buf/kafka.gen.yaml} +`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/events/proto"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/events/proto/invoice_service.invoice.events.v1.proto"), []byte("syntax = \"proto3\";\npackage acme.invoice.v1;\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "tools/buf"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "tools/buf/kafka.gen.yaml"), []byte("version: v2\nplugins: []\n"), 0o644)) + t.Setenv("PATH", testexec.StubPathCommand(t, "go", `#!/bin/sh +set -eu +test "$1" = tool +test "$2" = buf +test "$3" = generate +test "$4" = api/events/proto +test "$5" = --template +test "$6" = tools/buf/kafka.gen.yaml +test "$7" = --path +test "$8" = invoice_service.invoice.events.v1.proto +test "$9" = --output +mkdir -p "${10}/acme/invoice/v1" +printf '// Code generated by protoc-gen-go. DO NOT EDIT.\npackage invoicev1\n' > "${10}/acme/invoice/v1/invoice.pb.go" +`)) + + runCLI(t, binary, "gen", "kafka", "--target", "kafka-producer:invoice", "--file", manifestPath, "--json") + + require.FileExists(t, filepath.Join(root, "gen/kafka/producer/invoice/acme/invoice/v1/invoice.pb.go")) +} + +func TestLintGRPCPreservesCompletedFindingWhenBufIsUnavailable(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + grpc: + server: {proto_root: api/proto, buf_config: buf.yaml} +languages: + go: {module: example.test/sample} +`), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/proto/acme/v1"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/proto/acme/v1/service.proto"), []byte("syntax = \"proto3\";\npackage acme.v1;\nservice BillingService {}\n"), 0o644)) + + entry, exitCode := runCLIError(t, binary, "lint", "grpc", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + require.Equal(t, "unavailable", entry["code"]) + require.NotContains(t, entry, "data") + details, ok := entry["details"].(map[string]any) + require.True(t, ok) + partial, ok := details["partial_result"].(map[string]any) + require.True(t, ok) + require.Equal(t, false, partial["valid"]) + require.Equal(t, []any{"grpc-server"}, partial["contracts"]) + issues, ok := partial["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "proto_filename", issue["code"]) + require.Equal(t, "grpc-server", issue["target"]) + require.Equal(t, "api/proto/acme/v1/service.proto", issue["path"]) +} + +func TestLintKafkaReportsInvalidTopicName(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + kafka: + producers: + - name: audit + topic: audit.events + contract: {format: raw} +languages: + go: {module: example.test/sample} +`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "lint", "kafka", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + require.Equal(t, false, data["valid"]) + require.Equal(t, []any{"kafka-producer:audit"}, data["contracts"]) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "kafka_topic", issue["code"]) + require.Equal(t, "kafka-producer:audit", issue["target"]) +} + +func TestLintKafkaReportsMismatchedLocalSchemaFilename(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + events: {type: local, path: api/events} +exports: {} +components: + kafka: + producers: + - name: audit + topic: audit_service.audit.created.v1 + contract: + source: events + path: wrong_name.json + format: json +languages: + go: {module: example.test/sample} +`), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/events"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/events/wrong_name.json"), []byte(`{"title":"AuditEvent","type":"object"}`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "lint", "kafka", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + require.Equal(t, false, data["valid"]) + require.Equal(t, []any{"kafka-producer:audit"}, data["contracts"]) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "kafka_schema_filename", issue["code"]) + require.Equal(t, "kafka-producer:audit", issue["target"]) + require.Equal(t, "wrong_name.json", issue["path"]) +} + +func TestLintKafkaReportsMissingJSONSchemaTitle(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: + events: {type: local, path: api/events} +exports: {} +components: + kafka: + producers: + - name: audit + topic: audit_service.audit.created.v1 + contract: + source: events + path: audit_service.audit.created.v1.json + format: json +languages: + go: {module: example.test/sample} +`), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/events"), 0o755)) + schemaPath := filepath.Join(root, "api/events/audit_service.audit.created.v1.json") + require.NoError(t, os.WriteFile(schemaPath, []byte(`{"type":"object"}`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "lint", "kafka", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "json_schema_title", issue["code"]) + require.Equal(t, "kafka-producer:audit", issue["target"]) + require.Equal(t, schemaPath, issue["path"]) + require.Equal(t, "title", issue["field"]) +} + +func TestLintWithoutFamilyIncludesKafkaContracts(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: + kafka: + consumers: + - name: audit + topic: audit.events + contract: {format: raw} +languages: + go: {module: example.test/sample} +`), 0o644)) + + entry, exitCode := runCLIError(t, binary, "lint", "--file", manifestPath, "--json") + + require.Equal(t, 1, exitCode) + data, ok := entry["data"].(map[string]any) + require.True(t, ok) + require.Equal(t, []any{"kafka-consumer:audit"}, data["contracts"]) + issues, ok := data["issues"].([]any) + require.True(t, ok) + require.Len(t, issues, 1) + issue, ok := issues[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "kafka_topic", issue["code"]) +} + +type testManifest struct { + Sources map[string]testSource `yaml:"sources"` + Components struct { + GRPC *testGRPC `yaml:"grpc"` + Kafka *testKafka `yaml:"kafka"` + Redis *testRedis `yaml:"redis"` + S3 *testS3 `yaml:"s3"` + DB *testDB `yaml:"db"` + } `yaml:"components"` + Languages struct { + Go struct { + Generators struct { + GRPC *testGenerator `yaml:"grpc"` + Kafka *testGenerator `yaml:"kafka"` + } `yaml:"generators"` + } `yaml:"go"` + } `yaml:"languages"` +} + +type testSource struct { + Proto struct { + BufConfig string `yaml:"buf_config"` + } `yaml:"proto"` +} +type testGenerator struct { + Out string `yaml:"out"` + BufGenConfig string `yaml:"buf_gen_config"` +} +type testStart struct { + Env string `yaml:"env"` + Default *bool `yaml:"default"` +} +type testGRPC struct { + Server *testGRPCServer `yaml:"server"` + Clients []testGRPCClient `yaml:"clients"` +} +type testGRPCServer struct { + ProtoRoot string `yaml:"proto_root"` + BufConfig string `yaml:"buf_config"` + Start *testStart `yaml:"start"` +} +type testGRPCClient struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Path string `yaml:"path"` + ProtoRoot string `yaml:"proto_root"` + BufGenConfig string `yaml:"buf_gen_config"` + AddrEnv string `yaml:"addr_env"` +} +type testKafka struct { + Consumers []testKafkaConsumer `yaml:"consumers"` + Producers []testKafkaProducer `yaml:"producers"` +} +type testKafkaContract struct { + Source string `yaml:"source"` + Path string `yaml:"path"` + Format string `yaml:"format"` + ProtoRoot string `yaml:"proto_root"` + Message string `yaml:"message"` + Encoding string `yaml:"encoding"` +} +type testKafkaConsumer struct { + Name string `yaml:"name"` + Topic string `yaml:"topic"` + GroupEnv string `yaml:"group_env"` + Start *testStart `yaml:"start"` + Contract testKafkaContract `yaml:"contract"` +} +type testKafkaProducer struct { + Name string `yaml:"name"` + Topic string `yaml:"topic"` + TopicEnv string `yaml:"topic_env"` + Contract testKafkaContract `yaml:"contract"` +} +type testRedis struct { + Connections []testRedisConnection `yaml:"connections"` +} +type testRedisConnection struct { + Name string `yaml:"name"` + AddrEnv string `yaml:"addr_env"` + AddrDefault string `yaml:"addr_default"` +} +type testS3 struct { + Connections []testS3Connection `yaml:"connections"` + Buckets []testS3Bucket `yaml:"buckets"` +} +type testS3Connection struct { + Name string `yaml:"name"` + Credentials string `yaml:"credentials"` + Endpoint string `yaml:"endpoint"` + Region string `yaml:"region"` + PathStyle bool `yaml:"path_style"` +} +type testS3Bucket struct { + Name string `yaml:"name"` + Connection string `yaml:"connection"` + Bucket string `yaml:"bucket"` +} +type testDB struct { + Connections []testDBConnection `yaml:"connections"` +} +type testDBConnection struct { + Name string `yaml:"name"` + Variants []testDBVariant `yaml:"variants"` +} +type testDBVariant struct { + Kind string `yaml:"kind"` + DSNDefault string `yaml:"dsn_default"` + Migrations *testDBMigrations `yaml:"migrations"` +} +type testDBMigrations struct { + Path string `yaml:"path"` + DatabaseEnv string `yaml:"database_env"` + DatabaseDefault string `yaml:"database_default"` +} + +func readTestManifest(t *testing.T, path string) testManifest { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + var manifest testManifest + require.NoError(t, yaml.Unmarshal(data, &manifest)) + return manifest +} + +func boolPointer(value bool) *bool { return &value } + +func TestLeafHelpOwnsCommonFlags(t *testing.T) { + t.Parallel() + stdout := runCLI(t, buildCLI(t), "validate", "--help") + + require.Contains(t, stdout, "--file") + require.Contains(t, stdout, "--json") + require.Contains(t, stdout, "--verbose") + require.NotContains(t, stdout, "--format") +} + +func TestValidateEmitsJSONResultEvent(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(manifestPath), "go.mod"), []byte("module example.test/sample\n\ngo 1.25\n"), 0o644)) + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`), 0o644)) + + command := exec.CommandContext(context.Background(), binary, "validate", "--file", manifestPath, "--json") + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + + require.NoError(t, command.Run(), "stdout:\n%s\nstderr:\n%s", stdout.String(), stderr.String()) + require.Empty(t, stderr.String()) + var event struct { + Level string `json:"level"` + Message string `json:"msg"` + Command string `json:"command"` + Data struct { + Valid bool `json:"valid"` + Issues []any `json:"issues"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &event)) + require.Equal(t, "info", event.Level) + require.Equal(t, "project validation completed", event.Message) + require.Equal(t, "validate", event.Command) + require.True(t, event.Data.Valid) + require.Empty(t, event.Data.Issues) +} + +func TestUsageErrorsExitTwo(t *testing.T) { + t.Parallel() + + binary := buildCLI(t) + for _, args := range [][]string{{"--bad"}, {"--format", "json", "validate"}, {"--json", "validate"}, {"unknown"}, {"inspect", "extra"}} { + command := exec.CommandContext(context.Background(), binary, args...) + err := command.Run() + var exitError *exec.ExitError + require.ErrorAs(t, err, &exitError, "args: %v", args) + require.Equal(t, 2, exitError.ExitCode(), "args: %v", args) + } +} + +func TestErrorsUseSelectedLogEncoding(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + + t.Run("json usage", func(t *testing.T) { + t.Parallel() + entry, exitCode := runCLIError(t, binary, "validate", "--json", "extra") + + require.Equal(t, 2, exitCode) + require.Equal(t, "error", entry["level"]) + require.Equal(t, "usage", entry["code"]) + require.EqualValues(t, 2, entry["exit_code"]) + require.NotEmpty(t, entry["msg"]) + require.NotContains(t, entry, "logger") + require.NotContains(t, entry, "error") + }) + + t.Run("console usage", func(t *testing.T) { + t.Parallel() + command := exec.CommandContext(context.Background(), binary, "unknown") + output, err := command.CombinedOutput() + var exitError *exec.ExitError + require.ErrorAs(t, err, &exitError) + require.Equal(t, 2, exitError.ExitCode()) + require.Contains(t, string(output), "\terror\t") + require.Contains(t, string(output), `"code": "usage"`) + }) +} + +func TestExecutionErrorHidesRawCauseUnlessVerbose(t *testing.T) { + t.Parallel() + binary := buildCLI(t) + manifestPath := filepath.Join(t.TempDir(), "missing.yaml") + + safeEntry, exitCode := runCLIError(t, binary, "validate", "--json", "--file", manifestPath) + require.Equal(t, 1, exitCode) + require.Equal(t, "not_found", safeEntry["code"]) + require.EqualValues(t, 1, safeEntry["exit_code"]) + require.Equal(t, "requested resource was not found", safeEntry["msg"]) + require.NotContains(t, safeEntry, "error") + require.NotContains(t, safeEntry, "data") + require.NotContains(t, safeEntry, "details") + require.NotContains(t, stringJSON(t, safeEntry), manifestPath) + + verboseEntry, exitCode := runCLIError(t, binary, "validate", "--json", "--verbose", "--file", manifestPath) + require.Equal(t, 1, exitCode) + require.Contains(t, verboseEntry["error"], "readManifestFile") + require.Contains(t, verboseEntry["error"], filepath.Base(manifestPath)) +} + +func buildCLI(t *testing.T) string { + t.Helper() + if testCLIBinary == "" { + t.Fatal("test CLI binary was not initialized") + } + return testCLIBinary +} + +func runCLI(t *testing.T, binary string, args ...string) string { + t.Helper() + command := exec.CommandContext(context.Background(), binary, args...) + output, err := command.CombinedOutput() + require.NoError(t, err, "output:\n%s", output) + return string(output) +} + +func runCLIError(t *testing.T, binary string, args ...string) (map[string]any, int) { + t.Helper() + command := exec.CommandContext(context.Background(), binary, args...) + output, err := command.CombinedOutput() + var exitError *exec.ExitError + require.ErrorAs(t, err, &exitError) + + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + require.NotEmpty(t, lines) + var entry map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[len(lines)-1]), &entry), "output:\n%s", output) + return entry, exitError.ExitCode() +} + +func stringJSON(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + require.NoError(t, err) + return string(data) +} diff --git a/docs/adr/0001-canonical-project-model-and-command-boundaries.md b/docs/adr/0001-canonical-project-model-and-command-boundaries.md new file mode 100644 index 0000000..b7fa523 --- /dev/null +++ b/docs/adr/0001-canonical-project-model-and-command-boundaries.md @@ -0,0 +1,61 @@ +# ADR 0001: Use one canonical Project model and explicit command boundaries + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Project bootstrap, manifest mutation, contract acquisition, linting, code +generation, and scaffolding have different side effects and readiness needs. +Allowing each workflow to interpret the Manifest independently would duplicate +defaults and paths, while implicit chaining would make a command's filesystem +and network effects difficult to predict. + +## Decision + +The v1 CLI has exactly eight top-level commands: `init`, `validate`, `inspect`, +`enable`, `add`, `sync`, `lint`, and `gen`. Initialization is explicit through +`init manifest` and `init scaffold`. Manifest mutations do not install tools, +run generators, or change handwritten Go. `sync`, `lint`, and `gen` never +invoke one another implicitly. + +`devctl.yaml` is the canonical desired-state Manifest. The Project service is +the only gateway for manifest discovery, decoding, semantic validation, +inspection, and mutation. Structural decoding issues, semantic validity, and +Project Readiness are distinct. `validate` checks all three; other workflows +load the semantically valid Project and check only their own prerequisites. + +The project domain projects the Manifest into one immutable, total, and +deterministically ID-sorted Target Catalog. The catalog owns effective Target +IDs, families, roles, references, source locations, Logical Inputs, outputs, +defaults, and supported operations. A valid committed snapshot may add an +optional Resolved Input for inspection without changing the Logical Input. +Missing committed metadata does not make `inspect` fail. + +Workflows select and execute catalog Targets instead of deriving these facts +again. Unknown families are `invalid_input`; a known family with no applicable +Targets succeeds with an empty result; an unknown explicit Target ID is +`not_found`; and an existing Target that lacks the requested operation is +`unsupported`. Local sync is a supported no-op. Raw Kafka Targets do not claim +sync support. + +The catalog remains sorted by Target ID for stable inspection and selection. +Generation separately executes `config`, HTTP server/client, gRPC +server/client, then Kafka consumer/producer Targets so dependency-sensitive +work remains explicit. + +The dependency direction is `cmd -> service -> domain`. Delivery code invokes +one service root per executable command. Consumer-owned ports isolate +repositories, external tools, and protocol analyzers, while `internal/deps` is +the production composition root. + +## Consequences + +Adding a Component or Target requires one domain projection change followed by +the relevant inspect, sync, lint, generation, and scaffold adapters. Invalid +references remain visible to validation instead of disappearing from a partial +catalog. Readiness failures remain attributable to the workflow that actually +needs the missing file or tool. + +We reject command aliases with hidden orchestration, workflow-local copies of +Target defaults, and selectors whose meaning changes by workflow. diff --git a/docs/adr/0002-contract-sources-exports-and-snapshots.md b/docs/adr/0002-contract-sources-exports-and-snapshots.md new file mode 100644 index 0000000..f645fa6 --- /dev/null +++ b/docs/adr/0002-contract-sources-exports-and-snapshots.md @@ -0,0 +1,77 @@ +# ADR 0002: Materialize stable Contract Snapshots from bounded Sources + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Contracts can originate locally, at a URL, in Git, or as a named Export from +another Devctl Project. Generation and linting must remain reproducible and +must not silently depend on changing external suppliers. Cross-project Kafka +and gRPC contracts also need enough committed information to be interpreted +without filesystem discovery. + +## Decision + +A Source is a containment root. A Contract Reference selects a relative +Entrypoint or a named Export, and materialization produces a Snapshot with +`ModuleRoot`, `Entrypoint`, `Files`, and `Metadata`. OpenAPI and JSON Schema +Snapshots have an Entrypoint. A gRPC Export is module-only: it has a Module +Root and no Entrypoint. A Proto-backed Kafka Snapshot may have both, with the +Entrypoint contained by the Module Root. + +Local Exports are exact projections of effective Project surfaces. An OpenAPI +Export uses the effective HTTP server OpenAPI path. A gRPC Export uses the +effective gRPC server Proto root. A Kafka producer Export names an existing +producer and inherits that producer's topic and format. A Devctl Source always +addresses the upstream checkout root, forbids `path`, and selects a named +Export. Upstream Manifest loading is structural-only; the selected Export is +validated at materialization time. + +For a URL Source, `source.url` is the fetch base while the selected Target path +is the virtual committed Entrypoint. Only relative `$ref` values are followed; +absolute references are ignored even when same-origin. A relative reference is +resolved for fetching from the current document URL and for storage from the +current virtual path. Query participates in fetch identity, fragments do not, +and two distinct query identities that map to the same virtual path are an +invalid collision. Query values are redacted from diagnostics. + +Every URL request and redirect must retain the initial scheme, host, and +effective port. Credentials and scheme changes are forbidden. One Snapshot is +limited to 64 documents, 64 MiB aggregate, and 32 MiB per response; Devctl +keeps no persistent URL cache. Policy, reference, limit, and collision failures +map to `invalid_input`; HTTP 404 and 410 map to `not_found`; other non-success +HTTP responses and network, DNS, TLS, or timeout failures map to `unavailable`. + +Proto Snapshots carry the effective upstream Buf config declared by +`components.grpc.server.buf_config`, defaulting to `buf.yaml`, plus an adjacent +`buf.lock` when present. No discovery heuristic selects alternate Buf files. + +Every Devctl-sourced gRPC or Kafka Managed External Contract has a root +`.devctl-contract.json` sidecar whose relative paths are interpreted from that +root. gRPC metadata records `kind: grpc`, `format: proto`, `module_root`, and +`buf_config`. Kafka metadata records `kind`, `topic`, and `format`; JSON adds +`entrypoint`, Proto adds `entrypoint`, `module_root`, and `buf_config`, and raw +Kafka adds no file fields. `buf.lock` is a Snapshot file, not a metadata field. +Local Sources have no sidecar and local sync is a no-op. + +Downstream lint, generation, inspection resolution, and re-export consume the +committed metadata and never rediscover a schema or contact the original +supplier. Missing or invalid required metadata is a stale Snapshot, not an +invalid Contract. It returns `invalid_input` with typed reason +`snapshot_metadata_invalid`, the offending field and reason, and a hint to run +`devctl sync`. Legacy Devctl-sourced gRPC and non-raw Kafka snapshots without +complete metadata are handled the same way. `inspect` remains tolerant and +simply omits Resolved Input until committed metadata is valid. + +## Consequences + +Contract updates become visible repository changes and builds remain stable +when suppliers are unavailable. Same-origin URL closures support multi-file +specifications without becoming a general web crawler. Existing cross-project +snapshots must be refreshed before downstream operations rely on the new +metadata. + +We reject single-document URL semantics, absolute-reference crawling, +implicit network access from lint or generation, arbitrary gRPC first-file +entrypoints, and schema discovery by file count. diff --git a/docs/adr/0003-managed-artifacts-and-publication.md b/docs/adr/0003-managed-artifacts-and-publication.md new file mode 100644 index 0000000..f4b24c3 --- /dev/null +++ b/docs/adr/0003-managed-artifacts-and-publication.md @@ -0,0 +1,71 @@ +# ADR 0003: Separate Managed Outputs from Scaffold Seeds + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Sync, generation, and scaffold all publish files, but they do not have the +same ownership contract. Treating user-edited seeds as generated output risks +data loss, while treating managed trees as shared directories prevents safe +replacement and stale cleanup. Multi-Target workflows also need precise and +race-free change reporting. + +## Decision + +A Managed Output is a file or complete Target tree owned by one Devctl +workflow. It is rendered or materialized in temporary storage and atomically +published at the Target boundary. The publication adapter returns a structured +result that distinguishes `created`, `updated`, and `unchanged` as part of the +same operation; workflows do not infer this with a read-before-publish race. +This race-free publication guarantee applies to Managed Outputs only. + +Targets execute sequentially and deterministically. A later failure does not +roll back completed Targets and returns their partial result. Generation +replaces only each selected Target tree and removes stale files inside that +tree; it never prunes unrelated Targets. The external-contract namespace +belongs entirely to `sync`; a full family sync may prune stale Target children, +while targeted sync never prunes unrelated Targets. + +Observed changes are `created`, `updated`, `unchanged`, and `removed`. +Side-effect-free preview reports `planned_publish` and `planned_remove`. +Previewing removal uses a read-only repository operation with the same path, +symlink, and file-type validation as actual publication. Dry-run performs no +network acquisition, generator execution, publication, or pruning. + +A Scaffold Seed is created once and is thereafter user-owned. Scaffold first +preflights its complete artifact plan. For each Seed it then checks the current +entry, leaves an existing regular file unchanged, rejects symlinks and +non-regular entries, and uses ordinary file publication only when the path is +absent. Scaffold never deliberately overwrites or deletes an existing Seed, +including during refresh. Managed Outputs may be refreshed independently. + +The Seed check and publication are not a cross-process atomic +create-if-absent operation. An external writer can create the same path in the +narrow interval between them and have its file replaced. We accept this rare +race as a deliberate simplicity tradeoff rather than add a separate filesystem +primitive. Consequently `init scaffold` has no `--force` option; +`init manifest --force` remains a separate whole-manifest replacement command +that rebuilds the Manifest from its arguments or preset rather than merging. + +This is a pre-v1 ownership cutover. Old scaffold filenames receive no +compatibility shim or automatic migration; users review and move existing +custom code explicitly. + +## Consequences + +Users can predict which files may be replaced and review dry-run cleanup before +it occurs. A workflow can leave a valid partially updated Project after a late +failure, so callers must inspect the returned changes before retrying. Every +scaffold plan must classify each artifact as managed or create-once. + +Callers that require a hard cross-process create-only guarantee need a +different explicit publication operation; Scaffold does not claim that +guarantee for Seeds. + +We reject workflow-wide rollback, read-before-publish change detection for +Managed Outputs, automatic deletion or intentional overwrite of Scaffold +Seeds, and shared managed directories containing arbitrary user files. We also +reject adding a dedicated atomic create-only filesystem primitive for Scaffold +Seeds while the practical risk remains limited to the accepted external-writer +race. diff --git a/docs/adr/0004-project-owned-generator-toolchain.md b/docs/adr/0004-project-owned-generator-toolchain.md new file mode 100644 index 0000000..7ef1281 --- /dev/null +++ b/docs/adr/0004-project-owned-generator-toolchain.md @@ -0,0 +1,50 @@ +# ADR 0004: Keep generator toolchains project-owned + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Checked-in generated code must be reproducible from a standalone Project. A +Devctl-bundled or globally installed generator can drift independently of the +Project and makes upgrades invisible in code review. Native generator configs +also have different ownership depending on whether the canonical path or an +explicit override is selected. + +## Decision + +The consuming Project owns generator versions and native configuration. Go +generators are pinned to exact published module versions with `go.mod` tool +directives. Mise owns exact executable runtime and task versions that are not +Go tools, including Node 24, quicktype 26.0.0, and golang-migrate 4.19.1 when +their capabilities are used. Generator objects in the Manifest do not expose a +competing `tool` selector. + +Devctl invokes tools through the Project environment, writes into temporary +storage, validates expected output, and hands publication to the owning +workflow. Canonical shared native generator configs are Managed Outputs. An +explicit custom config path is user-owned: it must already exist, validation +checks it, generation consumes it, and scaffold never creates, overwrites, or +deletes it. Every distinct gRPC Target config is planned and validated. + +Proto input ownership is split deliberately. A supplier's Contract Snapshot +contains its declared Buf config and adjacent lock file; the consuming Project +owns its `*.gen.yaml` generation config. Buf paths must remain contained +project-relative regular files and each file is included exactly once. + +Quicktype 26.0.0 is the Kafka JSON Schema generator. Every input Schema has a +non-empty root `title`, which owns the generated top-level type name. The Go +output includes serialization helpers and marks non-required fields with +`omitempty`. Devctl remains Go-only in v1; choosing quicktype does not add a +multi-language Manifest surface or a `go:generate` workflow. + +## Consequences + +A fresh checkout can reconstruct its toolchain from versioned Project files. +Generator upgrades are explicit dependency changes and may update checked-in +Managed Outputs. Devctl reports missing Project tools with installation +guidance rather than silently substituting bundled versions. + +We reject global PATH lookup as the version policy, generated custom override +files, generator-specific version fields in the Manifest, and heuristic Buf +config discovery. diff --git a/docs/adr/0005-canonical-runtime-config.md b/docs/adr/0005-canonical-runtime-config.md new file mode 100644 index 0000000..08d11f7 --- /dev/null +++ b/docs/adr/0005-canonical-runtime-config.md @@ -0,0 +1,61 @@ +# ADR 0005: Use one canonical Runtime Config catalog + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Config generation, scaffold templates, and `inspect` previously derived +environment keys, defaults, types, and field names independently. Their +policies diverged, so a Managed Output refresh could generate Go code that did +not match the runtime consumers created by scaffold. + +## Decision + +The project domain owns an immutable, key-sorted Runtime Config catalog derived +from one Manifest. It is the sole policy owner for effective prefixes, keys, +types, defaults, secret markers, semantic Go field paths, and the runtime, +example, and inspect projections. Conflicting declarations and colliding Go +field paths are typed domain errors reported by `validate` as +`runtime_config_conflict`. + +Every Go Project has an implicit `config` Target. Its default Managed Output is +`gen/config/config.gen.go`; `languages.go.generators.config.out` only overrides +that location. Newly initialized Manifests omit the redundant default block. +`devctl gen config` and `devctl init scaffold` use the same renderer for one +grouped generated `Config` type and the root `.env.example`. Scaffold emits an +adapter that imports the generated type rather than maintaining a second +schema. `inspect` projects its environment list from the same catalog. + +No `start` block means a Capability is always on and has no environment toggle. +A present `start` block creates a toggle; if it omits `default`, its effective +default is `false`. Construction and startup are separate: disabled gateable +Capabilities may be constructed but do not run. A selected disabled Kafka +consumer is rejected before dependency resolution, so its client is not +constructed. Ecosystem-standard OpenTelemetry keys retain the `OTEL_*` prefix. + +Each Kafka consumer receives runtime fields for enabled state, group, topic, +batch maximum size, batch flush interval, retry maximum attempts, retry maximum +elapsed time, retry initial and maximum delays, rebalance and drain timeouts, +and shutdown timeout. The Manifest topic is the runtime default and may be +overridden through environment. `CommitRetry` inherits the main retry policy; +`OnReject` is fixed to `RejectStop`; rarer library knobs remain available only +through user-owned custom environment and explicit composition until they +become Manifest features. + +Migration database environment belongs only to example and inspect +projections. It is absent from application Runtime Config, and the migration +URL is distinct from the runtime database connection. Secret fields never +carry defaults: generated tags omit them, `.env.example` leaves them blank, +and inspect reports only the secret marker. + +## Consequences + +Generated field paths are semantic and grouped, such as `cfg.HTTP.Address`, +`cfg.Telemetry.ServiceVersion`, and `cfg.DBPrimary.PostgresDSN`. Projects using +the former flat scaffold config regenerate scaffold and config together. +Adding environment-backed behavior requires one catalog policy and projection +tests. + +We reject separate scaffold and generation config builders, hidden default +topic changes, and compatibility flat fields with no independent lifetime. diff --git a/docs/adr/0006-machine-readable-cli-contract.md b/docs/adr/0006-machine-readable-cli-contract.md new file mode 100644 index 0000000..ed87516 --- /dev/null +++ b/docs/adr/0006-machine-readable-cli-contract.md @@ -0,0 +1,46 @@ +# ADR 0006: Use structured events as the machine-readable CLI contract + +- Status: accepted +- Date: 2026-09-03 + +## Context + +Automation needs deterministic result and failure records while humans need +safe diagnostics. Success already uses structured log events, but late +failures currently place partial results in the same `data` field as success, +and adapters can collapse specific source failures into broader categories. + +## Decision + +`--json` emits compact JSONL. A successful command emits exactly one structured +event on stdout with `level`, `ts`, `msg`, and its command-specific payload in +`data`. It does not emit a bare result object. Diagnostic events may precede +the final event only under `--verbose`. + +Usage, execution, and cancellation failures emit one final event on stderr +with stable `code` and `exit_code` fields. Safe scalar context belongs in +`details`. A late workflow failure places the completed operation result in +`details.partial_result`; the reporter safely merges it with existing details, +top-level error `data` is removed, and stdout remains empty. This pre-v1 wire +change has no compatibility shim. Raw causes and external tool output appear +only under `--verbose`. + +Invalid `validate` and `lint` findings are normal results: the structured event +is written to stdout, stderr is empty, and the process exits `1`. Execution +errors also exit `1`, usage/help exits `2`, cancellation exits `130`, and +success exits `0`. + +Public failure codes remain `usage`, `invalid_input`, `not_found`, `conflict`, +`unavailable`, `unsupported`, `cancelled`, and `internal`. Adapters add safe +operation, Target, Source, field, and path context without replacing a more +specific underlying category. Partial results are attached only after actual +progress, never merely after planning or validation. + +## Consequences + +Callers can distinguish successful data from recovery information and branch +on stable failure categories without parsing messages. CLI DTO, reporter, and +end-to-end tests must change together. + +We reject bare success objects, mixed stdout error payloads, top-level error +`data`, and reducing every source acquisition failure to `unavailable`. diff --git a/docs/adr/0007-explicit-application-composition-and-runtime-scenarios.md b/docs/adr/0007-explicit-application-composition-and-runtime-scenarios.md new file mode 100644 index 0000000..ffdc42e --- /dev/null +++ b/docs/adr/0007-explicit-application-composition-and-runtime-scenarios.md @@ -0,0 +1,81 @@ +# ADR 0007: Use explicit application composition and runtime Scenarios + +- Status: accepted +- Date: 2026-09-03 + +## Context + +A generated dependency registry would keep scaffold refresh automatic, but it +would hide application composition, require AST-aware mutation or runtime +registration, and blur ownership between generated infrastructure and user +behavior. API and Kafka processes also need different root lifecycles without +constructing every runnable branch eagerly. + +## Decision + +All Devctl-owned Go scaffold files use the `*.gen.go` suffix and the standard +generated-file header. User-owned files use ordinary `.go` names and are +Scaffold Seeds. Managed `provideX` functions register infrastructure. A +create-once `provideApplication` function calls them explicitly and is the +user-owned composition list. Later refreshes may add new per-component Seeds +but never edit that list; the user adds each new provider call deliberately. +Missing registration fails normally during dependency resolution. Devctl does +not analyze Go syntax or maintain a runtime plugin registry. + +Named registrations use unexported, namespaced Canonical DI Keys such as +`db-connection:analytics`, `kafka-consumer:audit`, +`kafka-producer:audit`, `http-client:billing`, and +`grpc-client:billing`. The constants remain internal to the composition +package. Each Kafka consumer has a create-once provider binding that selects +its message type, decoder, handler, and retry policy; `provideApplication` +calls that binding. + +Managed scenario code defines `Scenario`, which owns a dependency graph and +selected runner and provides `Run(ctx)` and `Shutdown(ctx)`. `NewAPI(ctx)` +resolves an unnamed API Runtime whose lifecycle tasks contain HTTP, gRPC, +health, and pprof work. `NewConsumer(ctx, name)` validates the Manifest-derived +consumer name and enabled toggle before resolution, calls `provideApplication`, +and resolves only the selected runner by its Canonical DI Key. Providers are +registered eagerly but constructed lazily. Selecting a disabled consumer is a +typed scaffolded-application error and process exit 1, not a Devctl CLI public +error category. + +Application routes and services are registered through typed `HTTPRegistrar` +and `GRPCRegistrar` interfaces. Managed runtime code resolves these registrars +and applies them to the Echo and gRPC servers. Outbound clients expose raw +transport handles and base addresses: HTTP exposes `*http.Client` and its base +URL, while gRPC exposes `*grpc.ClientConn`. Application code constructs the +generated OpenAPI clients and Proto stubs it needs. + +Database Variants share one named logical provider that switches on effective +config and opens only the selected backend. A logical Connection cannot mix a +ClickHouse Variant with transactional Variants. Kafka producers are ordinary +`*kafka.Producer[[]byte]`; topic config is injected into application types +that need it rather than hidden behind a generated wrapper. + +Kafka consumer infrastructure uses a managed generic provider that resolves a +named `kafka.ConsumerConfig`, `kafka.Decoder[T]`, +`kafka.BatchHandler[T]`, and `retry.Policy`, then registers the named runner. +Per-consumer managed config maps generated Runtime Config and registers the +default exponential policy. Managed helpers cover raw bytes, JSON, and Proto; +Proto support and its dependency are conditional. Raw decoding is zero-copy +because the handler contract forbids retaining a batch. Schema-backed +consumers still default to `[]byte`; the user opts into actual generated JSON +or Proto types in the Provider Binding. Buf output receives no Devctl facade or +type-alias file. + +The initial raw handler Seed has no infrastructure dependencies and returns a +permanent `ErrNotImplemented`, making an unimplemented consumer fail closed +instead of acknowledging messages silently. + +## Consequences + +Composition remains readable Go and user intent survives scaffold refresh. +Adding a Component requires adding its explicit Provider Binding call, and the +generated documentation must include that checklist. Scenario resolution +constructs only the selected runnable branch while still allowing shared +infrastructure providers. + +We reject AST mutation of user code, implicit runtime registries, exported DI +key constants, generated wrappers around generated clients, and no-op default +handlers. diff --git a/docs/adr/0008-native-clickhouse-runtime-and-migrations.md b/docs/adr/0008-native-clickhouse-runtime-and-migrations.md new file mode 100644 index 0000000..3c6ee93 --- /dev/null +++ b/docs/adr/0008-native-clickhouse-runtime-and-migrations.md @@ -0,0 +1,52 @@ +# ADR 0008: Use the native ClickHouse runtime and separate migrations + +- Status: accepted +- Date: 2026-09-03 + +## Context + +ClickHouse's native Go API provides capabilities and operational semantics that +do not fit the transactional `database/sql` abstraction used by SQLite and +PostgreSQL. Migration tooling, however, already integrates through a separate +driver and migration URL. + +## Decision + +ClickHouse runtime connections use +`github.com/ClickHouse/clickhouse-go/v2` v2.48.0 and expose the native +`driver.Conn`. Construction parses the DSN, opens the connection, performs an +eager ping, and registers owned close behavior. The connection is named with +the same `db-connection:` convention as other database Resources, and a +narrow health checker delegates to `Ping`. ClickHouse does not receive the +transaction manager, read/write endpoint split, or generated repository +getter used for transactional databases; application repositories resolve the +named native connection and define their own narrow interfaces. + +ClickHouse migrations remain supported through golang-migrate's ClickHouse +database driver and a migration-only secret URL. They retain the same Project +surface as other databases: a default migration directory, an explicit path +override, and `--no-migrations`. Runtime native-driver config and migration +driver config are independent. + +ClickHouse migrations are non-transactional. Documentation recommends a +migration DSN with `x-multi-statement=true` when a file contains multiple +statements, but Devctl neither appends nor enforces that parameter. The known +semicolon-splitting limitations and the possibility of partial application and +manual recovery must be documented. + +## Consequences + +Runtime code retains ClickHouse-native behavior instead of presenting a false +transactional abstraction. Applications that support several database kinds +must keep ClickHouse separate from transactional Variants within one logical +Connection. Migration operators explicitly own multi-statement and recovery +trade-offs. + +We reject `database/sql` as the ClickHouse runtime contract, generated +transaction helpers for ClickHouse, and silent DSN mutation. + +## References + +- [clickhouse-go README](https://github.com/ClickHouse/clickhouse-go) +- [Native driver.Conn API](https://pkg.go.dev/github.com/ClickHouse/clickhouse-go/v2@v2.48.0/lib/driver#Conn) +- [golang-migrate ClickHouse driver](https://github.com/golang-migrate/migrate/blob/master/database/clickhouse/README.md) diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..4f148b9 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,53 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists: it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`**: read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/ +│ └── adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/ +│ └── adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..658e570 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,29 @@ +# Issue tracker: Local Markdown + +Issues and specs for this repo live as markdown files in `.scratch/`. + +## Conventions + +- One feature per directory: `.scratch//` +- The spec is `.scratch//spec.md` +- Implementation issues are one file per ticket at `.scratch//issues/-.md`, numbered from `01`, never a single combined tickets file +- Comments and conversation history append to the bottom of the file under a `## Comments` heading + +## When a skill says "publish to the issue tracker" + +Create a new file under `.scratch//` (creating the directory if needed). + +## When a skill says "fetch the relevant ticket" + +Read the file at the referenced path. The user will normally pass the path or the issue number directly. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a file with one **child** file per ticket. + +- **Map**: `.scratch//map.md` (the Notes / Decisions-so-far / Fog body). +- **Child ticket**: `.scratch//issues/NN-.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`. +- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`. +- **Frontier**: scan `.scratch//issues/` for files that are open, unblocked, and unclaimed; first by number wins. +- **Claim**: set `Status: claimed` and save before any work. +- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..f24c20b --- /dev/null +++ b/docs/development.md @@ -0,0 +1,57 @@ +# Developing Devctl + +This guide is for contributors to the Devctl repository. Project authors using +the CLI should start with the [user guide](user-guide/README.md). + +## Local checks + +Install the repository-pinned tools once, or again after `.mise.toml` changes: + +```sh +mise install +``` + +Run the complete local CI contract through Mise: + +```sh +mise run check +``` + +`check` runs formatting, golangci-lint (including `govet`), dependency-graph +hygiene, race-enabled Go tests, build, generated documentation, the Orders API +example, and e2e workflows. The ordinary `mise run test` task remains +available for faster non-race iteration. + +The pull request CI runs this complete check on Linux and cross-builds the +Devctl binary for macOS amd64 and arm64 with CGO disabled. Native macOS jobs +are intentionally not part of the regular CI because the repository has no +platform-specific implementation. The release workflow still builds and +publishes native macOS release archives for both architectures. + +Regenerate the CLI reference after changing visible command metadata: + +```sh +mise run docs:generate +``` + +## Working with sibling modules + +For local work across sibling `devctl` and `go-libs` checkouts, create a Go +workspace outside either repository, for example in their parent directory: + +```sh +go work init ./devctl ./go-libs/... +``` + +Use that workspace only for interactive development. Do not commit `go.work` +or add `replace` directives to the released module. Before submitting, rerun +the checks above with `GOWORK=off` so they exercise the dependency graph used +by CI and release builds. + +## Ownership boundaries + +Devctl uses `github.com/devctllabs/go-libs/filesystem` for rooted file +operations, `go-libs/log` for structured logging, and `go-libs/di` for command +scenario graphs. Atomic file publication, Git, process execution, and external +tool boundaries remain owned by Devctl. Completed Target or scaffold changes +are not rolled back after a later failure. diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md new file mode 100644 index 0000000..2d720cd --- /dev/null +++ b/docs/user-guide/README.md @@ -0,0 +1,28 @@ +# Devctl user guide + +Devctl turns one checked-in `devctl.yaml` Manifest into a reproducible Go +Project. This guide is organized around work you need to complete rather than +the internal packages that implement it. + +## Start here + +1. [Build an HTTP API with PostgreSQL](getting-started.md). +2. Learn the [core concepts and ownership rules](concepts-and-ownership.md). +3. Use the [Project workflow](project-workflow.md) when changing a Manifest. +4. Follow the [Contract and generation workflow](contracts-and-generation.md) + when an API or event schema changes. + +## Continue by task + +- [Work inside the generated Project](generated-project.md) +- [Add databases, storage, clients, and event endpoints](recipes.md) +- [Diagnose and recover from failures](troubleshooting.md) + +## Reference + +- [Commands and flags](reference/commands.md) +- [Manifest v1](reference/manifest/README.md) +- [JSONL, result shapes, errors, and exit codes](reference/output-and-errors.md) + +Documentation on `main` follows the current source tree. When using a released +binary, select the matching Git tag before reading these files. diff --git a/docs/user-guide/concepts-and-ownership.md b/docs/user-guide/concepts-and-ownership.md new file mode 100644 index 0000000..0bdb8b1 --- /dev/null +++ b/docs/user-guide/concepts-and-ownership.md @@ -0,0 +1,64 @@ +# Concepts and ownership + +## Project and Manifest + +A **Project** is the repository rooted at the selected Manifest. The +**Manifest** is the canonical desired-state document, normally `devctl.yaml`. +It declares Components, Resources, Sources, paths, Runtime Config, and the Go +generator policy. + +When `--file` is absent, Devctl searches upward from the current directory for +`devctl.yaml`. Use `--file ` when you need an explicit Manifest. + +## Components, Capabilities, and Resources + +A **Component** is an application-facing area such as HTTP, gRPC, Kafka, +logging, or telemetry. A **Capability** is enableable runtime behavior inside a +Component. A **Resource** is a named infrastructure dependency such as a +database, Redis Connection, or S3 bucket. + +`enable` and `add` mutate only the Manifest. After a structural change, run +`init scaffold` to materialize the corresponding Project foundation and `gen` +when generated code is required. + +## Contracts, Sources, and Targets + +A **Source** is a bounded origin for Contracts. A Contract Reference selects an +Entrypoint or named Export from that Source. External Contracts become +committed **Contract Snapshots** after `sync`, so later lint and generation can +run offline and produce reviewable changes. + +A **Target** is the effective unit addressed by `sync`, `lint`, or `gen`, for +example `http-client:billing`. `inspect` shows the complete, deterministically +ordered Target Catalog and the inputs, outputs, and operations of each Target. + +## Managed Outputs and Scaffold Seeds + +Every scaffolded path has one owner: + +- A **Managed Output** belongs entirely to Devctl. Its owning workflow may + atomically replace the file or Target tree and remove stale files inside it. +- A **Scaffold Seed** is created once and belongs to you afterwards. Devctl + does not deliberately overwrite or delete an existing Seed. + +Devctl-owned Go scaffold files end in `*.gen.go` and carry a generated-file +header. Ordinary `.go` files, application entrypoints, Provider Bindings, +handlers, and the scaffolded README are Seeds. + +`init scaffold` has no `--force`. It refreshes Managed Outputs, creates missing +Seeds, and rejects unsafe symlinks or non-regular entries. Adding a Component +may create a new Provider Binding, but you must add its `provideX` call to +`internal/deps/application.go` yourself. + +## Explicit workflows + +Devctl deliberately avoids hidden orchestration: + +- `init manifest`, `enable`, and `add` do not install tools, scaffold files, or + generate code. +- `sync`, `lint`, and `gen` never invoke one another. +- A later Target failure does not roll back Targets that already completed. +- `--dry-run` plans from committed local state without network acquisition, + generator execution, publication, or pruning. + +This makes command side effects predictable and repository diffs reviewable. diff --git a/docs/user-guide/contracts-and-generation.md b/docs/user-guide/contracts-and-generation.md new file mode 100644 index 0000000..f0a794f --- /dev/null +++ b/docs/user-guide/contracts-and-generation.md @@ -0,0 +1,79 @@ +# Contracts and generation + +## Choose a Source + +Declare where a Contract originates, then reference it from a client or Kafka +endpoint. Source containment prevents references from escaping the selected +root. + +```sh +devctl add source contracts --type local --path api/contracts +devctl add http-client billing \ + --source contracts \ + --path billing/openapi.yaml +``` + +Ordinary Sources select a relative `--path`. A Devctl Source selects a named +upstream `--export` instead. See [recipes](recipes.md) for the Source matrix. + +## Synchronize external state + +```sh +devctl sync +devctl sync http --target http-client:billing +``` + +`sync` materializes external Contract closures into Project-owned paths. +Review and commit those Snapshots. Local Sources are supported no-ops. + +A full family sync may remove stale Target children. Targeted sync publishes +only the selected Target and never prunes another Target. Use `--dry-run` to +preview publication and pruning without network access or writes. + +## Lint committed inputs + +```sh +devctl lint +devctl lint grpc +``` + +Linting reads local Contracts or committed external Snapshots. It never +contacts the supplier and never runs generation. Findings are normal results: +stdout contains the complete result, stderr remains empty, and the process +exits `1` when invalid. + +## Generate Managed Outputs + +```sh +devctl gen +devctl gen http --target http-client:billing +``` + +Generation invokes versions and native configuration owned by the Project. It +publishes one Target atomically, removes stale files only inside that Target +tree, and never prunes siblings. Execution order is config, HTTP, gRPC, then +Kafka. + +After a generator changes Go imports, finish with: + +```sh +go mod tidy +mise run check +``` + +## Supplier and consumer Buf files + +For Proto, the supplier's `buf.yaml` and adjacent `buf.lock` travel with the +Contract Snapshot. The consuming Project owns `*.gen.yaml`. Canonical consumer +configs are Managed Outputs; an explicitly selected `buf_gen_config` is +user-owned and must already exist before validation or generation. + +## When to run each command + +| Change | Scaffold | Sync | Lint | Gen | +|---|:---:|:---:|:---:|:---:| +| Manifest-only default | maybe | no | no | maybe | +| New Component or Resource | yes | if external | if Contract | if generated output | +| Local server Contract | no | no | yes | yes | +| External Contract revision | no | yes | yes | yes | +| Generator configuration | maybe | no | no | yes | diff --git a/docs/user-guide/generated-project.md b/docs/user-guide/generated-project.md new file mode 100644 index 0000000..3c7c7c2 --- /dev/null +++ b/docs/user-guide/generated-project.md @@ -0,0 +1,60 @@ +# Working in a generated Project + +## Refresh safely + +Run `devctl init scaffold` whenever the Manifest gains a Component, Resource, +or generator configuration. Review the reported `created`, `updated`, and +`unchanged` paths. + +Managed files may change on every refresh. Scaffold Seeds are yours after +creation. Do not put handwritten files inside a Target directory owned by +`sync` or `gen`, because publication may replace that complete tree. + +## Compose application behavior + +`internal/deps/application.go` is the user-owned composition list. Generated +`provideX` functions register infrastructure; call the providers your +application needs from `provideApplication`. Refresh may add new binding Seeds +but does not edit this list. + +HTTP and gRPC behavior is registered through `HTTPRegistrar` and +`GRPCRegistrar`. Generated outbound providers expose raw HTTP transports/base +URLs or gRPC connections; application code constructs the generated client or +stub it needs. + +## Run Scenarios + +The generated `Scenario` owns one lazy dependency graph and orderly shutdown: + +- `deps.NewAPI(ctx)` resolves the API runtime and its HTTP, gRPC, health, and + pprof work. +- `deps.NewConsumer(ctx, name)` validates the selected Kafka consumer and + resolves only that runner. + +Unknown or disabled consumers fail before their Kafka client is constructed. +The initial Kafka handler Seed returns a permanent not-implemented error so an +unfinished consumer cannot acknowledge messages silently. + +## Configure runtime behavior + +Generated Runtime Config is derived from the Manifest. `.env.example`, +`inspect`, generated config, and scaffold consumers share the same catalog. + +No `start` block means a Capability is always on. A present `start` block +creates an environment toggle; when its `default` is omitted, the effective +default is `false`. Secret entries never receive rendered defaults. + +## Manage migrations + +For each database Variant with migrations, scaffold creates the directory and +pinned Mise tasks. Devctl does not write SQL or apply migrations. + +```sh +mise run migrate:primary:postgres:create create_orders +mise run migrate:primary:postgres:up +mise run migrate:primary:postgres:down +``` + +Runtime DSNs and migration URLs are independent. ClickHouse migrations are +non-transactional; a failed multi-statement migration may require manual +inspection and recovery before forcing a version. diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md new file mode 100644 index 0000000..70ee4ee --- /dev/null +++ b/docs/user-guide/getting-started.md @@ -0,0 +1,305 @@ +# Build an Orders API with PostgreSQL + +This walkthrough starts with an empty directory and ends with a running HTTP +API that writes to PostgreSQL. It exercises the complete local Devctl workflow: +Manifest creation, scaffolding, Contract linting, generation, a migration, and +user-owned application code. + +The finished result is checked in at +[`examples/orders-api`](../../examples/orders-api/README.md). + +## Prerequisites + +Install Go 1.26, Git, Mise, and Docker. Then install Devctl: + +```sh +go install github.com/devctllabs/devctl/cmd/devctl@latest +devctl --version +``` + +## 1. Create the Project + +```sh +mkdir orders-api +cd orders-api + +devctl init manifest \ + --lang go \ + --preset http-service \ + --name orders-api \ + --module example.com/orders-api + +devctl add db primary --kind postgres +devctl init scaffold +mise install +go mod tidy +``` + +At this point `devctl.yaml` is the desired state, files ending in `*.gen.go` +belong to Devctl, and ordinary `.go` files belong to you. The database command +also added PostgreSQL configuration and migration tasks to the Project. + +## 2. Define the HTTP Contract + +Replace `api/openapi/swagger.yaml` with this OpenAPI 3.1 Contract: + +```yaml +openapi: 3.1.0 +info: + title: Orders API + version: 1.0.0 +paths: + /orders: + post: + operationId: createOrder + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateOrder" + responses: + "201": + description: Order created + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + /orders/{id}: + get: + operationId: getOrder + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + minimum: 1 + responses: + "200": + description: Order found + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "404": + description: Order not found + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" +components: + schemas: + CreateOrder: + type: object + additionalProperties: false + required: [customer_name, total_cents] + properties: + customer_name: + type: string + minLength: 1 + total_cents: + type: integer + format: int64 + minimum: 0 + Order: + type: object + additionalProperties: false + required: [id, customer_name, total_cents, created_at] + properties: + id: + type: integer + format: int64 + customer_name: + type: string + total_cents: + type: integer + format: int64 + created_at: + type: string + format: date-time + Problem: + type: object + additionalProperties: false + required: [message] + properties: + message: + type: string +``` + +Lint the Contract and generate the strict Echo server interface: + +```sh +devctl lint +devctl gen +go mod tidy +``` + +`devctl lint` validates the Contract but does not generate code. `devctl gen` +publishes the generated server to `gen/serverhttp/server.gen.go`. + +## 3. Create the database migration + +Create a timestamped migration pair: + +```sh +mise run migrate:primary:postgres:create create_orders +``` + +Put this in the new `.up.sql` file: + +```sql +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_name text NOT NULL, + total_cents bigint NOT NULL CHECK (total_cents >= 0), + created_at timestamptz NOT NULL DEFAULT now() +); +``` + +Put this in the matching `.down.sql` file: + +```sql +DROP TABLE orders; +``` + +## 4. Implement the user-owned code + +Create an `internal/orders` package that contains: + +- an `Order` domain value and a small `Store` interface; +- a PostgreSQL store using `*postgresdb.Endpoint`; +- a handler implementing `serverhttp.StrictServerInterface`; +- a mapping from the domain value to the generated API type. + +The complete implementation is +[`internal/orders/orders.go`](../../examples/orders-api/internal/orders/orders.go). +Its important boundary is deliberately small: + +```go +type Store interface { + Create(ctx context.Context, customerName string, totalCents int64) (Order, error) + Get(ctx context.Context, id int64) (Order, error) +} + +type Handler struct { + store Store +} +``` + +Then edit the user-owned `internal/deps/application.go`. Resolve the reader and +writer endpoints published by the generated database provider, construct the +store and handler, and register the strict server: + +```go +reader, err := di.ResolveNamed[*postgresdb.Endpoint]( + resolver, + storagePrimaryConnectionName+".reader", +) +// handle err +writer, err := di.ResolveNamed[*postgresdb.Endpoint]( + resolver, + storagePrimaryConnectionName+".writer", +) +// handle err + +store := orders.NewPostgresStore(reader, writer) +app := &application{orders: orders.NewHandler(store)} +``` + +```go +func (a *application) RegisterHTTP(server *echo.Echo) { + strict := serverhttp.NewStrictHandler(a.orders, nil) + serverhttp.RegisterHandlers(server, strict) +} +``` + +See the complete +[`application.go`](../../examples/orders-api/internal/deps/application.go) and +the handler tests in +[`handler_test.go`](../../examples/orders-api/internal/orders/handler_test.go). + +Verify that the Project compiles before starting infrastructure: + +```sh +go test ./... +``` + +## 5. Start PostgreSQL and apply the migration + +Start an isolated PostgreSQL container: + +```sh +docker run --rm --detach \ + --name devctl-orders-postgres \ + --publish 5432:5432 \ + --env POSTGRES_USER=orders \ + --env POSTGRES_PASSWORD=orders \ + --env POSTGRES_DB=orders \ + postgres:18.6-alpine3.23 +``` + +Wait until it is ready: + +```sh +docker exec devctl-orders-postgres pg_isready -U orders -d orders +``` + +Configure both the runtime pool and the migration tool, then migrate: + +```sh +export ORDERS_API_DB_PRIMARY_KIND=postgres +export ORDERS_API_DB_PRIMARY_POSTGRES_DSN='postgres://orders:orders@127.0.0.1:5432/orders?sslmode=disable' +export ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL="$ORDERS_API_DB_PRIMARY_POSTGRES_DSN" + +mise run migrate:primary:postgres:up +``` + +The generated `.env.example` lists every runtime key. Secrets have empty +values and must be supplied through the environment or your secret manager. + +## 6. Run and call the API + +Start the API in one terminal: + +```sh +go run ./cmd/orders-api api +``` + +Create an order from another terminal: + +```sh +curl --fail-with-body \ + --request POST \ + --header 'Content-Type: application/json' \ + --data '{"customer_name":"Ada","total_cents":1250}' \ + http://127.0.0.1:8080/orders +``` + +The response has status `201` and contains the assigned ID: + +```json +{"created_at":"2026-09-04T19:30:00Z","customer_name":"Ada","id":1,"total_cents":1250} +``` + +Use that ID to read the row back: + +```sh +curl --fail-with-body http://127.0.0.1:8080/orders/1 +``` + +Stop the API with Ctrl-C, then stop PostgreSQL: + +```sh +docker stop devctl-orders-postgres +``` + +## What to do next + +- Read [concepts and ownership](concepts-and-ownership.md) before editing + generated files. +- Follow the [Project workflow](project-workflow.md) when adding Components. +- Use [contracts and generation](contracts-and-generation.md) for local and + external Contract changes. +- Use the [recipes](recipes.md) to add more Resources and clients. diff --git a/docs/user-guide/project-workflow.md b/docs/user-guide/project-workflow.md new file mode 100644 index 0000000..adc5cd4 --- /dev/null +++ b/docs/user-guide/project-workflow.md @@ -0,0 +1,72 @@ +# Project workflow + +## Create a Manifest + +Choose the minimal CLI application or an HTTP service foundation: + +```sh +devctl init manifest \ + --lang go \ + --preset http-service \ + --name billing-api \ + --module example.com/billing-api +``` + +The command creates only `devctl.yaml`. `--force` replaces the complete +Manifest from the supplied arguments; it does not merge with the old file. + +## Change desired state + +Use `enable` for runtime Capabilities and `add` for named Resources, Sources, +clients, and Kafka endpoints: + +```sh +devctl enable grpc +devctl add db primary --kind postgres +devctl add redis cache +``` + +Mutation commands preserve a valid existing declaration unless `--force` is +given. They never install dependencies or change handwritten Go. + +## Materialize the foundation + +```sh +devctl init scaffold +mise install +go mod tidy +``` + +Run scaffold again after adding Components or Resources. Review created files, +then register new user-owned Provider Bindings in +`internal/deps/application.go`. See [generated Project](generated-project.md) +for the ownership contract. + +## Validate and inspect + +```sh +devctl validate +devctl inspect +``` + +`validate` checks three distinct layers: YAML/schema structure, semantic +validity, and Project Readiness. Findings are returned as a normal result and +produce exit status `1`. + +`inspect` shows the effective Project rather than repeating raw YAML. Use it to +find effective paths, Target IDs, Runtime Config keys and defaults, Resources, +and resolved Contract inputs. Missing or stale external Snapshot Metadata does +not make inspection fail; `resolved_input` is simply absent until `sync` +publishes valid metadata. + +## Preview destructive publication + +Before generation or a full external synchronization, inspect the plan: + +```sh +devctl gen --dry-run +devctl sync --dry-run +``` + +Dry runs report `planned_publish` and, for full synchronization, possible +`planned_remove` actions. A targeted sync never removes sibling Target trees. diff --git a/docs/user-guide/recipes.md b/docs/user-guide/recipes.md new file mode 100644 index 0000000..e0387c1 --- /dev/null +++ b/docs/user-guide/recipes.md @@ -0,0 +1,125 @@ +# Recipes + +These recipes show the Manifest mutation step. Follow each with `init +scaffold`, `validate`, `sync`, `lint`, or `gen` only when the changed surface +requires it. + +## Databases + +```sh +devctl add db primary --kind sqlite +devctl add db primary --kind postgres --default +devctl add db analytics --kind clickhouse +``` + +Migration targets default to `migrations//`. Override the +path with `--migrations-path`, or opt out with `--no-migrations`. + +A Connection with several Variants must name a default. ClickHouse cannot be +mixed with SQLite or PostgreSQL Variants in the same logical Connection because +its native runtime is not transactional. + +## Redis + +```sh +devctl add redis cache +devctl add redis sessions \ + --addr-env SESSIONS_REDIS_ADDR \ + --addr-default localhost:6380 +``` + +The default address is `localhost:6379`. Addresses may be `host:port` or a +`redis`/`rediss` URL without embedded credentials. + +## S3 + +```sh +devctl add s3-connection assets --credentials ambient +devctl add s3 uploads --connection assets +``` + +Credential modes are `ambient` and `static`. When `add s3` omits +`--connection`, Devctl creates the canonical local static Connection if +needed. + +## Contract Sources + +```sh +# Project-local containment root +devctl add source contracts --type local --path api/contracts + +# HTTPS closure rooted at a URL +devctl add source public-api \ + --type url \ + --url https://contracts.example.com/ \ + --filename openapi.yaml + +# Repository checkout +devctl add source shared \ + --type git \ + --repo https://github.com/acme/contracts.git \ + --ref v1.4.0 \ + --path services + +# Named Exports from another Devctl Project +devctl add source platform \ + --type devctl \ + --repo https://github.com/acme/platform-contracts.git \ + --ref v1.4.0 +``` + +URL Sources require HTTPS unless `--allow-insecure-http` is explicitly set. +Git Sources may select a containment path. Devctl Sources forbid `path` and +are consumed through named Exports. + +## HTTP and gRPC clients + +```sh +devctl add http-client billing \ + --source contracts \ + --path billing/openapi.yaml + +devctl add grpc-client ledger \ + --source platform \ + --export ledger-grpc +``` + +Use `--path` for local, URL, and Git Sources. Use `--export` for a Devctl +Source. A custom `--buf-gen-config` is user-owned and must exist before +validation. + +## Kafka endpoints + +```sh +devctl add kafka-consumer audit \ + --topic orders.audit.v1 \ + --format raw + +devctl add kafka-producer created \ + --topic orders.created.v1 \ + --format json \ + --source contracts \ + --path orders-created.schema.json + +devctl add kafka-consumer billing \ + --topic billing.events.v1 \ + --format proto \ + --source platform \ + --export billing-events \ + --message acme.billing.v1.Event \ + --encoding binary +``` + +`raw` has no Contract files and supports inspect/lint but not sync or code +generation. JSON Schema roots need a non-empty `title`. Proto encoding is +`binary` or `json`. + +## Targeted previews + +```sh +devctl inspect +devctl sync http --target http-client:billing --dry-run +devctl gen grpc --target grpc-client:ledger --dry-run +``` + +Copy Target IDs from `inspect`; do not derive them from output paths. diff --git a/docs/user-guide/reference/commands.md b/docs/user-guide/reference/commands.md new file mode 100644 index 0000000..dccc192 --- /dev/null +++ b/docs/user-guide/reference/commands.md @@ -0,0 +1,843 @@ +# Command reference + + + +## CLI interface - devctl + +Devctl defines, validates, and materializes reproducible Go projects from a devctl.yaml manifest. Commands are non-interactive and keep manifest mutation, synchronization, linting, scaffolding, and generation explicit. + +Manage Devctl Go projects. + +Usage: + +```bash +$ devctl [COMMAND] [COMMAND FLAGS] [ARGUMENTS...] +``` + +### `init` command + +Initialize a Devctl project. + +Create the canonical Manifest or materialize the Go project foundation declared by an existing Manifest. Initialization steps are explicit and never run one another implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] init [ARGUMENTS...] +``` + +### `init manifest` subcommand + +Create devctl.yaml. + +> devctl init manifest --lang go --preset --name --module + +Create a complete v1 Manifest from a supported preset. This command writes only the Manifest; it does not scaffold files, install tools, synchronize Contracts, lint, or generate code. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] init manifest [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--lang="…"` | set the project language; supported value: `go` | string | | *none* | +| `--preset="…"` | seed the Manifest from `cli` or `http-service` | string | | *none* | +| `--name="…"` | set the kebab-case `project-name` | string | | *none* | +| `--module="…"` | set the Go `module-path` | string | | *none* | +| `--force` | replace an existing Manifest instead of returning a conflict | bool | `false` | *none* | + +### `init scaffold` subcommand + +Create or refresh the Go project foundation. + +> devctl init scaffold [--file ] + +Publish Devctl-managed project files and create missing Scaffold Seeds. Managed Outputs may be replaced; existing user-owned Seeds are never deliberately overwritten or deleted. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] init scaffold [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `validate` command + +Validate the selected Project. + +> devctl validate [--file ] + +Check Manifest structure, semantic validity, references, safe paths, and Project Readiness. Validation findings are normal results and exit with status 1 when any issue is present. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] validate [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `inspect` command + +Inspect effective Project configuration. + +> devctl inspect [--file ] + +Show the selected Project root, effective paths, Target Catalog, Runtime Config, Resources, and resolved Contract inputs without requiring every external Snapshot to be ready. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] inspect [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `enable` command + +Enable a project capability. + +Add or update one supported Capability in the Manifest. This command changes only devctl.yaml and does not refresh scaffold files or generated code. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable [ARGUMENTS...] +``` + +### `enable http` subcommand + +Enable http. + +> devctl enable http [--always] [--force] + +Add the http Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable http [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `enable grpc` subcommand + +Enable grpc. + +> devctl enable grpc [--always] [--force] + +Add the grpc Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable grpc [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `enable logging` subcommand + +Enable logging. + +> devctl enable logging [--always] [--force] + +Add the logging Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable logging [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `enable health` subcommand + +Enable health. + +> devctl enable health [--always] [--force] + +Add the health Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable health [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `enable telemetry` subcommand + +Enable telemetry. + +> devctl enable telemetry [--always] [--force] + +Add the telemetry Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable telemetry [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `enable pprof` subcommand + +Enable pprof. + +> devctl enable pprof [--always] [--force] + +Add the pprof Capability and its canonical defaults to the Manifest. Run init scaffold and gen explicitly when the resulting Project files need to be refreshed. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] enable pprof [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--always` | omit the Runtime Start Policy so the Capability always starts | bool | `false` | *none* | +| `--force` | replace an existing Capability declaration | bool | `false` | *none* | + +### `add` command + +Add a named Project resource. + +Add or update a named Source, client, Kafka endpoint, database Variant, Redis Connection, S3 Connection, or S3 bucket in the Manifest. This command changes only devctl.yaml. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add [ARGUMENTS...] +``` + +### `add db` subcommand + +Add a database variant. + +> devctl add db --kind + +Add a SQLite, PostgreSQL, or ClickHouse Variant to a named database Connection. A migration target is declared by default; Devctl never writes SQL or applies migrations. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add db [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-------------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--kind="…"` | select `sqlite`, `postgres`, or `clickhouse` | string | | *none* | +| `--default` | make this Variant the Connection default | bool | `false` | *none* | +| `--force` | replace an existing Variant with the same identity | bool | `false` | *none* | +| `--no-migrations` | do not declare a migration target for this Variant | bool | `false` | *none* | +| `--migrations-path="…"` | override the project-relative migration `path` | string | | *none* | + +### `add source` subcommand + +Add a contract source. + +> devctl add source --type [type-specific flags] + +Declare a bounded origin for Contracts. Type-specific flags select a local directory, URL closure, Git checkout, or another Devctl Project. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add source [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|-------------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--type="…"` | select `local`, `url`, `git`, or `devctl` | string | | *none* | +| `--path="…"` | set the project-relative local or Git containment `path` | string | | *none* | +| `--url="…"` | set the base `URL` for a URL Source | string | | *none* | +| `--filename="…"` | store a single URL document under `filename` | string | | *none* | +| `--allow-insecure-http` | allow an http URL instead of requiring https | bool | `false` | *none* | +| `--repo="…"` | set the Git or Devctl repository `location` | string | | *none* | +| `--ref="…"` | select the immutable or reviewable repository `ref` | string | | *none* | +| `--buf-config="…"` | select the Source-relative supplier `buf-config` | string | | *none* | +| `--force` | replace an existing Source with the same name | bool | `false` | *none* | + +### `add http-client` subcommand + +Add an HTTP client. + +> devctl add http-client --source (--path | --export ) + +Declare a named OpenAPI client Target. Use --path with ordinary Sources or --export with a Devctl Source. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add http-client [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--source="…"` | select the named contract `source` | string | | *none* | +| `--export="…"` | select a named Export from a Devctl Source | string | | *none* | +| `--path="…"` | select an OpenAPI Entrypoint from a non-Devctl Source | string | | *none* | +| `--base-url-env="…"` | override the generated runtime base URL environment `key` | string | | *none* | +| `--force` | replace an existing HTTP client with the same name | bool | `false` | *none* | + +### `add grpc-client` subcommand + +Add a gRPC client. + +> devctl add grpc-client --source (--path | --export ) + +Declare a named Proto client Target. Use --path with ordinary Sources or --export with a Devctl Source; custom generator configs remain user-owned. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add grpc-client [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|------------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--source="…"` | select the named contract `source` | string | | *none* | +| `--export="…"` | select a named Export from a Devctl Source | string | | *none* | +| `--path="…"` | select the Contract path from a non-Devctl Source | string | | *none* | +| `--proto-root="…"` | set the Source-relative Proto `module-root` | string | | *none* | +| `--buf-gen-config="…"` | use the project-owned generator `config-path` | string | | *none* | +| `--addr-env="…"` | override the generated runtime address environment `key` | string | | *none* | +| `--force` | replace an existing gRPC client with the same name | bool | `false` | *none* | + +### `add kafka-consumer` subcommand + +Add a Kafka endpoint. + +> devctl add kafka-consumer --topic --format [contract flags] + +Declare a named Kafka endpoint and its raw, JSON Schema, or Proto Contract. Schema-backed endpoints use --path for ordinary Sources or --export for Devctl Sources. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add kafka-consumer [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------------|-----------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--topic="…"` | set the Kafka `topic` | string | | *none* | +| `--source="…"` | select the named contract `source` | string | | *none* | +| `--export="…"` | select a named Export from a Devctl Source | string | | *none* | +| `--path="…"` | select the schema Entrypoint from a non-Devctl Source | string | | *none* | +| `--format="…"` | select `raw`, `json`, or `proto` | string | | *none* | +| `--proto-root="…"` | set the Source-relative Proto `module-root` | string | | *none* | +| `--message="…"` | select the fully-qualified Proto `message` | string | | *none* | +| `--encoding="…"` | select Proto `binary` or `json` encoding | string | | *none* | +| `--group-env="…"` | override the consumer group environment `key` | string | | *none* | +| `--always` | omit the Runtime Start Policy so the consumer is always enabled | bool | `false` | *none* | +| `--force` | replace an existing Kafka endpoint with the same name | bool | `false` | *none* | + +### `add kafka-producer` subcommand + +Add a Kafka endpoint. + +> devctl add kafka-producer --topic --format [contract flags] + +Declare a named Kafka endpoint and its raw, JSON Schema, or Proto Contract. Schema-backed endpoints use --path for ordinary Sources or --export for Devctl Sources. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add kafka-producer [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--topic="…"` | set the Kafka `topic` | string | | *none* | +| `--source="…"` | select the named contract `source` | string | | *none* | +| `--export="…"` | select a named Export from a Devctl Source | string | | *none* | +| `--path="…"` | select the schema Entrypoint from a non-Devctl Source | string | | *none* | +| `--format="…"` | select `raw`, `json`, or `proto` | string | | *none* | +| `--proto-root="…"` | set the Source-relative Proto `module-root` | string | | *none* | +| `--message="…"` | select the fully-qualified Proto `message` | string | | *none* | +| `--encoding="…"` | select Proto `binary` or `json` encoding | string | | *none* | +| `--topic-env="…"` | override the producer topic environment `key` | string | | *none* | +| `--force` | replace an existing Kafka endpoint with the same name | bool | `false` | *none* | + +### `add redis` subcommand + +Add a redis resource. + +> devctl add redis [options] + +Declare a named Redis Connection with an environment-backed address and a local default. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add redis [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--addr-env="…"` | override the generated Redis address environment `key` | string | | *none* | +| `--addr-default="…"` | override the local Redis `address` default | string | | *none* | +| `--force` | replace an existing Redis Connection with the same name | bool | `false` | *none* | + +### `add s3-connection` subcommand + +Add a s3-connection resource. + +> devctl add s3-connection [options] + +Declare a named S3 Connection and choose ambient or static credentials. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add s3-connection [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|---------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--credentials="…"` | select `ambient` or `static` credentials | string | | *none* | +| `--force` | replace an existing S3 Connection with the same name | bool | `false` | *none* | + +### `add s3` subcommand + +Add a s3 resource. + +> devctl add s3 [options] + +Declare a named S3 bucket attached to an existing Connection, or create the canonical local Connection when omitted. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] add s3 [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--connection="…"` | attach the bucket to the named S3 `connection` | string | | *none* | +| `--force` | replace an existing S3 bucket with the same name | bool | `false` | *none* | + +### `sync` command + +Synchronize external Contracts. + +> devctl sync [--target ] [--dry-run] + +Materialize every supported external Contract Snapshot into Project-owned paths. Full synchronization may prune stale Target directories; use --dry-run to preview changes. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] sync [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--target="…"` | select one exact Target `id`, such as http-client:billing | string | | *none* | +| `--dry-run` | preview publication and pruning without network access or writes | bool | `false` | *none* | + +### `sync http` subcommand + +Synchronize external Contracts. + +> devctl sync http [--target ] [--dry-run] + +Materialize external http Contract Snapshots. Family synchronization may prune stale Target directories; an explicit --target never prunes sibling Targets. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] sync http [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--target="…"` | select one exact Target `id`, such as http-client:billing | string | | *none* | +| `--dry-run` | preview publication and pruning without network access or writes | bool | `false` | *none* | + +### `sync grpc` subcommand + +Synchronize external Contracts. + +> devctl sync grpc [--target ] [--dry-run] + +Materialize external grpc Contract Snapshots. Family synchronization may prune stale Target directories; an explicit --target never prunes sibling Targets. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] sync grpc [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--target="…"` | select one exact Target `id`, such as http-client:billing | string | | *none* | +| `--dry-run` | preview publication and pruning without network access or writes | bool | `false` | *none* | + +### `sync kafka` subcommand + +Synchronize external Contracts. + +> devctl sync kafka [--target ] [--dry-run] + +Materialize external kafka Contract Snapshots. Family synchronization may prune stale Target directories; an explicit --target never prunes sibling Targets. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] sync kafka [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--target="…"` | select one exact Target `id`, such as http-client:billing | string | | *none* | +| `--dry-run` | preview publication and pruning without network access or writes | bool | `false` | *none* | + +### `gen` command + +Generate Managed Outputs. + +> devctl gen [--target ] [--dry-run] + +Run the Project-owned generators for every supported Target and atomically publish each Target's Managed Output. Generation never synchronizes or lints implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] gen [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|---------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--dry-run` | preview Managed Outputs without running generators or writing files | bool | `false` | *none* | +| `--target="…"` | select one exact generation Target `id` | string | | *none* | + +### `gen config` subcommand + +Generate Managed Outputs. + +> devctl gen config [--dry-run] + +Run the Project-owned generators for config Targets and atomically publish their Managed Outputs without synchronizing or linting implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] gen config [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--dry-run` | preview Managed Outputs without running generators or writing files | bool | `false` | *none* | + +### `gen http` subcommand + +Generate Managed Outputs. + +> devctl gen http [--target ] [--dry-run] + +Run the Project-owned generators for http Targets and atomically publish their Managed Outputs without synchronizing or linting implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] gen http [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|---------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--dry-run` | preview Managed Outputs without running generators or writing files | bool | `false` | *none* | +| `--target="…"` | select one exact generation Target `id` | string | | *none* | + +### `gen grpc` subcommand + +Generate Managed Outputs. + +> devctl gen grpc [--target ] [--dry-run] + +Run the Project-owned generators for grpc Targets and atomically publish their Managed Outputs without synchronizing or linting implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] gen grpc [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|---------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--dry-run` | preview Managed Outputs without running generators or writing files | bool | `false` | *none* | +| `--target="…"` | select one exact generation Target `id` | string | | *none* | + +### `gen kafka` subcommand + +Generate Managed Outputs. + +> devctl gen kafka [--target ] [--dry-run] + +Run the Project-owned generators for kafka Targets and atomically publish their Managed Outputs without synchronizing or linting implicitly. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] gen kafka [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|----------------|---------------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | +| `--dry-run` | preview Managed Outputs without running generators or writing files | bool | `false` | *none* | +| `--target="…"` | select one exact generation Target `id` | string | | *none* | + +### `lint` command + +Lint Project Contracts. + +> devctl lint [--file ] + +Lint every supported Contract using committed local inputs. Findings are normal results and exit with status 1 without becoming execution errors. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] lint [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `lint http` subcommand + +Lint Project Contracts. + +> devctl lint http [--file ] + +Lint committed http Contracts without synchronizing or generating code. Findings are normal results and exit with status 1. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] lint http [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `lint grpc` subcommand + +Lint Project Contracts. + +> devctl lint grpc [--file ] + +Lint committed grpc Contracts without synchronizing or generating code. Findings are normal results and exit with status 1. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] lint grpc [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | + +### `lint kafka` subcommand + +Lint Project Contracts. + +> devctl lint kafka [--file ] + +Lint committed kafka Contracts without synchronizing or generating code. Findings are normal results and exit with status 1. + +Usage: + +```bash +$ devctl [GLOBAL FLAGS] lint kafka [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Type | Default value | Environment variables | +|--------------|---------------------------------------------------------------|--------|:-------------:|:---------------------:| +| `--file="…"` | use `path` as the Manifest instead of discovering devctl.yaml | string | | *none* | +| `--json` | emit compact JSONL events instead of text | bool | `false` | *none* | +| `--verbose` | include debug diagnostics and raw causes on stderr | bool | `false` | *none* | diff --git a/docs/user-guide/reference/manifest/README.md b/docs/user-guide/reference/manifest/README.md new file mode 100644 index 0000000..e8f5e19 --- /dev/null +++ b/docs/user-guide/reference/manifest/README.md @@ -0,0 +1,51 @@ +# Manifest v1 reference + +`devctl.yaml` is the canonical desired-state document for a Project. The root +must be a YAML mapping with these fields: + +```yaml +version: 1 +project: {} +env: {} +paths: {} +sources: {} +exports: {} +components: {} +languages: + go: {} +``` + +| Field | Required | Description | +|---|:---:|---| +| `version` | yes | Manifest format; only `1` is supported | +| `project` | yes | Project identity | +| `env` | yes | Global Runtime Config declarations | +| `paths` | yes | Project-owned path overrides | +| `sources` | yes | Named Contract Sources | +| `exports` | yes | Named surfaces supplied to other Projects | +| `components` | yes | Runtime Components, Capabilities, and Resources | +| `languages.go` | yes | Go module and generator policy | + +Unknown and duplicate fields are rejected. YAML scalar, sequence, and mapping +types are checked before semantic validation. Required values, references, +safe relative paths, conflicts, and Project Readiness are checked separately by +`devctl validate`. + +Continue with: + +- [Project, environment, paths, and Go tooling](project-and-language.md) +- [Sources and Exports](sources-and-exports.md) +- [Components and Resources](components.md) + +## Path rules + +Manifest paths are project-relative and must remain contained by the Project. +Absolute paths, traversal through `..`, symlinks at managed boundaries, and +non-regular files where files are required are rejected. Managed paths must not +overlap in ways that let one workflow replace another workflow's output. + +## Defaults + +Omitted optional fields decode to zero values. Effective defaults are applied +by the Project model and may therefore appear in `inspect` even when absent +from YAML. This reference lists effective defaults next to the owning field. diff --git a/docs/user-guide/reference/manifest/components.md b/docs/user-guide/reference/manifest/components.md new file mode 100644 index 0000000..7854118 --- /dev/null +++ b/docs/user-guide/reference/manifest/components.md @@ -0,0 +1,252 @@ +# Components and Resources + +## Shared environment and start policy + +Components may declare system and custom Runtime Config entries: + +```yaml +env: + system: + - key: HTTP_ADDR + type: string + default: :8080 + custom: + - key: HEADER_LIMIT + type: int + default: 50 +``` + +Both lists use `key`, optional `type`, optional `default`, and optional +`secret`. See [environment reference](project-and-language.md#env). + +Runnable Capabilities use this shape: + +```yaml +start: + env: HTTP_SERVER_ENABLED + default: true +``` + +`start.env` is required when `start` exists. An absent `start` means always on. +When `start` exists and `default` is omitted, the effective default is `false`. + +## HTTP + +```yaml +components: + http: + server: + openapi: api/openapi/swagger.yaml + start: {env: HTTP_SERVER_ENABLED, default: true} + clients: + - name: billing + source: contracts + path: billing/openapi.yaml + base_url_env: BILLING_BASE_URL + oapi_config: tools/oapi/clients.billing.yaml + env: + system: + - {key: HTTP_ADDR, type: string, default: ":8080"} +``` + +| Field | Required | Description/default | +|---|:---:|---| +| `server.openapi` | no | Server Entrypoint; `api/openapi/swagger.yaml` | +| `server.start` | no | HTTP Runtime Start Policy | +| `clients[].name` | yes | Unique client name | +| `clients[].source` | yes | Existing Source | +| `clients[].path` | conditional | Entrypoint for non-Devctl Sources | +| `clients[].export` | conditional | Named Export for a Devctl Source | +| `clients[].base_url_env` | no | Runtime base URL key | +| `clients[].oapi_config` | no | Per-client generator config | +| `env` | no | Component Runtime Config | + +`path` and `export` are alternatives selected by Source type. + +## gRPC + +```yaml +components: + grpc: + server: + proto_root: api/proto/grpc + buf_config: buf.yaml + start: {env: GRPC_SERVER_ENABLED, default: true} + clients: + - name: ledger + source: shared + export: ledger-grpc + proto_root: api/proto/ledger + buf_gen_config: tools/buf/ledger.gen.yaml + addr_env: LEDGER_GRPC_ADDR + env: {} +``` + +| Field | Required | Description/default | +|---|:---:|---| +| `server.proto_root` | no | Proto Module Root; `api/proto/grpc` | +| `server.buf_config` | no | Supplier Buf config; `buf.yaml` | +| `server.start` | no | gRPC Runtime Start Policy | +| `clients[].name` | yes | Unique client name | +| `clients[].source` | yes | Existing Source | +| `clients[].path` | conditional | Contract path for non-Devctl Sources | +| `clients[].export` | conditional | Named Export for a Devctl Source | +| `clients[].proto_root` | no | Module Root containing the selected path | +| `clients[].buf_gen_config` | no | Consumer generator config | +| `clients[].addr_env` | no | Runtime server address key | +| `env` | no | Component Runtime Config | + +Supplier `buf_config`/`buf.lock` and consumer `buf_gen_config` have different +owners. An explicit consumer config is a user-owned file. + +## Kafka + +```yaml +components: + kafka: + consumers: + - name: audit + topic: orders.audit.v1 + group_env: KAFKA_AUDIT_GROUP + start: {env: KAFKA_AUDIT_CONSUMER_ENABLED, default: false} + contract: {format: raw} + producers: + - name: created + topic: orders.created.v1 + topic_env: KAFKA_CREATED_TOPIC + contract: + source: contracts + path: orders-created.schema.json + format: json + env: {} +``` + +Consumers require `name` and `topic`; `group_env` and `start` are optional. +Producers require `name` and `topic`; `topic_env` is optional. Both use the +same `contract` shape: + +| Field | Required | Description | +|---|:---:|---| +| `format` | yes | `raw`, `json`, or `proto` | +| `source` | schema-backed | Existing Source | +| `path` | conditional | Entrypoint for non-Devctl Sources | +| `export` | conditional | Kafka Export for a Devctl Source | +| `proto_root` | Proto only | Module Root containing `path` | +| `message` | Proto only | Fully-qualified message name | +| `encoding` | Proto only | `binary` or `json` | + +Raw endpoints have no Source, path, Export, or Proto fields. JSON Schema roots +need a non-empty `title`, which owns the generated Go type name. Kafka Runtime +Config also derives brokers, batching, retry, rebalance, drain, and shutdown +settings from each effective endpoint. + +## Databases + +```yaml +components: + db: + connections: + - name: primary + default: postgres + kind_env: DB_PRIMARY_KIND + variants: + - name: postgres + kind: postgres + dsn_env: DB_PRIMARY_POSTGRES_DSN + secret: true + migrations: + path: migrations/primary/postgres + database_env: DB_PRIMARY_POSTGRES_MIGRATIONS_URL + env: {} +``` + +| Field | Required | Description | +|---|:---:|---| +| `connections[].name` | yes | Unique logical Connection name | +| `connections[].default` | multiple Variants | Name of the default Variant | +| `connections[].kind_env` | no | Runtime Variant selector key | +| `connections[].variants` | yes | One or more Variants | +| `variants[].name` | yes | Unique name within the Connection | +| `variants[].kind` | yes | `sqlite`, `postgres`, or `clickhouse` | +| `variants[].dsn_env` | no | Runtime DSN key | +| `variants[].dsn_default` | no | Runtime DSN default | +| `variants[].secret` | no | Redacts/suppresses the DSN default | +| `variants[].migrations` | no | Migration target | +| `migrations.path` | yes | Project-relative migration directory | +| `migrations.database_env` | yes | Migration-only database URL key | +| `migrations.database_default` | no | Migration URL default | +| `env` | no | Component Runtime Config | + +A single-Variant Connection may omit `default`; multiple Variants require an +existing default. Migration URL schemes must match the database kind. +ClickHouse cannot share a Connection with transactional Variants. + +## Redis + +```yaml +components: + redis: + connections: + - name: cache + addr_env: REDIS_CACHE_ADDR + addr_default: localhost:6379 + env: {} +``` + +Connections require `name`; `addr_env` and `addr_default` are optional. The +`add redis` defaults are `REDIS__ADDR` and `localhost:6379`. Credential- +bearing Redis URLs are rejected. + +## S3 + +```yaml +components: + s3: + connections: + - name: assets + credentials: static + endpoint: http://localhost:9000 + region: us-east-1 + path_style: true + access_key_env: S3_ASSETS_ACCESS_KEY + secret_key_env: S3_ASSETS_SECRET_KEY + buckets: + - name: uploads + connection: assets + bucket: uploads + env: {} +``` + +| Field | Required | Description | +|---|:---:|---| +| `connections[].name` | yes | Unique Connection name | +| `connections[].credentials` | no | `ambient` or `static` | +| `connections[].endpoint` | no | Custom service endpoint | +| `connections[].region` | no | AWS region | +| `connections[].path_style` | no | Use path-style bucket addressing | +| `connections[].access_key_env` | static | Access key environment name | +| `connections[].secret_key_env` | static | Secret key environment name | +| `buckets[].name` | yes | Unique logical bucket name | +| `buckets[].connection` | yes | Existing S3 Connection | +| `buckets[].bucket` | no | Physical bucket name | +| `env` | no | Component Runtime Config | + +## Logging, health, and telemetry + +```yaml +components: + logging: + env: {} + health: + server: + start: {env: HEALTH_SERVER_ENABLED, default: true} + env: {} + telemetry: + start: {env: TELEMETRY_ENABLED, default: false} + env: {} +``` + +`logging` contains only `env`. `health.server.start` and `telemetry.start` use +the shared Runtime Start Policy. The HTTP service preset also supplies +`HEALTH_ADDR=:8081`; telemetry uses the ecosystem-standard `OTEL_*` keys in +the effective Runtime Config catalog. diff --git a/docs/user-guide/reference/manifest/project-and-language.md b/docs/user-guide/reference/manifest/project-and-language.md new file mode 100644 index 0000000..0145df2 --- /dev/null +++ b/docs/user-guide/reference/manifest/project-and-language.md @@ -0,0 +1,115 @@ +# Project, environment, paths, and Go tooling + +## `project` + +```yaml +project: + name: orders-api + language: go +``` + +| Field | Required | Values | +|---|:---:|---| +| `project.name` | yes | Kebab-case Project name | +| `project.language` | yes | `go` | + +The name owns the default Runtime Config prefix. `orders-api` becomes +`ORDERS_API_`. + +## `env` + +```yaml +env: + prefix: ORDERS_ + custom: + - group: Payments + vars: + - key: PAYMENTS_TIMEOUT + type: duration + default: 5s + - key: PAYMENTS_TOKEN + type: string + secret: true +``` + +| Field | Required | Description | +|---|:---:|---| +| `env.prefix` | no | Overrides the Project-derived prefix | +| `env.custom[].group` | yes | Semantic group used in generated Go fields | +| `env.custom[].vars` | yes | Environment variables in the group | +| `vars[].key` | yes | Environment key before effective prefixing | +| `vars[].type` | no | `string` (default), `bool`, `int`, or `duration` | +| `vars[].default` | no | Typed runtime default | +| `vars[].secret` | no | Marks a secret and suppresses rendered defaults | + +Component `env.system` and `env.custom` entries use the same variable shape, +without the outer `group` field. Conflicting effective keys or generated Go +field paths produce `runtime_config_conflict`. + +Keys beginning with `OTEL_` retain that ecosystem prefix instead of receiving +the Project prefix. Secret values never carry defaults in generated config, +`.env.example`, or `inspect`. + +## `paths` + +```yaml +paths: + external_contracts: api/external +``` + +`paths.external_contracts` is optional and defaults to `api/external`. The +complete subtree belongs to `sync` and contains one child tree per external +Target. + +## `languages.go` + +```yaml +languages: + go: + module: example.com/orders-api + generators: + config: + out: gen/config/config.gen.go + http: + oapi_config: tools/oapi/server.yaml + server_out: gen/serverhttp + client_out: gen/clienthttp + grpc: + out: gen/grpc + buf_gen_config: tools/buf/grpc.gen.yaml + kafka: + out: gen/kafka + buf_gen_config: tools/buf/kafka.gen.yaml + components: + pprof: + server: + start: + env: PPROF_ENABLED + default: false + env: + system: + - key: PPROF_ADDR + type: string + default: 127.0.0.1:6060 +``` + +| Field | Required | Effective default | +|---|:---:|---| +| `languages.go.module` | yes | none | +| `generators.config.out` | no | `gen/config/config.gen.go` | +| `generators.http.oapi_config` | no | `tools/oapi/server.yaml` | +| `generators.http.server_out` | no | `gen/serverhttp` | +| `generators.http.client_out` | no | `gen/clienthttp` | +| `generators.grpc.out` | no | `gen/grpc` | +| `generators.grpc.buf_gen_config` | no | `tools/buf/grpc.gen.yaml` | +| `generators.kafka.out` | no | `gen/kafka` | +| `generators.kafka.buf_gen_config` | no | `tools/buf/kafka.gen.yaml` | + +Every Go Project has an implicit config Target even if `generators.config` is +omitted. Canonical native generator configs are Managed Outputs. An explicitly +selected alternate config is user-owned, must already exist, and is never +created or replaced by scaffold. + +`languages.go.components.pprof` uses the same `start` and component `env` +shapes described in [Components](components.md). The HTTP service preset uses +`127.0.0.1:6060` and a disabled-by-default `PPROF_ENABLED` toggle. diff --git a/docs/user-guide/reference/manifest/sources-and-exports.md b/docs/user-guide/reference/manifest/sources-and-exports.md new file mode 100644 index 0000000..975a12f --- /dev/null +++ b/docs/user-guide/reference/manifest/sources-and-exports.md @@ -0,0 +1,70 @@ +# Sources and Exports + +## `sources` + +Sources are named map entries: + +```yaml +sources: + contracts: + type: local + path: api/contracts +``` + +Every Source has `type`. Other fields are type-specific: + +| Type | Required | Optional | Forbidden or ignored | +|---|---|---|---| +| `local` | `path` | `proto.buf_config` | URL and repository fields | +| `url` | `url` | `filename`, `allow_insecure_http` | `path`, repository fields | +| `git` | `repo`, `ref` | `path`, `proto.buf_config` | URL fields | +| `devctl` | `repo`, `ref` | none | `path`, URL fields; consumers use `export` | + +### Source fields + +| Field | Description | +|---|---| +| `type` | `local`, `url`, `git`, or `devctl` | +| `path` | Relative containment root for local or Git content | +| `url` | Initial fetch URL for a URL closure | +| `filename` | Virtual committed filename for a single URL document | +| `allow_insecure_http` | Allows `http`; HTTPS is required by default | +| `repo` | Git clone location or upstream Devctl checkout | +| `ref` | Reviewable repository revision | +| `proto.buf_config` | Source-relative supplier Buf config | + +Credential-bearing URLs are rejected. URL references may fetch only relative +same-origin documents and are limited to 64 documents, 64 MiB total, and 32 +MiB per response. Query participates in fetch identity but is redacted from +diagnostics; fragments do not participate. Absolute references are ignored. + +For Proto Snapshots, the effective supplier Buf config and adjacent `buf.lock` +travel with the Contract. They are separate from consumer-owned `*.gen.yaml`. + +## `exports` + +Exports publish exact Project surfaces to a downstream Devctl Source: + +```yaml +exports: + public-api: + kind: openapi + path: api/openapi/swagger.yaml + billing-grpc: + kind: grpc + path: api/proto/grpc + order-events: + kind: kafka + producer: orders +``` + +| Kind | Required | Meaning | +|---|---|---| +| `openapi` | `path` | Must equal the effective HTTP server Entrypoint | +| `grpc` | `path` | Must equal the effective gRPC server Module Root | +| `kafka` | `producer` | Names an existing producer and inherits topic/format | + +`producer` is forbidden for OpenAPI/gRPC. `path` is forbidden for Kafka. +Local Project validation checks every Export. When a downstream Project syncs +one Export from a Devctl Source, only that selected upstream surface must be +materializable; unrelated upstream Readiness is not required. diff --git a/docs/user-guide/reference/output-and-errors.md b/docs/user-guide/reference/output-and-errors.md new file mode 100644 index 0000000..cb415e8 --- /dev/null +++ b/docs/user-guide/reference/output-and-errors.md @@ -0,0 +1,80 @@ +# Output and errors + +Every executable leaf accepts `--json`; text output is the default. +`--verbose` independently enables debug diagnostics and raw causes. + +## JSONL success + +JSON mode emits compact JSON Lines, not a bare result object. A successful +command emits exactly one final info event on stdout: + +```json +{"level":"info","ts":0,"msg":"project validation completed","command":"validate","data":{"valid":true,"issues":[]}} +``` + +Diagnostic events may precede the final event only with `--verbose`. + +## Findings + +Invalid `validate` and `lint` findings are normal results. The complete event +is written to stdout, stderr is empty, and the process exits `1`. Do not treat +every exit status `1` as an execution-error envelope; inspect stdout first for +these two commands. + +## Errors + +Usage, execution, and cancellation failures emit one final event on stderr. +stdout is empty for execution failures. + +```json +{"level":"error","ts":0,"msg":"internal error","code":"internal","exit_code":1,"details":{"partial_result":{"targets":["config"],"changes":[],"dry_run":false}}} +``` + +Safe scalar context appears in `details`. Raw causes and external tool output +appear only with `--verbose`. Error events never carry a top-level `data` +field. + +## Exit codes + +| Exit code | Meaning | +|---:|---| +| `0` | Success | +| `1` | Validation/lint findings or execution failure | +| `2` | Invalid CLI usage or help failure | +| `130` | Cancellation | + +## Stable error codes + +| Code | Meaning | +|---|---| +| `usage` | Invalid command, argument, or flag usage | +| `invalid_input` | Input is understood but invalid for the operation | +| `not_found` | Requested Project object, Target, Source, or file is absent | +| `conflict` | Existing state prevents the requested change | +| `unavailable` | External source or dependency cannot be reached | +| `unsupported` | The selected object does not support the operation | +| `cancelled` | Context or process cancellation | +| `internal` | Unexpected failure without a safer public category | + +## Command result payloads + +The command-specific payload is under `data` on success and may appear under +`details.partial_result` after real partial progress. + +| Commands | Payload shape | +|---|---| +| `init manifest`, `enable`, `add` | `{manifest, change}` | +| `init scaffold` | `{files: [{path, action}]}` | +| `validate` | `{valid, issues}` | +| `inspect` | `{project: {...}}` | +| `lint` | `{valid, contracts, issues}` | +| `sync`, `gen` | `{targets, changes, dry_run}` | + +Publication actions are `created`, `updated`, `unchanged`, and `removed`. +Static previews use `planned_publish` and `planned_remove`. + +## Partial results + +A partial result appears only when work completed before a later execution or +shutdown failure. It is recovery information, not success output. Review it +before retrying because already-published Targets are not rolled back. diff --git a/docs/user-guide/troubleshooting.md b/docs/user-guide/troubleshooting.md new file mode 100644 index 0000000..fda638d --- /dev/null +++ b/docs/user-guide/troubleshooting.md @@ -0,0 +1,80 @@ +# Troubleshooting + +Start with verbose diagnostics and an effective Project view: + +```sh +devctl validate --verbose +devctl inspect --json +``` + +## Manifest not found + +Without `--file`, Devctl searches upward from the current directory for +`devctl.yaml`. Run from inside the Project or pass an explicit path. + +## Invalid Manifest + +`validate` distinguishes malformed YAML, unknown or duplicate fields, semantic +conflicts, invalid references, unsafe paths, and missing Readiness. Fix every +reported issue before retrying the workflow that needs that state. + +Unknown fields are rejected; they are not ignored for forward compatibility. +Manifest `version` must currently be `1` and the supported language is `go`. + +## Missing Project tools or configs + +Run scaffold and install the Project-owned toolchain: + +```sh +devctl init scaffold +mise install +go mod download all +go mod tidy +devctl validate +``` + +An explicit custom generator config is user-owned. Scaffold will not create or +replace it; create the file at the configured path or return to the canonical +managed config. + +## Stale Snapshot Metadata + +Devctl-sourced gRPC and schema-backed Kafka Snapshots need valid root +`.devctl-contract.json` metadata. Missing, unsafe, or inconsistent metadata +returns `invalid_input` with reason `snapshot_metadata_invalid`. + +While the supplier is available, run the suggested targeted or full `devctl +sync`, review the refreshed Snapshot, and commit it. `inspect` remains usable +before the refresh but omits `resolved_input`. + +## Target selection failures + +- Unknown family: `invalid_input`. +- Known family with no applicable Targets: successful empty result. +- Unknown explicit Target ID: `not_found`. +- Existing Target without the requested operation: `unsupported`. + +Local Contract Targets support sync as a no-op. Raw Kafka Targets do not +support sync or generation. + +## A command failed after making progress + +Targets execute sequentially. A later failure does not roll back completed +Targets. In JSON mode, inspect `details.partial_result` on the final stderr +event, review the listed changes, correct the cause, and retry. Planning-only +failures do not contain a partial result. + +## Dry-run differs from execution + +Dry-run is intentionally static: it performs no network acquisition, external +tool execution, publication, or pruning. It can show intended Target paths and +stale removal candidates, but it cannot promise that a later network request +or generator will succeed. + +## ClickHouse migration failed partway + +ClickHouse migrations are non-transactional. For files containing several +statements, use a migration DSN with `x-multi-statement=true`. Driver statement +splitting can still leave a partial apply. Inspect the database, complete or +revert the intended changes manually, and only then force the migration +version. Devctl does not alter the DSN or perform recovery. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..e2b50ed --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,184 @@ +//go:build e2e + +package e2e_test + +import ( + "bytes" + "context" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPublicHTTPServiceWorkflow(t *testing.T) { + t.Parallel() + + repositoryRoot, err := filepath.Abs("..") + require.NoError(t, err) + workspace := t.TempDir() + binDir := filepath.Join(workspace, "bin") + require.NoError(t, os.MkdirAll(binDir, 0o755)) + binary := filepath.Join(binDir, "devctl") + run(t, repositoryRoot, nil, "go", "build", "-o", binary, "./cmd/devctl") + + project := filepath.Join(workspace, "project") + require.NoError(t, os.MkdirAll(project, 0o755)) + manifest := filepath.Join(project, "devctl.yaml") + projectEnv := append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + run(t, project, projectEnv, binary, "init", "manifest", "--file", manifest, "--lang", "go", "--preset", "http-service", "--name", "fixture-api", "--module", "example.test/fixture-api") + run(t, project, projectEnv, binary, "init", "scaffold", "--file", manifest) + run(t, project, projectEnv, "go", "mod", "tidy") + run(t, project, projectEnv, binary, "sync") + run(t, project, projectEnv, binary, "lint") + run(t, project, projectEnv, binary, "gen") + application := filepath.Join(binDir, "fixture-api") + run(t, project, projectEnv, "go", "build", "-o", application, "./cmd/fixture-api") + httpAddress := availableAddress(t) + runtimeCommand := exec.Command(application, "api") + runtimeCommand.Dir = project + runtimeCommand.Env = append(projectEnv, "FIXTURE_API_HTTP_ADDR="+httpAddress, "FIXTURE_API_HEALTH_SERVER_ENABLED=false") + var runtimeOutput bytes.Buffer + runtimeCommand.Stdout = &runtimeOutput + runtimeCommand.Stderr = &runtimeOutput + require.NoError(t, runtimeCommand.Start()) + t.Cleanup(func() { _ = runtimeCommand.Process.Kill() }) + waitForHTTP(t, httpAddress) + require.NoError(t, runtimeCommand.Process.Signal(syscall.SIGTERM)) + require.NoError(t, runtimeCommand.Wait(), runtimeOutput.String()) + + run(t, project, projectEnv, "go", "test", "./...") + + run(t, project, projectEnv, "git", "init", "--quiet") + run(t, project, projectEnv, "git", "add", ".") + run(t, project, projectEnv, "git", "-c", "user.name=Devctl E2E", "-c", "user.email=devctl@example.test", "commit", "--quiet", "-m", "fixture") + run(t, project, projectEnv, binary, "sync") + run(t, project, projectEnv, binary, "gen") + run(t, project, projectEnv, "git", "diff", "--exit-code") +} + +func TestKafkaJSONQuicktypeWorkflow(t *testing.T) { + t.Parallel() + + repositoryRoot, err := filepath.Abs("..") + require.NoError(t, err) + workspace := t.TempDir() + binDir := filepath.Join(workspace, "bin") + require.NoError(t, os.MkdirAll(binDir, 0o755)) + binary := filepath.Join(binDir, "devctl") + run(t, repositoryRoot, nil, "go", "build", "-o", binary, "./cmd/devctl") + + project := filepath.Join(workspace, "project") + require.NoError(t, os.MkdirAll(project, 0o755)) + manifest := filepath.Join(project, "devctl.yaml") + projectEnv := append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + run(t, project, projectEnv, binary, "init", "manifest", "--file", manifest, "--lang", "go", "--preset", "cli", "--name", "fixture-events", "--module", "example.test/fixture-events") + run(t, project, projectEnv, binary, "add", "source", "contracts", "--file", manifest, "--type", "local", "--path", "api/contracts") + run(t, project, projectEnv, binary, "add", "kafka-consumer", "audit", "--file", manifest, + "--topic", "fixture_events.audit.created.v1", "--source", "contracts", "--format", "json", + "--path", "fixture_events.audit.created.v1.json") + writeFile(t, project, "api/contracts/fixture_events.audit.created.v1.json", `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AuditEvent", + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string"}, + "note": {"type": ["string", "null"]}, + "payload": {"oneOf": [{"type": "string"}, {"type": "integer"}]} + }, + "required": ["id", "payload"] +} +`) + run(t, project, projectEnv, binary, "init", "scaffold", "--file", manifest) + run(t, project, projectEnv, binary, "validate", "--file", manifest) + run(t, project, projectEnv, binary, "lint", "kafka", "--file", manifest) + run(t, project, projectEnv, binary, "gen", "kafka", "--file", manifest) + + generatedPath := filepath.Join(project, "gen/kafka/consumer/audit/schema.gen.go") + generated, err := os.ReadFile(generatedPath) + require.NoError(t, err) + require.Contains(t, string(generated), "func UnmarshalAuditEvent") + require.Contains(t, string(generated), `json:"note,omitempty"`) + writeFile(t, project, "cmd/roundtrip/main.go", `package main + +import ( + "bytes" + + audit "example.test/fixture-events/gen/kafka/consumer/audit" +) + +func main() { + event, err := audit.UnmarshalAuditEvent([]byte("{\"id\":\"1\",\"payload\":\"created\"}")) + if err != nil { + panic(err) + } + encoded, err := event.Marshal() + if err != nil { + panic(err) + } + if !bytes.Contains(encoded, []byte("\"payload\":\"created\"")) { + panic(string(encoded)) + } +} +`) + run(t, project, projectEnv, "go", "mod", "tidy") + run(t, project, projectEnv, "go", "test", "./gen/kafka/consumer/audit") + run(t, project, projectEnv, "go", "run", "./cmd/roundtrip") + run(t, project, projectEnv, binary, "gen", "kafka", "--file", manifest) + regenerated, err := os.ReadFile(generatedPath) + require.NoError(t, err) + require.Equal(t, generated, regenerated) +} + +func writeFile(t *testing.T, root, relative, content string) { + t.Helper() + + filename := filepath.Join(root, filepath.FromSlash(relative)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(content), 0o644)) +} + +func availableAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + address := listener.Addr().String() + require.NoError(t, listener.Close()) + return address +} + +func waitForHTTP(t *testing.T, address string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client := &http.Client{Timeout: 250 * time.Millisecond} + for ctx.Err() == nil { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+address+"/", nil) + require.NoError(t, err) + response, err := client.Do(request) + if err == nil { + require.NoError(t, response.Body.Close()) + return + } + time.Sleep(25 * time.Millisecond) + } + require.FailNow(t, "HTTP server did not become ready", "address: %s", address) +} + +func run(t *testing.T, directory string, environment []string, name string, args ...string) { + t.Helper() + command := exec.Command(name, args...) + command.Dir = directory + if environment != nil { + command.Env = environment + } + output, err := command.CombinedOutput() + require.NoError(t, err, "%s %v failed:\n%s", name, args, output) +} diff --git a/examples/orders-api/.env.example b/examples/orders-api/.env.example new file mode 100644 index 0000000..f0b2157 --- /dev/null +++ b/examples/orders-api/.env.example @@ -0,0 +1,13 @@ +ORDERS_API_DB_PRIMARY_KIND=postgres +ORDERS_API_DB_PRIMARY_POSTGRES_DSN= +ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL= +ORDERS_API_DEPLOYMENT_ENVIRONMENT=development +ORDERS_API_HEALTH_ADDR=:8081 +ORDERS_API_HEALTH_SERVER_ENABLED=true +ORDERS_API_HTTP_ADDR=:8080 +ORDERS_API_HTTP_SERVER_ENABLED=true +ORDERS_API_LOG_LEVEL=info +ORDERS_API_PPROF_ADDR=127.0.0.1:6060 +ORDERS_API_PPROF_ENABLED=false +ORDERS_API_SERVICE_VERSION=dev +ORDERS_API_TELEMETRY_ENABLED=false diff --git a/examples/orders-api/.golangci.yml b/examples/orders-api/.golangci.yml new file mode 100644 index 0000000..a01c843 --- /dev/null +++ b/examples/orders-api/.golangci.yml @@ -0,0 +1,76 @@ +version: "2" +run: + relative-path-mode: gomod + tests: true + modules-download-mode: readonly +linters: + default: none + enable: + - asasalint + - bidichk + - bodyclose + - containedctx + - contextcheck + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - fatcontext + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - gocognit + - govet + - inamedparam + - ineffassign + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - predeclared + - reassign + - recvcheck + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - testifylint + - thelper + - tparallel + - unconvert + - unused + - usetesting + - wastedassign + - wrapcheck + settings: + gocognit: + min-complexity: 20 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + paralleltest: + ignore-missing: false + ignore-missing-subtests: false + check-cleanup: true + revive: + rules: + - name: argument-limit + arguments: [4] + - name: function-result-limit + arguments: [3] + exclusions: + generated: strict + paths: ["^gen/"] +formatters: + enable: [gofmt] + exclusions: + generated: strict + paths: ["^gen/"] diff --git a/examples/orders-api/.mise.toml b/examples/orders-api/.mise.toml new file mode 100644 index 0000000..621d406 --- /dev/null +++ b/examples/orders-api/.mise.toml @@ -0,0 +1,50 @@ +[tools] +go = "1.26.0" +golangci-lint = "2.12.2" +"go:github.com/golang-migrate/migrate/v4/cmd/migrate" = { version = "v4.19.1", tags = ["postgres"] } + +[tasks.fmt] +run = "golangci-lint fmt" +[tasks."fmt:check"] +run = "golangci-lint fmt --diff" +[tasks."lint:contracts"] +run = "devctl lint" +[tasks."lint:go"] +run = "golangci-lint run" +[tasks.lint] +depends = ["lint:contracts", "lint:go"] +[tasks.test] +run = "go test ./..." +[tasks.gen] +run = "devctl gen" +[tasks."gen:http"] +run = "devctl gen http" +[tasks."gen:grpc"] +run = "devctl gen grpc" +[tasks."gen:kafka"] +run = "devctl gen kafka" +[tasks.check] +depends = ["fmt:check", "lint", "test"] + +[tasks."migrate:primary:postgres:create"] +description = "Create timestamped migration files in migrations/primary/postgres" +usage = 'arg "" help="Migration name"' +run = ''' +migrate create -ext sql -dir "migrations/primary/postgres" -format "20060102150405" "${usage_name?}" +''' + +[tasks."migrate:primary:postgres:up"] +description = "Apply migrations from migrations/primary/postgres" +run = ''' +database_url="${ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL:?set ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL}" +migrate -path "migrations/primary/postgres" -database "$database_url" up +''' + +[tasks."migrate:primary:postgres:down"] +description = "Roll back migrations from migrations/primary/postgres" +usage = 'arg "[steps]" default="1" help="Number of migrations"' +confirm = "Roll back migrate:primary:postgres migrations?" +run = ''' +database_url="${ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL:?set ORDERS_API_DB_PRIMARY_POSTGRES_MIGRATIONS_URL}" +migrate -path "migrations/primary/postgres" -database "$database_url" down "${usage_steps?}" +''' diff --git a/examples/orders-api/README.md b/examples/orders-api/README.md new file mode 100644 index 0000000..06b9f40 --- /dev/null +++ b/examples/orders-api/README.md @@ -0,0 +1,30 @@ +# orders-api + +This project foundation is scaffolded by Devctl. + +## Bootstrap + +```sh +mise install +go mod download all +go mod tidy +devctl lint +devctl gen +go mod tidy +mise run check +``` + +Run the API with `go run ./cmd/orders-api api`. + +## Updating the foundation + +- Run `devctl sync` after changing remote sources. +- Run `devctl init scaffold` after changing components in `devctl.yaml`. +- Run `devctl gen` after changing API or schema contracts. + +Devctl replaces files ending in `*.gen.go`. Ordinary `.go` files and this +README are created once, so application code and local notes are preserved. + +When a component adds a provider seed, review it and call the provider from +`internal/deps/application.go`. That file is the user-owned composition root; +Devctl does not rewrite its provider list. diff --git a/examples/orders-api/api/openapi/swagger.yaml b/examples/orders-api/api/openapi/swagger.yaml new file mode 100644 index 0000000..b2b56b7 --- /dev/null +++ b/examples/orders-api/api/openapi/swagger.yaml @@ -0,0 +1,89 @@ +openapi: 3.1.0 +info: + title: Orders API + version: 1.0.0 +paths: + /orders: + post: + operationId: createOrder + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateOrder" + responses: + "201": + description: Order created + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + /orders/{id}: + get: + operationId: getOrder + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + minimum: 1 + responses: + "200": + description: Order found + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "404": + description: Order not found + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" +components: + schemas: + CreateOrder: + type: object + additionalProperties: false + required: + - customer_name + - total_cents + properties: + customer_name: + type: string + minLength: 1 + total_cents: + type: integer + format: int64 + minimum: 0 + Order: + type: object + additionalProperties: false + required: + - id + - customer_name + - total_cents + - created_at + properties: + id: + type: integer + format: int64 + customer_name: + type: string + total_cents: + type: integer + format: int64 + created_at: + type: string + format: date-time + Problem: + type: object + additionalProperties: false + required: + - message + properties: + message: + type: string diff --git a/examples/orders-api/cmd/orders-api/internal/api.go b/examples/orders-api/cmd/orders-api/internal/api.go new file mode 100644 index 0000000..554686e --- /dev/null +++ b/examples/orders-api/cmd/orders-api/internal/api.go @@ -0,0 +1,27 @@ +package internal + +import ( + "context" + "fmt" + + "example.com/orders-api/internal/deps" + "github.com/urfave/cli/v3" +) + +// NewCmdAPI constructs the API server command. +func NewCmdAPI() *cli.Command { + return &cli.Command{ + Name: "api", + Usage: "Run API servers", + Action: func(ctx context.Context, _ *cli.Command) error { + scenario, err := deps.NewAPI(ctx) + if err != nil { + return fmt.Errorf("deps.NewAPI: %w", err) + } + if err := scenario.Run(ctx); err != nil { + return fmt.Errorf("scenario.Run: %w", err) + } + return nil + }, + } +} diff --git a/examples/orders-api/cmd/orders-api/main.go b/examples/orders-api/cmd/orders-api/main.go new file mode 100644 index 0000000..7bb2ba2 --- /dev/null +++ b/examples/orders-api/cmd/orders-api/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/urfave/cli/v3" + + appcmd "example.com/orders-api/cmd/orders-api/internal" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + root := &cli.Command{ + Name: "orders-api", + Usage: "Run orders-api", + Commands: []*cli.Command{ + appcmd.NewCmdAPI(), + }, + } + if err := root.Run(ctx, os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/examples/orders-api/devctl.yaml b/examples/orders-api/devctl.yaml new file mode 100644 index 0000000..e119aeb --- /dev/null +++ b/examples/orders-api/devctl.yaml @@ -0,0 +1,72 @@ +version: 1 +project: + name: orders-api + language: go +env: {} +paths: + external_contracts: api/external +sources: {} +exports: {} +components: + http: + server: + openapi: api/openapi/swagger.yaml + start: + env: HTTP_SERVER_ENABLED + default: true + env: + system: + - key: HTTP_ADDR + type: string + default: :8080 + logging: + env: + system: + - key: LOG_LEVEL + type: string + default: info + health: + server: + start: + env: HEALTH_SERVER_ENABLED + default: true + env: + system: + - key: HEALTH_ADDR + type: string + default: :8081 + telemetry: + start: + env: TELEMETRY_ENABLED + default: false + db: + connections: + - name: primary + default: postgres + variants: + - name: postgres + kind: postgres + dsn_env: DB_PRIMARY_POSTGRES_DSN + secret: true + migrations: + path: migrations/primary/postgres + database_env: DB_PRIMARY_POSTGRES_MIGRATIONS_URL +languages: + go: + module: example.com/orders-api + generators: + http: + oapi_config: tools/oapi/server.yaml + server_out: gen/serverhttp + client_out: gen/clienthttp + components: + pprof: + server: + start: + env: PPROF_ENABLED + default: false + env: + system: + - key: PPROF_ADDR + type: string + default: 127.0.0.1:6060 diff --git a/examples/orders-api/gen/config/config.gen.go b/examples/orders-api/gen/config/config.gen.go new file mode 100644 index 0000000..379f8f6 --- /dev/null +++ b/examples/orders-api/gen/config/config.gen.go @@ -0,0 +1,56 @@ +// Code generated by devctl. DO NOT EDIT. + +package config + +import ( + "fmt" + "time" +) + +type Config struct { + DBPrimary DBPrimaryConfig + HTTP HTTPConfig + Health HealthConfig + Logging LoggingConfig + Pprof PprofConfig + Telemetry TelemetryConfig +} + +type DBPrimaryConfig struct { + Kind string `env:"ORDERS_API_DB_PRIMARY_KIND" default:"postgres"` + PostgresDSN string `env:"ORDERS_API_DB_PRIMARY_POSTGRES_DSN"` +} + +type HTTPConfig struct { + Address string `env:"ORDERS_API_HTTP_ADDR" default:":8080"` + Enabled bool `env:"ORDERS_API_HTTP_SERVER_ENABLED" default:"true"` +} + +type HealthConfig struct { + Address string `env:"ORDERS_API_HEALTH_ADDR" default:":8081"` + Enabled bool `env:"ORDERS_API_HEALTH_SERVER_ENABLED" default:"true"` +} + +type LoggingConfig struct { + Level string `env:"ORDERS_API_LOG_LEVEL" default:"info"` +} + +type PprofConfig struct { + Address string `env:"ORDERS_API_PPROF_ADDR" default:"127.0.0.1:6060"` + Enabled bool `env:"ORDERS_API_PPROF_ENABLED" default:"false"` +} + +type TelemetryConfig struct { + DeploymentEnvironment string `env:"ORDERS_API_DEPLOYMENT_ENVIRONMENT" default:"development"` + Enabled bool `env:"ORDERS_API_TELEMETRY_ENABLED" default:"false"` + ServiceVersion string `env:"ORDERS_API_SERVICE_VERSION" default:"dev"` +} + +func (c *Config) Validate() error { + if c == nil { + return fmt.Errorf("config is nil") + } + return nil +} + +var _ time.Duration diff --git a/examples/orders-api/gen/serverhttp/server.gen.go b/examples/orders-api/gen/serverhttp/server.gen.go new file mode 100644 index 0000000..faf2083 --- /dev/null +++ b/examples/orders-api/gen/serverhttp/server.gen.go @@ -0,0 +1,386 @@ +// Package serverhttp provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT. +package serverhttp + +import ( + "bytes" + "compress/flate" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/labstack/echo/v5" + "github.com/oapi-codegen/runtime" +) + +// CreateOrder defines model for CreateOrder. +type CreateOrder struct { + CustomerName string `json:"customer_name"` + TotalCents int64 `json:"total_cents"` +} + +// Order defines model for Order. +type Order struct { + CreatedAt time.Time `json:"created_at"` + CustomerName string `json:"customer_name"` + Id int64 `json:"id"` + TotalCents int64 `json:"total_cents"` +} + +// Problem defines model for Problem. +type Problem struct { + Message string `json:"message"` +} + +// CreateOrderJSONRequestBody defines body for CreateOrder for application/json ContentType. +type CreateOrderJSONRequestBody = CreateOrder + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (POST /orders) + CreateOrder(ctx *echo.Context) error + + // (GET /orders/{id}) + GetOrder(ctx *echo.Context, id int64) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// CreateOrder converts echo context to params. +func (w *ServerInterfaceWrapper) CreateOrder(ctx *echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.CreateOrder(ctx) + return err +} + +// GetOrder converts echo context to params. +func (w *ServerInterfaceWrapper) GetOrder(ctx *echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id int64 + + err = runtime.BindStyledParameterWithOptions("simple", "id", ctx.Param("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "integer", Format: "int64", ValueIsUnescaped: ctx.Request().URL.RawPath == ""}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetOrder(ctx, id) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) echo.RouteInfo +} + +// RegisterHandlersOptions configures RegisterHandlersWithOptions. +type RegisterHandlersOptions struct { + // BaseURL is prepended to every registered path so the API can be served + // under a prefix. + BaseURL string + // OperationMiddlewares lets the caller attach per-operation middleware at + // registration time. The map key is the OpenAPI `operationId` value as it + // appears in the spec (the raw, un-normalized form). Operations that have + // no entry are registered with no extra middleware. A nil map disables + // per-operation middleware entirely. + OperationMiddlewares map[string][]echo.MiddlewareFunc +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{}) +} + +// RegisterHandlersWithBaseURL registers handlers and prepends BaseURL to the +// paths so the API can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{BaseURL: baseURL}) +} + +// RegisterHandlersWithOptions registers handlers using the supplied options, +// including any per-operation middleware. +func RegisterHandlersWithOptions(router EchoRouter, si ServerInterface, options RegisterHandlersOptions) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.POST(options.BaseURL+"/orders", wrapper.CreateOrder, options.OperationMiddlewares["createOrder"]...) + router.GET(options.BaseURL+"/orders/:id", wrapper.GetOrder, options.OperationMiddlewares["getOrder"]...) + +} + +type CreateOrderRequestObject struct { + Body *CreateOrderJSONRequestBody +} + +type CreateOrderResponseObject interface { + VisitCreateOrderResponse(w http.ResponseWriter) error +} + +type CreateOrder201JSONResponse Order + +func (response CreateOrder201JSONResponse) VisitCreateOrderResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + _, err := buf.WriteTo(w) + return err +} + +type GetOrderRequestObject struct { + Id int64 `json:"id"` +} + +type GetOrderResponseObject interface { + VisitGetOrderResponse(w http.ResponseWriter) error +} + +type GetOrder200JSONResponse Order + +func (response GetOrder200JSONResponse) VisitGetOrderResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetOrder404JSONResponse Problem + +func (response GetOrder404JSONResponse) VisitGetOrderResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + _, err := buf.WriteTo(w) + return err +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + + // (POST /orders) + CreateOrder(ctx context.Context, request CreateOrderRequestObject) (CreateOrderResponseObject, error) + + // (GET /orders/{id}) + GetOrder(ctx context.Context, request GetOrderRequestObject) (GetOrderResponseObject, error) +} + +type StrictHandlerFunc func(ctx *echo.Context, request any) (any, error) +type StrictMiddlewareFunc func(f StrictHandlerFunc, operationID string) StrictHandlerFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// CreateOrder operation middleware +func (sh *strictHandler) CreateOrder(ctx *echo.Context) error { + var request CreateOrderRequestObject + + var body CreateOrderJSONRequestBody + var err error + if _, ok := ctx.Echo().Binder.(*echo.DefaultBinder); ok { + // Bind only the request body, so that path and query parameters + // are not also bound into the body struct. + err = echo.BindBody(ctx, &body) + } else { + // A custom binder is installed on the Echo instance; defer to it + // entirely, since echo.Binder does not expose body-only binding. + err = ctx.Bind(&body) + } + if err != nil { + return err + } + request.Body = &body + + handler := func(ctx *echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.CreateOrder(ctx.Request().Context(), request.(CreateOrderRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateOrder") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(CreateOrderResponseObject); ok { + return validResponse.VisitCreateOrderResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// GetOrder operation middleware +func (sh *strictHandler) GetOrder(ctx *echo.Context, id int64) error { + var request GetOrderRequestObject + + request.Id = id + + handler := func(ctx *echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetOrder(ctx.Request().Context(), request.(GetOrderRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetOrder") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetOrderResponseObject); ok { + return validResponse.VisitGetOrderResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// Base64 encoded, compressed with deflate, json marshaled OpenAPI spec. +// Stored as a slice of fixed-width chunks rather than one concatenated +// const string: with thousands of chunks the chained `+` fold is several +// times slower for the Go compiler than parsing a slice literal. +var swaggerSpec = []string{ + "vJTRb9MwEMb/leng0TQpVDz4DXhAk5Do+zRVXnxtb4p9xr4gTVH+d2RnGWmbMYEm+uQ6Pn/f98tdemjY", + "BfboJYHuITVHdKYsv0Q0gt+jxZj/GmtJiL1pt5EDRiFMoPemTaggzLZ6aLok7DDuvHGYNxz5b+gPcgS9", + "ViAPAUFDkkj+AIMCYTHtrpk87Dk6I6CBvHzcgMrl5DoHun4qJi94wAjDoCDij44iWtA3Z9KnV98+VfPd", + "PTaSpf8pXgFjd9njzK01gu+ERtnziBdILk6QXcx+nvdlWi8QIgvqj5jUPOASs23kuxbdX1JzmJI5LGU/", + "MzgdvJTOJ8nvudxB0uZn5QWmq0/ba1DwE2Mi9qBhvapXdTbLAb0JBBo+rNarGhQEI8fiqOJSm5eBU3mZ", + "2bDJga4t6JMJGD1iks9sH0oXsBf0pcqE0FJT6qr7lPWnQcqrtxH3oOFN9XvSqscxq+YKwykIiR2WjRTY", + "p5Hh+3r9atIzUYupiRRkRFceXD32QD4wqAlV1ZMd8sUHXMD1FWViFUw0DqXQvemB8r2ZOygYB2Bsw9O0", + "aub8+W/AeqHDby841f+L0547b3OjberNq2lOE/asqmeZlMvvVwAAAP//", +} + +// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, +// after base64-decoding and flate-decompressing the embedded blob. +func decodeSpec() ([]byte, error) { + encoded := strings.Join(swaggerSpec, "") + compressed, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr := flate.NewReader(bytes.NewReader(compressed)) + var buf bytes.Buffer + if _, err := buf.ReadFrom(zr); err != nil { + return nil, fmt.Errorf("read flate: %w", err) + } + if err := zr.Close(); err != nil { + return nil, fmt.Errorf("close flate reader: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cache of the decoded OpenAPI spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSpec returns the OpenAPI specification corresponding to the generated +// code in this file. External references in the spec are resolved through +// PathToRawSpec; externally-referenced files must be embedded in their +// corresponding Go packages (via the import-mapping feature). URL-based +// external refs are not supported. +func GetSpec() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} + +// GetSpecJSON returns the raw JSON bytes of the embedded OpenAPI +// specification: decompressed but not unmarshaled. External references +// are not resolved here; the bytes are the spec exactly as embedded by +// codegen. The result is cached at package init time, so repeated calls +// are cheap. +func GetSpecJSON() ([]byte, error) { + return rawSpec() +} + +// GetSwagger returns the OpenAPI specification corresponding to the +// generated code in this file. +// +// Deprecated: GetSwagger predates kin-openapi renaming openapi3.Swagger +// to openapi3.T. Use [GetSpec] instead. This wrapper is retained for +// backwards compatibility. +func GetSwagger() (*openapi3.T, error) { + return GetSpec() +} diff --git a/examples/orders-api/go.mod b/examples/orders-api/go.mod new file mode 100644 index 0000000..ed98664 --- /dev/null +++ b/examples/orders-api/go.mod @@ -0,0 +1,85 @@ +module example.com/orders-api + +go 1.26.0 + +require ( + github.com/devctllabs/go-libs/config v0.1.0 + github.com/devctllabs/go-libs/debugserver v0.1.0 + github.com/devctllabs/go-libs/di v0.1.0 + github.com/devctllabs/go-libs/health v0.1.0 + github.com/devctllabs/go-libs/healthserver v0.1.0 + github.com/devctllabs/go-libs/lifecycle v0.2.0 + github.com/devctllabs/go-libs/log v0.2.0 + github.com/devctllabs/go-libs/postgresdb v0.2.0 + github.com/devctllabs/go-libs/telemetry v0.1.0 + github.com/devctllabs/go-libs/txmanager v0.1.0 + github.com/getkin/kin-openapi v0.142.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/labstack/echo/v5 v5.3.1 + github.com/oapi-codegen/runtime v1.6.0 + github.com/stretchr/testify v1.11.1 + github.com/urfave/cli/v3 v3.10.1 + go.uber.org/zap v1.28.0 +) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + +require github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/caarlos0/env/v11 v11.4.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/creasty/defaults v1.8.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/exaring/otelpgx v0.11.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/pelletier/go-toml/v2 v2.4.3 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/samber/do/v2 v2.1.0 // indirect + github.com/samber/go-type-to-string v1.8.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.24.0 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/examples/orders-api/go.sum b/examples/orders-api/go.sum new file mode 100644 index 0000000..9698b8f --- /dev/null +++ b/examples/orders-api/go.sum @@ -0,0 +1,372 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= +github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/devctllabs/go-libs/config v0.1.0 h1:jCQF7MS1ZwYNtxtZPsE1fApqdM63BW3YODXe7EJm5ek= +github.com/devctllabs/go-libs/config v0.1.0/go.mod h1:ukrS7MSVLYC2eZCPiU9B/f4slQtTDU+uxWlt7MEX4iA= +github.com/devctllabs/go-libs/debugserver v0.1.0 h1:3QCLJqJBgiIldazi5gwt6L4/Pa+ry+MnSHriDB+fYcE= +github.com/devctllabs/go-libs/debugserver v0.1.0/go.mod h1:RwHLtXQNGBMTLeZBWRrat/1gijO64E0mbs40KMlW/Ss= +github.com/devctllabs/go-libs/di v0.1.0 h1:pPhlKmdYyvKVYr/5eV51Ea9HcfqsqWCs3GZKG6jFGW8= +github.com/devctllabs/go-libs/di v0.1.0/go.mod h1:kr9mSKElEmsDHgKsZbDGJhSCdmlcluT81PjbCvcOmbY= +github.com/devctllabs/go-libs/health v0.1.0 h1:pYlxHqx90HSWZ1S64h6gVajorDdmNnQWNa0jW8HdafE= +github.com/devctllabs/go-libs/health v0.1.0/go.mod h1:n9PLAIiXIM7p5DNtc5LNY0xaZNuywevsnqnM6mUO+LU= +github.com/devctllabs/go-libs/healthserver v0.1.0 h1:bnQA/jJFsrSbuqaa9CE1LP3/oOudMLexHlNbV2sYG70= +github.com/devctllabs/go-libs/healthserver v0.1.0/go.mod h1:H6IXWM5QF68aaNPBnjdzIrvZZbZHimzUsQRWl+NVaUI= +github.com/devctllabs/go-libs/lifecycle v0.2.0 h1:vafo21o5tjrU3Qf4tVa0a2A6a8NQPSR4blpHiTQU3O4= +github.com/devctllabs/go-libs/lifecycle v0.2.0/go.mod h1:/m8kzixQx7IhAi3jx7hFfICu96VxBKNuQxHd+tY6VIY= +github.com/devctllabs/go-libs/log v0.2.0 h1:RICLkubslpX8CGSw/m+i2FxrRo64PzhE7hVFQXlwReM= +github.com/devctllabs/go-libs/log v0.2.0/go.mod h1:KfCsyQUkit4D2kounIhY6AauWB/Lo5SMiHZZXaTtMJ4= +github.com/devctllabs/go-libs/postgresdb v0.2.0 h1:4QhmPbVOnQXiy4qBmmBxHH0k8veqf8mNUmjT1z8dcas= +github.com/devctllabs/go-libs/postgresdb v0.2.0/go.mod h1:q0cD+nVZz/S+CTzVVAJIx1XSnuIqtXbDcapd3SHhdPs= +github.com/devctllabs/go-libs/telemetry v0.1.0 h1:hJF0DtkyPLldeMuBx3/ue2zhVw0my1i8tYBpUPMEma4= +github.com/devctllabs/go-libs/telemetry v0.1.0/go.mod h1:4HdPyn83FwbZuasGTa0Og8+aBDiEDc1QfD3T7EGB90E= +github.com/devctllabs/go-libs/txmanager v0.1.0 h1:UEiZ1vDeM+e4umlBynka5k+Go56N4N1EpIZwZ7nnhsY= +github.com/devctllabs/go-libs/txmanager v0.1.0/go.mod h1:yDtccFPCuXFEFLVnMpJDJhPn4hFeQ4o8UHnq7HoQwDI= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/exaring/otelpgx v0.11.1 h1:pE79fIg/qh/Lpu00kvswFC5dKfqyJJhMJ4Y4N3w5Lj4= +github.com/exaring/otelpgx v0.11.1/go.mod h1:3OojrUKhhy3lTbYIMBijP3YjMey/jo14eHAW5cXcUdk= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/georgysavva/scany/v2 v2.1.4 h1:nrzHEJ4oQVRoiKmocRqA1IyGOmM/GQOEsg9UjMR5Ip4= +github.com/georgysavva/scany/v2 v2.1.4/go.mod h1:fqp9yHZzM/PFVa3/rYEC57VmDx+KDch0LoqrJzkvtos= +github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w= +github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v5 v5.3.1 h1:75maCxkQVGualckLc/5s/ihgpH1a1Dc6AuGWNVNs6bw= +github.com/labstack/echo/v5 v5.3.1/go.mod h1:4iEGNQiPPZnkfYpNR/L6fINd3NLiGWUD5+eBotFALas= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo= +github.com/oapi-codegen/runtime v1.6.0 h1:7Xx+GlueD6nRuyKoCPzL434Jfi3BetbiJOrzCHp/VPU= +github.com/oapi-codegen/runtime v1.6.0/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/samber/do/v2 v2.1.0 h1:lqCHn05XvY3VqwxvZDQPSkH+jIGWSVHUrSVLEbPOopo= +github.com/samber/do/v2 v2.1.0/go.mod h1:wJBoiaZcUZyGuraOhfz15b517ZMogGs+U03DvnqvT6Q= +github.com/samber/go-type-to-string v1.8.0 h1:5z6tDTjtXxkIAoAuHAZYMYR8mkBZjVgeSH7jcSLqc8w= +github.com/samber/go-type-to-string v1.8.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04= +github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.44.0 h1:/Fwh6HY1mIikhnm9e7HwoxGycx0lzRAE0f5VQpjFxzI= +github.com/testcontainers/testcontainers-go v0.44.0/go.mod h1:IcnwQrYTO86xHXu5bvMaBH7ATlbS3Qn1M1QWW3c66rE= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 h1:8fdv/9y3JMxjQ+ULAcOG8RtgeNu5t9XF9LolSXDuTwM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0/go.mod h1:CFr2LncGYokw+OKjXcr8ARCKG1SaC2UEnGxFBovE86g= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY= +github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 h1:MtkMsuRo3zEXTTMALfyrszwCDZTkB6wolyPjbwFAdq0= +go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0/go.mod h1:FYTxnpsm+UPD0erZNq20GvnM8T2YQHiHtT2vokdpoac= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/orders-api/internal/deps/application.go b/examples/orders-api/internal/deps/application.go new file mode 100644 index 0000000..7d28db1 --- /dev/null +++ b/examples/orders-api/internal/deps/application.go @@ -0,0 +1,52 @@ +package deps + +import ( + "context" + "fmt" + + "example.com/orders-api/gen/serverhttp" + "example.com/orders-api/internal/orders" + "github.com/devctllabs/go-libs/di" + "github.com/devctllabs/go-libs/postgresdb" + "github.com/labstack/echo/v5" +) + +// application is the user-owned composition root. Add application dependencies here. +type application struct { + orders *orders.Handler +} + +func (a *application) RegisterHTTP(server *echo.Echo) { + serverhttp.RegisterHandlers(server, serverhttp.NewStrictHandler(a.orders, nil)) +} + +// provideApplication is created once. Add newly scaffolded provider calls manually. +func provideApplication(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.Provide[HTTPRegistrar](graph, func(resolver di.Resolver) (HTTPRegistrar, error) { + reader, err := di.ResolveNamed[*postgresdb.Endpoint](resolver, storagePrimaryConnectionName+".reader") + if err != nil { + return nil, fmt.Errorf("resolve primary reader: %w", err) + } + writer, err := di.ResolveNamed[*postgresdb.Endpoint](resolver, storagePrimaryConnectionName+".writer") + if err != nil { + return nil, fmt.Errorf("resolve primary writer: %w", err) + } + store := orders.NewPostgresStore(reader, writer) + return &application{orders: orders.NewHandler(store)}, nil + }); err != nil { + return fmt.Errorf("di.Provide HTTPRegistrar: %w", err) + } + if err := provideLogging(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideLogging: %w", err) + } + if err := provideTelemetry(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideTelemetry: %w", err) + } + if err := provideStoragePrimary(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideStoragePrimary: %w", err) + } + if err := provideRuntime(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideRuntime: %w", err) + } + return nil +} diff --git a/examples/orders-api/internal/deps/config.gen.go b/examples/orders-api/internal/deps/config.gen.go new file mode 100644 index 0000000..2b7e455 --- /dev/null +++ b/examples/orders-api/internal/deps/config.gen.go @@ -0,0 +1,23 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + generatedconfig "example.com/orders-api/gen/config" + configlib "github.com/devctllabs/go-libs/config" +) + +// Config is the canonical generated runtime configuration. +type Config = generatedconfig.Config + +func loadConfig(ctx context.Context) (*Config, error) { + var cfg Config + loader := configlib.Chain(configlib.Defaults(), configlib.OSEnv()) + if err := loader.Load(ctx, &cfg); err != nil { + return nil, fmt.Errorf("loader.Load: %w", err) + } + return &cfg, nil +} diff --git a/examples/orders-api/internal/deps/container.gen.go b/examples/orders-api/internal/deps/container.gen.go new file mode 100644 index 0000000..7d30c66 --- /dev/null +++ b/examples/orders-api/internal/deps/container.gen.go @@ -0,0 +1,97 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + "github.com/devctllabs/go-libs/di" + "github.com/devctllabs/go-libs/lifecycle" + loglib "github.com/devctllabs/go-libs/log" + telemetrylib "github.com/devctllabs/go-libs/telemetry" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "time" +) + +type dbChecker interface{ Check(context.Context) error } +type scenarioRunner interface{ Run(context.Context) error } + +// Scenario owns one lazily resolved runnable branch and its dependency graph. +type Scenario struct { + graph *di.Container + tasks []lifecycle.Task +} + +// Run coordinates the selected branch until cancellation or failure. +func (s *Scenario) Run(ctx context.Context) error { + return lifecycle.Run(ctx, lifecycle.Config{ + ShutdownTimeout: 30 * time.Second, + Shutdown: s.Shutdown, + Tasks: s.tasks, + }) +} + +// Shutdown closes only resources constructed by this Scenario. +func (s *Scenario) Shutdown(ctx context.Context) error { + if s == nil || s.graph == nil { + return nil + } + if err := s.graph.Shutdown(ctx); err != nil { + return fmt.Errorf("graph.Shutdown: %w", err) + } + return nil +} + +func newScenarioGraph(ctx context.Context) (*di.Container, *Config, error) { + cfg, err := loadConfig(ctx) + if err != nil { + return nil, nil, fmt.Errorf("loadConfig: %w", err) + } + graph := di.New() + if err := di.ProvideValue(graph, cfg); err != nil { + return nil, nil, fmt.Errorf("di.ProvideValue: %w", err) + } + if err := provideApplication(ctx, graph, cfg); err != nil { + shutdownErr := graph.Shutdown(context.WithoutCancel(ctx)) + return nil, nil, errors.Join(fmt.Errorf("provideApplication: %w", err), shutdownErr) + } + return graph, cfg, nil +} +func provideLogging(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*zap.Logger, error) { + level := zapcore.InfoLevel + if err := level.Set(cfg.Logging.Level); err != nil { + return nil, fmt.Errorf("logging level: %w", err) + } + return loglib.New(level, false).Named("orders-api"), nil + }, func(_ context.Context, value *zap.Logger) error { _ = value.Sync(); return nil }) +} +func provideTelemetry(ctx context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*telemetrylib.Runtime, error) { + value, err := telemetrylib.Open(ctx, telemetrylib.Config{ + Enabled: cfg.Telemetry.Enabled, + ServiceName: "orders-api", + ServiceVersion: cfg.Telemetry.ServiceVersion, + DeploymentEnvironment: cfg.Telemetry.DeploymentEnvironment, + }) + if err != nil { + return nil, fmt.Errorf("telemetrylib.Open: %w", err) + } + return value, nil + }, func(ctx context.Context, value *telemetrylib.Runtime) error { return value.Shutdown(ctx) }) +} + +// NewAPI resolves only the API Runtime branch. +func NewAPI(ctx context.Context) (*Scenario, error) { + graph, _, err := newScenarioGraph(ctx) + if err != nil { + return nil, err + } + runtime, err := di.Resolve[*Runtime](graph) + if err != nil { + return nil, errors.Join(fmt.Errorf("di.Resolve Runtime: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + return &Scenario{graph: graph, tasks: runtime.Tasks()}, nil +} diff --git a/examples/orders-api/internal/deps/runtime.gen.go b/examples/orders-api/internal/deps/runtime.gen.go new file mode 100644 index 0000000..c0a721e --- /dev/null +++ b/examples/orders-api/internal/deps/runtime.gen.go @@ -0,0 +1,131 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + + debugserverlib "github.com/devctllabs/go-libs/debugserver" + "github.com/devctllabs/go-libs/di" + healthlib "github.com/devctllabs/go-libs/health" + healthserverlib "github.com/devctllabs/go-libs/healthserver" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/labstack/echo/v5" + "net/http" + "time" +) + +// HTTPRegistrar is implemented by user-owned application composition. +type HTTPRegistrar interface{ RegisterHTTP(*echo.Echo) } + +// Runtime owns the optional long-lived components selected by configuration. +type Runtime struct { + http *http.Server + httpEnabled bool + health *healthserverlib.Server + healthEnabled bool + pprof *debugserverlib.Server + pprofEnabled bool +} + +func newRuntime(resolver di.Resolver, cfg *Config) (*Runtime, error) { + httpRegistrar, err := di.Resolve[HTTPRegistrar](resolver) + if err != nil { + return nil, fmt.Errorf("di.Resolve HTTPRegistrar: %w", err) + } + options := make([]healthlib.Option, 0, 1) + { + checker, err := di.ResolveNamed[dbChecker](resolver, "db-connection:primary") + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + options = append(options, healthlib.NonCritical("db.primary", checker)) + } + return NewRuntime(cfg, httpRegistrar, options...) +} + +func NewRuntime(cfg *Config, httpRegistrar HTTPRegistrar, options ...healthlib.Option) (*Runtime, error) { + if cfg == nil { + return nil, errors.New("config is nil") + } + runtime := &Runtime{} + httpRouter := echo.New() + httpRegistrar.RegisterHTTP(httpRouter) + runtime.http = &http.Server{Addr: cfg.HTTP.Address, Handler: httpRouter, ReadHeaderTimeout: 2 * time.Second, IdleTimeout: 30 * time.Second} + runtime.httpEnabled = cfg.HTTP.Enabled + probes, err := healthlib.New(options...) + if err != nil { + return nil, fmt.Errorf("healthlib.New: %w", err) + } + runtime.health, err = healthserverlib.NewServer(probes, healthserverlib.WithAddress(cfg.Health.Address)) + if err != nil { + return nil, fmt.Errorf("healthserverlib.NewServer: %w", err) + } + runtime.healthEnabled = cfg.Health.Enabled + pprofServer, err := debugserverlib.NewServer(debugserverlib.WithAddress(cfg.Pprof.Address)) + if err != nil { + return nil, fmt.Errorf("debugserverlib.NewServer: %w", err) + } + runtime.pprof = pprofServer + runtime.pprofEnabled = cfg.Pprof.Enabled + return runtime, nil +} + +func provideRuntime(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(resolver di.Resolver) (*Runtime, error) { + return newRuntime(resolver, cfg) + }, func(ctx context.Context, value *Runtime) error { return value.Shutdown(ctx) }) +} + +// Tasks returns enabled runtime roots in deterministic startup order. +func (r *Runtime) Tasks() []lifecycle.Task { + tasks := make([]lifecycle.Task, 0, 3) + if r.httpEnabled { + tasks = append(tasks, lifecycle.Task{Name: "http", Run: func(context.Context) error { + if err := r.http.ListenAndServe(); err != nil { + return fmt.Errorf("r.http.ListenAndServe: %w", err) + } + return nil + }}) + } + if r.healthEnabled { + tasks = append(tasks, lifecycle.Task{Name: "health", Run: func(context.Context) error { + if err := r.health.ListenAndServe(); err != nil { + return fmt.Errorf("r.health.ListenAndServe: %w", err) + } + return nil + }}) + } + if r.pprofEnabled { + tasks = append(tasks, lifecycle.Task{Name: "pprof", Run: func(context.Context) error { + if err := r.pprof.ListenAndServe(); err != nil { + return fmt.Errorf("r.pprof.ListenAndServe: %w", err) + } + return nil + }}) + } + return tasks +} + +// Shutdown stops every constructed runtime component and joins cleanup failures. +func (r *Runtime) Shutdown(ctx context.Context) error { + var shutdownErrors []error + if r.http != nil { + if err := r.http.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.http.Shutdown: %w", err)) + } + } + if r.health != nil { + if err := r.health.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.health.Shutdown: %w", err)) + } + } + if r.pprof != nil { + if err := r.pprof.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.pprof.Shutdown: %w", err)) + } + } + return errors.Join(shutdownErrors...) +} diff --git a/examples/orders-api/internal/deps/storage_primary.gen.go b/examples/orders-api/internal/deps/storage_primary.gen.go new file mode 100644 index 0000000..d0567e6 --- /dev/null +++ b/examples/orders-api/internal/deps/storage_primary.gen.go @@ -0,0 +1,109 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + + "github.com/devctllabs/go-libs/di" + postgresdb "github.com/devctllabs/go-libs/postgresdb" + telemetrylib "github.com/devctllabs/go-libs/telemetry" + "github.com/devctllabs/go-libs/txmanager" +) + +const storagePrimaryConnectionName = "db-connection:primary" + +type storagePrimary struct { + postgres *postgresdb.DB +} + +func provideStoragePrimary(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedResource[*storagePrimary](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (*storagePrimary, error) { + return openStoragePrimary(ctx, resolver, cfg) + }, func(_ context.Context, value *storagePrimary) error { return value.close() }); err != nil { + return fmt.Errorf("di.ProvideNamedResource: %w", err) + } + if err := di.ProvideNamed[*postgresdb.Endpoint](graph, storagePrimaryConnectionName+".reader", func(resolver di.Resolver) (*postgresdb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.postgres == nil { + return nil, fmt.Errorf("storage primary does not use postgres") + } + return storage.postgres.Reader(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed reader: %w", err) + } + if err := di.ProvideNamed[*postgresdb.Endpoint](graph, storagePrimaryConnectionName+".writer", func(resolver di.Resolver) (*postgresdb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.postgres == nil { + return nil, fmt.Errorf("storage primary does not use postgres") + } + return storage.postgres.Writer(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed writer: %w", err) + } + if err := di.ProvideNamed[txmanager.Managers](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (txmanager.Managers, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + return storage.managers(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed tx managers: %w", err) + } + return di.ProvideNamed[dbChecker](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (dbChecker, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + return storage.checker(), nil + }) +} + +func openStoragePrimary(ctx context.Context, resolver di.Resolver, cfg *Config) (*storagePrimary, error) { + telemetryRuntime, err := di.Resolve[*telemetrylib.Runtime](resolver) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + switch cfg.DBPrimary.Kind { + case "postgres": + db, err := postgresdb.Open(ctx, postgresdb.Config{Writer: postgresdb.EndpointConfig{DSN: cfg.DBPrimary.PostgresDSN}, Telemetry: postgresdb.Telemetry{TracerProvider: telemetryRuntime.TracerProvider(), MeterProvider: telemetryRuntime.MeterProvider()}}) + if err != nil { + return nil, fmt.Errorf("postgresdb.Open: %w", err) + } + return &storagePrimary{postgres: db}, nil + default: + return nil, fmt.Errorf("unsupported primary database kind %q", cfg.DBPrimary.Kind) + } +} + +func (s *storagePrimary) managers() txmanager.Managers { + if s.postgres != nil { + return s.postgres.TxManagers() + } + return nil +} + +func (s *storagePrimary) checker() dbChecker { + if s.postgres != nil { + return s.postgres.Writer() + } + return nil +} + +func (s *storagePrimary) close() error { + var closeErrors []error + if s.postgres != nil { + if err := s.postgres.Close(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close postgres: %w", err)) + } + } + return errors.Join(closeErrors...) +} diff --git a/examples/orders-api/internal/orders/handler_test.go b/examples/orders-api/internal/orders/handler_test.go new file mode 100644 index 0000000..01e9ddd --- /dev/null +++ b/examples/orders-api/internal/orders/handler_test.go @@ -0,0 +1,49 @@ +package orders + +import ( + "context" + "testing" + "time" + + "example.com/orders-api/gen/serverhttp" + "github.com/stretchr/testify/require" +) + +type stubStore struct { + created Order + got Order + err error +} + +func (s *stubStore) Create(context.Context, string, int64) (Order, error) { + return s.created, s.err +} + +func (s *stubStore) Get(context.Context, int64) (Order, error) { + return s.got, s.err +} + +func TestHandlerCreatesOrder(t *testing.T) { + t.Parallel() + + want := Order{ID: 7, CustomerName: "Ada", TotalCents: 1250, CreatedAt: time.Unix(1, 0).UTC()} + handler := NewHandler(&stubStore{created: want}) + + response, err := handler.CreateOrder(t.Context(), serverhttp.CreateOrderRequestObject{ + Body: &serverhttp.CreateOrder{CustomerName: "Ada", TotalCents: 1250}, + }) + + require.NoError(t, err) + require.Equal(t, serverhttp.CreateOrder201JSONResponse(toAPIOrder(want)), response) +} + +func TestHandlerReturnsNotFound(t *testing.T) { + t.Parallel() + + handler := NewHandler(&stubStore{err: ErrNotFound}) + + response, err := handler.GetOrder(t.Context(), serverhttp.GetOrderRequestObject{Id: 404}) + + require.NoError(t, err) + require.Equal(t, serverhttp.GetOrder404JSONResponse{Message: "order not found"}, response) +} diff --git a/examples/orders-api/internal/orders/orders.go b/examples/orders-api/internal/orders/orders.go new file mode 100644 index 0000000..94f6607 --- /dev/null +++ b/examples/orders-api/internal/orders/orders.go @@ -0,0 +1,115 @@ +package orders + +import ( + "context" + "errors" + "fmt" + "time" + + "example.com/orders-api/gen/serverhttp" + "github.com/devctllabs/go-libs/postgresdb" + "github.com/jackc/pgx/v5" +) + +var ErrNotFound = errors.New("order not found") + +type Order struct { + ID int64 + CustomerName string + TotalCents int64 + CreatedAt time.Time +} + +type Store interface { + Create(ctx context.Context, customerName string, totalCents int64) (Order, error) + Get(ctx context.Context, id int64) (Order, error) +} + +type PostgresStore struct { + reader *postgresdb.Endpoint + writer *postgresdb.Endpoint +} + +func NewPostgresStore(reader, writer *postgresdb.Endpoint) *PostgresStore { + return &PostgresStore{reader: reader, writer: writer} +} + +func (s *PostgresStore) Create(ctx context.Context, customerName string, totalCents int64) (Order, error) { + const query = ` + INSERT INTO orders (customer_name, total_cents) + VALUES ($1, $2) + RETURNING id, customer_name, total_cents, created_at` + + var order Order + err := s.writer.QueryRow(ctx, query, customerName, totalCents).Scan( + &order.ID, + &order.CustomerName, + &order.TotalCents, + &order.CreatedAt, + ) + if err != nil { + return Order{}, fmt.Errorf("insert order: %w", err) + } + return order, nil +} + +func (s *PostgresStore) Get(ctx context.Context, id int64) (Order, error) { + const query = ` + SELECT id, customer_name, total_cents, created_at + FROM orders + WHERE id = $1` + + var order Order + err := s.reader.QueryRow(ctx, query, id).Scan( + &order.ID, + &order.CustomerName, + &order.TotalCents, + &order.CreatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return Order{}, ErrNotFound + } + if err != nil { + return Order{}, fmt.Errorf("select order: %w", err) + } + return order, nil +} + +type Handler struct { + store Store +} + +func NewHandler(store Store) *Handler { + return &Handler{store: store} +} + +func (h *Handler) CreateOrder(ctx context.Context, request serverhttp.CreateOrderRequestObject) (serverhttp.CreateOrderResponseObject, error) { + if request.Body == nil { + return nil, errors.New("create order body is required") + } + order, err := h.store.Create(ctx, request.Body.CustomerName, request.Body.TotalCents) + if err != nil { + return nil, err + } + return serverhttp.CreateOrder201JSONResponse(toAPIOrder(order)), nil +} + +func (h *Handler) GetOrder(ctx context.Context, request serverhttp.GetOrderRequestObject) (serverhttp.GetOrderResponseObject, error) { + order, err := h.store.Get(ctx, request.Id) + if errors.Is(err, ErrNotFound) { + return serverhttp.GetOrder404JSONResponse{Message: "order not found"}, nil + } + if err != nil { + return nil, err + } + return serverhttp.GetOrder200JSONResponse(toAPIOrder(order)), nil +} + +func toAPIOrder(order Order) serverhttp.Order { + return serverhttp.Order{ + Id: order.ID, + CustomerName: order.CustomerName, + TotalCents: order.TotalCents, + CreatedAt: order.CreatedAt, + } +} diff --git a/examples/orders-api/migrations/primary/postgres/.gitkeep b/examples/orders-api/migrations/primary/postgres/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/orders-api/migrations/primary/postgres/000001_create_orders.down.sql b/examples/orders-api/migrations/primary/postgres/000001_create_orders.down.sql new file mode 100644 index 0000000..1ba41c1 --- /dev/null +++ b/examples/orders-api/migrations/primary/postgres/000001_create_orders.down.sql @@ -0,0 +1 @@ +DROP TABLE orders; diff --git a/examples/orders-api/migrations/primary/postgres/000001_create_orders.up.sql b/examples/orders-api/migrations/primary/postgres/000001_create_orders.up.sql new file mode 100644 index 0000000..cc63f74 --- /dev/null +++ b/examples/orders-api/migrations/primary/postgres/000001_create_orders.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE orders ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + customer_name text NOT NULL, + total_cents bigint NOT NULL CHECK (total_cents >= 0), + created_at timestamptz NOT NULL DEFAULT now() +); diff --git a/examples/orders-api/tools/oapi/server.yaml b/examples/orders-api/tools/oapi/server.yaml new file mode 100644 index 0000000..45c9a22 --- /dev/null +++ b/examples/orders-api/tools/oapi/server.yaml @@ -0,0 +1,6 @@ +package: serverhttp +generate: + models: true + echo5-server: true + strict-server: true + embedded-spec: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2a3787b --- /dev/null +++ b/go.mod @@ -0,0 +1,58 @@ +module github.com/devctllabs/devctl + +go 1.26.0 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/devctllabs/go-libs/di v0.1.0 + github.com/devctllabs/go-libs/filesystem v0.2.0 + github.com/devctllabs/go-libs/lifecycle v0.2.0 + github.com/devctllabs/go-libs/log v0.2.0 + github.com/pb33f/libopenapi v0.38.6 + github.com/pb33f/libopenapi-validator v0.14.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 + github.com/stretchr/testify v1.11.1 + github.com/urfave/cli-docs/v3 v3.1.0 + github.com/urfave/cli/v3 v3.10.1 + go.uber.org/mock v0.6.0 + go.uber.org/zap v1.28.0 + golang.org/x/mod v0.38.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad // indirect + github.com/buger/jsonparser v1.1.2 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/getkin/kin-openapi v0.142.0 // indirect + github.com/go-openapi/jsonpointer v0.23.2 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/pb33f/jsonpath v0.8.2 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/samber/do/v2 v2.1.0 // indirect + github.com/samber/go-type-to-string v1.8.0 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.24.0 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect +) + +tool ( + github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + go.uber.org/mock/mockgen +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2ede17b --- /dev/null +++ b/go.sum @@ -0,0 +1,247 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad h1:3swAvbzgfaI6nKuDDU7BiKfZRdF+h2ZwKgMHd8Ha4t8= +github.com/basgys/goxml2json v1.1.1-0.20231018121955-e66ee54ceaad/go.mod h1:9+nBLYNWkvPcq9ep0owWUsPTLgL9ZXTsZWcCSVGGLJ0= +github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow= +github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/devctllabs/go-libs/di v0.1.0 h1:pPhlKmdYyvKVYr/5eV51Ea9HcfqsqWCs3GZKG6jFGW8= +github.com/devctllabs/go-libs/di v0.1.0/go.mod h1:kr9mSKElEmsDHgKsZbDGJhSCdmlcluT81PjbCvcOmbY= +github.com/devctllabs/go-libs/filesystem v0.2.0 h1:PdPKAoFy90RoH0+8qOSvJnyr8ASj0L1RiFngBe/2bns= +github.com/devctllabs/go-libs/filesystem v0.2.0/go.mod h1:XjGXmwFAmeRMknEchaEtj5jxQ1wwtJj2xPiwEZB2Yz4= +github.com/devctllabs/go-libs/lifecycle v0.2.0 h1:vafo21o5tjrU3Qf4tVa0a2A6a8NQPSR4blpHiTQU3O4= +github.com/devctllabs/go-libs/lifecycle v0.2.0/go.mod h1:/m8kzixQx7IhAi3jx7hFfICu96VxBKNuQxHd+tY6VIY= +github.com/devctllabs/go-libs/log v0.2.0 h1:RICLkubslpX8CGSw/m+i2FxrRo64PzhE7hVFQXlwReM= +github.com/devctllabs/go-libs/log v0.2.0/go.mod h1:KfCsyQUkit4D2kounIhY6AauWB/Lo5SMiHZZXaTtMJ4= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n8NO7w= +github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= +github.com/go-openapi/jsonpointer v0.23.2 h1:DK7R/3zAt4xTytxNkw7jARGPFI7rkaSsii58n8X45x0= +github.com/go-openapi/jsonpointer v0.23.2/go.mod h1:noUOckXtq7b4bVkqw0sbHKieq9uEZRN7p6EF/dalc4w= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 h1:s4hxMxuqtR8jPzXkBTtFwY/SBuj3gEAYikmbBSdtLMM= +github.com/oapi-codegen/oapi-codegen/v2 v2.8.0/go.mod h1:yae2TI9IYB5vxQ35gFrpXh9L5H1eJv4MAUK1jumGMTo= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/pb33f/jsonpath v0.8.2 h1:Ou4C7zjYClBm97dfZjDCjdZGusJoynv/vrtiEKNfj2Y= +github.com/pb33f/jsonpath v0.8.2/go.mod h1:zBV5LJW4OQOPatmQE2QdKpGQJvhDTlE5IEj6ASaRNTo= +github.com/pb33f/libopenapi v0.38.6 h1:rnLshOSCSLx3JmN/MnftyYcdN1FfaeRkbaBE/ZHdIXg= +github.com/pb33f/libopenapi v0.38.6/go.mod h1:8yHl64vr+ICrnzSgiwJmZ54heRqCqrhI/JL1ge+CPIY= +github.com/pb33f/libopenapi-validator v0.14.0 h1:K9cdv1kL6cZdQmSbzitkVcWCJKyRmmSZryZqgUqIv+Y= +github.com/pb33f/libopenapi-validator v0.14.0/go.mod h1:EU6yVajX6rWwUAC4OWfsVPPwAZ7k29fruoZnMnzdKjI= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= +github.com/pb33f/testify v0.1.0 h1:g48/HDU/jn2COspS4nM0scptxiKTJ4DnbX/4ehK6IZ8= +github.com/pb33f/testify v0.1.0/go.mod h1:nq283P/jJ8hXMmdhAqfj7BJIz0y+6IOHj9q0044rKt4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/do/v2 v2.1.0 h1:lqCHn05XvY3VqwxvZDQPSkH+jIGWSVHUrSVLEbPOopo= +github.com/samber/do/v2 v2.1.0/go.mod h1:wJBoiaZcUZyGuraOhfz15b517ZMogGs+U03DvnqvT6Q= +github.com/samber/go-type-to-string v1.8.0 h1:5z6tDTjtXxkIAoAuHAZYMYR8mkBZjVgeSH7jcSLqc8w= +github.com/samber/go-type-to-string v1.8.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.24.0 h1:opoD27rupX7zBVPq1HkIGLeMOzNNA7JalhYP8q34i04= +github.com/speakeasy-api/openapi v1.24.0/go.mod h1:g3+dIMe0AYgbbGvnlQZqesmjAVWSm9BmsjLevnefQrg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw= +github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to= +github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY= +github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/bufgen/client.go b/internal/client/bufgen/client.go new file mode 100644 index 0000000..33f70e0 --- /dev/null +++ b/internal/client/bufgen/client.go @@ -0,0 +1,106 @@ +package bufgen + +import ( + "context" + "fmt" + + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/client/toolsafety" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "golang.org/x/mod/modfile" +) + +// Client runs the project-declared Buf tool in a temporary output workspace. +type Client struct { + runner toolrun.Runner +} + +func New(runner toolrun.Runner) *Client { return &Client{runner: runner} } + +// Generate returns unpublished generated Proto output. +func (c *Client) Generate( + ctx context.Context, + project projectdomain.Project, + target projectdomain.Target, +) (generatedomain.Output, error) { + if err := ctx.Err(); err != nil { + return generatedomain.Output{}, fmt.Errorf("ctx.Err: %w", err) + } + if target.Family != "grpc" && target.Family != "kafka" { + return generatedomain.Output{}, fmt.Errorf("unsupported generation target kind %q", target.Family) + } + if err := toolsafety.RequireDirectory(project.Root, target.Input); err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.RequireDirectory input: %w", err) + } + if _, err := toolsafety.ReadRegularFile(project.Root, target.Config); err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile config: %w", err) + } + if err := requireBufTool(project.Root); err != nil { + return generatedomain.Output{}, err + } + + output, err := toolrun.WithTemporaryOutput(ctx, "devctl-buf-generate-", func(temporary string) (generatedomain.Output, error) { + arguments := []string{"tool", "buf", "generate", target.Input, "--template", target.Config} + for _, selectedPath := range target.Paths { + arguments = append(arguments, "--path", selectedPath) + } + arguments = append(arguments, "--output", temporary) + if err := c.runner.Run(ctx, toolrun.Command{Name: "go", Args: arguments, Dir: project.Root}); err != nil { + return generatedomain.Output{}, fmt.Errorf("runner.Run: %w", err) + } + tree, err := toolsafety.ReadRegularTree(temporary, ".") + if err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularTree: %w", err) + } + if len(tree.Files) == 0 { + return generatedomain.Output{}, fmt.Errorf("buf produced no generated files") + } + return generatedomain.Output{Directory: tree}, nil + }) + if err != nil { + return output, fmt.Errorf("toolrun.WithTemporaryOutput: %w", err) + } + return output, nil +} + +// Lint checks one Proto-backed target with the project-declared Buf tool. +func (c *Client) Lint(ctx context.Context, project projectdomain.Project, target projectdomain.Target) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if target.Family != "grpc" && target.Family != "kafka" { + return fmt.Errorf("unsupported lint target kind %q", target.Family) + } + if err := toolsafety.RequireDirectory(project.Root, target.Input); err != nil { + return fmt.Errorf("toolsafety.RequireDirectory input: %w", err) + } + if err := requireBufTool(project.Root); err != nil { + return err + } + arguments := []string{"tool", "buf", "lint", target.Input} + for _, selectedPath := range target.Paths { + arguments = append(arguments, "--path", selectedPath) + } + if err := c.runner.Run(ctx, toolrun.Command{Name: "go", Args: arguments, Dir: project.Root}); err != nil { + return fmt.Errorf("runner.Run: %w", err) + } + return nil +} + +func requireBufTool(root string) error { + goMod, err := toolsafety.ReadRegularFile(root, "go.mod") + if err != nil { + return fmt.Errorf("toolsafety.ReadRegularFile go.mod: %w", err) + } + parsed, err := modfile.Parse("go.mod", goMod.Content, nil) + if err != nil { + return fmt.Errorf("modfile.Parse: %w", err) + } + for _, tool := range parsed.Tool { + if tool.Path == "github.com/bufbuild/buf/cmd/buf" { + return nil + } + } + return fmt.Errorf("go.mod does not declare the Buf tool") +} diff --git a/internal/client/bufgen/client_test.go b/internal/client/bufgen/client_test.go new file mode 100644 index 0000000..629cd3d --- /dev/null +++ b/internal/client/bufgen/client_test.go @@ -0,0 +1,40 @@ +package bufgen_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + bufgen "github.com/devctllabs/devctl/internal/client/bufgen" + "github.com/devctllabs/devctl/internal/client/toolrun" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientPreservesBufRunnerFailure(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFixture(t, root, "go.mod", "module example.test/service\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n") + writeFixture(t, root, "api/proto/acme/v1/service.proto", "syntax = \"proto3\";\npackage acme.v1;\n") + writeFixture(t, root, "tools/buf/grpc.gen.yaml", "version: v2\nplugins: []\n") + primary := errors.New("buf process failed") + runner := &recordingRunner{run: func(toolrun.Command) error { return primary }} + target := projectdomain.Target{ + ID: "grpc-server", Family: "grpc", Input: "api/proto", Config: "tools/buf/grpc.gen.yaml", + } + + _, err := bufgen.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, target) + + require.ErrorIs(t, err, primary) + require.ErrorContains(t, err, "runner.Run") +} + +func writeFixture(t *testing.T, root, relative, content string) { + t.Helper() + filename := filepath.Join(root, filepath.FromSlash(relative)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(content), 0o644)) +} diff --git a/internal/client/bufgen/runner_test.go b/internal/client/bufgen/runner_test.go new file mode 100644 index 0000000..8afe52c --- /dev/null +++ b/internal/client/bufgen/runner_test.go @@ -0,0 +1,75 @@ +package bufgen_test + +import ( + "context" + "testing" + + bufgen "github.com/devctllabs/devctl/internal/client/bufgen" + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientDelegatesBufGenerationToRunner(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFixture(t, root, "go.mod", "module example.test/service\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n") + writeFixture(t, root, "api/proto/acme/v1/service.proto", "syntax = \"proto3\";\npackage acme.v1;\n") + writeFixture(t, root, "tools/buf/grpc.gen.yaml", "version: v2\nplugins: []\n") + var temporary string + runner := &recordingRunner{run: func(command toolrun.Command) error { + temporary = command.Args[len(command.Args)-1] + writeFixture(t, temporary, "acme/v1/service.pb.go", "generated") + return nil + }} + target := projectdomain.Target{ + ID: "grpc-server", Family: "grpc", Input: "api/proto", + Paths: []string{"acme/v1"}, Config: "tools/buf/grpc.gen.yaml", + } + + output, err := bufgen.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, target) + + require.NoError(t, err) + require.Equal(t, []toolrun.Command{{ + Name: "go", + Args: []string{"tool", "buf", "generate", "api/proto", "--template", "tools/buf/grpc.gen.yaml", "--path", "acme/v1", "--output", temporary}, + Dir: root, + }}, runner.commands) + require.Equal(t, artifact.Tree{Files: []artifact.File{{Path: "acme/v1/service.pb.go", Content: []byte("generated"), Mode: 0o644}}}, output.Directory) + require.NoDirExists(t, temporary) +} + +func TestClientDelegatesBufLintToRunnerWithoutTemporaryOutput(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFixture(t, root, "go.mod", "module example.test/service\n\ngo 1.25\n\ntool github.com/bufbuild/buf/cmd/buf\n") + writeFixture(t, root, "api/proto/acme/v1/service.proto", "syntax = \"proto3\";\npackage acme.v1;\n") + runner := &recordingRunner{} + target := projectdomain.Target{ + ID: "grpc-server", Family: "grpc", Input: "api/proto", Paths: []string{"acme/v1"}, + } + + err := bufgen.New(runner).Lint(context.Background(), projectdomain.Project{Root: root}, target) + + require.NoError(t, err) + require.Equal(t, []toolrun.Command{{ + Name: "go", Args: []string{"tool", "buf", "lint", "api/proto", "--path", "acme/v1"}, Dir: root, + }}, runner.commands) +} + +type recordingRunner struct { + commands []toolrun.Command + run func(toolrun.Command) error +} + +func (r *recordingRunner) Run(_ context.Context, command toolrun.Command) error { + command.Args = append([]string(nil), command.Args...) + r.commands = append(r.commands, command) + if r.run != nil { + return r.run(command) + } + return nil +} diff --git a/internal/client/generator/client.go b/internal/client/generator/client.go new file mode 100644 index 0000000..4ce2389 --- /dev/null +++ b/internal/client/generator/client.go @@ -0,0 +1,65 @@ +// Package generator routes generation Targets to their concrete tool adapters. +package generator + +import ( + "context" + "fmt" + + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +//go:generate go tool mockgen -destination mocks/client.go -package mocks -typed . Adapter + +// Adapter generates unpublished Managed Output for one supported Target. +type Adapter interface { + // Generate returns unpublished Managed Output without modifying the Project workspace. + Generate(ctx context.Context, project projectdomain.Project, target projectdomain.Target) (generatedomain.Output, error) +} + +// Adapters contains the concrete tool capabilities selected by Client. +type Adapters struct { + OpenAPI Adapter + Proto Adapter + JSONSchema Adapter +} + +// Client routes each supported Target to its concrete generator adapter. +type Client struct { + adapters Adapters +} + +// New returns a Target-aware generator using adapters. +func New(adapters Adapters) *Client { + return &Client{adapters: adapters} +} + +// Generate returns unpublished Managed Output for target. +func (c *Client) Generate( + ctx context.Context, + project projectdomain.Project, + target projectdomain.Target, +) (generatedomain.Output, error) { + adapter, name := c.adapter(target) + if adapter == nil { + return generatedomain.Output{}, fmt.Errorf("unsupported generation target %q", target.ID) + } + output, err := adapter.Generate(ctx, project, target) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("%s.Generate: %w", name, err) + } + return output, nil +} + +func (c *Client) adapter(target projectdomain.Target) (Adapter, string) { + switch { + case target.Family == "http": + return c.adapters.OpenAPI, "openAPI" + case target.Family == "grpc", target.Family == "kafka" && target.Format == "proto": + return c.adapters.Proto, "proto" + case target.Family == "kafka" && target.Format == "json": + return c.adapters.JSONSchema, "jsonSchema" + default: + return nil, "" + } +} diff --git a/internal/client/generator/client_test.go b/internal/client/generator/client_test.go new file mode 100644 index 0000000..64d7216 --- /dev/null +++ b/internal/client/generator/client_test.go @@ -0,0 +1,96 @@ +package generator_test + +import ( + "context" + "errors" + "testing" + + generatorclient "github.com/devctllabs/devctl/internal/client/generator" + "github.com/devctllabs/devctl/internal/client/generator/mocks" + "github.com/devctllabs/devctl/internal/domain/artifact" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestClientRoutesSupportedTargets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target projectdomain.Target + selected func(*mocks.MockAdapter, *mocks.MockAdapter, *mocks.MockAdapter) *mocks.MockAdapter + }{ + { + name: "http to OpenAPI", + target: projectdomain.Target{ID: "http-client:billing", Family: "http", Format: "openapi"}, + selected: func(openAPI, _, _ *mocks.MockAdapter) *mocks.MockAdapter { return openAPI }, + }, + { + name: "gRPC to Proto", + target: projectdomain.Target{ID: "grpc-server", Family: "grpc", Format: "proto"}, + selected: func(_, proto, _ *mocks.MockAdapter) *mocks.MockAdapter { return proto }, + }, + { + name: "Kafka Proto to Proto", + target: projectdomain.Target{ID: "kafka-producer:audit", Family: "kafka", Format: "proto"}, + selected: func(_, proto, _ *mocks.MockAdapter) *mocks.MockAdapter { return proto }, + }, + { + name: "Kafka JSON to JSON Schema", + target: projectdomain.Target{ID: "kafka-consumer:audit", Family: "kafka", Format: "json"}, + selected: func(_, _, jsonSchema *mocks.MockAdapter) *mocks.MockAdapter { return jsonSchema }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + openAPI := mocks.NewMockAdapter(ctrl) + proto := mocks.NewMockAdapter(ctrl) + jsonSchema := mocks.NewMockAdapter(ctrl) + project := projectdomain.Project{Root: "/project"} + expected := generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{Path: "generated.go"}}}} + test.selected(openAPI, proto, jsonSchema).EXPECT().Generate(gomock.Any(), project, test.target).Return(expected, nil) + client := generatorclient.New(generatorclient.Adapters{ + OpenAPI: openAPI, Proto: proto, JSONSchema: jsonSchema, + }) + + actual, err := client.Generate(context.Background(), project, test.target) + + require.NoError(t, err) + require.Equal(t, expected, actual) + }) + } +} + +func TestClientRejectsUnsupportedTarget(t *testing.T) { + t.Parallel() + + client := generatorclient.New(generatorclient.Adapters{}) + + _, err := client.Generate(context.Background(), projectdomain.Project{}, projectdomain.Target{ + ID: "kafka-consumer:audit", Family: "kafka", Format: "raw", + }) + + require.EqualError(t, err, `unsupported generation target "kafka-consumer:audit"`) +} + +func TestClientPreservesAdapterFailure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + openAPI := mocks.NewMockAdapter(ctrl) + cause := errors.New("tool failed") + project := projectdomain.Project{Root: "/project"} + target := projectdomain.Target{ID: "http-server", Family: "http", Format: "openapi"} + openAPI.EXPECT().Generate(gomock.Any(), project, target).Return(generatedomain.Output{}, cause) + client := generatorclient.New(generatorclient.Adapters{OpenAPI: openAPI}) + + _, err := client.Generate(context.Background(), project, target) + + require.ErrorContains(t, err, "openAPI.Generate") + require.ErrorIs(t, err, cause) +} diff --git a/internal/client/generator/mocks/client.go b/internal/client/generator/mocks/client.go new file mode 100644 index 0000000..7456007 --- /dev/null +++ b/internal/client/generator/mocks/client.go @@ -0,0 +1,82 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/client/generator (interfaces: Adapter) +// +// Generated by this command: +// +// mockgen -destination mocks/client.go -package mocks -typed . Adapter +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + generate "github.com/devctllabs/devctl/internal/domain/generate" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockAdapter is a mock of Adapter interface. +type MockAdapter struct { + ctrl *gomock.Controller + recorder *MockAdapterMockRecorder + isgomock struct{} +} + +// MockAdapterMockRecorder is the mock recorder for MockAdapter. +type MockAdapterMockRecorder struct { + mock *MockAdapter +} + +// NewMockAdapter creates a new mock instance. +func NewMockAdapter(ctrl *gomock.Controller) *MockAdapter { + mock := &MockAdapter{ctrl: ctrl} + mock.recorder = &MockAdapterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAdapter) EXPECT() *MockAdapterMockRecorder { + return m.recorder +} + +// Generate mocks base method. +func (m *MockAdapter) Generate(ctx context.Context, arg1 project.Project, target project.Target) (generate.Output, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Generate", ctx, arg1, target) + ret0, _ := ret[0].(generate.Output) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Generate indicates an expected call of Generate. +func (mr *MockAdapterMockRecorder) Generate(ctx, arg1, target any) *MockAdapterGenerateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockAdapter)(nil).Generate), ctx, arg1, target) + return &MockAdapterGenerateCall{Call: call} +} + +// MockAdapterGenerateCall wrap *gomock.Call +type MockAdapterGenerateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockAdapterGenerateCall) Return(arg0 generate.Output, arg1 error) *MockAdapterGenerateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockAdapterGenerateCall) Do(f func(context.Context, project.Project, project.Target) (generate.Output, error)) *MockAdapterGenerateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockAdapterGenerateCall) DoAndReturn(f func(context.Context, project.Project, project.Target) (generate.Output, error)) *MockAdapterGenerateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/client/git/client.go b/internal/client/git/client.go new file mode 100644 index 0000000..8a04e02 --- /dev/null +++ b/internal/client/git/client.go @@ -0,0 +1,73 @@ +package git + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Client provides detached temporary Git checkouts without source-selection policy. +type Client struct{} + +func New() *Client { return &Client{} } + +// WithCheckout invokes use with a detached checkout and always attempts cleanup before returning. +// Callback and cleanup failures are both preserved. +func (c *Client) WithCheckout(ctx context.Context, repository, ref string, use func(root string) error) error { + workspace, err := os.MkdirTemp("", "devctl-source-") + if err != nil { + return fmt.Errorf("os.MkdirTemp: %w", err) + } + worktree := &Worktree{root: filepath.Join(workspace, "repository"), workspace: workspace} + if err := run(ctx, "git", "clone", "--quiet", "--no-checkout", "--", repository, worktree.root); err != nil { + return errors.Join(err, worktree.Close()) + } + if err := run(ctx, "git", "-C", worktree.root, "checkout", "--quiet", "--detach", ref); err != nil { + return errors.Join(err, worktree.Close()) + } + return errors.Join(use(worktree.root), worktree.Close()) +} + +// Worktree owns one temporary checkout and its surrounding temporary directory. +type Worktree struct { + root string + workspace string + closed bool +} + +// Root returns the checkout directory, which remains valid until Close. +func (w *Worktree) Root() string { return w.root } + +// Close attempts to remove the complete temporary workspace at most once. +func (w *Worktree) Close() error { + if w.closed { + return nil + } + w.closed = true + if err := os.RemoveAll(w.workspace); err != nil { + return fmt.Errorf("os.RemoveAll: %w", err) + } + return nil +} + +func run(ctx context.Context, executable string, arguments ...string) error { + command := exec.CommandContext(ctx, executable, arguments...) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("command.CombinedOutput: %s: %w", bounded(output), err) + } + return nil +} + +func bounded(data []byte) string { + const limit = 1024 + value := strings.TrimSpace(string(data)) + if len(value) > limit { + return value[:limit] + "…" + } + return value +} diff --git a/internal/client/git/client_test.go b/internal/client/git/client_test.go new file mode 100644 index 0000000..a20d53b --- /dev/null +++ b/internal/client/git/client_test.go @@ -0,0 +1,52 @@ +package git_test + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + gitclient "github.com/devctllabs/devctl/internal/client/git" + "github.com/stretchr/testify/require" +) + +func TestClientChecksOutDetachedWorktreeAndCleansItUp(t *testing.T) { + t.Parallel() + + repository := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(repository, "openapi.yaml"), []byte("openapi: 3.1.0\n"), 0o644)) + commit := commitFixture(t, repository) + + var root string + err := gitclient.New().WithCheckout(context.Background(), repository, commit, func(checkoutRoot string) error { + root = checkoutRoot + require.FileExists(t, filepath.Join(root, "openapi.yaml")) + return nil + }) + + require.NoError(t, err) + require.NoDirExists(t, root) +} + +func commitFixture(t *testing.T, repository string) string { + t.Helper() + for _, arguments := range [][]string{ + {"init", "--quiet"}, + {"config", "user.email", "devctl@example.test"}, + {"config", "user.name", "Devctl Test"}, + {"add", "."}, + {"commit", "--quiet", "-m", "fixture"}, + } { + command := exec.CommandContext(context.Background(), "git", arguments...) + command.Dir = repository + output, err := command.CombinedOutput() + require.NoError(t, err, "%s", output) + } + command := exec.CommandContext(context.Background(), "git", "rev-parse", "HEAD") + command.Dir = repository + output, err := command.Output() + require.NoError(t, err) + return string(bytes.TrimSpace(output)) +} diff --git a/internal/client/http/client.go b/internal/client/http/client.go new file mode 100644 index 0000000..e5443dc --- /dev/null +++ b/internal/client/http/client.go @@ -0,0 +1,151 @@ +package http + +import ( + "context" + "fmt" + "io" + stdhttp "net/http" + "net/url" + "strings" + "time" + + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" +) + +const maxResponseSize = 32 << 20 + +// Client retrieves bounded contract documents with a fixed request timeout. +type Client struct { + client *stdhttp.Client +} + +func New() *Client { + return &Client{client: &stdhttp.Client{Timeout: 30 * time.Second}} +} + +// Fetch accepts only 2xx responses, limits bodies to 32 MiB, and returns the effective response URL. +func (c *Client) Fetch(ctx context.Context, fetch materializedomain.HTTPFetchRequest) (materializedomain.HTTPDocument, error) { + request, err := stdhttp.NewRequestWithContext(ctx, stdhttp.MethodGet, fetch.URL, nil) + if err != nil { + return materializedomain.HTTPDocument{}, fmt.Errorf("stdhttp.NewRequestWithContext: %w", err) + } + origin, err := url.Parse(fetch.OriginURL) + if err != nil || origin.Host == "" { + return materializedomain.HTTPDocument{}, &PolicyError{Reason: "invalid origin URL", Cause: err} + } + if err := validateURLPolicy(origin, request.URL, fetch.AllowInsecureHTTP); err != nil { + return materializedomain.HTTPDocument{}, err + } + client := *c.client + client.CheckRedirect = redirectPolicy(origin, fetch.AllowInsecureHTTP) + response, err := client.Do(request) + if err != nil { + category := failure.CategoryOf(err) + if category == failure.Internal { + category = failure.Unavailable + } + return materializedomain.HTTPDocument{}, &FetchError{Kind: category, Cause: err} + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode < 200 || response.StatusCode > 299 { + return materializedomain.HTTPDocument{}, &StatusError{StatusCode: response.StatusCode} + } + data, err := io.ReadAll(io.LimitReader(response.Body, maxResponseSize+1)) + if err != nil { + return materializedomain.HTTPDocument{}, fmt.Errorf("io.ReadAll: %w", err) + } + if len(data) > maxResponseSize { + return materializedomain.HTTPDocument{}, &BodyTooLargeError{Limit: maxResponseSize} + } + return materializedomain.HTTPDocument{URL: response.Request.URL.String(), Content: data}, nil +} + +// redirectPolicy rejects origin changes, credentials, disallowed schemes, and excessive redirect chains. +func redirectPolicy(origin *url.URL, allowInsecureHTTP bool) func(*stdhttp.Request, []*stdhttp.Request) error { + return func(request *stdhttp.Request, via []*stdhttp.Request) error { + if len(via) >= 10 { + return &PolicyError{Reason: "too many redirects"} + } + return validateURLPolicy(origin, request.URL, allowInsecureHTTP) + } +} + +func validateURLPolicy(origin, target *url.URL, allowInsecureHTTP bool) error { + if origin.User != nil || target.User != nil { + return &PolicyError{Reason: "URL contains credentials"} + } + targetScheme := strings.ToLower(target.Scheme) + if targetScheme != "https" && (targetScheme != "http" || !allowInsecureHTTP) { + return &PolicyError{Reason: "URL uses a disallowed scheme"} + } + if !sameOrigin(origin, target) { + return &PolicyError{Reason: "URL changes source origin"} + } + return nil +} + +func sameOrigin(left, right *url.URL) bool { + return strings.EqualFold(left.Scheme, right.Scheme) && + strings.EqualFold(left.Hostname(), right.Hostname()) && + effectivePort(left) == effectivePort(right) +} + +func effectivePort(value *url.URL) string { + if value.Port() != "" { + return value.Port() + } + switch strings.ToLower(value.Scheme) { + case "http": + return "80" + case "https": + return "443" + default: + return "" + } +} + +// PolicyError reports a URL request that violates Source fetch policy. +type PolicyError struct { + Reason string + Cause error +} + +func (e *PolicyError) Error() string { return e.Reason } +func (e *PolicyError) Unwrap() error { return e.Cause } +func (e *PolicyError) Category() failure.Category { + return failure.InvalidInput +} + +// FetchError retains the raw transport cause behind a query-safe public message. +type FetchError struct { + Kind failure.Category + Cause error +} + +func (e *FetchError) Error() string { return "HTTP fetch failed" } +func (e *FetchError) Unwrap() error { return e.Cause } +func (e *FetchError) Category() failure.Category { return e.Kind } + +// StatusError retains a non-success HTTP response status without interpreting application policy. +type StatusError struct { + StatusCode int +} + +func (e *StatusError) Error() string { return fmt.Sprintf("HTTP status %d", e.StatusCode) } + +func (e *StatusError) Category() failure.Category { + if e.StatusCode == stdhttp.StatusNotFound || e.StatusCode == stdhttp.StatusGone { + return failure.NotFound + } + return failure.Unavailable +} + +// BodyTooLargeError reports the byte limit exceeded by a response body. +type BodyTooLargeError struct { + Limit int +} + +func (e *BodyTooLargeError) Error() string { return fmt.Sprintf("response exceeds %d bytes", e.Limit) } + +func (e *BodyTooLargeError) Category() failure.Category { return failure.InvalidInput } diff --git a/internal/client/http/client_test.go b/internal/client/http/client_test.go new file mode 100644 index 0000000..1e23e0b --- /dev/null +++ b/internal/client/http/client_test.go @@ -0,0 +1,148 @@ +package http_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + httpclient "github.com/devctllabs/devctl/internal/client/http" + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/stretchr/testify/require" +) + +func TestClientGetsResponseBody(t *testing.T) { + t.Parallel() + + methods := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + methods <- request.Method + _, _ = writer.Write([]byte("openapi: 3.1.0\n")) + })) + t.Cleanup(server.Close) + + document, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: server.URL, OriginURL: server.URL, AllowInsecureHTTP: true, + }) + + require.NoError(t, err) + require.Equal(t, http.MethodGet, <-methods) + require.Equal(t, server.URL, document.URL) + require.Equal(t, []byte("openapi: 3.1.0\n"), document.Content) +} + +func TestClientRejectsCrossOriginRedirect(t *testing.T) { + t.Parallel() + + destination := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte("should not be fetched")) + })) + t.Cleanup(destination.Close) + origin := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + http.Redirect(writer, &http.Request{}, destination.URL+"/contract.yaml", http.StatusFound) + })) + t.Cleanup(origin.Close) + + _, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: origin.URL, OriginURL: origin.URL, AllowInsecureHTTP: true, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestClientReturnsEffectiveURLAfterSameOriginRedirect(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/start" { + http.Redirect(writer, request, "/nested/contract.yaml", http.StatusFound) + return + } + _, _ = writer.Write([]byte("openapi: 3.1.0\n")) + })) + t.Cleanup(server.Close) + + document, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: server.URL + "/start", OriginURL: server.URL + "/start", AllowInsecureHTTP: true, + }) + + require.NoError(t, err) + require.Equal(t, server.URL+"/nested/contract.yaml", document.URL) +} + +func TestClientClassifiesHTTPStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + status int + expected failure.Category + }{ + {status: http.StatusNotFound, expected: failure.NotFound}, + {status: http.StatusGone, expected: failure.NotFound}, + {status: http.StatusInternalServerError, expected: failure.Unavailable}, + } + for _, test := range tests { + t.Run(http.StatusText(test.status), func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(test.status) + })) + t.Cleanup(server.Close) + + _, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: server.URL, OriginURL: server.URL, AllowInsecureHTTP: true, + }) + + require.Equal(t, test.expected, failure.CategoryOf(err)) + }) + } +} + +func TestClientClassifiesOversizedResponseAsInvalidInput(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write(make([]byte, (32<<20)+1)) + })) + t.Cleanup(server.Close) + + _, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: server.URL, OriginURL: server.URL, AllowInsecureHTTP: true, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + var sizeErr *httpclient.BodyTooLargeError + require.ErrorAs(t, err, &sizeErr) +} + +func TestClientClassifiesTransportFailureAsUnavailable(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + serverURL := server.URL + server.Close() + + _, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: serverURL, OriginURL: serverURL, AllowInsecureHTTP: true, + }) + + require.Equal(t, failure.Unavailable, failure.CategoryOf(err)) +} + +func TestClientReturnsTypedStatusError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusBadGateway) + })) + t.Cleanup(server.Close) + + _, err := httpclient.New().Fetch(context.Background(), materializedomain.HTTPFetchRequest{ + URL: server.URL, OriginURL: server.URL, AllowInsecureHTTP: true, + }) + + var statusError *httpclient.StatusError + require.ErrorAs(t, err, &statusError) + require.Equal(t, http.StatusBadGateway, statusError.StatusCode) +} diff --git a/internal/client/http/policy_test.go b/internal/client/http/policy_test.go new file mode 100644 index 0000000..a43ea96 --- /dev/null +++ b/internal/client/http/policy_test.go @@ -0,0 +1,66 @@ +package http + +import ( + "net/url" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/stretchr/testify/require" +) + +func TestSameOriginUsesSchemeHostAndEffectivePort(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + left string + right string + expected bool + }{ + {name: "https default port", left: "https://example.test/a", right: "https://EXAMPLE.test:443/b", expected: true}, + {name: "http default port", left: "http://example.test/a", right: "http://example.test:80/b", expected: true}, + {name: "different explicit port", left: "https://example.test/a", right: "https://example.test:8443/b", expected: false}, + {name: "different scheme", left: "http://example.test/a", right: "https://example.test/b", expected: false}, + {name: "different host", left: "https://example.test/a", right: "https://other.test/b", expected: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + left, err := url.Parse(test.left) + require.NoError(t, err) + right, err := url.Parse(test.right) + require.NoError(t, err) + + require.Equal(t, test.expected, sameOrigin(left, right)) + }) + } +} + +func TestURLPolicyRejectsCredentialsAndInsecureScheme(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + origin string + target string + allowInsecure bool + }{ + {name: "origin credentials", origin: "https://user:secret@example.test/a", target: "https://example.test/b"}, + {name: "target credentials", origin: "https://example.test/a", target: "https://user:secret@example.test/b"}, + {name: "insecure scheme", origin: "http://example.test/a", target: "http://example.test/b"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + origin, err := url.Parse(test.origin) + require.NoError(t, err) + target, err := url.Parse(test.target) + require.NoError(t, err) + + err = validateURLPolicy(origin, target, test.allowInsecure) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + require.NotContains(t, err.Error(), "secret") + }) + } +} diff --git a/internal/client/jsonschema/client.go b/internal/client/jsonschema/client.go new file mode 100644 index 0000000..5728ff7 --- /dev/null +++ b/internal/client/jsonschema/client.go @@ -0,0 +1,102 @@ +package jsonschema + +import ( + "context" + "fmt" + "go/parser" + "go/token" + "os/exec" + "path/filepath" + "strings" + + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/client/toolsafety" + "github.com/devctllabs/devctl/internal/domain/artifact" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + platformjsonschema "github.com/devctllabs/devctl/internal/platform/jsonschema" +) + +// Client runs project-pinned quicktype in a temporary output directory. +type Client struct { + runner toolrun.Runner +} + +func New(runner toolrun.Runner) *Client { return &Client{runner: runner} } + +// Generate returns one parsed Go file without writing managed project output. +func (c *Client) Generate(ctx context.Context, project projectdomain.Project, target projectdomain.Target) (generatedomain.Output, error) { + if err := ctx.Err(); err != nil { + return generatedomain.Output{}, fmt.Errorf("ctx.Err: %w", err) + } + if target.Family != "kafka" || target.Format != "json" { + return generatedomain.Output{}, fmt.Errorf("unsupported generation target %q", target.ID) + } + input, err := toolsafety.ReadRegularFile(project.Root, target.Input) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile input: %w", err) + } + title, err := platformjsonschema.RootTitle(input.Content) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("jsonschema.RootTitle: %w", err) + } + outputName := target.OutputFile + if outputName == "" { + outputName = "schema.gen.go" + } + if filepath.IsAbs(outputName) || filepath.Base(outputName) != outputName || outputName == "." { + return generatedomain.Output{}, fmt.Errorf("invalid generated output filename %q", outputName) + } + quicktype, err := exec.LookPath("quicktype") + if err != nil { + return generatedomain.Output{}, fmt.Errorf("exec.LookPath: quicktype is unavailable; run mise install and execute generation through mise: %w", err) + } + output, err := toolrun.WithTemporaryOutput(ctx, "devctl-jsonschema-generate-", func(temporary string) (generatedomain.Output, error) { + outputPath := filepath.Join(temporary, outputName) + generatedPackage := packageName(target.ID) + command := toolrun.Command{ + Name: quicktype, + Args: []string{ + "--src", target.Input, + "--src-lang", "schema", + "--lang", "go", + "--package", generatedPackage, + "--top-level", title, + "--out", outputPath, + "--omit-empty", + }, + Dir: project.Root, + } + if err := c.runner.Run(ctx, command); err != nil { + return generatedomain.Output{}, fmt.Errorf("runner.Run: %w", err) + } + generated, err := toolsafety.ReadRegularFile(temporary, outputName) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile output: %w", err) + } + parsed, err := parser.ParseFile(token.NewFileSet(), outputName, generated.Content, parser.AllErrors) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("parser.ParseFile: %w", err) + } + if parsed.Name.Name != generatedPackage { + return generatedomain.Output{}, fmt.Errorf("generated package %q does not match %q", parsed.Name.Name, generatedPackage) + } + mode := uint32(generated.Mode.Perm()) + if mode == 0 { + mode = 0o644 + } + return generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{Path: outputName, Content: generated.Content, Mode: mode}}}}, nil + }) + if err != nil { + return output, fmt.Errorf("toolrun.WithTemporaryOutput: %w", err) + } + return output, nil +} + +func packageName(targetID string) string { + name := targetID + if _, selected, found := strings.Cut(targetID, ":"); found { + name = selected + } + return strings.ReplaceAll(name, "-", "_") +} diff --git a/internal/client/jsonschema/client_test.go b/internal/client/jsonschema/client_test.go new file mode 100644 index 0000000..6805540 --- /dev/null +++ b/internal/client/jsonschema/client_test.go @@ -0,0 +1,127 @@ +package jsonschema_test + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + jsonschema "github.com/devctllabs/devctl/internal/client/jsonschema" + "github.com/devctllabs/devctl/internal/client/toolrun" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientRejectsSchemaWithoutTitleBeforeRunningQuicktype(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"type":"object"}`) + t.Setenv("PATH", t.TempDir()) + runner := &recordingRunner{} + + _, err := jsonschema.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + + require.ErrorContains(t, err, "root title is required") + require.Empty(t, runner.commands) +} + +func TestClientReportsMissingQuicktypeWithMiseGuidance(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + t.Setenv("PATH", t.TempDir()) + + _, err := jsonschema.New(&recordingRunner{}).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + + require.ErrorContains(t, err, "quicktype is unavailable") + require.ErrorContains(t, err, "run mise install") +} + +func TestClientPreservesQuicktypeRunnerFailure(t *testing.T) { //nolint:paralleltest // PATH is part of the LookPath contract. + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + installQuicktypePlaceholder(t) + primary := errors.New("quicktype process failed") + runner := &recordingRunner{run: func(toolrun.Command) error { return primary }} + + _, err := jsonschema.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + + require.ErrorIs(t, err, primary) + require.ErrorContains(t, err, "runner.Run") +} + +func TestClientRejectsMalformedGeneratedGo(t *testing.T) { //nolint:paralleltest // The helper sets PATH for LookPath. + err := generateWithFakeOutput(t, "not Go") + + require.ErrorContains(t, err, "parser.ParseFile") +} + +func TestClientRejectsUnexpectedGeneratedPackage(t *testing.T) { //nolint:paralleltest // The helper sets PATH for LookPath. + err := generateWithFakeOutput(t, "package wrong\n") + + require.ErrorContains(t, err, `generated package "wrong" does not match "audit_events"`) +} + +func TestClientReportsMissingGeneratedOutput(t *testing.T) { //nolint:paralleltest // PATH is part of the LookPath contract. + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + installQuicktypePlaceholder(t) + + _, err := jsonschema.New(&recordingRunner{}).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + + require.ErrorContains(t, err, "toolsafety.ReadRegularFile output") +} + +func TestClientRejectsUnsafeOutputNameBeforeResolvingQuicktype(t *testing.T) { + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + t.Setenv("PATH", t.TempDir()) + target := jsonTarget() + target.OutputFile = "../outside.go" + + _, err := jsonschema.New(&recordingRunner{}).Generate(context.Background(), projectdomain.Project{Root: root}, target) + + require.EqualError(t, err, `invalid generated output filename "../outside.go"`) +} + +func TestClientHonorsCancellationBeforeGeneration(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := jsonschema.New(&recordingRunner{}).Generate(ctx, projectdomain.Project{}, jsonTarget()) + + require.ErrorIs(t, err, context.Canceled) +} + +func generateWithFakeOutput(t *testing.T, content string) error { + t.Helper() + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + installQuicktypePlaceholder(t) + runner := &recordingRunner{run: func(command toolrun.Command) error { + outputPath := command.Args[11] + writeFixture(t, filepath.Dir(outputPath), filepath.Base(outputPath), content) + return nil + }} + _, err := jsonschema.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + if err != nil { + return fmt.Errorf("client.Generate: %w", err) + } + return nil +} + +func jsonTarget() projectdomain.Target { + return projectdomain.Target{ + ID: "kafka-consumer:audit-events", Family: "kafka", Format: "json", + Input: "api/contracts/audit.json", OutputDir: "gen/kafka/consumer/audit-events", OutputFile: "schema.gen.go", + } +} + +func writeFixture(t *testing.T, root, relative, content string) { + t.Helper() + filename := filepath.Join(root, filepath.FromSlash(relative)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(content), 0o644)) +} diff --git a/internal/client/jsonschema/runner_test.go b/internal/client/jsonschema/runner_test.go new file mode 100644 index 0000000..b04f1df --- /dev/null +++ b/internal/client/jsonschema/runner_test.go @@ -0,0 +1,67 @@ +package jsonschema_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + jsonschema "github.com/devctllabs/devctl/internal/client/jsonschema" + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientDelegatesQuicktypeGenerationToRunner(t *testing.T) { //nolint:paralleltest // PATH is part of the LookPath contract. + root := t.TempDir() + writeFixture(t, root, "api/contracts/audit.json", `{"title":"AuditEvent","type":"object"}`) + quicktype := installQuicktypePlaceholder(t) + var temporary string + runner := &recordingRunner{run: func(command toolrun.Command) error { + outputPath := command.Args[11] + temporary = filepath.Dir(outputPath) + writeFixture(t, temporary, filepath.Base(outputPath), "package audit_events\n\ntype AuditEvent struct{}\n") + return nil + }} + + output, err := jsonschema.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, jsonTarget()) + + require.NoError(t, err) + require.Equal(t, []toolrun.Command{{ + Name: quicktype, + Args: []string{ + "--src", "api/contracts/audit.json", "--src-lang", "schema", "--lang", "go", + "--package", "audit_events", "--top-level", "AuditEvent", + "--out", filepath.Join(temporary, "schema.gen.go"), "--omit-empty", + }, + Dir: root, + }}, runner.commands) + require.Equal(t, artifact.Tree{Files: []artifact.File{{ + Path: "schema.gen.go", Content: []byte("package audit_events\n\ntype AuditEvent struct{}\n"), Mode: 0o644, + }}}, output.Directory) + require.NoDirExists(t, temporary) +} + +type recordingRunner struct { + commands []toolrun.Command + run func(toolrun.Command) error +} + +func (r *recordingRunner) Run(_ context.Context, command toolrun.Command) error { + command.Args = append([]string(nil), command.Args...) + r.commands = append(r.commands, command) + if r.run != nil { + return r.run(command) + } + return nil +} + +func installQuicktypePlaceholder(t *testing.T) string { + t.Helper() + directory := t.TempDir() + executable := filepath.Join(directory, "quicktype") + require.NoError(t, os.WriteFile(executable, nil, 0o755)) + t.Setenv("PATH", directory) + return executable +} diff --git a/internal/client/oapicodegen/client.go b/internal/client/oapicodegen/client.go new file mode 100644 index 0000000..d42920a --- /dev/null +++ b/internal/client/oapicodegen/client.go @@ -0,0 +1,77 @@ +package oapicodegen + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/client/toolsafety" + "github.com/devctllabs/devctl/internal/domain/artifact" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +// Client runs the project-declared oapi-codegen tool in a temporary output workspace. +type Client struct { + runner toolrun.Runner +} + +func New(runner toolrun.Runner) *Client { return &Client{runner: runner} } + +// Generate validates contained inputs and returns unpublished generated output. +func (c *Client) Generate( + ctx context.Context, + project projectdomain.Project, + target projectdomain.Target, +) (generatedomain.Output, error) { + if err := ctx.Err(); err != nil { + return generatedomain.Output{}, fmt.Errorf("ctx.Err: %w", err) + } + if target.Family != "http" { + return generatedomain.Output{}, fmt.Errorf("unsupported generation target kind %q", target.Family) + } + return c.generateHTTP(ctx, project.Root, target) +} + +func (c *Client) generateHTTP(ctx context.Context, root string, target projectdomain.Target) (generatedomain.Output, error) { + if target.OutputFile == "" || filepath.Base(target.OutputFile) != target.OutputFile { + return generatedomain.Output{}, fmt.Errorf("invalid generated output filename %q", target.OutputFile) + } + if _, err := toolsafety.ReadRegularFile(root, target.Input); err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile input: %w", err) + } + if _, err := toolsafety.ReadRegularFile(root, target.Config); err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile config: %w", err) + } + goMod, err := toolsafety.ReadRegularFile(root, "go.mod") + if err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile go.mod: %w", err) + } + if !bytes.Contains(goMod.Content, []byte("tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen")) { + return generatedomain.Output{}, fmt.Errorf("go.mod does not declare the oapi-codegen tool") + } + + output, err := toolrun.WithTemporaryOutput(ctx, "devctl-generate-", func(temporary string) (generatedomain.Output, error) { + outputPath := filepath.Join(temporary, target.OutputFile) + command := toolrun.Command{ + Name: "go", Args: []string{"tool", "oapi-codegen", "--config", target.Config, "-o", outputPath, target.Input}, Dir: root, + } + if err := c.runner.Run(ctx, command); err != nil { + return generatedomain.Output{}, fmt.Errorf("runner.Run: %w", err) + } + generated, err := toolsafety.ReadRegularFile(temporary, target.OutputFile) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("toolsafety.ReadRegularFile output: %w", err) + } + if !bytes.Contains(generated.Content, []byte("Code generated")) { + return generatedomain.Output{}, fmt.Errorf("oapi-codegen did not create a canonical generated Go file") + } + return generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{Path: target.OutputFile, Content: generated.Content, Mode: 0o644}}}}, nil + }) + if err != nil { + return output, fmt.Errorf("toolrun.WithTemporaryOutput: %w", err) + } + return output, nil +} diff --git a/internal/client/oapicodegen/client_test.go b/internal/client/oapicodegen/client_test.go new file mode 100644 index 0000000..8cbf23c --- /dev/null +++ b/internal/client/oapicodegen/client_test.go @@ -0,0 +1,41 @@ +package oapicodegen_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + oapicodegen "github.com/devctllabs/devctl/internal/client/oapicodegen" + "github.com/devctllabs/devctl/internal/client/toolrun" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientPreservesOAPIRunnerFailure(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFixture(t, root, "go.mod", "module example.test/service\n\ngo 1.26\n\ntool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen\n") + writeFixture(t, root, "api/openapi.yaml", "openapi: 3.1.0\ninfo:\n title: service\n version: 1.0.0\npaths: {}\n") + writeFixture(t, root, "tools/oapi/client.yaml", "package: clienthttp\ngenerate:\n client: true\n") + primary := errors.New("oapi process failed") + runner := &recordingRunner{run: func(toolrun.Command) error { return primary }} + target := projectdomain.Target{ + ID: "http-client:payments", Family: "http", Input: "api/openapi.yaml", + Config: "tools/oapi/client.yaml", OutputFile: "client.gen.go", + } + + _, err := oapicodegen.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, target) + + require.ErrorIs(t, err, primary) + require.ErrorContains(t, err, "runner.Run") +} + +func writeFixture(t *testing.T, root, relative, content string) { + t.Helper() + filename := filepath.Join(root, filepath.FromSlash(relative)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(content), 0o644)) +} diff --git a/internal/client/oapicodegen/runner_test.go b/internal/client/oapicodegen/runner_test.go new file mode 100644 index 0000000..5d08aa2 --- /dev/null +++ b/internal/client/oapicodegen/runner_test.go @@ -0,0 +1,57 @@ +package oapicodegen_test + +import ( + "context" + "path/filepath" + "testing" + + oapicodegen "github.com/devctllabs/devctl/internal/client/oapicodegen" + "github.com/devctllabs/devctl/internal/client/toolrun" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestClientDelegatesOAPIGenerationToRunner(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFixture(t, root, "go.mod", "module example.test/service\n\ngo 1.26\n\ntool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen\n") + writeFixture(t, root, "api/openapi.yaml", "openapi: 3.1.0\ninfo:\n title: service\n version: 1.0.0\npaths: {}\n") + writeFixture(t, root, "tools/oapi/client.yaml", "package: clienthttp\ngenerate:\n client: true\n") + var temporary string + runner := &recordingRunner{run: func(command toolrun.Command) error { + outputPath := command.Args[5] + temporary = filepath.Dir(outputPath) + writeFixture(t, temporary, filepath.Base(outputPath), "// Code generated by oapi-codegen. DO NOT EDIT.\npackage clienthttp\n") + return nil + }} + target := projectdomain.Target{ + ID: "http-client:payments", Family: "http", Input: "api/openapi.yaml", + Config: "tools/oapi/client.yaml", OutputFile: "client.gen.go", + } + + output, err := oapicodegen.New(runner).Generate(context.Background(), projectdomain.Project{Root: root}, target) + + require.NoError(t, err) + require.Equal(t, []toolrun.Command{{ + Name: "go", + Args: []string{"tool", "oapi-codegen", "--config", "tools/oapi/client.yaml", "-o", filepath.Join(temporary, "client.gen.go"), "api/openapi.yaml"}, + Dir: root, + }}, runner.commands) + require.Equal(t, []byte("// Code generated by oapi-codegen. DO NOT EDIT.\npackage clienthttp\n"), output.Directory.Files[0].Content) + require.NoDirExists(t, temporary) +} + +type recordingRunner struct { + commands []toolrun.Command + run func(toolrun.Command) error +} + +func (r *recordingRunner) Run(_ context.Context, command toolrun.Command) error { + command.Args = append([]string(nil), command.Args...) + r.commands = append(r.commands, command) + if r.run != nil { + return r.run(command) + } + return nil +} diff --git a/internal/client/toolrun/runner.go b/internal/client/toolrun/runner.go new file mode 100644 index 0000000..7bf1929 --- /dev/null +++ b/internal/client/toolrun/runner.go @@ -0,0 +1,42 @@ +// Package toolrun executes project-owned tools without assigning tool-specific policy. +package toolrun + +import ( + "context" + "fmt" + "os/exec" + + "github.com/devctllabs/devctl/internal/client/toolsafety" +) + +// Command describes one local process invocation. +type Command struct { + Name string + Args []string + Dir string +} + +// Runner executes local tool commands. +type Runner interface { + Run(ctx context.Context, command Command) error +} + +// OSRunner executes commands through the operating system. +type OSRunner struct{} + +// New returns an operating-system-backed Runner. +func New() *OSRunner { return &OSRunner{} } + +// Run executes command and includes bounded combined output in failures. +func (*OSRunner) Run(ctx context.Context, command Command) error { + process := exec.CommandContext(ctx, command.Name, command.Args...) + process.Dir = command.Dir + output, err := process.CombinedOutput() + if err == nil { + return nil + } + if cancellation := ctx.Err(); cancellation != nil { + err = cancellation + } + return fmt.Errorf("command.CombinedOutput: %s: %w", toolsafety.BoundedOutput(output), err) +} diff --git a/internal/client/toolrun/runner_test.go b/internal/client/toolrun/runner_test.go new file mode 100644 index 0000000..4212eba --- /dev/null +++ b/internal/client/toolrun/runner_test.go @@ -0,0 +1,85 @@ +package toolrun_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/stretchr/testify/require" +) + +func TestOSRunnerPreservesCommandArgumentsAndWorkingDirectory(t *testing.T) { + workingDirectory := t.TempDir() + t.Setenv("DEVCTL_TOOLRUN_HELPER", "command") + t.Setenv("DEVCTL_TOOLRUN_EXPECTED_DIR", workingDirectory) + + err := toolrun.New().Run(context.Background(), toolrun.Command{ + Name: os.Args[0], + Args: []string{"-test.run=TestToolrunHelperProcess", "--", "first", "second value"}, + Dir: workingDirectory, + }) + + require.NoError(t, err) +} + +func TestOSRunnerPreservesExitErrorAndBoundsDiagnostics(t *testing.T) { + t.Setenv("DEVCTL_TOOLRUN_HELPER", "failure") + + err := toolrun.New().Run(context.Background(), toolrun.Command{ + Name: os.Args[0], Args: []string{"-test.run=TestToolrunHelperProcess"}, + }) + + require.Error(t, err) + var exitError *exec.ExitError + require.ErrorAs(t, err, &exitError) + require.Equal(t, 7, exitError.ExitCode()) + require.Contains(t, err.Error(), strings.Repeat("x", 64)) + require.Less(t, len(err.Error()), 4300) +} + +func TestOSRunnerPreservesContextCancellation(t *testing.T) { + t.Setenv("DEVCTL_TOOLRUN_HELPER", "wait") + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + err := toolrun.New().Run(ctx, toolrun.Command{ + Name: os.Args[0], Args: []string{"-test.run=TestToolrunHelperProcess"}, + }) + + require.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestToolrunHelperProcess(t *testing.T) { //nolint:paralleltest // The parent tests control this subprocess through inherited environment. + switch os.Getenv("DEVCTL_TOOLRUN_HELPER") { + case "": + return + case "command": + workingDirectory, err := os.Getwd() + if err != nil { + os.Exit(2) + } + separator := -1 + for index, argument := range os.Args { + if argument == "--" { + separator = index + break + } + } + if workingDirectory != os.Getenv("DEVCTL_TOOLRUN_EXPECTED_DIR") || + separator == -1 || fmt.Sprint(os.Args[separator+1:]) != "[first second value]" { + os.Exit(3) + } + case "failure": + _, _ = fmt.Fprint(os.Stderr, strings.Repeat("x", 5000)) + os.Exit(7) + case "wait": + time.Sleep(time.Minute) + default: + os.Exit(4) + } +} diff --git a/internal/client/toolrun/temporary.go b/internal/client/toolrun/temporary.go new file mode 100644 index 0000000..7dcdc01 --- /dev/null +++ b/internal/client/toolrun/temporary.go @@ -0,0 +1,25 @@ +package toolrun + +import ( + "context" + "fmt" + "os" +) + +// WithTemporaryOutput runs use in a temporary directory and always attempts cleanup. +func WithTemporaryOutput[T any]( + ctx context.Context, + prefix string, + use func(string) (T, error), +) (T, error) { + var zero T + if err := ctx.Err(); err != nil { + return zero, fmt.Errorf("ctx.Err: %w", err) + } + temporary, err := os.MkdirTemp("", prefix) + if err != nil { + return zero, fmt.Errorf("os.MkdirTemp: %w", err) + } + defer func() { _ = os.RemoveAll(temporary) }() + return use(temporary) +} diff --git a/internal/client/toolrun/temporary_test.go b/internal/client/toolrun/temporary_test.go new file mode 100644 index 0000000..498bf58 --- /dev/null +++ b/internal/client/toolrun/temporary_test.go @@ -0,0 +1,78 @@ +package toolrun_test + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/stretchr/testify/require" +) + +func TestWithTemporaryOutputReturnsValueAndCleansAfterSuccess(t *testing.T) { + t.Parallel() + + var temporary string + + value, err := toolrun.WithTemporaryOutput(context.Background(), "devctl-toolrun-success-", func(directory string) (string, error) { + temporary = directory + require.DirExists(t, directory) + require.True(t, strings.HasPrefix(filepath.Base(directory), "devctl-toolrun-success-")) + return "generated", nil + }) + + require.NoError(t, err) + require.Equal(t, "generated", value) + require.NoDirExists(t, temporary) +} + +func TestWithTemporaryOutputPreservesValueAndErrorAndCleansAfterFailure(t *testing.T) { + t.Parallel() + + primary := errors.New("generation failed") + var temporary string + + value, err := toolrun.WithTemporaryOutput(context.Background(), "devctl-toolrun-failure-", func(directory string) (int, error) { + temporary = directory + return 42, primary + }) + + require.Equal(t, 42, value) + require.ErrorIs(t, err, primary) + require.NoDirExists(t, temporary) +} + +func TestWithTemporaryOutputCleansAfterCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + var temporary string + + value, err := toolrun.WithTemporaryOutput(ctx, "devctl-toolrun-cancel-", func(directory string) (int, error) { + temporary = directory + cancel() + return 7, ctx.Err() + }) + + require.Equal(t, 7, value) + require.ErrorIs(t, err, context.Canceled) + require.NoDirExists(t, temporary) +} + +func TestWithTemporaryOutputDoesNotStartAfterCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + called := false + + _, err := toolrun.WithTemporaryOutput(ctx, "devctl-toolrun-cancelled-", func(string) (int, error) { + called = true + return 0, nil + }) + + require.ErrorIs(t, err, context.Canceled) + require.False(t, called) +} diff --git a/internal/client/toolsafety/safety.go b/internal/client/toolsafety/safety.go new file mode 100644 index 0000000..90b45ba --- /dev/null +++ b/internal/client/toolsafety/safety.go @@ -0,0 +1,157 @@ +// Package toolsafety contains shared filesystem and diagnostic safety rules for generator tools. +package toolsafety + +import ( + "bytes" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" + + "github.com/devctllabs/devctl/internal/domain/artifact" + devfs "github.com/devctllabs/go-libs/filesystem" +) + +// File is a contained regular file read from a tool workspace. +type File struct { + Content []byte + Mode fs.FileMode +} + +// ReadRegularFile reads name below root without accepting symlinks or non-regular files. +func ReadRegularFile(root, name string) (File, error) { + contained, err := containedName(root, name) + if err != nil { + return File{}, err + } + disk, err := devfs.Open(root) + if err != nil { + return File{}, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + info, err := disk.Lstat(contained) + if err != nil { + return File{}, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return File{}, fmt.Errorf("%w: path is not a regular non-symlink file: %s", fs.ErrInvalid, name) + } + content, err := fs.ReadFile(disk, contained) + if err != nil { + return File{}, fmt.Errorf("filesystem.ReadFile: %w", err) + } + return File{Content: content, Mode: info.Mode().Perm()}, nil +} + +// RequireDirectory requires name to be a real directory contained below root. +func RequireDirectory(root, name string) error { + contained, err := containedName(root, name) + if err != nil { + return err + } + disk, err := devfs.Open(root) + if err != nil { + return fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + info, err := disk.Lstat(contained) + if err != nil { + return fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("%w: path is not a real directory: %s", fs.ErrInvalid, name) + } + return nil +} + +// ReadRegularTree reads a contained tree of regular files using slash-separated relative paths. +func ReadRegularTree(root, directory string) (artifact.Tree, error) { + contained, err := containedName(root, directory) + if err != nil { + return artifact.Tree{}, err + } + disk, err := devfs.Open(root) + if err != nil { + return artifact.Tree{}, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + info, err := disk.Lstat(contained) + if err != nil { + return artifact.Tree{}, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return artifact.Tree{}, fmt.Errorf("%w: path is not a real directory: %s", fs.ErrInvalid, directory) + } + + var tree artifact.Tree + err = fs.WalkDir(disk, contained, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if name == contained { + return nil + } + if entry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("%w: generated output is a symlink: %s", fs.ErrInvalid, name) + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return fmt.Errorf("entry.Info: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%w: generated output is not a regular file: %s", fs.ErrInvalid, name) + } + content, err := fs.ReadFile(disk, name) + if err != nil { + return fmt.Errorf("filesystem.ReadFile: %w", err) + } + relative := strings.TrimPrefix(name, contained+"/") + tree.Files = append(tree.Files, artifact.File{ + Path: relative, Content: content, Mode: uint32(info.Mode().Perm()), + }) + return nil + }) + if err != nil { + return artifact.Tree{}, fmt.Errorf("filesystem.WalkDir: %w", err) + } + sort.Slice(tree.Files, func(i, j int) bool { return tree.Files[i].Path < tree.Files[j].Path }) + return tree, nil +} + +// BoundedOutput trims tool output and limits it to 4096 bytes. +func BoundedOutput(output []byte) string { + const limit = 4 << 10 + output = bytes.TrimSpace(output) + if len(output) > limit { + output = output[:limit] + } + return string(output) +} + +func containedName(root, name string) (string, error) { + absoluteRoot, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("filepath.Abs root: %w", err) + } + absoluteName := name + if !filepath.IsAbs(absoluteName) { + absoluteName = filepath.Join(absoluteRoot, filepath.FromSlash(name)) + } + absoluteName, err = filepath.Abs(absoluteName) + if err != nil { + return "", fmt.Errorf("filepath.Abs name: %w", err) + } + relative, err := filepath.Rel(absoluteRoot, absoluteName) + if err != nil { + return "", fmt.Errorf("filepath.Rel: %w", err) + } + relative = filepath.ToSlash(relative) + if !fs.ValidPath(relative) { + return "", fmt.Errorf("%w: path %q is outside root", fs.ErrInvalid, name) + } + return relative, nil +} diff --git a/internal/client/toolsafety/safety_test.go b/internal/client/toolsafety/safety_test.go new file mode 100644 index 0000000..5c1d384 --- /dev/null +++ b/internal/client/toolsafety/safety_test.go @@ -0,0 +1,165 @@ +package toolsafety_test + +import ( + "context" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/devctllabs/devctl/internal/client/toolsafety" + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/stretchr/testify/require" +) + +func TestReadRegularFileAcceptsContainedRelativeAndAbsolutePaths(t *testing.T) { + t.Parallel() + + root := t.TempDir() + filename := filepath.Join(root, "nested", "input.json") + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte("schema"), 0o600)) + + for _, name := range []string{"nested/input.json", filename} { + file, err := toolsafety.ReadRegularFile(root, name) + + require.NoError(t, err) + require.Equal(t, []byte("schema"), file.Content) + require.Equal(t, os.FileMode(0o600), file.Mode.Perm()) + } +} + +func TestReadRegularFileRejectsUnsafeEntries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setup func(*testing.T, string) string + }{ + {name: "relative traversal", setup: func(t *testing.T, _ string) string { + t.Helper() + outside := filepath.Join(t.TempDir(), "outside.txt") + require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o644)) + return "../" + filepath.Base(filepath.Dir(outside)) + "/outside.txt" + }}, + {name: "absolute outside", setup: func(t *testing.T, _ string) string { + t.Helper() + outside := filepath.Join(t.TempDir(), "outside.txt") + require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o644)) + return outside + }}, + {name: "leaf symlink", setup: func(t *testing.T, root string) string { + t.Helper() + outside := filepath.Join(t.TempDir(), "outside.txt") + require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o644)) + require.NoError(t, os.Symlink(outside, filepath.Join(root, "input.json"))) + return "input.json" + }}, + {name: "ancestor symlink", setup: func(t *testing.T, root string) string { + t.Helper() + outside := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outside, "input.json"), []byte("outside"), 0o644)) + require.NoError(t, os.Symlink(outside, filepath.Join(root, "linked"))) + return "linked/input.json" + }}, + {name: "directory", setup: func(t *testing.T, root string) string { + t.Helper() + require.NoError(t, os.Mkdir(filepath.Join(root, "input.json"), 0o755)) + return "input.json" + }}, + {name: "socket", setup: func(t *testing.T, root string) string { + t.Helper() + return listenUnixSocket(t, root, "input.sock") + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + if test.name == "socket" { + root = shortTempDir(t) + } + + _, err := toolsafety.ReadRegularFile(root, test.setup(t, root)) + + require.Error(t, err) + }) + } +} + +func TestRequireDirectoryRejectsFilesAndSymlinks(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, "contracts"), 0o755)) + require.NoError(t, toolsafety.RequireDirectory(root, "contracts")) + require.NoError(t, os.WriteFile(filepath.Join(root, "file"), []byte("data"), 0o644)) + require.Error(t, toolsafety.RequireDirectory(root, "file")) + require.NoError(t, os.Symlink(filepath.Join(root, "contracts"), filepath.Join(root, "linked"))) + require.Error(t, toolsafety.RequireDirectory(root, "linked")) +} + +func TestReadRegularTreeReturnsStableFilesAndRejectsUnsafeEntries(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeFile(t, root, artifact.File{Path: "generated/zeta.go", Content: []byte("zeta"), Mode: 0o644}) + writeFile(t, root, artifact.File{Path: "generated/alpha/nested.go", Content: []byte("alpha"), Mode: 0o600}) + + tree, err := toolsafety.ReadRegularTree(root, "generated") + + require.NoError(t, err) + require.Equal(t, artifact.Tree{Files: []artifact.File{ + {Path: "alpha/nested.go", Content: []byte("alpha"), Mode: 0o600}, + {Path: "zeta.go", Content: []byte("zeta"), Mode: 0o644}, + }}, tree) + + require.NoError(t, os.Symlink(filepath.Join(root, "generated", "zeta.go"), filepath.Join(root, "generated", "linked.go"))) + _, err = toolsafety.ReadRegularTree(root, "generated") + require.Error(t, err) + + socketRoot := shortTempDir(t) + require.NoError(t, os.Mkdir(filepath.Join(socketRoot, "generated"), 0o755)) + listenUnixSocket(t, filepath.Join(socketRoot, "generated"), "output.sock") + _, err = toolsafety.ReadRegularTree(socketRoot, "generated") + require.Error(t, err) +} + +func TestBoundedOutputTrimsAndLimitsDiagnostics(t *testing.T) { + t.Parallel() + + require.Equal(t, "message", toolsafety.BoundedOutput([]byte(" message\n"))) + exact := strings.Repeat("x", 4096) + require.Equal(t, exact, toolsafety.BoundedOutput([]byte(exact))) + require.Equal(t, exact, toolsafety.BoundedOutput([]byte(exact+"overflow"))) +} + +func writeFile(t *testing.T, root string, file artifact.File) { + t.Helper() + filename := filepath.Join(root, filepath.FromSlash(file.Path)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, file.Content, os.FileMode(file.Mode))) +} + +func listenUnixSocket(t *testing.T, root, name string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("Unix socket file-kind check is not available on Windows") + } + listener, err := (&net.ListenConfig{}).Listen(context.Background(), "unix", filepath.Join(root, name)) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, listener.Close()) }) + return name +} + +func shortTempDir(t *testing.T) string { + t.Helper() + //nolint:usetesting // A regular t.TempDir path exceeds the Unix socket path limit on macOS. + root, err := os.MkdirTemp("/tmp", "devctl-toolsafety-") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, os.RemoveAll(root)) }) + return root +} diff --git a/internal/deps/clients.go b/internal/deps/clients.go new file mode 100644 index 0000000..688d909 --- /dev/null +++ b/internal/deps/clients.go @@ -0,0 +1,124 @@ +package deps + +import ( + "fmt" + + bufgenclient "github.com/devctllabs/devctl/internal/client/bufgen" + generatorclient "github.com/devctllabs/devctl/internal/client/generator" + gitclient "github.com/devctllabs/devctl/internal/client/git" + httpclient "github.com/devctllabs/devctl/internal/client/http" + jsonschemaclient "github.com/devctllabs/devctl/internal/client/jsonschema" + oapicodegenclient "github.com/devctllabs/devctl/internal/client/oapicodegen" + "github.com/devctllabs/devctl/internal/client/toolrun" + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + "github.com/devctllabs/devctl/internal/service/materialize" + "github.com/devctllabs/go-libs/di" +) + +func (c *Container) provideClients() error { + providers := []func() error{ + c.provideToolRunner, + c.provideMaterializeClients, + c.provideGeneratorClients, + } + for _, provide := range providers { + if err := provide(); err != nil { + return err + } + } + return nil +} + +func (c *Container) provideToolRunner() error { + if err := di.Provide(c.di, func(di.Resolver) (*toolrun.OSRunner, error) { + return toolrun.New(), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (toolrun.Runner, error) { + return resolve[*toolrun.OSRunner](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideMaterializeClients() error { + if err := di.Provide(c.di, func(di.Resolver) (*httpclient.Client, error) { + return httpclient.New(), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (materialize.HTTPClient, error) { + return resolve[*httpclient.Client](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(di.Resolver) (*gitclient.Client, error) { + return gitclient.New(), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (materialize.GitClient, error) { + return resolve[*gitclient.Client](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideGeneratorClients() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*oapicodegenclient.Client, error) { + runner, err := resolve[toolrun.Runner](resolver) + if err != nil { + return nil, err + } + return oapicodegenclient.New(runner), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (*bufgenclient.Client, error) { + runner, err := resolve[toolrun.Runner](resolver) + if err != nil { + return nil, err + } + return bufgenclient.New(runner), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (*jsonschemaclient.Client, error) { + runner, err := resolve[toolrun.Runner](resolver) + if err != nil { + return nil, err + } + return jsonschemaclient.New(runner), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (generateservice.GeneratorClient, error) { + openAPI, err := resolve[*oapicodegenclient.Client](resolver) + if err != nil { + return nil, err + } + proto, err := resolve[*bufgenclient.Client](resolver) + if err != nil { + return nil, err + } + jsonSchema, err := resolve[*jsonschemaclient.Client](resolver) + if err != nil { + return nil, err + } + return generatorclient.New(generatorclient.Adapters{ + OpenAPI: openAPI, Proto: proto, JSONSchema: jsonSchema, + }), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (lintservice.ProtoLinter, error) { + return resolve[*bufgenclient.Client](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/container.go b/internal/deps/container.go new file mode 100644 index 0000000..64bd991 --- /dev/null +++ b/internal/deps/container.go @@ -0,0 +1,93 @@ +package deps + +import ( + "context" + "fmt" + + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/scaffold" + syncservice "github.com/devctllabs/devctl/internal/service/sync" + "github.com/devctllabs/go-libs/di" + "go.uber.org/zap" +) + +// Container owns the lazy dependency graph and the resources resolved from it. +type Container struct { + di *di.Container +} + +// New registers the complete lazy CLI dependency graph. Providers construct +// dependencies only when a typed getter resolves the selected command root. +func New(logger *zap.Logger) (*Container, error) { + container := &Container{di: di.New()} + providers := []func() error{ + func() error { return container.provideLogger(logger) }, + container.provideRepositories, + container.provideTargetInput, + container.provideScaffoldRepositories, + container.provideClients, + container.provideServices, + container.provideScaffoldServices, + } + for _, provide := range providers { + if err := provide(); err != nil { + return nil, err + } + } + return container, nil +} + +// ProjectService resolves the project query and mutation root on demand. +func (c *Container) ProjectService() (*project.Service, error) { + service, err := resolve[*project.Service](c.di) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + return service, nil +} + +// ScaffoldService resolves the project scaffolding root on demand. +func (c *Container) ScaffoldService() (*scaffold.Service, error) { + service, err := resolve[*scaffold.Service](c.di) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + return service, nil +} + +// SyncService resolves the contract synchronization root on demand. +func (c *Container) SyncService() (*syncservice.Service, error) { + service, err := resolve[*syncservice.Service](c.di) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + return service, nil +} + +// LintService resolves the contract lint root on demand. +func (c *Container) LintService() (*lintservice.Service, error) { + service, err := resolve[*lintservice.Service](c.di) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + return service, nil +} + +// GenService resolves the managed-output generation root on demand. +func (c *Container) GenService() (*generateservice.Service, error) { + service, err := resolve[*generateservice.Service](c.di) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + return service, nil +} + +// Shutdown closes every constructed resource in dependency order. +func (c *Container) Shutdown(ctx context.Context) error { + if err := c.di.Shutdown(ctx); err != nil { + return fmt.Errorf("di.Shutdown: %w", err) + } + return nil +} diff --git a/internal/deps/init_scaffold.go b/internal/deps/init_scaffold.go new file mode 100644 index 0000000..3072c42 --- /dev/null +++ b/internal/deps/init_scaffold.go @@ -0,0 +1 @@ +package deps diff --git a/internal/deps/logger.go b/internal/deps/logger.go new file mode 100644 index 0000000..192bb3b --- /dev/null +++ b/internal/deps/logger.go @@ -0,0 +1,15 @@ +package deps + +import ( + "fmt" + + "github.com/devctllabs/go-libs/di" + "go.uber.org/zap" +) + +func (c *Container) provideLogger(logger *zap.Logger) error { + if err := di.ProvideValue(c.di, logger); err != nil { + return fmt.Errorf("di.ProvideValue: %w", err) + } + return nil +} diff --git a/internal/deps/repositories.go b/internal/deps/repositories.go new file mode 100644 index 0000000..732c948 --- /dev/null +++ b/internal/deps/repositories.go @@ -0,0 +1,84 @@ +package deps + +import ( + "fmt" + + manifestrepo "github.com/devctllabs/devctl/internal/repository/manifest" + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + "github.com/devctllabs/devctl/internal/service/materialize" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/projectreadiness" + syncservice "github.com/devctllabs/devctl/internal/service/sync" + "github.com/devctllabs/go-libs/di" +) + +func (c *Container) provideRepositories() error { + if err := di.Provide(c.di, func(di.Resolver) (*manifestrepo.FilesystemRepo, error) { + return manifestrepo.NewFilesystemRepo(), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (projectservice.ManifestRepository, error) { + return resolve[*manifestrepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(di.Resolver) (*workspacerepo.FilesystemRepo, error) { + return workspacerepo.NewFilesystemRepo(), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (projectservice.ManifestLocator, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (projectreadiness.Workspace, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (syncservice.ProjectRepository, error) { + return resolve[*projectservice.Service](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (syncservice.WorkspaceRepository, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (materialize.FileReader, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (materialize.ManifestRepository, error) { + return resolve[*manifestrepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (generateservice.ProjectRepository, error) { + return resolve[*projectservice.Service](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (generateservice.WorkspaceRepository, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (lintservice.ProjectRepository, error) { + return resolve[*projectservice.Service](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (lintservice.ContractLocator, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/resolve.go b/internal/deps/resolve.go new file mode 100644 index 0000000..b3eb91d --- /dev/null +++ b/internal/deps/resolve.go @@ -0,0 +1,16 @@ +package deps + +import ( + "fmt" + + "github.com/devctllabs/go-libs/di" +) + +func resolve[T any](resolver di.Resolver) (T, error) { + value, err := di.Resolve[T](resolver) + if err != nil { + var zero T + return zero, fmt.Errorf("di.Resolve: %w", err) + } + return value, nil +} diff --git a/internal/deps/scaffold_repositories.go b/internal/deps/scaffold_repositories.go new file mode 100644 index 0000000..696402d --- /dev/null +++ b/internal/deps/scaffold_repositories.go @@ -0,0 +1,24 @@ +package deps + +import ( + "fmt" + + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + projectservice "github.com/devctllabs/devctl/internal/service/project" + scaffoldservice "github.com/devctllabs/devctl/internal/service/scaffold" + "github.com/devctllabs/go-libs/di" +) + +func (c *Container) provideScaffoldRepositories() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (scaffoldservice.ProjectRepository, error) { + return resolve[*projectservice.Service](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (scaffoldservice.WorkspaceRepository, error) { + return resolve[*workspacerepo.FilesystemRepo](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/scaffold_services.go b/internal/deps/scaffold_services.go new file mode 100644 index 0000000..4e3d3c8 --- /dev/null +++ b/internal/deps/scaffold_services.go @@ -0,0 +1,30 @@ +package deps + +import ( + "fmt" + + scaffoldservice "github.com/devctllabs/devctl/internal/service/scaffold" + "github.com/devctllabs/go-libs/di" + "go.uber.org/zap" +) + +func (c *Container) provideScaffoldServices() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*scaffoldservice.Service, error) { + logger, err := resolve[*zap.Logger](resolver) + if err != nil { + return nil, err + } + projects, err := resolve[scaffoldservice.ProjectRepository](resolver) + if err != nil { + return nil, err + } + workspace, err := resolve[scaffoldservice.WorkspaceRepository](resolver) + if err != nil { + return nil, err + } + return scaffoldservice.New(logger.Named("service.scaffold"), projects, workspace), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/services.go b/internal/deps/services.go new file mode 100644 index 0000000..3910e71 --- /dev/null +++ b/internal/deps/services.go @@ -0,0 +1,192 @@ +package deps + +import ( + "fmt" + + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + "github.com/devctllabs/devctl/internal/service/materialize" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/projectreadiness" + syncservice "github.com/devctllabs/devctl/internal/service/sync" + "github.com/devctllabs/go-libs/di" + "go.uber.org/zap" +) + +func (c *Container) provideServices() error { + providers := []func() error{ + c.provideProjectReadiness, + c.provideProjectService, + c.provideMaterializeService, + c.provideSyncService, + c.provideLintService, + c.provideGenService, + } + for _, provide := range providers { + if err := provide(); err != nil { + return err + } + } + return nil +} + +func (c *Container) provideProjectReadiness() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*projectreadiness.Checker, error) { + workspace, err := resolve[projectreadiness.Workspace](resolver) + if err != nil { + return nil, err + } + return projectreadiness.New(workspace), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideMaterializeService() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*materialize.Service, error) { + reader, err := resolve[materialize.FileReader](resolver) + if err != nil { + return nil, err + } + httpClient, err := resolve[materialize.HTTPClient](resolver) + if err != nil { + return nil, err + } + gitClient, err := resolve[materialize.GitClient](resolver) + if err != nil { + return nil, err + } + manifests, err := resolve[materialize.ManifestRepository](resolver) + if err != nil { + return nil, err + } + return materialize.New( + materialize.NewLocal(reader), + materialize.NewURL(httpClient), + materialize.NewGit(gitClient, reader), + materialize.NewDevctl(gitClient, manifests, reader), + ) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideProjectService() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*projectservice.Service, error) { + logger, err := resolve[*zap.Logger](resolver) + if err != nil { + return nil, err + } + manifests, err := resolve[projectservice.ManifestRepository](resolver) + if err != nil { + return nil, err + } + locator, err := resolve[projectservice.ManifestLocator](resolver) + if err != nil { + return nil, err + } + inputs, err := resolve[projectservice.TargetResolver](resolver) + if err != nil { + return nil, err + } + readiness, err := resolve[*projectreadiness.Checker](resolver) + if err != nil { + return nil, err + } + return projectservice.New(logger.Named("service.project"), projectservice.Dependencies{ + Manifests: manifests, Locator: locator, Inputs: inputs, Readiness: readiness, + }), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideSyncService() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*syncservice.Service, error) { + logger, err := resolve[*zap.Logger](resolver) + if err != nil { + return nil, err + } + projects, err := resolve[syncservice.ProjectRepository](resolver) + if err != nil { + return nil, err + } + sources, err := resolve[*materialize.Service](resolver) + if err != nil { + return nil, err + } + workspace, err := resolve[syncservice.WorkspaceRepository](resolver) + if err != nil { + return nil, err + } + return syncservice.New(logger.Named("service.sync"), projects, sources, workspace), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideLintService() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*lintservice.Service, error) { + logger, err := resolve[*zap.Logger](resolver) + if err != nil { + return nil, err + } + projects, err := resolve[lintservice.ProjectRepository](resolver) + if err != nil { + return nil, err + } + contracts, err := resolve[lintservice.ContractLocator](resolver) + if err != nil { + return nil, err + } + proto, err := resolve[lintservice.ProtoLinter](resolver) + if err != nil { + return nil, err + } + inputs, err := resolve[lintservice.TargetResolver](resolver) + if err != nil { + return nil, err + } + return lintservice.New(logger.Named("service.lint"), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: inputs, Proto: proto, + }), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} + +func (c *Container) provideGenService() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*generateservice.Service, error) { + logger, err := resolve[*zap.Logger](resolver) + if err != nil { + return nil, err + } + projects, err := resolve[generateservice.ProjectRepository](resolver) + if err != nil { + return nil, err + } + generator, err := resolve[generateservice.GeneratorClient](resolver) + if err != nil { + return nil, err + } + inputs, err := resolve[generateservice.TargetResolver](resolver) + if err != nil { + return nil, err + } + workspace, err := resolve[generateservice.WorkspaceRepository](resolver) + if err != nil { + return nil, err + } + return generateservice.New(logger.Named("service.gen"), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, Generator: generator, Workspace: workspace, + }), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/target_input.go b/internal/deps/target_input.go new file mode 100644 index 0000000..387b469 --- /dev/null +++ b/internal/deps/target_input.go @@ -0,0 +1,54 @@ +package deps + +import ( + "fmt" + + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + "github.com/devctllabs/devctl/internal/service/contractsnapshot" + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/targetinput" + "github.com/devctllabs/go-libs/di" +) + +func (c *Container) provideTargetInput() error { + if err := di.Provide(c.di, func(resolver di.Resolver) (*contractsnapshot.Loader, error) { + workspace, err := resolve[*workspacerepo.FilesystemRepo](resolver) + if err != nil { + return nil, err + } + return contractsnapshot.New(workspace), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (*targetinput.Resolver, error) { + workspace, err := resolve[*workspacerepo.FilesystemRepo](resolver) + if err != nil { + return nil, err + } + snapshots, err := resolve[*contractsnapshot.Loader](resolver) + if err != nil { + return nil, err + } + return targetinput.New(workspace, snapshots), nil + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (generateservice.TargetResolver, error) { + return resolve[*targetinput.Resolver](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (lintservice.TargetResolver, error) { + return resolve[*targetinput.Resolver](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + if err := di.Provide(c.di, func(resolver di.Resolver) (projectservice.TargetResolver, error) { + return resolve[*targetinput.Resolver](resolver) + }); err != nil { + return fmt.Errorf("di.Provide: %w", err) + } + return nil +} diff --git a/internal/deps/validate.go b/internal/deps/validate.go new file mode 100644 index 0000000..3072c42 --- /dev/null +++ b/internal/deps/validate.go @@ -0,0 +1 @@ +package deps diff --git a/internal/deps/validate_test.go b/internal/deps/validate_test.go new file mode 100644 index 0000000..e8c1954 --- /dev/null +++ b/internal/deps/validate_test.go @@ -0,0 +1,118 @@ +package deps + +import ( + "context" + "testing" + + bufgenclient "github.com/devctllabs/devctl/internal/client/bufgen" + generatorclient "github.com/devctllabs/devctl/internal/client/generator" + "github.com/devctllabs/devctl/internal/client/toolrun" + "github.com/devctllabs/devctl/internal/service/contractsnapshot" + generateservice "github.com/devctllabs/devctl/internal/service/generate" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/projectreadiness" + "github.com/devctllabs/devctl/internal/service/targetinput" + "github.com/devctllabs/go-libs/di" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewRegistersOneLazyGraphWithTypedRoots(t *testing.T) { + t.Parallel() + + container, err := New(zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + + projectService, err := container.ProjectService() + require.NoError(t, err) + require.NotNil(t, projectService) + scaffoldService, err := container.ScaffoldService() + require.NoError(t, err) + require.NotNil(t, scaffoldService) + syncService, err := container.SyncService() + require.NoError(t, err) + require.NotNil(t, syncService) + lintService, err := container.LintService() + require.NoError(t, err) + require.NotNil(t, lintService) + genService, err := container.GenService() + require.NoError(t, err) + require.NotNil(t, genService) +} + +func TestNewRegistersTheCommandDiagnosticLogger(t *testing.T) { + t.Parallel() + + logger := zap.NewExample() + container, err := New(logger) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + resolved, err := di.Resolve[*zap.Logger](container.di) + require.NoError(t, err) + require.Same(t, logger, resolved) +} + +func TestNewRegistersCompositeGeneratorAndSharesBufWithLint(t *testing.T) { + t.Parallel() + + container, err := New(zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + + generator, err := di.Resolve[generateservice.GeneratorClient](container.di) + require.NoError(t, err) + require.IsType(t, &generatorclient.Client{}, generator) + buf, err := di.Resolve[*bufgenclient.Client](container.di) + require.NoError(t, err) + linter, err := di.Resolve[lintservice.ProtoLinter](container.di) + require.NoError(t, err) + require.Same(t, buf, linter) +} + +func TestNewRegistersOneSharedToolRunner(t *testing.T) { + t.Parallel() + + container, err := New(zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + + osRunner, err := di.Resolve[*toolrun.OSRunner](container.di) + require.NoError(t, err) + runner, err := di.Resolve[toolrun.Runner](container.di) + require.NoError(t, err) + require.Same(t, osRunner, runner) +} + +func TestNewRegistersSharedTargetInputServices(t *testing.T) { + t.Parallel() + + container, err := New(zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + + snapshots, err := di.Resolve[*contractsnapshot.Loader](container.di) + require.NoError(t, err) + require.NotNil(t, snapshots) + inputs, err := di.Resolve[*targetinput.Resolver](container.di) + require.NoError(t, err) + require.NotNil(t, inputs) +} + +func TestNewRegistersSplitProjectLocationAndReadinessServices(t *testing.T) { + t.Parallel() + + container, err := New(zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, container.Shutdown(context.Background())) }) + + locator, err := di.Resolve[projectservice.ManifestLocator](container.di) + require.NoError(t, err) + readinessWorkspace, err := di.Resolve[projectreadiness.Workspace](container.di) + require.NoError(t, err) + require.Same(t, locator, readinessWorkspace) + checker, err := di.Resolve[*projectreadiness.Checker](container.di) + require.NoError(t, err) + require.NotNil(t, checker) +} diff --git a/internal/domain/artifact/tree.go b/internal/domain/artifact/tree.go new file mode 100644 index 0000000..f479660 --- /dev/null +++ b/internal/domain/artifact/tree.go @@ -0,0 +1,36 @@ +package artifact + +// File is one managed output file relative to its containing Tree. +type File struct { + Path string + Content []byte + // Mode contains Unix permission bits; publishers decide which other mode bits they support. + Mode uint32 +} + +// Tree is the complete desired snapshot of one managed output directory. +type Tree struct { + Files []File +} + +// PublishAction classifies one atomically observed publication effect. +type PublishAction string + +const ( + PublishCreated PublishAction = "created" + PublishUpdated PublishAction = "updated" + PublishUnchanged PublishAction = "unchanged" + PublishRemoved PublishAction = "removed" +) + +// PublishChange describes one file inside a completely published Tree. +type PublishChange struct { + Path string + Action PublishAction +} + +// PublishResult reports the target effect and precise file effects from one publication call. +type PublishResult struct { + Action PublishAction + Changes []PublishChange +} diff --git a/internal/domain/contract/metadata.go b/internal/domain/contract/metadata.go new file mode 100644 index 0000000..c2fac23 --- /dev/null +++ b/internal/domain/contract/metadata.go @@ -0,0 +1,271 @@ +package contract + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "path" + "strings" + + "github.com/devctllabs/devctl/internal/domain/failure" +) + +// MetadataInvalidReason identifies why committed Snapshot Metadata is stale. +type MetadataInvalidReason string + +const ( + MetadataRequired MetadataInvalidReason = "required" + MetadataInvalidType MetadataInvalidReason = "invalid_type" + MetadataInvalidPath MetadataInvalidReason = "invalid_path" + MetadataMismatch MetadataInvalidReason = "mismatch" + MetadataNotFound MetadataInvalidReason = "not_found" + MetadataNotRegular MetadataInvalidReason = "not_regular" + MetadataUnexpected MetadataInvalidReason = "unexpected" +) + +// SnapshotMetadataError reports stale committed metadata and tells callers how to refresh it. +type SnapshotMetadataError struct { + Field string + Reason MetadataInvalidReason + Hint string + Cause error +} + +func (e *SnapshotMetadataError) Error() string { + return fmt.Sprintf("snapshot metadata field %q is invalid: %s", e.Field, e.Reason) +} + +func (e *SnapshotMetadataError) Unwrap() error { return e.Cause } + +func (e *SnapshotMetadataError) Category() failure.Category { return failure.InvalidInput } + +// MetadataExpectation binds a committed Snapshot to its consuming Target. +type MetadataExpectation struct { + Kind string + Topic string + Format string +} + +// DecodeMetadata decodes one strict sidecar document. +func DecodeMetadata(data []byte) (Metadata, error) { + var metadata Metadata + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&metadata); err != nil { + return Metadata{}, invalidMetadata(jsonErrorField(err), MetadataInvalidType, err) + } + if err := requireJSONEnd(decoder); err != nil { + return Metadata{}, invalidMetadata(".devctl-contract.json", MetadataInvalidType, err) + } + return metadata, nil +} + +func requireJSONEnd(decoder *json.Decoder) error { + var extra any + err := decoder.Decode(&extra) + if errors.Is(err, io.EOF) { + return nil + } + if err == nil { + return errors.New("multiple JSON values") + } + return fmt.Errorf("decoder.Decode: %w", err) +} + +func jsonErrorField(err error) string { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) && typeErr.Field != "" { + parts := strings.Split(typeErr.Field, ".") + return jsonFieldName(parts[len(parts)-1]) + } + const unknownPrefix = "json: unknown field \"" + if message := err.Error(); strings.HasPrefix(message, unknownPrefix) && strings.HasSuffix(message, "\"") { + return strings.TrimSuffix(strings.TrimPrefix(message, unknownPrefix), "\"") + } + return ".devctl-contract.json" +} + +func jsonFieldName(name string) string { + switch name { + case "Kind": + return "kind" + case "Topic": + return "topic" + case "Format": + return "format" + case "Entrypoint": + return "entrypoint" + case "ModuleRoot": + return "module_root" + case "BufConfig": + return "buf_config" + default: + return name + } +} + +// ValidateSnapshot checks metadata shape, Target identity, paths, and referenced files. +func ValidateSnapshot(snapshot Snapshot, expected MetadataExpectation) error { + if snapshot.Metadata == nil { + return invalidMetadata(".devctl-contract.json", MetadataRequired, nil) + } + metadata := *snapshot.Metadata + if err := ValidateMetadata(metadata, expected); err != nil { + return err + } + files := make(map[string]struct{}, len(snapshot.Files)) + for _, file := range snapshot.Files { + files[path.Clean(file.Path)] = struct{}{} + } + if metadata.Entrypoint != "" { + if _, exists := files[metadata.Entrypoint]; !exists { + return invalidMetadata("entrypoint", MetadataNotFound, nil) + } + } + if metadata.BufConfig != "" { + if _, exists := files[metadata.BufConfig]; !exists { + return invalidMetadata("buf_config", MetadataNotFound, nil) + } + } + if metadata.ModuleRoot != "" && !containsModuleFile(files, metadata.ModuleRoot) { + return invalidMetadata("module_root", MetadataNotFound, nil) + } + if metadata.Format == "raw" && len(snapshot.Files) != 0 { + return invalidMetadata("files", MetadataUnexpected, nil) + } + return nil +} + +// ValidateMetadata checks one sidecar's shape and consuming Target identity. +func ValidateMetadata(metadata Metadata, expected MetadataExpectation) error { + if err := validateIdentity(metadata, expected); err != nil { + return err + } + return validateMetadataShape(metadata) +} + +func validateIdentity(metadata Metadata, expected MetadataExpectation) error { + for _, value := range []struct { + field, actual, expected string + }{ + {"kind", metadata.Kind, expected.Kind}, + {"topic", metadata.Topic, expected.Topic}, + {"format", metadata.Format, expected.Format}, + } { + if value.expected != "" && value.actual != value.expected { + return invalidMetadata(value.field, MetadataMismatch, nil) + } + } + return nil +} + +func validateMetadataShape(metadata Metadata) error { + if metadata.Kind == "" { + return invalidMetadata("kind", MetadataRequired, nil) + } + switch metadata.Kind { + case "grpc": + if metadata.Format != "proto" { + return invalidMetadata("format", MetadataMismatch, nil) + } + if metadata.Topic != "" { + return invalidMetadata("topic", MetadataUnexpected, nil) + } + if metadata.Entrypoint != "" { + return invalidMetadata("entrypoint", MetadataUnexpected, nil) + } + return validateProtoMetadata(metadata, false) + case "kafka": + return validateKafkaMetadata(metadata) + default: + return invalidMetadata("kind", MetadataMismatch, nil) + } +} + +func validateKafkaMetadata(metadata Metadata) error { + if metadata.Topic == "" { + return invalidMetadata("topic", MetadataRequired, nil) + } + switch metadata.Format { + case "raw": + for _, value := range []struct{ field, value string }{ + {"entrypoint", metadata.Entrypoint}, {"module_root", metadata.ModuleRoot}, {"buf_config", metadata.BufConfig}, + } { + if value.value != "" { + return invalidMetadata(value.field, MetadataUnexpected, nil) + } + } + return nil + case "json": + if err := requireMetadataPath("entrypoint", metadata.Entrypoint, false); err != nil { + return err + } + if metadata.ModuleRoot != "" { + return invalidMetadata("module_root", MetadataUnexpected, nil) + } + if metadata.BufConfig != "" { + return invalidMetadata("buf_config", MetadataUnexpected, nil) + } + return nil + case "proto": + return validateProtoMetadata(metadata, true) + default: + return invalidMetadata("format", MetadataMismatch, nil) + } +} + +func validateProtoMetadata(metadata Metadata, requireEntrypoint bool) error { + if err := requireMetadataPath("module_root", metadata.ModuleRoot, true); err != nil { + return err + } + if err := requireMetadataPath("buf_config", metadata.BufConfig, false); err != nil { + return err + } + if requireEntrypoint { + if err := requireMetadataPath("entrypoint", metadata.Entrypoint, false); err != nil { + return err + } + if !pathWithin(metadata.ModuleRoot, metadata.Entrypoint) { + return invalidMetadata("entrypoint", MetadataInvalidPath, nil) + } + } + return nil +} + +func requireMetadataPath(field, value string, allowCurrent bool) error { + if value == "" { + return invalidMetadata(field, MetadataRequired, nil) + } + if allowCurrent && value == "." { + return nil + } + if !safeMetadataPath(value) { + return invalidMetadata(field, MetadataInvalidPath, nil) + } + return nil +} + +func safeMetadataPath(value string) bool { + return value != "" && value != "." && !path.IsAbs(value) && + path.Clean(value) == value && value != ".." && !strings.HasPrefix(value, "../") && + !strings.Contains(value, "\\") +} + +func pathWithin(root, name string) bool { + return root == "." || name == root || strings.HasPrefix(name, root+"/") +} + +func containsModuleFile(files map[string]struct{}, moduleRoot string) bool { + for name := range files { + if pathWithin(moduleRoot, name) { + return true + } + } + return false +} + +func invalidMetadata(field string, reason MetadataInvalidReason, cause error) error { + return &SnapshotMetadataError{Field: field, Reason: reason, Hint: "devctl sync", Cause: cause} +} diff --git a/internal/domain/contract/snapshot.go b/internal/domain/contract/snapshot.go new file mode 100644 index 0000000..1291aa9 --- /dev/null +++ b/internal/domain/contract/snapshot.go @@ -0,0 +1,46 @@ +package contract + +// Reference selects a contract entrypoint directly or through a named upstream export. +type Reference struct { + Entrypoint string + Export string + Format string + ProtoRoot string + Topic string +} + +// Location describes where a materialized contract can be resolved inside a project. +type Location struct { + // Root is the containment boundary for RelativePath and Entrypoint. + Root string + RelativePath string + Entrypoint string + // Local distinguishes project-owned inputs from previously materialized managed output. + Local bool +} + +// File is one contract-closure file relative to its Snapshot root. +type File struct { + Path string + Content []byte + // Mode contains the source file's Unix permission bits. + Mode uint32 +} + +// Snapshot is an exact local contract closure with Entrypoint naming one of Files. +type Snapshot struct { + ModuleRoot string + Entrypoint string + Files []File + Metadata *Metadata +} + +// Metadata records upstream facts needed to detect stale materialized contracts. +type Metadata struct { + Kind string `json:"kind"` + Topic string `json:"topic,omitempty"` + Format string `json:"format,omitempty"` + Entrypoint string `json:"entrypoint,omitempty"` + ModuleRoot string `json:"module_root,omitempty"` + BufConfig string `json:"buf_config,omitempty"` +} diff --git a/internal/domain/failure/category.go b/internal/domain/failure/category.go new file mode 100644 index 0000000..95667b6 --- /dev/null +++ b/internal/domain/failure/category.go @@ -0,0 +1,41 @@ +package failure + +import ( + "context" + "errors" +) + +// Category is the transport-neutral class of an application failure. +type Category string + +const ( + InvalidInput Category = "invalid_input" + NotFound Category = "not_found" + Conflict Category = "conflict" + Unavailable Category = "unavailable" + Unsupported Category = "unsupported" + Cancelled Category = "cancelled" + Internal Category = "internal" +) + +// Categorized exposes a stable failure class without prescribing one error type. +type Categorized interface { + error + // Category returns the stable transport-neutral class of the failure. + Category() Category +} + +// CategoryOf prioritizes cancellation semantics, preserves typed categories, and classifies unknown errors as Internal. +func CategoryOf(err error) Category { + switch { + case errors.Is(err, context.Canceled): + return Cancelled + case errors.Is(err, context.DeadlineExceeded): + return Unavailable + } + var categorized Categorized + if errors.As(err, &categorized) { + return categorized.Category() + } + return Internal +} diff --git a/internal/domain/failure/category_test.go b/internal/domain/failure/category_test.go new file mode 100644 index 0000000..6243b8d --- /dev/null +++ b/internal/domain/failure/category_test.go @@ -0,0 +1,41 @@ +package failure_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/stretchr/testify/require" +) + +type categorizedError struct { + category failure.Category +} + +func (e categorizedError) Error() string { return "categorized" } +func (e categorizedError) Category() failure.Category { return e.category } + +func TestCategoryOf(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + expected failure.Category + }{ + {name: "typed category", err: fmt.Errorf("repository.Load: %w", categorizedError{category: failure.NotFound}), expected: failure.NotFound}, + {name: "wrapped cancellation", err: fmt.Errorf("client.Do: %w", context.Canceled), expected: failure.Cancelled}, + {name: "deadline", err: context.DeadlineExceeded, expected: failure.Unavailable}, + {name: "unknown", err: errors.New("boom"), expected: failure.Internal}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, test.expected, failure.CategoryOf(test.err)) + }) + } +} diff --git a/internal/domain/generate/command.go b/internal/domain/generate/command.go new file mode 100644 index 0000000..1e9190f --- /dev/null +++ b/internal/domain/generate/command.go @@ -0,0 +1,44 @@ +package generate + +import "github.com/devctllabs/devctl/internal/domain/artifact" + +// Command selects generation targets and whether execution is a side-effect-free preview. +type Command struct { + ManifestPath string + Family string + Target string + DryRun bool +} + +// ChangeAction classifies an observed or planned managed-output change. +type ChangeAction string + +const ( + ChangeCreated ChangeAction = "created" + ChangeUpdated ChangeAction = "updated" + ChangeUnchanged ChangeAction = "unchanged" + ChangeRemoved ChangeAction = "removed" + ChangePlannedPublish ChangeAction = "planned_publish" + ChangePlannedRemove ChangeAction = "planned_remove" +) + +// Change records one generated managed-output decision. +type Change struct { + Target string + // Path is relative to the project root. + Path string + Action ChangeAction +} + +// Result contains targets completed before success or the first execution error. +type Result struct { + Targets []string + Changes []Change + DryRun bool +} + +// Output separates an atomically published target directory from auxiliary project files. +type Output struct { + Directory artifact.Tree + Files artifact.Tree +} diff --git a/internal/domain/generate/errors.go b/internal/domain/generate/errors.go new file mode 100644 index 0000000..54ada43 --- /dev/null +++ b/internal/domain/generate/errors.go @@ -0,0 +1,47 @@ +package generate + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// Operation identifies the generation stage that failed. +type Operation string + +const ( + OperationSelectTarget Operation = "select_target" + OperationLocateContract Operation = "locate_contract" + OperationRunGenerator Operation = "run_generator" + OperationPublishOutput Operation = "publish_output" +) + +// FailureKind maps generation facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureNotFound FailureKind = iota + 1 + FailureUnavailable +) + +// OperationError retains generation facts and the underlying execution cause. +type OperationError struct { + Operation Operation + Target string + Path string + Kind FailureKind + Cause error +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + if e.Kind == FailureNotFound { + return failure.NotFound + } + return failure.Unavailable +} diff --git a/internal/domain/generate/errors_test.go b/internal/domain/generate/errors_test.go new file mode 100644 index 0000000..d4190c2 --- /dev/null +++ b/internal/domain/generate/errors_test.go @@ -0,0 +1,20 @@ +package generate_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/generate" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("tool failed") + err := &generate.OperationError{Operation: generate.OperationRunGenerator, Kind: generate.FailureUnavailable, Cause: cause} + + require.Equal(t, failure.Unavailable, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} diff --git a/internal/domain/lint/command.go b/internal/domain/lint/command.go new file mode 100644 index 0000000..d2841f4 --- /dev/null +++ b/internal/domain/lint/command.go @@ -0,0 +1,36 @@ +package lint + +// Command selects the configured contracts to lint. +type Command struct { + ManifestPath string + Family string +} + +// Result reports findings collected before success or the first execution error. +// Findings are normal results: Valid is false when Issues is non-empty. +type Result struct { + Valid bool + Contracts []string + Issues []Issue +} + +// Issue is one stable, presentation-neutral devctl lint finding. +type Issue struct { + Code string + Target string + Path string + // Line and Column are one-based; zero means the location is unavailable. + Line int + Column int + Field string + Parameters *Parameters +} + +// Parameters carries code-specific facts used by delivery renderers. +type Parameters struct { + OperationID string + Location string + Type string + Subtype string + SpecPath string +} diff --git a/internal/domain/lint/errors.go b/internal/domain/lint/errors.go new file mode 100644 index 0000000..fd219fa --- /dev/null +++ b/internal/domain/lint/errors.go @@ -0,0 +1,46 @@ +package lint + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// Operation identifies the lint execution stage that failed. +type Operation string + +const ( + OperationSelectContracts Operation = "select_contracts" + OperationLocateContract Operation = "locate_contract" + OperationReadContract Operation = "read_contract" +) + +// FailureKind maps lint execution facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureInvalid FailureKind = iota + 1 + FailureUnavailable +) + +// OperationError represents an execution failure, never a lint finding. +type OperationError struct { + Operation Operation + Target string + Path string + Kind FailureKind + Cause error +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + if e.Kind == FailureInvalid { + return failure.InvalidInput + } + return failure.Unavailable +} diff --git a/internal/domain/lint/errors_test.go b/internal/domain/lint/errors_test.go new file mode 100644 index 0000000..ae6bfea --- /dev/null +++ b/internal/domain/lint/errors_test.go @@ -0,0 +1,20 @@ +package lint_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/lint" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("read failed") + err := &lint.OperationError{Operation: lint.OperationReadContract, Kind: lint.FailureUnavailable, Cause: cause} + + require.Equal(t, failure.Unavailable, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} diff --git a/internal/domain/materialize/contract.go b/internal/domain/materialize/contract.go new file mode 100644 index 0000000..f28fd12 --- /dev/null +++ b/internal/domain/materialize/contract.go @@ -0,0 +1,27 @@ +package materialize + +import ( + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/project" +) + +// Request selects a contract closure from one source within a project root. +type Request struct { + // Root is the containment boundary for project-relative source paths. + Root string + Source project.Source + Reference contract.Reference +} + +// HTTPFetchRequest describes one bounded fetch within a URL Source origin. +type HTTPFetchRequest struct { + URL string + OriginURL string + AllowInsecureHTTP bool +} + +// HTTPDocument is one fetched Contract document and its effective URL. +type HTTPDocument struct { + URL string + Content []byte +} diff --git a/internal/domain/materialize/errors.go b/internal/domain/materialize/errors.go new file mode 100644 index 0000000..f2975f3 --- /dev/null +++ b/internal/domain/materialize/errors.go @@ -0,0 +1,143 @@ +package materialize + +import ( + "fmt" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/project" +) + +// Operation identifies the materialization stage that failed. +type Operation string + +const ( + OperationConfigureRouter Operation = "configure_router" + OperationValidateSource Operation = "validate_source" + OperationReadFile Operation = "read_file" + OperationDownload Operation = "download" + OperationCheckout Operation = "checkout" + OperationBuildSnapshot Operation = "build_snapshot" +) + +// FailureKind maps materialization facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureInvalid FailureKind = iota + 1 + FailureNotFound + FailureUnavailable + FailureUnsupported +) + +// OperationError retains source facts and the underlying I/O or protocol cause. +type OperationError struct { + Operation Operation + SourceType project.SourceType + Path string + Kind FailureKind + Cause error +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + switch e.Kind { + case FailureInvalid: + return failure.InvalidInput + case FailureNotFound: + return failure.NotFound + case FailureUnavailable: + return failure.Unavailable + case FailureUnsupported: + return failure.Unsupported + default: + return failure.Internal + } +} + +// UnsupportedSourceError reports a source type for which no strategy was configured. +type UnsupportedSourceError struct { + SourceType project.SourceType +} + +func (e *UnsupportedSourceError) Error() string { + return fmt.Sprintf("source type %q is unsupported", e.SourceType) +} + +func (e *UnsupportedSourceError) Category() failure.Category { return failure.Unsupported } + +// UpstreamManifestError identifies an invalid or inaccessible upstream Devctl project. +type UpstreamManifestError struct { + Repository string + Ref string + Cause error +} + +func (e *UpstreamManifestError) Error() string { + return "upstream Devctl manifest is invalid or inaccessible" +} + +func (e *UpstreamManifestError) Category() failure.Category { return failure.InvalidInput } +func (e *UpstreamManifestError) Unwrap() error { return e.Cause } + +// ExportNotFoundError reports a requested export absent from an upstream manifest. +type ExportNotFoundError struct{ Name string } + +func (e *ExportNotFoundError) Error() string { + return fmt.Sprintf("upstream export %q was not found", e.Name) +} + +func (e *ExportNotFoundError) Category() failure.Category { return failure.NotFound } + +// InvalidExportError reports a selected Export that does not match an effective upstream surface. +type InvalidExportError struct{ Name string } + +func (e *InvalidExportError) Error() string { + return fmt.Sprintf("upstream export %q is invalid", e.Name) +} + +func (e *InvalidExportError) Category() failure.Category { return failure.InvalidInput } + +// UnsupportedExportError reports an export whose contract kind cannot be materialized. +type UnsupportedExportError struct { + Name string + Kind string +} + +func (e *UnsupportedExportError) Error() string { + return fmt.Sprintf("upstream export %q has unsupported kind %q", e.Name, e.Kind) +} + +func (e *UnsupportedExportError) Category() failure.Category { return failure.Unsupported } + +// KafkaTopicMismatchError reports a downstream topic that disagrees with its exported producer. +type KafkaTopicMismatchError struct { + Expected string + Actual string +} + +func (e *KafkaTopicMismatchError) Error() string { + return fmt.Sprintf("Kafka export topic mismatch: expected %q, got %q", e.Expected, e.Actual) +} + +func (e *KafkaTopicMismatchError) Category() failure.Category { return failure.InvalidInput } + +// KafkaFormatMismatchError reports a downstream format that disagrees with its exported producer. +type KafkaFormatMismatchError struct { + Expected string + Actual string +} + +func (e *KafkaFormatMismatchError) Error() string { + return fmt.Sprintf("Kafka export format mismatch: expected %q, got %q", e.Expected, e.Actual) +} + +func (e *KafkaFormatMismatchError) Category() failure.Category { return failure.InvalidInput } diff --git a/internal/domain/materialize/errors_test.go b/internal/domain/materialize/errors_test.go new file mode 100644 index 0000000..cc9bea9 --- /dev/null +++ b/internal/domain/materialize/errors_test.go @@ -0,0 +1,39 @@ +package materialize_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("download failed") + err := &materialize.OperationError{Operation: materialize.OperationDownload, Kind: materialize.FailureUnavailable, Cause: cause} + + require.Equal(t, failure.Unavailable, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} + +func TestInvalidExportErrorIsInvalidInput(t *testing.T) { + t.Parallel() + + err := &materialize.InvalidExportError{Name: "public-api"} + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + require.Contains(t, err.Error(), "public-api") +} + +func TestKafkaFormatMismatchErrorIsInvalidInput(t *testing.T) { + t.Parallel() + + err := &materialize.KafkaFormatMismatchError{Expected: "raw", Actual: "json"} + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + require.Contains(t, err.Error(), "raw") + require.Contains(t, err.Error(), "json") +} diff --git a/internal/domain/project/command.go b/internal/domain/project/command.go new file mode 100644 index 0000000..4672801 --- /dev/null +++ b/internal/domain/project/command.go @@ -0,0 +1,117 @@ +package project + +// ChangeAction classifies the persisted effect of a manifest operation. +type ChangeAction string + +const ( + ChangeCreated ChangeAction = "created" + ChangeUpdated ChangeAction = "updated" + ChangeUnchanged ChangeAction = "unchanged" +) + +// ManifestResult identifies the selected manifest and its persisted change. +type ManifestResult struct { + Manifest string + Change ChangeAction +} + +// InitManifestCommand describes the canonical manifest to create or replace. +type InitManifestCommand struct { + Destination string + Language string + Preset string + Name string + Module string + Force bool +} + +// EnableCommand enables one project capability in an existing valid manifest. +type EnableCommand struct { + ManifestPath string + Capability string + Always bool + Force bool +} + +// AddDBCommand adds or replaces one database connection declaration. +type AddDBCommand struct { + ManifestPath string + Name string + Kind string + Default bool + NoMigrations bool + MigrationsPath string + Force bool +} + +// AddSourceCommand adds or replaces one local or external contract source. +type AddSourceCommand struct { + ManifestPath string + Name string + Type string + Path string + URL string + Filename string + AllowInsecureHTTP bool + Repo string + Ref string + BufConfig string + Force bool +} + +// AddHTTPClientCommand adds or replaces one generated HTTP client declaration. +type AddHTTPClientCommand struct { + ManifestPath string + Name string + Source string + Export string + Path string + BaseURLEnv string + Force bool +} + +type AddGRPCClientCommand struct { + ManifestPath string + Name string + Source string + Export string + Path string + ProtoRoot string + BufGenConfig string + AddrEnv string + Force bool +} + +type AddKafkaConsumerCommand struct { + ManifestPath string + Name, Topic, Source, Export, Path, Format, ProtoRoot, Message, Encoding, GroupEnv string + Always, Force bool +} + +type AddKafkaProducerCommand struct { + ManifestPath string + Name, Topic, Source, Export, Path, Format, ProtoRoot, Message, Encoding, TopicEnv string + Force bool +} + +type AddRedisCommand struct { + ManifestPath string + Name string + AddrEnv string + AddrDefault string + Force bool +} + +type AddS3ConnectionCommand struct { + ManifestPath string + Name string + Credentials string + Force bool +} + +type AddS3Command struct { + ManifestPath string + Name string + Connection string + Force bool +} diff --git a/internal/domain/project/errors.go b/internal/domain/project/errors.go new file mode 100644 index 0000000..5e2e9a8 --- /dev/null +++ b/internal/domain/project/errors.go @@ -0,0 +1,104 @@ +package project + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// Operation identifies the project or manifest stage that failed. +type Operation string + +const ( + OperationLoadManifest Operation = "load_manifest" + OperationSaveManifest Operation = "save_manifest" + OperationInspectFile Operation = "inspect_file" + OperationReadFile Operation = "read_file" + OperationInitManifest Operation = "init_manifest" +) + +// FailureKind maps project facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureInvalid FailureKind = iota + 1 + FailureNotFound + FailureConflict + FailureUnavailable + FailureInternal +) + +// OperationError retains project path facts and the underlying execution cause. +type OperationError struct { + Operation Operation + Path string + Kind FailureKind + Cause error +} + +// MutationReason identifies the stable policy fact that rejected a manifest mutation. +type MutationReason string + +const ( + MutationUnsupportedOption MutationReason = "unsupported_option" + MutationUnsupportedValue MutationReason = "unsupported_value" + MutationInvalidName MutationReason = "invalid_name" + MutationInvalidOptions MutationReason = "invalid_options" + MutationInvalidURL MutationReason = "invalid_url" + MutationInsecureURL MutationReason = "insecure_url" + MutationNotFound MutationReason = "not_found" + MutationExistingConflict MutationReason = "existing_conflict" +) + +// MutationError contains presentation-neutral facts about a rejected manifest mutation. +type MutationError struct { + Reason MutationReason + Field string + Value string + Conflict bool +} + +func (e *MutationError) Error() string { return "manifest mutation failed" } + +func (e *MutationError) Category() failure.Category { + if e.Conflict { + return failure.Conflict + } + return failure.InvalidInput +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + switch e.Kind { + case FailureInvalid: + return failure.InvalidInput + case FailureNotFound: + return failure.NotFound + case FailureConflict: + return failure.Conflict + case FailureUnavailable: + return failure.Unavailable + case FailureInternal: + return failure.Internal + default: + return failure.Internal + } +} + +// InvalidManifestError reports structural or semantic issues that block a project operation. +type InvalidManifestError struct { + Path string + Issues []Issue + Cause error +} + +func (e *InvalidManifestError) Error() string { return "manifest is invalid" } + +func (e *InvalidManifestError) Category() failure.Category { return failure.InvalidInput } + +func (e *InvalidManifestError) Unwrap() error { return e.Cause } diff --git a/internal/domain/project/errors_test.go b/internal/domain/project/errors_test.go new file mode 100644 index 0000000..038ce70 --- /dev/null +++ b/internal/domain/project/errors_test.go @@ -0,0 +1,20 @@ +package project_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("storage failed") + err := &project.OperationError{Operation: project.OperationLoadManifest, Kind: project.FailureNotFound, Cause: cause} + + require.Equal(t, failure.NotFound, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} diff --git a/internal/domain/project/export.go b/internal/domain/project/export.go new file mode 100644 index 0000000..13ccf14 --- /dev/null +++ b/internal/domain/project/export.go @@ -0,0 +1,40 @@ +package project + +import ( + "path" + "strings" +) + +// ExportMatchesSurface reports whether exported is an exact alias of one effective Project surface. +func (m Manifest) ExportMatchesSurface(exported Export) bool { + switch exported.Kind { + case "openapi": + target, exists := m.target("http-server") + return exists && exported.Producer == "" && validExportPath(exported.Path, false) && exported.Path == target.Reference.Entrypoint + case "grpc": + target, exists := m.target("grpc-server") + return exists && exported.Producer == "" && validExportPath(exported.Path, true) && exported.Path == target.Reference.ProtoRoot + case "kafka": + _, exists := m.target("kafka-producer:" + exported.Producer) + return exists && exported.Producer != "" && exported.Path == "" + default: + return false + } +} + +func (m Manifest) target(id string) (Target, bool) { + for _, target := range NewTargetCatalog(m).All() { + if target.ID == id { + return target, true + } + } + return Target{}, false +} + +func validExportPath(name string, allowRoot bool) bool { + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + if allowRoot && clean == "." && name != "" { + return true + } + return name != "" && clean != "." && clean != ".." && !strings.HasPrefix(clean, "/") && !strings.HasPrefix(clean, "../") +} diff --git a/internal/domain/project/export_test.go b/internal/domain/project/export_test.go new file mode 100644 index 0000000..e5cfc9e --- /dev/null +++ b/internal/domain/project/export_test.go @@ -0,0 +1,39 @@ +package project_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestManifestExportMatchesEffectiveSurface(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{Components: project.Components{ + HTTP: &project.HTTP{Server: &project.HTTPServer{OpenAPI: "api/openapi.yaml"}}, + GRPC: &project.GRPC{Server: &project.GRPCServer{ProtoRoot: "api/proto/grpc"}}, + Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "audit", Topic: "audit.events", Contract: project.KafkaContract{Format: "json"}, + }}}, + }} + tests := []struct { + name string + exported project.Export + expected bool + }{ + {name: "OpenAPI", exported: project.Export{Kind: "openapi", Path: "api/openapi.yaml"}, expected: true}, + {name: "OpenAPI mismatch", exported: project.Export{Kind: "openapi", Path: "api/other.yaml"}}, + {name: "gRPC", exported: project.Export{Kind: "grpc", Path: "api/proto/grpc"}, expected: true}, + {name: "gRPC mismatch", exported: project.Export{Kind: "grpc", Path: "api/proto/other"}}, + {name: "Kafka", exported: project.Export{Kind: "kafka", Producer: "audit"}, expected: true}, + {name: "Kafka missing producer", exported: project.Export{Kind: "kafka", Producer: "missing"}}, + {name: "unknown kind", exported: project.Export{Kind: "graphql", Path: "api/schema.graphql"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, test.expected, manifest.ExportMatchesSurface(test.exported)) + }) + } +} diff --git a/internal/domain/project/manifest.go b/internal/domain/project/manifest.go new file mode 100644 index 0000000..487816c --- /dev/null +++ b/internal/domain/project/manifest.go @@ -0,0 +1,277 @@ +package project + +// Manifest is the canonical desired project configuration before effective defaults are applied. +type Manifest struct { + Version int + Project Identity + Env Env + Paths ManifestPaths + Sources map[string]Source + Exports map[string]Export + Components Components + Languages Languages +} + +// Project binds one decoded Manifest to its selected filesystem root and manifest path. +type Project struct { + Root string + ManifestPath string + Manifest Manifest +} + +type Identity struct { + Name string + Language string +} + +// Export publishes a named contract path from an upstream Devctl project. +type Export struct { + Kind string + Path string + Producer string +} + +type Env struct { + Prefix string + Custom []EnvGroup +} + +type EnvGroup struct { + Group string + Vars []EnvVar +} + +// EnvVar describes one generated environment variable declaration. +type EnvVar struct { + Key string + Type string + // Default retains the scalar type declared by the manifest. + Default any + Secret bool +} + +type ManifestPaths struct { + ExternalContracts string +} + +type Components struct { + HTTP *HTTP + GRPC *GRPC + Kafka *Kafka + Logging *Logging + Health *Health + Telemetry *Telemetry + DB *DB + Redis *Redis + S3 *S3 +} + +type Redis struct { + Connections []RedisConnection + Env ComponentEnv +} + +type RedisConnection struct { + Name string + AddrEnv string + AddrDefault string +} + +type S3 struct { + Connections []S3Connection + Buckets []S3Bucket + Env ComponentEnv +} + +type S3Connection struct { + Name string + Credentials string + Endpoint string + Region string + PathStyle bool + AccessKeyEnv string + SecretKeyEnv string +} + +type S3Bucket struct { + Name string + Connection string + Bucket string +} + +type Kafka struct { + Consumers []KafkaConsumer + Producers []KafkaProducer + Env ComponentEnv +} + +type KafkaContract struct { + Source string + Export string + Path string + Format string + ProtoRoot string + Message string + Encoding string +} + +type KafkaConsumer struct { + Name string + Topic string + GroupEnv string + Start *Start + Contract KafkaContract +} + +type KafkaProducer struct { + Name string + Topic string + TopicEnv string + Contract KafkaContract +} + +type GRPC struct { + Server *GRPCServer + Clients []GRPCClient + Env ComponentEnv +} + +type GRPCClient struct { + Name string + Source string + Export string + Path string + ProtoRoot string + BufGenConfig string + AddrEnv string +} + +type GRPCServer struct { + ProtoRoot string + BufConfig string + Start *Start +} + +type ComponentEnv struct { + System []EnvVar + Custom []EnvVar +} + +// Start controls whether a runtime component starts by default. +type Start struct { + Env string + // Default is nil when the manifest leaves the start policy unspecified. + Default *bool +} + +type HTTP struct { + Server *HTTPServer + Clients []HTTPClient + Env ComponentEnv +} + +type HTTPServer struct { + OpenAPI string + Start *Start +} + +// HTTPClient describes one generated client and its contract selection. +type HTTPClient struct { + Name string + Source string + Export string + // Path is the contract entrypoint within Source when Export is not used. + Path string + BaseURLEnv string + // OAPIConfig is a project-relative oapi-codegen configuration path. + OAPIConfig string +} + +type Logging struct{ Env ComponentEnv } + +type Health struct { + Server *HealthServer + Env ComponentEnv +} + +type HealthServer struct{ Start *Start } + +type Telemetry struct { + Start *Start + Env ComponentEnv +} + +type DB struct { + Connections []DBConnection + Env ComponentEnv +} + +// DBConnection groups selectable variants of one logical database connection. +type DBConnection struct { + Name string + // Default names the variant selected when KindEnv is unset. + Default string + KindEnv string + Variants []DBVariant +} + +// DBVariant describes one concrete database driver and DSN source. +type DBVariant struct { + Name string + Kind string + DSNEnv string + DSNDefault string + Secret bool + Migrations *DBMigrations +} + +// DBMigrations describes a golang-migrate target owned by one database variant. +type DBMigrations struct { + Path string + DatabaseEnv string + DatabaseDefault string +} + +type Languages struct{ Go GoLanguage } + +type GoLanguage struct { + Module string + Generators GoGenerators + Components GoComponents +} + +type GoGenerators struct { + Config *ConfigGenerator + HTTP *HTTPGenerator + GRPC *GRPCGenerator + Kafka *KafkaGenerator +} + +type KafkaGenerator struct { + Out string + BufGenConfig string +} + +type GRPCGenerator struct { + Out string + BufGenConfig string +} + +// ConfigGenerator configures the project-relative managed config output directory. +type ConfigGenerator struct{ Out string } + +// HTTPGenerator configures oapi-codegen inputs and managed output directories. +type HTTPGenerator struct { + OAPIConfig string + ServerOut string + ClientOut string +} + +type GoComponents struct{ Pprof *Pprof } + +type Pprof struct { + Server *PprofServer + Env ComponentEnv +} + +type PprofServer struct{ Start *Start } diff --git a/internal/domain/project/query.go b/internal/domain/project/query.go new file mode 100644 index 0000000..7668c8e --- /dev/null +++ b/internal/domain/project/query.go @@ -0,0 +1,171 @@ +package project + +// InspectQuery selects the manifest whose effective project view is requested. +type InspectQuery struct { + ManifestPath string +} + +// InspectResult contains the effective view derived from one valid manifest. +type InspectResult struct { + Project Inspection +} + +// Inspection is the effective view of one selected manifest-managed directory. +type Inspection struct { + Root string + ManifestPath string + Name string + Language string + Module string + EnvPrefix string + Paths Paths + Targets []InspectionTarget + Env []EffectiveEnv + Resources InspectionResources +} + +// InspectionTarget describes one effective contract or config generation target. +type InspectionTarget struct { + ID string + Family string + Format string + Input string + ResolvedInput string + Config string + Output string +} + +// EffectiveEnv describes one fully-prefixed environment variable. +type EffectiveEnv struct { + Key string + Type string + Default any + Secret bool +} + +// InspectionResources inventories named runtime resources. +type InspectionResources struct { + DBConnections []string + RedisConnections []string + S3Connections []string + S3Buckets []string + Migrations []InspectionMigration +} + +// InspectionMigration is one effective golang-migrate target. +type InspectionMigration struct { + Connection string + Variant string + Kind string + Path string + DatabaseEnv string +} + +// Paths contains effective project-relative managed-output locations. +type Paths struct { + ExternalContracts string + ConfigOut string + ServerOut string + ClientOut string +} + +// ValidateQuery selects the manifest whose project readiness should be checked. +type ValidateQuery struct { + ManifestPath string +} + +// ValidationResult reports all project readiness issues that could be collected. +// Invalid project data is a normal result rather than an execution error. +type ValidationResult struct { + Issues []Issue +} + +// IsValid reports whether validation found no issues. +func (r ValidationResult) IsValid() bool { + return len(r.Issues) == 0 +} + +// IssueCode identifies one stable project validation rule. +type IssueCode string + +const ( + IssueYAMLInvalid IssueCode = "yaml_invalid" + IssueSchemaInvalid IssueCode = "schema_invalid" + IssueYAMLDuplicateKey IssueCode = "yaml_duplicate_key" + IssueSchemaUnknownField IssueCode = "schema_unknown_field" + IssueVersionUnsupported IssueCode = "version_unsupported" + IssueNameInvalid IssueCode = "name_invalid" + IssueLanguageUnsupported IssueCode = "language_unsupported" + IssueGoModuleRequired IssueCode = "go_module_required" + IssuePathInvalid IssueCode = "path_invalid" + IssuePathOverlap IssueCode = "path_overlap" + IssueSourceNameInvalid IssueCode = "source_name_invalid" + IssueSourceInvalid IssueCode = "source_invalid" + IssueSourceInsecure IssueCode = "source_insecure" + IssueSourceTypeUnsupported IssueCode = "source_type_unsupported" + IssueExportInvalid IssueCode = "export_invalid" + IssueHTTPClientInvalid IssueCode = "http_client_invalid" + IssueGRPCClientInvalid IssueCode = "grpc_client_invalid" + IssueKafkaContractInvalid IssueCode = "kafka_contract_invalid" + IssueSourceNotFound IssueCode = "source_not_found" + IssueDBConnectionInvalid IssueCode = "db_connection_invalid" + IssueDBVariantInvalid IssueCode = "db_variant_invalid" + IssueDBDefaultInvalid IssueCode = "db_default_invalid" + IssueDBMigrationsInvalid IssueCode = "db_migrations_invalid" + IssueMigrationPathMissing IssueCode = "migration_path_missing" + IssueRedisConnectionInvalid IssueCode = "redis_connection_invalid" + IssueRedisAddressInvalid IssueCode = "redis_address_invalid" + IssueS3ConnectionNotFound IssueCode = "s3_connection_not_found" + IssueGoModMissing IssueCode = "go_mod_missing" + IssueGoModInvalid IssueCode = "go_mod_invalid" + IssueSourceMissing IssueCode = "source_missing" + IssueOpenAPIMissing IssueCode = "openapi_missing" + IssueHTTPGeneratorMissing IssueCode = "http_generator_missing" + IssueToolConfigMissing IssueCode = "tool_config_missing" + IssueToolConfigInvalid IssueCode = "tool_config_invalid" + IssueToolMissing IssueCode = "tool_missing" + IssueRuntimeConfigConflict IssueCode = "runtime_config_conflict" +) + +// DecodeIssueKind identifies a structural manifest decoding failure. +type DecodeIssueKind uint8 + +const ( + DecodeYAMLInvalid DecodeIssueKind = iota + 1 + DecodeSchemaInvalid + DecodeDuplicateKey + DecodeUnknownField +) + +// DecodeIssue is a structured manifest persistence fact. +type DecodeIssue struct { + Kind DecodeIssueKind + Field string + // Line and Column are one-based; zero means the location is unavailable. + Line int + Column int +} + +// LoadManifestResult contains the selected project and structural manifest issues. +type LoadManifestResult struct { + Project Project + Issues []DecodeIssue +} + +// Issue is one stable, presentation-neutral project validation fact. +type Issue struct { + Code IssueCode + Path string + // Line and Column are one-based; zero means the location is unavailable. + Line int + Column int + Field string + Parameters *Parameters +} + +// Parameters carries code-specific validation facts used by delivery renderers. +type Parameters struct { + Expected string + Actual string + Value string +} diff --git a/internal/domain/project/query_test.go b/internal/domain/project/query_test.go new file mode 100644 index 0000000..eaddebf --- /dev/null +++ b/internal/domain/project/query_test.go @@ -0,0 +1,14 @@ +package project + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidationResultIsValid(t *testing.T) { + t.Parallel() + + require.True(t, ValidationResult{}.IsValid()) + require.False(t, ValidationResult{Issues: []Issue{{}}}.IsValid()) +} diff --git a/internal/domain/project/runtime_config.go b/internal/domain/project/runtime_config.go new file mode 100644 index 0000000..3a65d3a --- /dev/null +++ b/internal/domain/project/runtime_config.go @@ -0,0 +1,497 @@ +package project + +import ( + "reflect" + "sort" + "strings" + "unicode" +) + +// RuntimeConfigType identifies the typed value loaded from one environment key. +type RuntimeConfigType string + +const ( + RuntimeConfigString RuntimeConfigType = "string" + RuntimeConfigBool RuntimeConfigType = "bool" + RuntimeConfigInt RuntimeConfigType = "int" + RuntimeConfigDuration RuntimeConfigType = "duration" + RuntimeConfigStringList RuntimeConfigType = "string_list" +) + +// RuntimeConfigScope selects one projection of the canonical runtime configuration. +type RuntimeConfigScope uint8 + +const ( + RuntimeConfigRuntime RuntimeConfigScope = 1 << iota + RuntimeConfigExample + RuntimeConfigInspect +) + +// RuntimeConfigField is one effective environment-backed configuration fact. +type RuntimeConfigField struct { + Group string + Name string + Key string + Type RuntimeConfigType + Default any + HasDefault bool + Secret bool +} + +// RuntimeConfigCatalog is an immutable effective Runtime Config projection of one Manifest. +type RuntimeConfigCatalog struct { + prefix string + entries []runtimeConfigEntry +} + +type runtimeConfigEntry struct { + field RuntimeConfigField + scopes RuntimeConfigScope +} + +// RuntimeConfigConflictError reports declarations that cannot own the same effective key. +type RuntimeConfigConflictError struct { + Key string + Field string +} + +func (e *RuntimeConfigConflictError) Error() string { + if e.Field != "" { + return "conflicting runtime config field " + e.Field + } + return "conflicting runtime config declaration for " + e.Key +} + +// NewRuntimeConfigCatalog resolves effective Runtime Config policy for manifest. +func NewRuntimeConfigCatalog(manifest Manifest) (RuntimeConfigCatalog, error) { + builder := runtimeConfigBuilder{ + prefix: runtimeConfigPrefix(manifest), + byKey: make(map[string]*runtimeConfigCandidate), + } + builder.addDerived(manifest) + if err := builder.addExplicit(manifest); err != nil { + return RuntimeConfigCatalog{}, err + } + entries, err := builder.sortedEntries() + if err != nil { + return RuntimeConfigCatalog{}, err + } + return RuntimeConfigCatalog{prefix: builder.prefix, entries: entries}, nil +} + +// Prefix returns the effective project environment prefix. +func (c RuntimeConfigCatalog) Prefix() string { + return c.prefix +} + +// Entries returns a defensive key-sorted projection for scope. +func (c RuntimeConfigCatalog) Entries(scope RuntimeConfigScope) []RuntimeConfigField { + fields := make([]RuntimeConfigField, 0, len(c.entries)) + for _, entry := range c.entries { + if entry.scopes&scope != 0 { + fields = append(fields, entry.field) + } + } + return fields +} + +type runtimeConfigCandidate struct { + entry runtimeConfigEntry + explicit *runtimeConfigDeclaration +} + +type runtimeConfigDeclaration struct { + group string + name string + typeName RuntimeConfigType + defaultVal any + hasDefault bool + secret bool +} + +type runtimeConfigBuilder struct { + prefix string + byKey map[string]*runtimeConfigCandidate +} + +type derivedRuntimeConfig struct { + typeName RuntimeConfigType + defaultVal any + hasDefault bool + secret bool + scopes RuntimeConfigScope +} + +func runtimeDerived(typeName RuntimeConfigType, defaultValue any, hasDefault bool) derivedRuntimeConfig { + return derivedRuntimeConfig{typeName: typeName, defaultVal: defaultValue, hasDefault: hasDefault, scopes: runtimeScopes()} +} + +func runtimeDerivedSecret(typeName RuntimeConfigType, defaultValue any, hasDefault, secret bool) derivedRuntimeConfig { + return derivedRuntimeConfig{typeName: typeName, defaultVal: defaultValue, hasDefault: hasDefault, secret: secret, scopes: runtimeScopes()} +} + +func (b *runtimeConfigBuilder) addDerived(manifest Manifest) { + b.addCapabilities(manifest) + b.addDatabase(manifest.Components.DB) + b.addRedis(manifest.Components.Redis) + b.addS3(manifest.Components.S3) + b.addClients(manifest.Components.HTTP, manifest.Components.GRPC) + b.addMigrations(manifest.Components.DB) +} + +func (b *runtimeConfigBuilder) addCapabilities(manifest Manifest) { + components := manifest.Components + if components.Logging != nil { + b.derived("Logging", "Level", "LOG_LEVEL", runtimeDerived(RuntimeConfigString, "info", true)) + } + if components.HTTP != nil && components.HTTP.Server != nil { + b.derived("HTTP", "Address", "HTTP_ADDR", runtimeDerived(RuntimeConfigString, ":8080", true)) + b.start("HTTP", "Enabled", "HTTP_SERVER_ENABLED", components.HTTP.Server.Start) + } + if components.GRPC != nil && components.GRPC.Server != nil { + b.derived("GRPC", "Address", "GRPC_ADDR", runtimeDerived(RuntimeConfigString, ":9090", true)) + b.start("GRPC", "Enabled", "GRPC_SERVER_ENABLED", components.GRPC.Server.Start) + } + if components.Kafka != nil { + b.derived("Kafka", "Brokers", "KAFKA_BROKERS", runtimeDerived(RuntimeConfigStringList, "localhost:29092", true)) + for _, consumer := range components.Kafka.Consumers { + name := runtimeConfigExportedName(consumer.Name) + keyPart := runtimeConfigEnvName(consumer.Name) + groupKey := valueOrDefault(consumer.GroupEnv, "KAFKA_"+keyPart+"_GROUP") + b.derived("Kafka", name+"Group", groupKey, runtimeDerived(RuntimeConfigString, manifest.Project.Name+"-"+consumer.Name+"-group", true)) + b.derived("Kafka", name+"Topic", "KAFKA_"+keyPart+"_TOPIC", runtimeDerived(RuntimeConfigString, consumer.Topic, true)) + b.derived("Kafka", name+"BatchMaxSize", "KAFKA_"+keyPart+"_BATCH_MAX_SIZE", runtimeDerived(RuntimeConfigInt, 1, true)) + b.derived("Kafka", name+"BatchFlushInterval", "KAFKA_"+keyPart+"_BATCH_FLUSH_INTERVAL", runtimeDerived(RuntimeConfigDuration, "1s", true)) + b.derived("Kafka", name+"RetryMaxAttempts", "KAFKA_"+keyPart+"_RETRY_MAX_ATTEMPTS", runtimeDerived(RuntimeConfigInt, 3, true)) + b.derived("Kafka", name+"RetryMaxElapsedTime", "KAFKA_"+keyPart+"_RETRY_MAX_ELAPSED_TIME", runtimeDerived(RuntimeConfigDuration, "0s", true)) + b.derived("Kafka", name+"RetryInitialDelay", "KAFKA_"+keyPart+"_RETRY_INITIAL_DELAY", runtimeDerived(RuntimeConfigDuration, "1s", true)) + b.derived("Kafka", name+"RetryMaxDelay", "KAFKA_"+keyPart+"_RETRY_MAX_DELAY", runtimeDerived(RuntimeConfigDuration, "30s", true)) + b.derived("Kafka", name+"RebalanceTimeout", "KAFKA_"+keyPart+"_REBALANCE_TIMEOUT", runtimeDerived(RuntimeConfigDuration, "30s", true)) + b.derived("Kafka", name+"RebalanceDrainTimeout", "KAFKA_"+keyPart+"_REBALANCE_DRAIN_TIMEOUT", runtimeDerived(RuntimeConfigDuration, "20s", true)) + b.derived("Kafka", name+"ShutdownTimeout", "KAFKA_"+keyPart+"_SHUTDOWN_TIMEOUT", runtimeDerived(RuntimeConfigDuration, "30s", true)) + b.start("Kafka", name+"Enabled", "KAFKA_"+keyPart+"_CONSUMER_ENABLED", consumer.Start) + } + for _, producer := range components.Kafka.Producers { + name := runtimeConfigExportedName(producer.Name) + key := valueOrDefault(producer.TopicEnv, "KAFKA_"+runtimeConfigEnvName(producer.Name)+"_TOPIC") + b.derived("Kafka", name+"Topic", key, runtimeDerived(RuntimeConfigString, producer.Topic, producer.Topic != "")) + } + } + if components.Health != nil { + b.derived("Health", "Address", "HEALTH_ADDR", runtimeDerived(RuntimeConfigString, ":8081", true)) + if components.Health.Server != nil { + b.start("Health", "Enabled", "HEALTH_SERVER_ENABLED", components.Health.Server.Start) + } + } + if components.Telemetry != nil { + b.start("Telemetry", "Enabled", "TELEMETRY_ENABLED", components.Telemetry.Start) + b.derived("Telemetry", "ServiceVersion", "SERVICE_VERSION", runtimeDerived(RuntimeConfigString, "dev", true)) + b.derived("Telemetry", "DeploymentEnvironment", "DEPLOYMENT_ENVIRONMENT", runtimeDerived(RuntimeConfigString, "development", true)) + } + if pprof := manifest.Languages.Go.Components.Pprof; pprof != nil { + b.derived("Pprof", "Address", "PPROF_ADDR", runtimeDerived(RuntimeConfigString, "127.0.0.1:6060", true)) + if pprof.Server != nil { + b.start("Pprof", "Enabled", "PPROF_ENABLED", pprof.Server.Start) + } + } +} + +func (b *runtimeConfigBuilder) start(group, name, fallbackKey string, start *Start) { + if start == nil { + return + } + defaultValue := false + if start.Default != nil { + defaultValue = *start.Default + } + b.derived(group, name, valueOrDefault(start.Env, fallbackKey), runtimeDerived(RuntimeConfigBool, defaultValue, true)) +} + +func (b *runtimeConfigBuilder) addDatabase(database *DB) { + if database == nil { + return + } + for _, connection := range database.Connections { + connectionName := runtimeConfigExportedName(connection.Name) + group := "DB" + connectionName + defaultVariant := connection.Default + if defaultVariant == "" && len(connection.Variants) == 1 { + defaultVariant = connection.Variants[0].Name + } + kindKey := valueOrDefault(connection.KindEnv, "DB_"+runtimeConfigEnvName(connection.Name)+"_KIND") + b.derived(group, "Kind", kindKey, runtimeDerived(RuntimeConfigString, defaultVariant, defaultVariant != "")) + for _, variant := range connection.Variants { + key := valueOrDefault(variant.DSNEnv, "DB_"+runtimeConfigEnvName(connection.Name)+"_"+runtimeConfigEnvName(variant.Name)+"_DSN") + b.derived(group, runtimeConfigExportedName(variant.Name)+"DSN", key, runtimeDerivedSecret(RuntimeConfigString, variant.DSNDefault, variant.DSNDefault != "", variant.Secret)) + } + } +} + +func (b *runtimeConfigBuilder) addRedis(redis *Redis) { + if redis == nil { + return + } + for _, connection := range redis.Connections { + key := valueOrDefault(connection.AddrEnv, "REDIS_"+runtimeConfigEnvName(connection.Name)+"_ADDR") + b.derived("Redis", runtimeConfigExportedName(connection.Name)+"Address", key, runtimeDerived(RuntimeConfigString, connection.AddrDefault, connection.AddrDefault != "")) + } +} + +func (b *runtimeConfigBuilder) addS3(storage *S3) { + if storage == nil { + return + } + for _, connection := range storage.Connections { + keyPrefix := "S3" + fieldPrefix := "" + if connection.Name != "" && connection.Name != "default" { + keyPrefix += "_" + runtimeConfigEnvName(connection.Name) + fieldPrefix = runtimeConfigExportedName(connection.Name) + } + b.derived("S3", fieldPrefix+"Endpoint", keyPrefix+"_ENDPOINT", runtimeDerived(RuntimeConfigString, connection.Endpoint, connection.Endpoint != "")) + b.derived("S3", fieldPrefix+"Region", keyPrefix+"_REGION", runtimeDerived(RuntimeConfigString, connection.Region, connection.Region != "")) + b.derived("S3", fieldPrefix+"ForcePathStyle", keyPrefix+"_FORCE_PATH_STYLE", runtimeDerived(RuntimeConfigBool, connection.PathStyle, true)) + if connection.Credentials == "static" { + accessKey := valueOrDefault(connection.AccessKeyEnv, keyPrefix+"_ACCESS_KEY_ID") + secretKey := valueOrDefault(connection.SecretKeyEnv, keyPrefix+"_SECRET_ACCESS_KEY") + b.derived("S3", fieldPrefix+"AccessKeyID", accessKey, runtimeDerivedSecret(RuntimeConfigString, nil, false, true)) + b.derived("S3", fieldPrefix+"SecretAccessKey", secretKey, runtimeDerivedSecret(RuntimeConfigString, nil, false, true)) + } + } + for _, bucket := range storage.Buckets { + key := "S3_" + runtimeConfigEnvName(bucket.Name) + "_BUCKET" + b.derived("S3", runtimeConfigExportedName(bucket.Name)+"Bucket", key, runtimeDerived(RuntimeConfigString, bucket.Bucket, bucket.Bucket != "")) + } +} + +func (b *runtimeConfigBuilder) addClients(http *HTTP, grpc *GRPC) { + if http != nil { + for _, client := range http.Clients { + key := valueOrDefault(client.BaseURLEnv, "HTTP_"+runtimeConfigEnvName(client.Name)+"_BASE_URL") + b.derived("HTTPClients", runtimeConfigExportedName(client.Name)+"BaseURL", key, runtimeDerived(RuntimeConfigString, nil, false)) + } + } + if grpc != nil { + for _, client := range grpc.Clients { + key := valueOrDefault(client.AddrEnv, "GRPC_"+runtimeConfigEnvName(client.Name)+"_ADDR") + b.derived("GRPCClients", runtimeConfigExportedName(client.Name)+"Address", key, runtimeDerived(RuntimeConfigString, nil, false)) + } + } +} + +func (b *runtimeConfigBuilder) addMigrations(database *DB) { + if database == nil { + return + } + for _, connection := range database.Connections { + for _, variant := range connection.Variants { + if variant.Migrations == nil || variant.Migrations.DatabaseEnv == "" { + continue + } + name := "DB" + runtimeConfigExportedName(connection.Name) + runtimeConfigExportedName(variant.Name) + "MigrationsURL" + b.derived("Migrations", name, variant.Migrations.DatabaseEnv, derivedRuntimeConfig{ + typeName: RuntimeConfigString, defaultVal: variant.Migrations.DatabaseDefault, + hasDefault: variant.Migrations.DatabaseDefault != "", secret: variant.Kind == "postgres" || variant.Kind == "clickhouse", + scopes: RuntimeConfigExample | RuntimeConfigInspect, + }) + } + } +} + +func (b *runtimeConfigBuilder) addExplicit(manifest Manifest) error { + for _, group := range manifest.Env.Custom { + if err := b.explicitVars(runtimeConfigExportedName(group.Group), group.Vars); err != nil { + return err + } + } + for _, environment := range explicitComponentEnvironments(manifest) { + if err := b.explicitEnv(environment.group, environment.environment); err != nil { + return err + } + } + return nil +} + +type namedComponentEnvironment struct { + group string + environment ComponentEnv +} + +func explicitComponentEnvironments(manifest Manifest) []namedComponentEnvironment { + components := manifest.Components + result := make([]namedComponentEnvironment, 0, 10) + if components.HTTP != nil { + result = append(result, namedComponentEnvironment{"HTTP", components.HTTP.Env}) + } + if components.GRPC != nil { + result = append(result, namedComponentEnvironment{"GRPC", components.GRPC.Env}) + } + if components.Kafka != nil { + result = append(result, namedComponentEnvironment{"Kafka", components.Kafka.Env}) + } + if components.Logging != nil { + result = append(result, namedComponentEnvironment{"Logging", components.Logging.Env}) + } + if components.Health != nil { + result = append(result, namedComponentEnvironment{"Health", components.Health.Env}) + } + if components.Telemetry != nil { + result = append(result, namedComponentEnvironment{"Telemetry", components.Telemetry.Env}) + } + if components.DB != nil { + result = append(result, namedComponentEnvironment{"Database", components.DB.Env}) + } + if components.Redis != nil { + result = append(result, namedComponentEnvironment{"Redis", components.Redis.Env}) + } + if components.S3 != nil { + result = append(result, namedComponentEnvironment{"S3", components.S3.Env}) + } + if pprof := manifest.Languages.Go.Components.Pprof; pprof != nil { + result = append(result, namedComponentEnvironment{"Pprof", pprof.Env}) + } + return result +} + +func (b *runtimeConfigBuilder) explicitEnv(group string, environment ComponentEnv) error { + variables := append(append([]EnvVar(nil), environment.System...), environment.Custom...) + return b.explicitVars(group, variables) +} + +func (b *runtimeConfigBuilder) explicitVars(group string, variables []EnvVar) error { + for _, variable := range variables { + if variable.Key == "" { + continue + } + name := runtimeConfigCustomName(group, variable.Key) + declaration := runtimeConfigDeclaration{ + group: group, name: name, typeName: runtimeConfigType(variable.Type), + defaultVal: variable.Default, hasDefault: variable.Default != nil, secret: variable.Secret, + } + if declaration.secret { + declaration.defaultVal = nil + declaration.hasDefault = false + } + key := b.key(variable.Key) + existing := b.byKey[key] + if existing == nil { + b.byKey[key] = &runtimeConfigCandidate{ + entry: runtimeConfigEntry{field: RuntimeConfigField{ + Group: group, Name: name, Key: key, Type: declaration.typeName, + Default: declaration.defaultVal, HasDefault: declaration.hasDefault, Secret: declaration.secret, + }, scopes: runtimeScopes()}, + explicit: &declaration, + } + continue + } + if existing.explicit != nil && !reflect.DeepEqual(*existing.explicit, declaration) { + return &RuntimeConfigConflictError{Key: key} + } + if existing.explicit == nil { + existing.explicit = &declaration + existing.entry.field.Type = declaration.typeName + existing.entry.field.Default = declaration.defaultVal + existing.entry.field.HasDefault = declaration.hasDefault + existing.entry.field.Secret = declaration.secret + } + } + return nil +} + +func (b *runtimeConfigBuilder) derived(group, name, key string, config derivedRuntimeConfig) { + if key == "" { + return + } + if config.secret { + config.defaultVal = nil + config.hasDefault = false + } + finalKey := b.key(key) + if _, exists := b.byKey[finalKey]; exists { + return + } + b.byKey[finalKey] = &runtimeConfigCandidate{entry: runtimeConfigEntry{field: RuntimeConfigField{ + Group: group, Name: name, Key: finalKey, Type: config.typeName, + Default: config.defaultVal, HasDefault: config.hasDefault, Secret: config.secret, + }, scopes: config.scopes}} +} + +func (b *runtimeConfigBuilder) key(key string) string { + if strings.HasPrefix(key, "OTEL_") { + return key + } + return b.prefix + key +} + +func (b *runtimeConfigBuilder) sortedEntries() ([]runtimeConfigEntry, error) { + entries := make([]runtimeConfigEntry, 0, len(b.byKey)) + paths := make(map[string]string, len(b.byKey)) + for key, candidate := range b.byKey { + fieldPath := candidate.entry.field.Group + "." + candidate.entry.field.Name + if otherKey, exists := paths[fieldPath]; exists && otherKey != key { + return nil, &RuntimeConfigConflictError{Field: fieldPath} + } + paths[fieldPath] = key + entries = append(entries, candidate.entry) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].field.Key < entries[j].field.Key }) + return entries, nil +} + +func runtimeConfigPrefix(manifest Manifest) string { + if manifest.Env.Prefix != "" { + return manifest.Env.Prefix + } + return runtimeConfigEnvName(manifest.Project.Name) + "_" +} + +func runtimeConfigType(value string) RuntimeConfigType { + switch value { + case "bool": + return RuntimeConfigBool + case "int": + return RuntimeConfigInt + case "duration": + return RuntimeConfigDuration + default: + return RuntimeConfigString + } +} + +func runtimeConfigCustomName(group, key string) string { + if group == "Telemetry" && strings.HasPrefix(key, "OTEL_") { + key = strings.TrimPrefix(key, "OTEL_") + } + return runtimeConfigExportedName(key) +} + +func runtimeConfigEnvName(value string) string { + return strings.ToUpper(strings.ReplaceAll(value, "-", "_")) +} + +func runtimeConfigExportedName(value string) string { + parts := strings.FieldsFunc(value, func(char rune) bool { + return char == '_' || char == '-' || !unicode.IsLetter(char) && !unicode.IsDigit(char) + }) + var builder strings.Builder + for _, part := range parts { + if part == "" { + continue + } + runes := []rune(strings.ToLower(part)) + runes[0] = unicode.ToUpper(runes[0]) + builder.WriteString(string(runes)) + } + if builder.Len() == 0 { + return "Value" + } + name := builder.String() + if unicode.IsDigit([]rune(name)[0]) { + return "Value" + name + } + return name +} + +func runtimeScopes() RuntimeConfigScope { + return RuntimeConfigRuntime | RuntimeConfigExample | RuntimeConfigInspect +} diff --git a/internal/domain/project/runtime_config_test.go b/internal/domain/project/runtime_config_test.go new file mode 100644 index 0000000..8ec5fa6 --- /dev/null +++ b/internal/domain/project/runtime_config_test.go @@ -0,0 +1,112 @@ +package project_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestRuntimeConfigCatalogBuildsCanonicalScopedFields(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Project: project.Identity{Name: "sample-api", Language: "go"}, + Components: project.Components{ + HTTP: &project.HTTP{ + Server: &project.HTTPServer{Start: &project.Start{}}, + Env: project.ComponentEnv{System: []project.EnvVar{{Key: "HTTP_ADDR", Type: "string", Default: ":8088"}}}, + }, + Telemetry: &project.Telemetry{Env: project.ComponentEnv{System: []project.EnvVar{{ + Key: "OTEL_SERVICE_NAME", Type: "string", Default: "sample-api", + }}}}, + DB: &project.DB{Connections: []project.DBConnection{{ + Name: "primary", + Variants: []project.DBVariant{{ + Name: "postgres", Kind: "postgres", DSNDefault: "postgres://secret", Secret: true, + Migrations: &project.DBMigrations{DatabaseEnv: "DB_PRIMARY_MIGRATIONS_URL", DatabaseDefault: "postgres://migration-secret"}, + }}, + }}}, + }, + } + + catalog, err := project.NewRuntimeConfigCatalog(manifest) + require.NoError(t, err) + require.Equal(t, "SAMPLE_API_", catalog.Prefix()) + require.Equal(t, []project.RuntimeConfigField{ + {Group: "Telemetry", Name: "ServiceName", Key: "OTEL_SERVICE_NAME", Type: project.RuntimeConfigString, Default: "sample-api", HasDefault: true}, + {Group: "DBPrimary", Name: "Kind", Key: "SAMPLE_API_DB_PRIMARY_KIND", Type: project.RuntimeConfigString, Default: "postgres", HasDefault: true}, + {Group: "DBPrimary", Name: "PostgresDSN", Key: "SAMPLE_API_DB_PRIMARY_POSTGRES_DSN", Type: project.RuntimeConfigString, Secret: true}, + {Group: "Telemetry", Name: "DeploymentEnvironment", Key: "SAMPLE_API_DEPLOYMENT_ENVIRONMENT", Type: project.RuntimeConfigString, Default: "development", HasDefault: true}, + {Group: "HTTP", Name: "Address", Key: "SAMPLE_API_HTTP_ADDR", Type: project.RuntimeConfigString, Default: ":8088", HasDefault: true}, + {Group: "HTTP", Name: "Enabled", Key: "SAMPLE_API_HTTP_SERVER_ENABLED", Type: project.RuntimeConfigBool, Default: false, HasDefault: true}, + {Group: "Telemetry", Name: "ServiceVersion", Key: "SAMPLE_API_SERVICE_VERSION", Type: project.RuntimeConfigString, Default: "dev", HasDefault: true}, + }, catalog.Entries(project.RuntimeConfigRuntime)) + require.Equal(t, []project.RuntimeConfigField{ + {Group: "Migrations", Name: "DBPrimaryPostgresMigrationsURL", Key: "SAMPLE_API_DB_PRIMARY_MIGRATIONS_URL", Type: project.RuntimeConfigString, Secret: true}, + }, onlyMigrationFields(catalog.Entries(project.RuntimeConfigExample))) + + fields := catalog.Entries(project.RuntimeConfigRuntime) + originalName := fields[0].Name + fields[0].Name = "Changed" + require.Equal(t, originalName, catalog.Entries(project.RuntimeConfigRuntime)[0].Name) +} + +func TestRuntimeConfigCatalogRejectsConflictingExplicitDeclarations(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Project: project.Identity{Name: "sample", Language: "go"}, + Env: project.Env{Custom: []project.EnvGroup{ + {Group: "First", Vars: []project.EnvVar{{Key: "SHARED", Type: "string", Default: "one"}}}, + {Group: "Second", Vars: []project.EnvVar{{Key: "SHARED", Type: "string", Default: "two"}}}, + }}, + } + + _, err := project.NewRuntimeConfigCatalog(manifest) + + var conflict *project.RuntimeConfigConflictError + require.ErrorAs(t, err, &conflict) + require.Equal(t, "SAMPLE_SHARED", conflict.Key) + require.NotContains(t, err.Error(), "one") + require.NotContains(t, err.Error(), "two") +} + +func TestRuntimeConfigCatalogSeparatesClickHouseRuntimeAndMigrationURLs(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Project: project.Identity{Name: "sample", Language: "go"}, + Components: project.Components{DB: &project.DB{Connections: []project.DBConnection{{ + Name: "analytics", Default: "clickhouse", Variants: []project.DBVariant{{ + Name: "clickhouse", Kind: "clickhouse", DSNDefault: "clickhouse://localhost:9000/default", Secret: true, + Migrations: &project.DBMigrations{Path: "migrations/analytics/clickhouse", DatabaseEnv: "DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL"}, + }}, + }}}}, + } + + catalog, err := project.NewRuntimeConfigCatalog(manifest) + require.NoError(t, err) + require.Contains(t, catalog.Entries(project.RuntimeConfigRuntime), project.RuntimeConfigField{ + Group: "DBAnalytics", Name: "ClickhouseDSN", Key: "SAMPLE_DB_ANALYTICS_CLICKHOUSE_DSN", + Type: project.RuntimeConfigString, Secret: true, + }) + require.NotContains(t, catalog.Entries(project.RuntimeConfigRuntime), project.RuntimeConfigField{ + Group: "Migrations", Name: "DBAnalyticsClickhouseMigrationsURL", Key: "SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", + Type: project.RuntimeConfigString, Secret: true, + }) + require.Contains(t, catalog.Entries(project.RuntimeConfigExample), project.RuntimeConfigField{ + Group: "Migrations", Name: "DBAnalyticsClickhouseMigrationsURL", Key: "SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", + Type: project.RuntimeConfigString, Secret: true, + }) +} + +func onlyMigrationFields(fields []project.RuntimeConfigField) []project.RuntimeConfigField { + result := make([]project.RuntimeConfigField, 0, len(fields)) + for _, field := range fields { + if field.Group == "Migrations" { + result = append(result, field) + } + } + return result +} diff --git a/internal/domain/project/source.go b/internal/domain/project/source.go new file mode 100644 index 0000000..3d20e01 --- /dev/null +++ b/internal/domain/project/source.go @@ -0,0 +1,29 @@ +package project + +// SourceType identifies the mechanism used to obtain a contract. +type SourceType string + +const ( + SourceLocal SourceType = "local" + SourceURL SourceType = "url" + SourceGit SourceType = "git" + SourceDevctl SourceType = "devctl" +) + +// Source describes a named local or external origin of contracts. +type Source struct { + Name string + Type SourceType + Path string + URL string + Filename string + AllowInsecureHTTP bool + Repo string + Ref string + Proto SourceProto +} + +// SourceProto contains protobuf tooling metadata relative to the source root. +type SourceProto struct { + BufConfig string +} diff --git a/internal/domain/project/target.go b/internal/domain/project/target.go new file mode 100644 index 0000000..5ece0ca --- /dev/null +++ b/internal/domain/project/target.go @@ -0,0 +1,403 @@ +package project + +import ( + "path" + "sort" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" +) + +// TargetOperation identifies a workflow that can address a Target. +type TargetOperation uint8 + +const ( + TargetOperationSync TargetOperation = iota + 1 + TargetOperationLint + TargetOperationGenerate +) + +// Target contains the stable effective facts shared by target workflows. +type Target struct { + ID string + Family string + Role string + Name string + Format string + + SourceName string + Source Source + SourceFound bool + Reference contract.Reference + Location contract.Location + + Input string + Paths []string + Config string + OutputDir string + OutputFile string +} + +// TargetCatalog is an immutable effective Target projection of one Manifest. +type TargetCatalog struct { + entries []targetCatalogEntry +} + +type targetCatalogEntry struct { + target Target + operations targetOperations +} + +type targetOperations uint8 + +const ( + targetSync targetOperations = 1 << iota + targetLint + targetGenerate +) + +// NewTargetCatalog applies effective defaults and returns Targets sorted by ID. +// It is total: malformed references remain visible for validation instead of returning an error. +func NewTargetCatalog(manifest Manifest) TargetCatalog { + entries := targetEntries(manifest) + sort.Slice(entries, func(i, j int) bool { return entries[i].target.ID < entries[j].target.ID }) + return TargetCatalog{entries: entries} +} + +// All returns every configured Target as a defensive copy in stable ID order. +func (c TargetCatalog) All() []Target { + return copyCatalogTargets(c.entries) +} + +// Select returns CLI-addressable Targets for operation, family, and id in stable ID order. +// Empty family or id values do not constrain that dimension; an unsupported selection is empty. +func (c TargetCatalog) Select(operation TargetOperation, family, id string) []Target { + mask := targetOperationMask(operation) + if mask == 0 { + return nil + } + entries := make([]targetCatalogEntry, 0, len(c.entries)) + for _, entry := range c.entries { + if entry.operations&mask == 0 || family != "" && entry.target.Family != family || id != "" && entry.target.ID != id { + continue + } + entries = append(entries, entry) + } + return copyCatalogTargets(entries) +} + +func targetEntries(manifest Manifest) []targetCatalogEntry { + var entries []targetCatalogEntry + if generator := manifest.Languages.Go.Generators.Config; generator != nil || manifest.Project.Language == "go" { + outputDir := "gen/config" + if generator != nil { + outputDir = valueOrDefault(generator.Out, outputDir) + } + entries = append(entries, targetCatalogEntry{ + target: Target{ + ID: "config", Family: "config", Format: "go", + OutputDir: outputDir, OutputFile: "config.gen.go", + }, + operations: targetGenerate, + }) + } + entries = append(entries, httpTargetEntries(manifest)...) + entries = append(entries, grpcTargetEntries(manifest)...) + entries = append(entries, kafkaTargetEntries(manifest)...) + return entries +} + +func httpTargetEntries(manifest Manifest) []targetCatalogEntry { + http := manifest.Components.HTTP + if http == nil { + return nil + } + generator := manifest.Languages.Go.Generators.HTTP + var entries []targetCatalogEntry + if server := http.Server; server != nil { + entrypoint := valueOrDefault(server.OpenAPI, "api/openapi/swagger.yaml") + entries = append(entries, targetCatalogEntry{ + target: Target{ + ID: "http-server", Family: "http", Role: "server", Format: "openapi", + Reference: contract.Reference{Entrypoint: entrypoint}, + Location: contract.Location{RelativePath: entrypoint, Entrypoint: entrypoint, Local: true}, + Input: entrypoint, Config: httpServerConfig(generator), + OutputDir: httpServerOutput(generator), OutputFile: "server.gen.go", + }, + operations: targetLint | targetGenerate, + }) + } + for _, client := range http.Clients { + source, found := manifest.Sources[client.Source] + location := targetContractLocation(manifest, targetLocationRequest{ + source: source, sourceFound: found, entrypoint: client.Path, + externalSuffix: path.Join("http", "client", client.Name), + }) + input := location.RelativePath + if location.Local { + input = path.Join(input, client.Path) + } + operations := targetLint | targetGenerate + if found { + operations |= targetSync + } + entries = append(entries, targetCatalogEntry{ + target: Target{ + ID: "http-client:" + client.Name, Family: "http", Role: "client", Name: client.Name, Format: "openapi", + SourceName: client.Source, Source: source, SourceFound: found, + Reference: contract.Reference{Entrypoint: client.Path, Export: client.Export}, Location: location, + Input: input, Config: valueOrDefault(client.OAPIConfig, "tools/oapi/clients."+client.Name+".yaml"), + OutputDir: path.Join(httpClientOutput(generator), client.Name), OutputFile: "client.gen.go", + }, + operations: operations, + }) + } + return entries +} + +func grpcTargetEntries(manifest Manifest) []targetCatalogEntry { + grpc := manifest.Components.GRPC + if grpc == nil { + return nil + } + generator := manifest.Languages.Go.Generators.GRPC + root := grpcOutput(generator) + var entries []targetCatalogEntry + if server := grpc.Server; server != nil { + entries = append(entries, targetCatalogEntry{ + target: Target{ + ID: "grpc-server", Family: "grpc", Role: "server", Format: "proto", + Reference: contract.Reference{Format: "proto", ProtoRoot: valueOrDefault(server.ProtoRoot, "api/proto/grpc")}, + Input: valueOrDefault(server.ProtoRoot, "api/proto/grpc"), Config: grpcConfig(generator, ""), + OutputDir: path.Join(root, "server"), + }, + operations: targetLint | targetGenerate, + }) + } + for _, client := range grpc.Clients { + source, found := manifest.Sources[client.Source] + protoRoot := valueOrDefault(client.ProtoRoot, client.Path) + location := targetContractLocation(manifest, targetLocationRequest{ + source: source, sourceFound: found, entrypoint: client.Path, + externalSuffix: path.Join("grpc", "client", client.Name), + }) + inputRoot := location.RelativePath + if protoRoot != "." { + inputRoot = path.Join(inputRoot, protoRoot) + } + operations := targetLint | targetGenerate + if found { + operations |= targetSync + } + entries = append(entries, targetCatalogEntry{ + target: Target{ + ID: "grpc-client:" + client.Name, Family: "grpc", Role: "client", Name: client.Name, Format: "proto", + SourceName: client.Source, Source: source, SourceFound: found, + Reference: contract.Reference{Entrypoint: client.Path, Export: client.Export, Format: "proto", ProtoRoot: client.ProtoRoot}, + Location: location, Input: inputRoot, Paths: selectedProtoPaths(client.Path, protoRoot), + Config: grpcConfig(generator, client.BufGenConfig), OutputDir: path.Join(root, "client", client.Name), + }, + operations: operations, + }) + } + return entries +} + +func kafkaTargetEntries(manifest Manifest) []targetCatalogEntry { + kafka := manifest.Components.Kafka + if kafka == nil { + return nil + } + entries := make([]targetCatalogEntry, 0, len(kafka.Consumers)+len(kafka.Producers)) + for _, consumer := range kafka.Consumers { + entries = append(entries, kafkaTargetEntry(manifest, kafkaTargetSelection{ + role: "consumer", name: consumer.Name, topic: consumer.Topic, contract: consumer.Contract, + })) + } + for _, producer := range kafka.Producers { + entries = append(entries, kafkaTargetEntry(manifest, kafkaTargetSelection{ + role: "producer", name: producer.Name, topic: producer.Topic, contract: producer.Contract, + })) + } + return entries +} + +type kafkaTargetSelection struct { + role string + name string + topic string + contract KafkaContract +} + +func kafkaTargetEntry(manifest Manifest, selection kafkaTargetSelection) targetCatalogEntry { + role, name, topic, selected := selection.role, selection.name, selection.topic, selection.contract + format := valueOrDefault(selected.Format, "raw") + target := Target{ + ID: "kafka-" + role + ":" + name, Family: "kafka", Role: role, Name: name, Format: format, + Reference: contract.Reference{ + Entrypoint: selected.Path, Export: selected.Export, Format: format, + ProtoRoot: selected.ProtoRoot, Topic: topic, + }, + } + operations := targetLint | targetGenerate + if format == "raw" { + return targetCatalogEntry{target: target, operations: operations} + } + source, found := manifest.Sources[selected.Source] + target.SourceName, target.Source, target.SourceFound = selected.Source, source, found + target.Location = targetContractLocation(manifest, targetLocationRequest{ + source: source, sourceFound: found, entrypoint: selected.Path, + externalSuffix: path.Join("kafka", role, name), + }) + target.OutputDir = path.Join(kafkaOutput(manifest.Languages.Go.Generators.Kafka), role, name) + if format == "json" { + target.Input = path.Join(target.Location.RelativePath, selected.Path) + target.OutputFile = "schema.gen.go" + } else { + protoRoot := valueOrDefault(selected.ProtoRoot, path.Dir(selected.Path)) + target.Input = path.Join(target.Location.RelativePath, protoRoot) + target.Paths = selectedProtoPaths(selected.Path, protoRoot) + target.Config = kafkaConfig(manifest.Languages.Go.Generators.Kafka) + } + if found { + operations |= targetSync + } + return targetCatalogEntry{target: target, operations: operations} +} + +type targetLocationRequest struct { + source Source + sourceFound bool + entrypoint string + externalSuffix string +} + +func targetContractLocation(manifest Manifest, request targetLocationRequest) contract.Location { + if request.sourceFound && request.source.Type == SourceLocal { + return contract.Location{RelativePath: request.source.Path, Entrypoint: request.entrypoint, Local: true} + } + return contract.Location{ + RelativePath: path.Join(externalContractsRoot(manifest), request.externalSuffix), + Entrypoint: request.entrypoint, + } +} + +func selectedProtoPaths(entrypoint, protoRoot string) []string { + selected := strings.TrimPrefix(path.Clean(entrypoint), path.Clean(protoRoot)+"/") + if selected == "." { + return nil + } + return []string{selected} +} + +func targetOperationMask(operation TargetOperation) targetOperations { + switch operation { + case TargetOperationSync: + return targetSync + case TargetOperationLint: + return targetLint + case TargetOperationGenerate: + return targetGenerate + default: + return 0 + } +} + +func copyCatalogTargets(entries []targetCatalogEntry) []Target { + targets := make([]Target, len(entries)) + for index, entry := range entries { + targets[index] = entry.target + targets[index].Paths = append([]string(nil), entry.target.Paths...) + } + return targets +} + +// SnapshotExpectation binds a Devctl-sourced Target to committed Snapshot Metadata. +func (target Target) SnapshotExpectation() contract.MetadataExpectation { + expected := contract.MetadataExpectation{Kind: target.Family, Format: target.Format} + if target.Family == "kafka" { + expected.Topic = target.Reference.Topic + } + return expected +} + +// WithSnapshot resolves a Target's concrete input from validated committed metadata. +func (target Target) WithSnapshot(snapshot contract.Snapshot) Target { + root := target.Location.RelativePath + switch { + case target.Family == "grpc": + target.Input = path.Join(root, snapshot.ModuleRoot) + target.Paths = nil + case target.Family == "kafka" && target.Format == "proto": + target.Input = path.Join(root, snapshot.ModuleRoot) + target.Paths = selectedProtoPaths(snapshot.Entrypoint, snapshot.ModuleRoot) + target.Location.Entrypoint = snapshot.Entrypoint + case target.Family == "kafka" && target.Format == "json": + target.Input = path.Join(root, snapshot.Entrypoint) + target.Location.Entrypoint = snapshot.Entrypoint + } + return target +} + +func valueOrDefault(value, fallback string) string { + if value != "" { + return value + } + return fallback +} + +func externalContractsRoot(manifest Manifest) string { + return valueOrDefault(manifest.Paths.ExternalContracts, "api/external") +} + +func httpServerConfig(generator *HTTPGenerator) string { + if generator == nil { + return "tools/oapi/server.yaml" + } + return valueOrDefault(generator.OAPIConfig, "tools/oapi/server.yaml") +} + +func httpServerOutput(generator *HTTPGenerator) string { + if generator == nil { + return "gen/serverhttp" + } + return valueOrDefault(generator.ServerOut, "gen/serverhttp") +} + +func httpClientOutput(generator *HTTPGenerator) string { + if generator == nil { + return "gen/clienthttp" + } + return valueOrDefault(generator.ClientOut, "gen/clienthttp") +} + +func grpcConfig(generator *GRPCGenerator, selected string) string { + if selected != "" { + return selected + } + if generator != nil && generator.BufGenConfig != "" { + return generator.BufGenConfig + } + return "tools/buf/grpc.gen.yaml" +} + +func grpcOutput(generator *GRPCGenerator) string { + if generator == nil { + return "gen/grpc" + } + return valueOrDefault(generator.Out, "gen/grpc") +} + +func kafkaConfig(generator *KafkaGenerator) string { + if generator != nil && generator.BufGenConfig != "" { + return generator.BufGenConfig + } + return "tools/buf/kafka.gen.yaml" +} + +func kafkaOutput(generator *KafkaGenerator) string { + if generator == nil { + return "gen/kafka" + } + return valueOrDefault(generator.Out, "gen/kafka") +} diff --git a/internal/domain/project/target_selection.go b/internal/domain/project/target_selection.go new file mode 100644 index 0000000..1ab2084 --- /dev/null +++ b/internal/domain/project/target_selection.go @@ -0,0 +1,80 @@ +package project + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// TargetSelectionReason identifies why a workflow target selection failed. +type TargetSelectionReason string + +const ( + TargetSelectionUnknownFamily TargetSelectionReason = "unknown_family" + TargetSelectionTargetNotFound TargetSelectionReason = "target_not_found" + TargetSelectionOperationUnsupported TargetSelectionReason = "operation_unsupported" +) + +// TargetSelectionError reports one presentation-neutral catalog selection fact. +type TargetSelectionError struct { + Operation TargetOperation + Family string + Target string + Reason TargetSelectionReason +} + +func (e *TargetSelectionError) Error() string { return "target selection failed" } + +// Category maps selection policy to the stable transport-neutral failure contract. +func (e *TargetSelectionError) Category() failure.Category { + switch e.Reason { + case TargetSelectionUnknownFamily: + return failure.InvalidInput + case TargetSelectionTargetNotFound: + return failure.NotFound + case TargetSelectionOperationUnsupported: + return failure.Unsupported + default: + return failure.Internal + } +} + +// Resolve applies the workflow selection contract and returns targets in catalog ID order. +func (c TargetCatalog) Resolve(operation TargetOperation, family, id string) ([]Target, error) { + if family != "" && !knownTargetFamily(family) { + return nil, &TargetSelectionError{ + Operation: operation, Family: family, Target: id, Reason: TargetSelectionUnknownFamily, + } + } + if id == "" { + return c.Select(operation, family, ""), nil + } + + entry, exists := c.entry(id) + if !exists || family != "" && entry.target.Family != family { + return nil, &TargetSelectionError{ + Operation: operation, Family: family, Target: id, Reason: TargetSelectionTargetNotFound, + } + } + mask := targetOperationMask(operation) + if mask == 0 || entry.operations&mask == 0 { + return nil, &TargetSelectionError{ + Operation: operation, Family: family, Target: id, Reason: TargetSelectionOperationUnsupported, + } + } + return copyCatalogTargets([]targetCatalogEntry{entry}), nil +} + +func (c TargetCatalog) entry(id string) (targetCatalogEntry, bool) { + for _, entry := range c.entries { + if entry.target.ID == id { + return entry, true + } + } + return targetCatalogEntry{}, false +} + +func knownTargetFamily(family string) bool { + switch family { + case "config", "http", "grpc", "kafka": + return true + default: + return false + } +} diff --git a/internal/domain/project/target_test.go b/internal/domain/project/target_test.go new file mode 100644 index 0000000..a9a8ef1 --- /dev/null +++ b/internal/domain/project/target_test.go @@ -0,0 +1,242 @@ +package project_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestTargetCatalogBuildsStableFacts(t *testing.T) { + t.Parallel() + + localSource := project.Source{Type: project.SourceLocal, Path: "api/contracts"} + remoteSource := project.Source{Type: project.SourceGit, Repo: "acme/contracts", Ref: "v1"} + manifest := project.Manifest{ + Paths: project.ManifestPaths{ExternalContracts: "contracts/external"}, + Sources: map[string]project.Source{ + "local": localSource, + "remote": remoteSource, + }, + Components: project.Components{ + HTTP: &project.HTTP{ + Server: &project.HTTPServer{}, + Clients: []project.HTTPClient{{ + Name: "catalog", Source: "remote", Path: "openapi/catalog.yaml", + }}, + }, + GRPC: &project.GRPC{Clients: []project.GRPCClient{{ + Name: "billing", Source: "local", Path: "proto/acme/billing/v1/service.proto", + ProtoRoot: "proto", BufGenConfig: "tools/buf/billing.gen.yaml", + }}}, + Kafka: &project.Kafka{ + Consumers: []project.KafkaConsumer{{ + Name: "audit", Topic: "audit.events.v1", Contract: project.KafkaContract{Format: "raw"}, + }}, + Producers: []project.KafkaProducer{{ + Name: "invoice", Topic: "invoice.events.v1", Contract: project.KafkaContract{ + Source: "remote", Path: "proto/acme/invoice/v1/event.proto", Format: "proto", + ProtoRoot: "proto", Message: "acme.invoice.v1.Event", Encoding: "binary", + }, + }}, + }, + }, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + Config: &project.ConfigGenerator{Out: "generated/config"}, + HTTP: &project.HTTPGenerator{ServerOut: "generated/http/server", ClientOut: "generated/http/client"}, + GRPC: &project.GRPCGenerator{Out: "generated/grpc"}, + Kafka: &project.KafkaGenerator{Out: "generated/kafka", BufGenConfig: "tools/buf/kafka.custom.yaml"}, + }}}, + } + + targets := project.NewTargetCatalog(manifest).All() + + require.Equal(t, []project.Target{ + { + ID: "config", Family: "config", Format: "go", + OutputDir: "generated/config", OutputFile: "config.gen.go", + }, + { + ID: "grpc-client:billing", Family: "grpc", Role: "client", Name: "billing", Format: "proto", + SourceName: "local", Source: localSource, SourceFound: true, + Reference: contract.Reference{Entrypoint: "proto/acme/billing/v1/service.proto", Format: "proto", ProtoRoot: "proto"}, + Location: contract.Location{RelativePath: "api/contracts", Entrypoint: "proto/acme/billing/v1/service.proto", Local: true}, + Input: "api/contracts/proto", Paths: []string{"acme/billing/v1/service.proto"}, + Config: "tools/buf/billing.gen.yaml", OutputDir: "generated/grpc/client/billing", + }, + { + ID: "http-client:catalog", Family: "http", Role: "client", Name: "catalog", Format: "openapi", + SourceName: "remote", Source: remoteSource, SourceFound: true, + Reference: contract.Reference{Entrypoint: "openapi/catalog.yaml"}, + Location: contract.Location{RelativePath: "contracts/external/http/client/catalog", Entrypoint: "openapi/catalog.yaml"}, + Input: "contracts/external/http/client/catalog", Config: "tools/oapi/clients.catalog.yaml", + OutputDir: "generated/http/client/catalog", OutputFile: "client.gen.go", + }, + { + ID: "http-server", Family: "http", Role: "server", Format: "openapi", + Reference: contract.Reference{Entrypoint: "api/openapi/swagger.yaml"}, + Location: contract.Location{RelativePath: "api/openapi/swagger.yaml", Entrypoint: "api/openapi/swagger.yaml", Local: true}, + Input: "api/openapi/swagger.yaml", Config: "tools/oapi/server.yaml", + OutputDir: "generated/http/server", OutputFile: "server.gen.go", + }, + { + ID: "kafka-consumer:audit", Family: "kafka", Role: "consumer", Name: "audit", Format: "raw", + Reference: contract.Reference{Format: "raw", Topic: "audit.events.v1"}, + }, + { + ID: "kafka-producer:invoice", Family: "kafka", Role: "producer", Name: "invoice", Format: "proto", + SourceName: "remote", Source: remoteSource, SourceFound: true, + Reference: contract.Reference{ + Entrypoint: "proto/acme/invoice/v1/event.proto", Format: "proto", ProtoRoot: "proto", Topic: "invoice.events.v1", + }, + Location: contract.Location{RelativePath: "contracts/external/kafka/producer/invoice", Entrypoint: "proto/acme/invoice/v1/event.proto"}, + Input: "contracts/external/kafka/producer/invoice/proto", Paths: []string{"acme/invoice/v1/event.proto"}, + Config: "tools/buf/kafka.custom.yaml", OutputDir: "generated/kafka/producer/invoice", + }, + }, targets) +} + +func TestTargetCatalogSelectsCLIAddressableTargets(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Sources: map[string]project.Source{ + "local": {Type: project.SourceLocal, Path: "api/contracts"}, + "remote": {Type: project.SourceURL, URL: "https://example.test/openapi.yaml"}, + }, + Components: project.Components{ + HTTP: &project.HTTP{ + Server: &project.HTTPServer{}, + Clients: []project.HTTPClient{ + {Name: "local", Source: "local", Path: "openapi.yaml"}, + {Name: "remote", Source: "remote", Path: "openapi.yaml"}, + }, + }, + Kafka: &project.Kafka{ + Consumers: []project.KafkaConsumer{{Name: "raw", Contract: project.KafkaContract{Format: "raw"}}}, + Producers: []project.KafkaProducer{{ + Name: "schema", Contract: project.KafkaContract{Source: "local", Path: "schema.json", Format: "json"}, + }}, + }, + }, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{Config: &project.ConfigGenerator{}}}}, + } + catalog := project.NewTargetCatalog(manifest) + + require.Equal(t, + []string{"http-client:local", "http-client:remote", "kafka-producer:schema"}, + targetIDs(catalog.Select(project.TargetOperationSync, "", "")), + ) + require.Equal(t, + []string{"http-client:local", "http-client:remote", "http-server", "kafka-consumer:raw", "kafka-producer:schema"}, + targetIDs(catalog.Select(project.TargetOperationLint, "", "")), + ) + require.Equal(t, + []string{"config", "http-client:local", "http-client:remote", "http-server", "kafka-consumer:raw", "kafka-producer:schema"}, + targetIDs(catalog.Select(project.TargetOperationGenerate, "", "")), + ) + require.Equal(t, + []string{"http-client:local"}, + targetIDs(catalog.Select(project.TargetOperationSync, "http", "http-client:local")), + ) + require.Empty(t, catalog.Select(project.TargetOperationSync, "kafka", "kafka-consumer:raw")) + require.Empty(t, catalog.Select(project.TargetOperationGenerate, "grpc", "http-server")) +} + +func TestTargetCatalogDefaultsGoConfigTarget(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Project: project.Identity{Language: "go"}, + Languages: project.Languages{Go: project.GoLanguage{Module: "example.test/sample"}}, + } + + targets := project.NewTargetCatalog(manifest).Select(project.TargetOperationGenerate, "config", "") + + require.Equal(t, []project.Target{{ + ID: "config", Family: "config", Format: "go", + OutputDir: "gen/config", OutputFile: "config.gen.go", + }}, targets) +} + +func TestTargetCatalogIsTotalAndReturnsDefensiveCopies(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{Components: project.Components{GRPC: &project.GRPC{Clients: []project.GRPCClient{{ + Name: "missing", Source: "unknown", Path: "proto/missing.proto", ProtoRoot: "proto", + }}}}} + catalog := project.NewTargetCatalog(manifest) + + targets := catalog.All() + require.Len(t, targets, 1) + require.Equal(t, "unknown", targets[0].SourceName) + require.False(t, targets[0].SourceFound) + require.Equal(t, "api/external/grpc/client/missing/proto", targets[0].Input) + + targets[0].ID = "changed" + targets[0].Paths[0] = "changed.proto" + require.Equal(t, "grpc-client:missing", catalog.All()[0].ID) + require.Equal(t, []string{"missing.proto"}, catalog.All()[0].Paths) +} + +func TestTargetCatalogAppliesOneWorkflowSelectionContract(t *testing.T) { + t.Parallel() + + manifest := project.Manifest{ + Project: project.Identity{Language: "go"}, + Sources: map[string]project.Source{ + "local": {Type: project.SourceLocal, Path: "api/contracts"}, + }, + Components: project.Components{ + HTTP: &project.HTTP{Clients: []project.HTTPClient{{Name: "local", Source: "local", Path: "openapi.yaml"}}}, + Kafka: &project.Kafka{Consumers: []project.KafkaConsumer{{ + Name: "raw", Topic: "sample.events.raw.v1", Contract: project.KafkaContract{Format: "raw"}, + }}}, + }, + } + catalog := project.NewTargetCatalog(manifest) + + tests := []struct { + name string + operation project.TargetOperation + family string + id string + ids []string + category failure.Category + }{ + {name: "known empty family", operation: project.TargetOperationSync, family: "grpc", ids: []string{}}, + {name: "known empty lint family", operation: project.TargetOperationLint, family: "config", ids: []string{}}, + {name: "local sync no-op is supported", operation: project.TargetOperationSync, family: "http", id: "http-client:local", ids: []string{"http-client:local"}}, + {name: "unknown family", operation: project.TargetOperationSync, family: "other", category: failure.InvalidInput}, + {name: "unknown lint family", operation: project.TargetOperationLint, family: "other", category: failure.InvalidInput}, + {name: "unknown target", operation: project.TargetOperationGenerate, id: "grpc-client:missing", category: failure.NotFound}, + {name: "existing target unsupported by operation", operation: project.TargetOperationSync, id: "config", category: failure.Unsupported}, + {name: "existing target unsupported by lint", operation: project.TargetOperationLint, id: "config", category: failure.Unsupported}, + {name: "raw Kafka does not support sync", operation: project.TargetOperationSync, id: "kafka-consumer:raw", category: failure.Unsupported}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + targets, err := catalog.Resolve(test.operation, test.family, test.id) + + if test.category != "" { + require.Equal(t, test.category, failure.CategoryOf(err)) + return + } + require.NoError(t, err) + require.Equal(t, test.ids, targetIDs(targets)) + }) + } +} + +func targetIDs(targets []project.Target) []string { + ids := make([]string, len(targets)) + for index, target := range targets { + ids[index] = target.ID + } + return ids +} diff --git a/internal/domain/project/validation.go b/internal/domain/project/validation.go new file mode 100644 index 0000000..4b6f910 --- /dev/null +++ b/internal/domain/project/validation.go @@ -0,0 +1,512 @@ +package project + +import ( + "errors" + "net" + "net/url" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +var validationName = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) +var validationEnvironmentKey = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +type semanticValidation struct { + project Project + issues []Issue +} + +// Validate returns every context-free semantic issue in stable order. +func Validate(project Project) []Issue { + validation := &semanticValidation{project: project} + validation.validateIdentityAndPaths() + validation.validateSources() + validation.validateExports() + validation.validateHTTP() + validation.validateGRPC() + validation.validateKafka() + validation.validateComponentPolicies() + validation.validateRuntimeConfig() + return validation.issues +} + +func (v *semanticValidation) add(code IssueCode, field string) { + v.issues = append(v.issues, Issue{Code: code, Path: v.project.ManifestPath, Field: field}) +} + +func (v *semanticValidation) validateIdentityAndPaths() { + manifest := v.project.Manifest + if manifest.Version != 1 { + v.add(IssueVersionUnsupported, "version") + } + if !validationName.MatchString(manifest.Project.Name) { + v.add(IssueNameInvalid, "project.name") + } + if manifest.Project.Language != "go" { + v.add(IssueLanguageUnsupported, "project.language") + } + if manifest.Languages.Go.Module == "" { + v.add(IssueGoModuleRequired, "languages.go.module") + } + if manifest.Paths.ExternalContracts != "" && !validationSafeRelative(manifest.Paths.ExternalContracts) { + v.add(IssuePathInvalid, "paths.external_contracts") + } + v.validateOutputPaths() +} + +func (v *semanticValidation) validateSources() { + for _, name := range validationSortedKeys(v.project.Manifest.Sources) { + v.validateSource(name, v.project.Manifest.Sources[name]) + } +} + +func (v *semanticValidation) validateSource(name string, source Source) { + base := "sources." + name + if !validationName.MatchString(name) { + v.add(IssueSourceNameInvalid, base) + } + unsafeBufConfig := source.Proto.BufConfig != "" && !validationSafeRelative(source.Proto.BufConfig) + validatedSource := source + if unsafeBufConfig { + validatedSource.Proto.BufConfig = "" + v.add(IssuePathInvalid, base+".proto.buf_config") + } + if !sourceShapeValid(validatedSource) { + v.add(IssueSourceInvalid, base) + } + switch source.Type { + case SourceLocal: + if source.Path == "" || !validationSafeRelative(source.Path) { + v.add(IssueSourceInvalid, base) + } + case SourceURL: + if source.URL == "" { + v.add(IssueSourceInvalid, base) + } + if strings.HasPrefix(strings.ToLower(source.URL), "http://") && !source.AllowInsecureHTTP { + v.add(IssueSourceInsecure, base) + } + case SourceGit, SourceDevctl: + if source.Repo == "" || source.Ref == "" { + v.add(IssueSourceInvalid, base) + } + default: + v.add(IssueSourceTypeUnsupported, base) + } +} + +func sourceShapeValid(source Source) bool { + if source.Proto.BufConfig != "" && !validationSafeRelative(source.Proto.BufConfig) { + return false + } + switch source.Type { + case SourceLocal: + return validationSafeRelative(source.Path) && source.URL == "" && source.Repo == "" && + source.Ref == "" && source.Filename == "" && !source.AllowInsecureHTTP + case SourceURL: + parsed, err := url.Parse(source.URL) + validURL := err == nil && parsed.Host != "" && parsed.User == nil && + (parsed.Scheme == "https" || parsed.Scheme == "http" && source.AllowInsecureHTTP) + return validURL && source.Path == "" && source.Repo == "" && source.Ref == "" && + (source.Filename == "" || !strings.Contains(source.Filename, "/")) + case SourceGit: + return source.Repo != "" && source.Ref != "" && + (source.Path == "" || validationSafeRelative(source.Path)) && source.URL == "" && + source.Filename == "" && !source.AllowInsecureHTTP + case SourceDevctl: + return source.Repo != "" && source.Ref != "" && source.Path == "" && source.URL == "" && + source.Filename == "" && !source.AllowInsecureHTTP + default: + return false + } +} + +func (v *semanticValidation) validateExports() { + for _, name := range validationSortedKeys(v.project.Manifest.Exports) { + if !v.project.Manifest.ExportMatchesSurface(v.project.Manifest.Exports[name]) { + v.add(IssueExportInvalid, "exports."+name) + } + } +} + +func (v *semanticValidation) validateHTTP() { + httpComponent := v.project.Manifest.Components.HTTP + if httpComponent == nil { + return + } + if server := httpComponent.Server; server != nil { + entrypoint := valueOrDefault(server.OpenAPI, "api/openapi/swagger.yaml") + if !validationSafeRelative(entrypoint) { + v.add(IssuePathInvalid, "components.http.server.openapi") + } + } + seen := make(map[string]bool, len(httpComponent.Clients)) + for _, client := range httpComponent.Clients { + base := "components.http.clients." + client.Name + if !validationName.MatchString(client.Name) || seen[client.Name] { + v.add(IssueHTTPClientInvalid, base) + } + source, exists := v.project.Manifest.Sources[client.Source] + if !exists { + v.add(IssueSourceNotFound, base+".source") + } else if clientContractSelectionInvalid(source, client.Export, client.Path) { + v.add(IssueHTTPClientInvalid, base) + } + seen[client.Name] = true + } +} + +func (v *semanticValidation) validateGRPC() { + grpc := v.project.Manifest.Components.GRPC + if grpc == nil { + return + } + if server := grpc.Server; server != nil { + if server.ProtoRoot != "" && !validationSafeProtoRoot(server.ProtoRoot) { + v.add(IssuePathInvalid, "components.grpc.server.proto_root") + } + if server.BufConfig != "" && !validationSafeRelative(server.BufConfig) { + v.add(IssuePathInvalid, "components.grpc.server.buf_config") + } + } + if generator := v.project.Manifest.Languages.Go.Generators.GRPC; generator != nil && + generator.BufGenConfig != "" && !validationSafeRelative(generator.BufGenConfig) { + v.add(IssuePathInvalid, "languages.go.generators.grpc.buf_gen_config") + } + seen := make(map[string]bool, len(grpc.Clients)) + for _, client := range grpc.Clients { + v.validateGRPCClient(client, seen[client.Name]) + seen[client.Name] = true + } +} + +func (v *semanticValidation) validateGRPCClient(client GRPCClient, duplicate bool) { + base := "components.grpc.clients." + client.Name + if !validationName.MatchString(client.Name) || duplicate { + v.add(IssueGRPCClientInvalid, base) + } + source, exists := v.project.Manifest.Sources[client.Source] + if !exists { + v.add(IssueSourceNotFound, base+".source") + return + } + if clientContractSelectionInvalid(source, client.Export, client.Path) { + v.add(IssueGRPCClientInvalid, base) + } + if client.Path != "" && !validationSafeRelative(client.Path) { + v.add(IssuePathInvalid, base+".path") + } + if client.ProtoRoot != "" && !validationSafeProtoRoot(client.ProtoRoot) { + v.add(IssuePathInvalid, base+".proto_root") + } + if client.BufGenConfig != "" && !validationSafeRelative(client.BufGenConfig) { + v.add(IssuePathInvalid, base+".buf_gen_config") + } +} + +func clientContractSelectionInvalid(source Source, exported, selectedPath string) bool { + if source.Type == SourceDevctl { + return exported == "" || selectedPath != "" + } + return selectedPath == "" || exported != "" +} + +func (v *semanticValidation) validateKafka() { + kafka := v.project.Manifest.Components.Kafka + if kafka == nil { + return + } + for _, consumer := range kafka.Consumers { + base := "components.kafka.consumers." + consumer.Name + ".contract" + v.validateKafkaContract(base, consumer.Contract) + v.validateKafkaSource(base+".source", consumer.Contract) + } + for _, producer := range kafka.Producers { + base := "components.kafka.producers." + producer.Name + ".contract" + v.validateKafkaContract(base, producer.Contract) + v.validateKafkaSource(base+".source", producer.Contract) + } +} + +func (v *semanticValidation) validateKafkaContract(field string, selected KafkaContract) { + format := valueOrDefault(selected.Format, "raw") + if format == "raw" { + if selected.Source != "" || selected.Export != "" || selected.Path != "" || + selected.ProtoRoot != "" || selected.Message != "" || selected.Encoding != "" { + v.add(IssueKafkaContractInvalid, field) + } + return + } + if format != "json" && format != "proto" || selected.Source == "" { + v.add(IssueKafkaContractInvalid, field) + return + } + source, exists := v.project.Manifest.Sources[selected.Source] + if !exists { + return + } + if kafkaSourceSelectionInvalid(source, selected) { + v.add(IssueKafkaContractInvalid, field) + return + } + if selected.Path != "" && !validationSafeRelative(selected.Path) { + v.add(IssueKafkaContractInvalid, field) + return + } + if format == "json" { + if selected.ProtoRoot != "" || selected.Message != "" || selected.Encoding != "" { + v.add(IssueKafkaContractInvalid, field) + } + return + } + protoRoot := valueOrDefault(selected.ProtoRoot, filepath.ToSlash(filepath.Dir(selected.Path))) + if !validationSafeProtoRoot(protoRoot) || selected.Path != "" && !validationPathWithin(protoRoot, selected.Path) || + selected.Encoding != "" && selected.Encoding != "binary" && selected.Encoding != "json" { + v.add(IssueKafkaContractInvalid, field) + } +} + +func kafkaSourceSelectionInvalid(source Source, selected KafkaContract) bool { + if source.Type == SourceDevctl { + return selected.Export == "" || selected.Path != "" + } + return selected.Path == "" || selected.Export != "" +} + +func (v *semanticValidation) validateKafkaSource(field string, selected KafkaContract) { + if selected.Source == "" { + return + } + if _, exists := v.project.Manifest.Sources[selected.Source]; !exists { + v.add(IssueSourceNotFound, field) + } +} + +func (v *semanticValidation) validateComponentPolicies() { + manifest := v.project.Manifest + if manifest.Components.DB != nil { + v.validateDB(manifest.Components.DB) + } + if manifest.Components.S3 != nil { + v.validateS3(manifest.Components.S3) + } + if manifest.Components.Redis != nil { + v.validateRedis(manifest.Components.Redis) + } +} + +func (v *semanticValidation) validateDB(database *DB) { + if len(database.Connections) == 0 { + v.add(IssueDBConnectionInvalid, "components.db.connections") + return + } + seen := make(map[string]bool, len(database.Connections)) + for _, connection := range database.Connections { + v.validateDBConnection(connection, seen[connection.Name]) + seen[connection.Name] = true + } +} + +func (v *semanticValidation) validateDBConnection(connection DBConnection, duplicate bool) { + base := "components.db.connections." + connection.Name + if !validationName.MatchString(connection.Name) || duplicate { + v.add(IssueDBConnectionInvalid, base) + } + seen := make(map[string]bool, len(connection.Variants)) + for _, variant := range connection.Variants { + validKind := variant.Kind == "sqlite" || variant.Kind == "postgres" || variant.Kind == "clickhouse" + if !validationName.MatchString(variant.Name) || seen[variant.Name] || !validKind { + v.add(IssueDBVariantInvalid, base) + } + seen[variant.Name] = true + if variant.Migrations != nil && !validDBMigrations(variant.Kind, variant.Migrations) { + v.add(IssueDBMigrationsInvalid, base+".variants."+variant.Name+".migrations") + } + } + if connection.Default == "" && len(connection.Variants) != 1 || + connection.Default != "" && !seen[connection.Default] { + v.add(IssueDBDefaultInvalid, base+".default") + } +} + +func validDBMigrations(kind string, migrations *DBMigrations) bool { + if migrations == nil { + return true + } + if kind != "sqlite" && kind != "postgres" && kind != "clickhouse" || + !validationSafeRelative(migrations.Path) || !validationEnvironmentKey.MatchString(migrations.DatabaseEnv) { + return false + } + if migrations.DatabaseDefault == "" { + return true + } + parsed, err := url.Parse(migrations.DatabaseDefault) + if err != nil { + return false + } + switch kind { + case "sqlite": + return parsed.Scheme == "sqlite" + case "clickhouse": + return parsed.Scheme == "clickhouse" + default: + return parsed.Scheme == "postgres" || parsed.Scheme == "postgresql" + } +} + +func (v *semanticValidation) validateRedis(redis *Redis) { + seen := make(map[string]bool, len(redis.Connections)) + for _, connection := range redis.Connections { + base := "components.redis.connections." + connection.Name + if !validationName.MatchString(connection.Name) || seen[connection.Name] || + !validationEnvironmentKey.MatchString(connection.AddrEnv) { + v.add(IssueRedisConnectionInvalid, base) + } + if connection.AddrDefault != "" && !validRedisAddress(connection.AddrDefault) { + v.add(IssueRedisAddressInvalid, base+".addr_default") + } + seen[connection.Name] = true + } +} + +func validRedisAddress(value string) bool { + if strings.Contains(value, "://") { + parsed, err := url.Parse(value) + return err == nil && (parsed.Scheme == "redis" || parsed.Scheme == "rediss") && + parsed.Hostname() != "" && parsed.User == nil + } + host, port, err := net.SplitHostPort(value) + if err != nil || host == "" { + return false + } + number, err := strconv.Atoi(port) + return err == nil && number > 0 && number <= 65535 +} + +func (v *semanticValidation) validateS3(storage *S3) { + connections := make(map[string]struct{}, len(storage.Connections)) + for _, connection := range storage.Connections { + connections[connection.Name] = struct{}{} + } + for _, bucket := range storage.Buckets { + if _, exists := connections[bucket.Connection]; !exists { + v.add(IssueS3ConnectionNotFound, "components.s3.buckets."+bucket.Name+".connection") + } + } +} + +func (v *semanticValidation) validateRuntimeConfig() { + _, err := NewRuntimeConfigCatalog(v.project.Manifest) + var conflict *RuntimeConfigConflictError + if errors.As(err, &conflict) { + v.add(IssueRuntimeConfigConflict, "env") + } +} + +func (v *semanticValidation) validateOutputPaths() { + paths := managedOutputPaths(v.project.Manifest) + for _, candidate := range paths { + if candidate.value != "" && !validationSafeRelative(candidate.value) { + v.add(IssuePathInvalid, candidate.field) + } + } + for index := range paths { + v.validateOutputOverlaps(paths, index) + } +} + +type managedPath struct { + field string + value string +} + +func managedOutputPaths(manifest Manifest) []managedPath { + paths := []managedPath{{field: "paths.external_contracts", value: valueOrDefault(manifest.Paths.ExternalContracts, "api/external")}} + seen := make(map[managedPath]struct{}) + for _, target := range NewTargetCatalog(manifest).All() { + candidate := managedPath{field: targetOutputField(target), value: target.OutputDir} + if candidate.field == "" || candidate.value == "" { + continue + } + if _, exists := seen[candidate]; exists { + continue + } + seen[candidate] = struct{}{} + paths = append(paths, candidate) + } + return paths +} + +func (v *semanticValidation) validateOutputOverlaps(paths []managedPath, leftIndex int) { + leftCandidate := paths[leftIndex] + if leftCandidate.value == "" { + return + } + left := filepath.Clean(filepath.FromSlash(leftCandidate.value)) + for _, rightCandidate := range paths[leftIndex+1:] { + if rightCandidate.value == "" { + continue + } + right := filepath.Clean(filepath.FromSlash(rightCandidate.value)) + if validationPathsOverlap(left, right) { + v.add(IssuePathOverlap, leftCandidate.field) + } + } +} + +func targetOutputField(target Target) string { + switch target.Family { + case "config": + return "languages.go.generators.config.out" + case "http": + if target.Role == "server" { + return "languages.go.generators.http.server_out" + } + return "languages.go.generators.http.client_out" + case "grpc": + return "languages.go.generators.grpc.out" + case "kafka": + return "languages.go.generators.kafka.out" + default: + return "" + } +} + +func validationSafeRelative(name string) bool { + if name == "" || strings.HasPrefix(name, "/") { + return false + } + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") +} + +func validationSafeProtoRoot(value string) bool { + return value == "." || validationSafeRelative(value) +} + +func validationPathWithin(root, selected string) bool { + root = filepath.ToSlash(filepath.Clean(root)) + selected = filepath.ToSlash(filepath.Clean(selected)) + if root == "." { + return validationSafeRelative(selected) + } + return selected == root || strings.HasPrefix(selected, root+"/") +} + +func validationPathsOverlap(left, right string) bool { + separator := string(filepath.Separator) + return left == right || strings.HasPrefix(left, right+separator) || strings.HasPrefix(right, left+separator) +} + +func validationSortedKeys[Value any](values map[string]Value) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/domain/project/validation_policy_test.go b/internal/domain/project/validation_policy_test.go new file mode 100644 index 0000000..6720130 --- /dev/null +++ b/internal/domain/project/validation_policy_test.go @@ -0,0 +1,174 @@ +package project_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestValidateContextFreePolicyGroups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*project.Manifest) + expected []project.Issue + }{ + { + name: "sources", + mutate: func(manifest *project.Manifest) { + manifest.Sources = map[string]project.Source{ + "Bad": {Type: project.SourceLocal, Path: "contracts"}, + "devctl": {Type: project.SourceDevctl, Repo: "example/contracts", Ref: "v1", Path: "unexpected"}, + "insecure": {Type: project.SourceURL, URL: "http://example.test/openapi.yaml"}, + "unknown": {Type: project.SourceType("ftp")}, + } + }, + expected: []project.Issue{ + {Code: project.IssueSourceNameInvalid, Path: projectManifestPath, Field: "sources.Bad"}, + {Code: project.IssueSourceInvalid, Path: projectManifestPath, Field: "sources.devctl"}, + {Code: project.IssueSourceInvalid, Path: projectManifestPath, Field: "sources.insecure"}, + {Code: project.IssueSourceInsecure, Path: projectManifestPath, Field: "sources.insecure"}, + {Code: project.IssueSourceInvalid, Path: projectManifestPath, Field: "sources.unknown"}, + {Code: project.IssueSourceTypeUnsupported, Path: projectManifestPath, Field: "sources.unknown"}, + }, + }, + { + name: "exports are checked in name order", + mutate: func(manifest *project.Manifest) { + manifest.Exports = map[string]project.Export{ + "zeta": {Kind: "openapi", Path: "api/zeta.yaml"}, + "alpha": {Kind: "proto", Path: "api/alpha.proto"}, + } + }, + expected: []project.Issue{ + {Code: project.IssueExportInvalid, Path: projectManifestPath, Field: "exports.alpha"}, + {Code: project.IssueExportInvalid, Path: projectManifestPath, Field: "exports.zeta"}, + }, + }, + { + name: "http", + mutate: func(manifest *project.Manifest) { + manifest.Sources = map[string]project.Source{ + "local": {Type: project.SourceLocal, Path: "contracts"}, + } + manifest.Components.HTTP = &project.HTTP{ + Server: &project.HTTPServer{OpenAPI: "../openapi.yaml"}, + Clients: []project.HTTPClient{ + {Name: "Bad", Source: "missing"}, + {Name: "local", Source: "local"}, + {Name: "local", Source: "local", Path: "openapi.yaml"}, + }, + } + }, + expected: []project.Issue{ + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.http.server.openapi"}, + {Code: project.IssueHTTPClientInvalid, Path: projectManifestPath, Field: "components.http.clients.Bad"}, + {Code: project.IssueSourceNotFound, Path: projectManifestPath, Field: "components.http.clients.Bad.source"}, + {Code: project.IssueHTTPClientInvalid, Path: projectManifestPath, Field: "components.http.clients.local"}, + {Code: project.IssueHTTPClientInvalid, Path: projectManifestPath, Field: "components.http.clients.local"}, + }, + }, + { + name: "grpc", + mutate: func(manifest *project.Manifest) { + manifest.Sources = map[string]project.Source{ + "contracts": {Type: project.SourceLocal, Path: "contracts"}, + } + manifest.Components.GRPC = &project.GRPC{ + Server: &project.GRPCServer{ProtoRoot: "../proto", BufConfig: "/buf.yaml"}, + Clients: []project.GRPCClient{{ + Name: "client", Source: "contracts", Path: "../client.proto", ProtoRoot: "../proto", BufGenConfig: "/gen.yaml", + }}, + } + manifest.Languages.Go.Generators.GRPC = &project.GRPCGenerator{BufGenConfig: "../grpc.gen.yaml"} + }, + expected: []project.Issue{ + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.grpc.server.proto_root"}, + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.grpc.server.buf_config"}, + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "languages.go.generators.grpc.buf_gen_config"}, + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.grpc.clients.client.path"}, + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.grpc.clients.client.proto_root"}, + {Code: project.IssuePathInvalid, Path: projectManifestPath, Field: "components.grpc.clients.client.buf_gen_config"}, + }, + }, + { + name: "kafka", + mutate: func(manifest *project.Manifest) { + manifest.Sources = map[string]project.Source{ + "contracts": {Type: project.SourceLocal, Path: "contracts"}, + } + manifest.Components.Kafka = &project.Kafka{ + Consumers: []project.KafkaConsumer{{Name: "raw", Contract: project.KafkaContract{Source: "contracts"}}}, + Producers: []project.KafkaProducer{{Name: "proto", Contract: project.KafkaContract{ + Source: "missing", Path: "event.proto", Format: "proto", + }}}, + } + }, + expected: []project.Issue{ + {Code: project.IssueKafkaContractInvalid, Path: projectManifestPath, Field: "components.kafka.consumers.raw.contract"}, + {Code: project.IssueSourceNotFound, Path: projectManifestPath, Field: "components.kafka.producers.proto.contract.source"}, + }, + }, + { + name: "database redis and s3", + mutate: func(manifest *project.Manifest) { + manifest.Components.DB = &project.DB{Connections: []project.DBConnection{{ + Name: "Bad", Variants: []project.DBVariant{{Name: "bad", Kind: "oracle"}}, + }}} + manifest.Components.S3 = &project.S3{Buckets: []project.S3Bucket{{Name: "media", Connection: "missing"}}} + manifest.Components.Redis = &project.Redis{Connections: []project.RedisConnection{{ + Name: "cache", AddrEnv: "bad-env", AddrDefault: "localhost", + }}} + }, + expected: []project.Issue{ + {Code: project.IssueDBConnectionInvalid, Path: projectManifestPath, Field: "components.db.connections.Bad"}, + {Code: project.IssueDBVariantInvalid, Path: projectManifestPath, Field: "components.db.connections.Bad"}, + {Code: project.IssueS3ConnectionNotFound, Path: projectManifestPath, Field: "components.s3.buckets.media.connection"}, + {Code: project.IssueRedisConnectionInvalid, Path: projectManifestPath, Field: "components.redis.connections.cache"}, + {Code: project.IssueRedisAddressInvalid, Path: projectManifestPath, Field: "components.redis.connections.cache.addr_default"}, + }, + }, + { + name: "runtime config conflict", + mutate: func(manifest *project.Manifest) { + manifest.Env.Custom = []project.EnvGroup{ + {Group: "service", Vars: []project.EnvVar{{Key: "MODE", Type: "string"}}}, + {Group: "worker", Vars: []project.EnvVar{{Key: "MODE", Type: "bool"}}}, + } + }, + expected: []project.Issue{{Code: project.IssueRuntimeConfigConflict, Path: projectManifestPath, Field: "env"}}, + }, + { + name: "managed output overlap", + mutate: func(manifest *project.Manifest) { + manifest.Paths.ExternalContracts = "gen" + manifest.Languages.Go.Generators.Config = &project.ConfigGenerator{Out: "gen/config"} + }, + expected: []project.Issue{{Code: project.IssuePathOverlap, Path: projectManifestPath, Field: "paths.external_contracts"}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + manifest := validSemanticManifest() + test.mutate(&manifest) + + issues := project.Validate(project.Project{ManifestPath: projectManifestPath, Manifest: manifest}) + + require.Equal(t, test.expected, issues) + }) + } +} + +const projectManifestPath = "/project/devctl.yaml" + +func validSemanticManifest() project.Manifest { + return project.Manifest{ + Version: 1, + Project: project.Identity{Name: "example", Language: "go"}, + Languages: project.Languages{Go: project.GoLanguage{Module: "example.test/example"}}, + } +} diff --git a/internal/domain/project/validation_test.go b/internal/domain/project/validation_test.go new file mode 100644 index 0000000..bcad505 --- /dev/null +++ b/internal/domain/project/validation_test.go @@ -0,0 +1,36 @@ +package project_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestValidateReportsIdentityAndPathIssuesInStableOrder(t *testing.T) { + t.Parallel() + + selected := project.Project{ + ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Version: 2, + Project: project.Identity{Name: "Invalid Name", Language: "python"}, + Paths: project.ManifestPaths{ExternalContracts: "../external"}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + Config: &project.ConfigGenerator{Out: "/generated/config"}, + }}}, + }, + } + + issues := project.Validate(selected) + + require.Equal(t, []project.Issue{ + {Code: project.IssueVersionUnsupported, Path: selected.ManifestPath, Field: "version"}, + {Code: project.IssueNameInvalid, Path: selected.ManifestPath, Field: "project.name"}, + {Code: project.IssueLanguageUnsupported, Path: selected.ManifestPath, Field: "project.language"}, + {Code: project.IssueGoModuleRequired, Path: selected.ManifestPath, Field: "languages.go.module"}, + {Code: project.IssuePathInvalid, Path: selected.ManifestPath, Field: "paths.external_contracts"}, + {Code: project.IssuePathInvalid, Path: selected.ManifestPath, Field: "paths.external_contracts"}, + {Code: project.IssuePathInvalid, Path: selected.ManifestPath, Field: "languages.go.generators.config.out"}, + }, issues) +} diff --git a/internal/domain/scaffold/command.go b/internal/domain/scaffold/command.go new file mode 100644 index 0000000..abc254a --- /dev/null +++ b/internal/domain/scaffold/command.go @@ -0,0 +1,27 @@ +package scaffold + +// Command selects a project scaffold refresh. +type Command struct { + ManifestPath string +} + +// FileAction classifies the persisted effect on one scaffold file. +type FileAction string + +const ( + FileCreated FileAction = "created" + FileUpdated FileAction = "updated" + FileUnchanged FileAction = "unchanged" +) + +// FileChange records one scaffold file processed before success or failure. +type FileChange struct { + // Path is relative to the project root. + Path string + Action FileAction +} + +// Result contains files completed before success or the first publication error. +type Result struct { + Files []FileChange +} diff --git a/internal/domain/scaffold/errors.go b/internal/domain/scaffold/errors.go new file mode 100644 index 0000000..6914700 --- /dev/null +++ b/internal/domain/scaffold/errors.go @@ -0,0 +1,52 @@ +package scaffold + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// Operation identifies the scaffold stage that failed. +type Operation string + +const ( + OperationPlan Operation = "plan" + OperationPreflight Operation = "preflight" + OperationPublish Operation = "publish" +) + +// FailureKind maps scaffold facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureConflict FailureKind = iota + 1 + FailureUnavailable + FailureInternal +) + +// OperationError retains scaffold path facts and the underlying execution cause. +type OperationError struct { + Operation Operation + Path string + Kind FailureKind + Cause error +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + switch e.Kind { + case FailureConflict: + return failure.Conflict + case FailureUnavailable: + return failure.Unavailable + case FailureInternal: + return failure.Internal + default: + return failure.Internal + } +} diff --git a/internal/domain/scaffold/errors_test.go b/internal/domain/scaffold/errors_test.go new file mode 100644 index 0000000..99fe2a0 --- /dev/null +++ b/internal/domain/scaffold/errors_test.go @@ -0,0 +1,20 @@ +package scaffold_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + "github.com/devctllabs/devctl/internal/domain/scaffold" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("conflict") + err := &scaffold.OperationError{Operation: scaffold.OperationPreflight, Kind: scaffold.FailureConflict, Cause: cause} + + require.Equal(t, failure.Conflict, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} diff --git a/internal/domain/sync/command.go b/internal/domain/sync/command.go new file mode 100644 index 0000000..fe87968 --- /dev/null +++ b/internal/domain/sync/command.go @@ -0,0 +1,36 @@ +package sync + +// Command selects synchronization targets and whether execution is a side-effect-free preview. +type Command struct { + ManifestPath string + Family string + Target string + DryRun bool +} + +// ChangeAction classifies an observed or planned managed-contract change. +type ChangeAction string + +const ( + ChangeCreated ChangeAction = "created" + ChangeUpdated ChangeAction = "updated" + ChangeUnchanged ChangeAction = "unchanged" + ChangeRemoved ChangeAction = "removed" + ChangePlannedPublish ChangeAction = "planned_publish" + ChangePlannedRemove ChangeAction = "planned_remove" +) + +// Change records one managed-contract decision. +type Change struct { + Target string + // Path is relative to the project root. + Path string + Action ChangeAction +} + +// Result contains targets completed before success or the first execution error. +type Result struct { + Targets []string + Changes []Change + DryRun bool +} diff --git a/internal/domain/sync/errors.go b/internal/domain/sync/errors.go new file mode 100644 index 0000000..a8ed468 --- /dev/null +++ b/internal/domain/sync/errors.go @@ -0,0 +1,54 @@ +package sync + +import "github.com/devctllabs/devctl/internal/domain/failure" + +// Operation identifies the synchronization stage that failed. +type Operation string + +const ( + OperationSelectTarget Operation = "select_target" + OperationMaterialize Operation = "materialize" + OperationPublish Operation = "publish" + OperationPrune Operation = "prune" +) + +// FailureKind maps synchronization facts to a transport-neutral failure category. +type FailureKind uint8 + +const ( + FailureNotFound FailureKind = iota + 1 + FailureUnavailable +) + +// OperationError retains target facts and the underlying execution cause. +type OperationError struct { + Operation Operation + Target string + Source string + Path string + Kind FailureKind + Cause error +} + +func (e *OperationError) Error() string { + message := string(e.Operation) + " failed" + if e.Cause != nil { + return message + ": " + e.Cause.Error() + } + return message +} + +func (e *OperationError) Unwrap() error { return e.Cause } + +func (e *OperationError) Category() failure.Category { + if e.Operation == OperationMaterialize && e.Cause != nil { + category := failure.CategoryOf(e.Cause) + if category != failure.Internal { + return category + } + } + if e.Kind == FailureNotFound { + return failure.NotFound + } + return failure.Unavailable +} diff --git a/internal/domain/sync/errors_test.go b/internal/domain/sync/errors_test.go new file mode 100644 index 0000000..17388f8 --- /dev/null +++ b/internal/domain/sync/errors_test.go @@ -0,0 +1,20 @@ +package sync_test + +import ( + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/failure" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" + "github.com/stretchr/testify/require" +) + +func TestOperationErrorPreservesCategoryAndCause(t *testing.T) { + t.Parallel() + + cause := errors.New("target missing") + err := &syncdomain.OperationError{Operation: syncdomain.OperationSelectTarget, Kind: syncdomain.FailureNotFound, Cause: cause} + + require.Equal(t, failure.NotFound, failure.CategoryOf(err)) + require.ErrorIs(t, err, cause) +} diff --git a/internal/platform/jsonschema/jsonschema.go b/internal/platform/jsonschema/jsonschema.go new file mode 100644 index 0000000..c58f65f --- /dev/null +++ b/internal/platform/jsonschema/jsonschema.go @@ -0,0 +1,22 @@ +package jsonschema + +import ( + "encoding/json" + "fmt" + "strings" +) + +// RootTitle returns the non-empty root title that owns generated top-level type naming. +func RootTitle(data []byte) (string, error) { + var document struct { + Title string `json:"title"` + } + if err := json.Unmarshal(data, &document); err != nil { + return "", fmt.Errorf("json.Unmarshal: %w", err) + } + title := strings.TrimSpace(document.Title) + if title == "" { + return "", fmt.Errorf("root title is required") + } + return title, nil +} diff --git a/internal/platform/jsonschema/jsonschema_test.go b/internal/platform/jsonschema/jsonschema_test.go new file mode 100644 index 0000000..66a3ed0 --- /dev/null +++ b/internal/platform/jsonschema/jsonschema_test.go @@ -0,0 +1,27 @@ +package jsonschema_test + +import ( + "testing" + + platformjsonschema "github.com/devctllabs/devctl/internal/platform/jsonschema" + "github.com/stretchr/testify/require" +) + +func TestRootTitle(t *testing.T) { + t.Parallel() + + title, err := platformjsonschema.RootTitle([]byte(`{"title":" AuditEvent ","type":"object"}`)) + + require.NoError(t, err) + require.Equal(t, "AuditEvent", title) +} + +func TestRootTitleRejectsMissingOrInvalidTitle(t *testing.T) { + t.Parallel() + + for _, schema := range []string{`{"type":"object"}`, `{"title":" "}`, `{`} { + _, err := platformjsonschema.RootTitle([]byte(schema)) + + require.Error(t, err) + } +} diff --git a/internal/platform/openapi/openapi.go b/internal/platform/openapi/openapi.go new file mode 100644 index 0000000..50f9c49 --- /dev/null +++ b/internal/platform/openapi/openapi.go @@ -0,0 +1,152 @@ +package openapi + +import ( + "sort" + "strings" + + "github.com/pb33f/libopenapi" + validator "github.com/pb33f/libopenapi-validator" + "gopkg.in/yaml.v3" +) + +// FindingKind classifies protocol-level parse, reference, and standard-validation facts. +type FindingKind string + +const ( + DocumentInvalid FindingKind = "document_invalid" + ReferenceInvalid FindingKind = "reference_invalid" + StandardInvalid FindingKind = "openapi_standard" +) + +// Finding is one presentation-neutral OpenAPI analysis fact. +type Finding struct { + Kind FindingKind + Type string + Subtype string + SpecPath string + Field string + // Line and Column are one-based; zero means the location is unavailable. + Line int + Column int +} + +// Operation contains the fields needed by devctl rules for one OpenAPI operation. +type Operation struct { + Method string + Path string + OperationID string + Responses []string + // Line and Column are one-based source coordinates. + Line int + Column int +} + +// Report contains deterministic facts extracted without performing I/O. +type Report struct { + Version string + Operations []Operation + Findings []Finding +} + +// Analyze parses and validates one OpenAPI document without applying devctl-specific lint policy. +func Analyze(data []byte) Report { + var document yaml.Node + if err := yaml.Unmarshal(data, &document); err != nil { + return Report{Operations: []Operation{}, Findings: []Finding{{Kind: DocumentInvalid}}} + } + root := documentRoot(&document) + report := Report{ + Version: mappingScalar(root, "openapi"), Operations: operations(root), Findings: []Finding{}, + } + openAPIDocument, err := libopenapi.NewDocument(data) + if err != nil { + report.Findings = append(report.Findings, Finding{Kind: DocumentInvalid}) + return report + } + defer openAPIDocument.Release() + if _, err := openAPIDocument.BuildV3Model(); err != nil { + report.Findings = append(report.Findings, Finding{Kind: ReferenceInvalid}) + return report + } + openAPIValidator, setupErrors := validator.NewValidator(openAPIDocument) + if len(setupErrors) > 0 { + report.Findings = append(report.Findings, Finding{Kind: StandardInvalid}) + return report + } + defer openAPIValidator.Release() + valid, validationErrors := openAPIValidator.ValidateDocument() + if !valid { + for range validationErrors { + report.Findings = append(report.Findings, Finding{Kind: StandardInvalid}) + } + } + return report +} + +func operations(root *yaml.Node) []Operation { + paths := mappingValue(root, "paths") + if paths == nil || paths.Kind != yaml.MappingNode { + return []Operation{} + } + methods := map[string]bool{"get": true, "put": true, "post": true, "delete": true, "options": true, "head": true, "patch": true, "trace": true} + result := []Operation{} + for index := 0; index+1 < len(paths.Content); index += 2 { + pathName, item := paths.Content[index], paths.Content[index+1] + if item.Kind != yaml.MappingNode { + continue + } + for operationIndex := 0; operationIndex+1 < len(item.Content); operationIndex += 2 { + methodNode, operationNode := item.Content[operationIndex], item.Content[operationIndex+1] + method := strings.ToLower(methodNode.Value) + if !methods[method] || operationNode.Kind != yaml.MappingNode { + continue + } + responses := mappingKeys(mappingValue(operationNode, "responses")) + result = append(result, Operation{ + Method: strings.ToUpper(method), Path: pathName.Value, + OperationID: mappingScalar(operationNode, "operationId"), Responses: responses, + Line: methodNode.Line, Column: methodNode.Column, + }) + } + } + return result +} + +func mappingKeys(node *yaml.Node) []string { + if node == nil || node.Kind != yaml.MappingNode { + return []string{} + } + keys := make([]string, 0, len(node.Content)/2) + for index := 0; index+1 < len(node.Content); index += 2 { + keys = append(keys, node.Content[index].Value) + } + sort.Strings(keys) + return keys +} + +func documentRoot(node *yaml.Node) *yaml.Node { + if node != nil && node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return node.Content[0] + } + return node +} + +func mappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for index := 0; index+1 < len(node.Content); index += 2 { + if node.Content[index].Value == key { + return node.Content[index+1] + } + } + return nil +} + +func mappingScalar(node *yaml.Node, key string) string { + value := mappingValue(node, key) + if value == nil { + return "" + } + return value.Value +} diff --git a/internal/platform/openapi/openapi_test.go b/internal/platform/openapi/openapi_test.go new file mode 100644 index 0000000..0a46544 --- /dev/null +++ b/internal/platform/openapi/openapi_test.go @@ -0,0 +1,35 @@ +package openapi_test + +import ( + "testing" + + "github.com/devctllabs/devctl/internal/platform/openapi" + "github.com/stretchr/testify/require" +) + +func TestAnalyzeReturnsMalformedDocumentFact(t *testing.T) { + t.Parallel() + + report := openapi.Analyze([]byte("openapi: [\n")) + + require.Equal(t, []openapi.Finding{{Kind: openapi.DocumentInvalid}}, report.Findings) +} + +func TestAnalyzeExtractsOperationFacts(t *testing.T) { + t.Parallel() + + report := openapi.Analyze([]byte(`openapi: 3.1.0 +info: {title: Fixture, version: 1.0.0} +paths: + /widgets: + get: + operationId: listWidgets + responses: + 2XX: {description: success} +`)) + + require.Equal(t, "3.1.0", report.Version) + require.Equal(t, []openapi.Operation{{ + Method: "GET", Path: "/widgets", OperationID: "listWidgets", Responses: []string{"2XX"}, Line: 5, Column: 5, + }}, report.Operations) +} diff --git a/internal/repository/manifest/decode.go b/internal/repository/manifest/decode.go new file mode 100644 index 0000000..738710f --- /dev/null +++ b/internal/repository/manifest/decode.go @@ -0,0 +1,162 @@ +package manifest + +import ( + "errors" + "fmt" + "reflect" + "strings" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "gopkg.in/yaml.v3" +) + +func parse(data []byte) (document, []projectdomain.DecodeIssue, error) { + var node yaml.Node + if err := yaml.Unmarshal(data, &node); err != nil { + return document{}, []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeYAMLInvalid}}, fmt.Errorf("yaml.Unmarshal: %w", err) + } + if len(node.Content) != 1 || node.Content[0].Kind != yaml.MappingNode { + err := errors.New("manifest root must be a mapping") + return document{}, []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeSchemaInvalid}}, err + } + issues := validateKnownFields(node.Content[0], "") + if len(issues) > 0 { + return document{}, issues, errors.New("manifest schema validation failed") + } + var manifest document + if err := node.Content[0].Decode(&manifest); err != nil { + return document{}, []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeSchemaInvalid}}, fmt.Errorf("node.Decode: %w", err) + } + return manifest, issues, nil +} + +func validateKnownFields(root *yaml.Node, path string) []projectdomain.DecodeIssue { + issues := make([]projectdomain.DecodeIssue, 0) + validateNode(root, reflect.TypeFor[document](), path, &issues) + return issues +} + +func validateNode(node *yaml.Node, target reflect.Type, path string, issues *[]projectdomain.DecodeIssue) { + if node.Tag == "!!null" { + return + } + for target.Kind() == reflect.Pointer { + target = target.Elem() + } + kind := target.Kind() + if kind == reflect.Struct { + validateStructNode(node, target, path, issues) + return + } + if kind == reflect.Map { + validateMapNode(node, target.Elem(), path, issues) + return + } + if kind == reflect.Slice { + validateSliceNode(node, target.Elem(), path, issues) + return + } + if kind == reflect.Interface { + return + } + validateScalarNode(node, target, path, issues) +} + +func validateStructNode(node *yaml.Node, target reflect.Type, path string, issues *[]projectdomain.DecodeIssue) { + if node.Kind != yaml.MappingNode { + appendSchemaIssue(issues, path, node) + return + } + fields := yamlFields(target) + visitMapping(node, path, issues, func(value *yaml.Node, fieldPath, name string) { + fieldType, exists := fields[name] + if !exists { + key := mappingKey(node, name) + *issues = append(*issues, projectdomain.DecodeIssue{Kind: projectdomain.DecodeUnknownField, Field: fieldPath, Line: key.Line, Column: key.Column}) + return + } + validateNode(value, fieldType, fieldPath, issues) + }) +} + +func validateMapNode(node *yaml.Node, element reflect.Type, path string, issues *[]projectdomain.DecodeIssue) { + if node.Kind != yaml.MappingNode { + appendSchemaIssue(issues, path, node) + return + } + visitMapping(node, path, issues, func(value *yaml.Node, fieldPath, _ string) { + validateNode(value, element, fieldPath, issues) + }) +} + +func validateSliceNode(node *yaml.Node, element reflect.Type, path string, issues *[]projectdomain.DecodeIssue) { + if node.Kind != yaml.SequenceNode { + appendSchemaIssue(issues, path, node) + return + } + for index, item := range node.Content { + validateNode(item, element, fmt.Sprintf("%s[%d]", path, index), issues) + } +} + +func validateScalarNode(node *yaml.Node, target reflect.Type, path string, issues *[]projectdomain.DecodeIssue) { + if node.Kind != yaml.ScalarNode { + appendSchemaIssue(issues, path, node) + return + } + value := reflect.New(target).Interface() + if err := node.Decode(value); err != nil { + appendSchemaIssue(issues, path, node) + } +} + +func visitMapping( + node *yaml.Node, + path string, + issues *[]projectdomain.DecodeIssue, + visit func(value *yaml.Node, fieldPath, name string), +) { + seen := make(map[string]struct{}, len(node.Content)/2) + for index := 0; index+1 < len(node.Content); index += 2 { + key, value := node.Content[index], node.Content[index+1] + fieldPath := joinFieldPath(path, key.Value) + if _, exists := seen[key.Value]; exists { + *issues = append(*issues, projectdomain.DecodeIssue{Kind: projectdomain.DecodeDuplicateKey, Field: fieldPath, Line: key.Line, Column: key.Column}) + continue + } + seen[key.Value] = struct{}{} + visit(value, fieldPath, key.Value) + } +} + +func yamlFields(target reflect.Type) map[string]reflect.Type { + fields := make(map[string]reflect.Type, target.NumField()) + for index := 0; index < target.NumField(); index++ { + field := target.Field(index) + name := strings.SplitN(field.Tag.Get("yaml"), ",", 2)[0] + if name != "" && name != "-" { + fields[name] = field.Type + } + } + return fields +} + +func mappingKey(node *yaml.Node, name string) *yaml.Node { + for index := 0; index+1 < len(node.Content); index += 2 { + if node.Content[index].Value == name { + return node.Content[index] + } + } + return node +} + +func appendSchemaIssue(issues *[]projectdomain.DecodeIssue, path string, node *yaml.Node) { + *issues = append(*issues, projectdomain.DecodeIssue{Kind: projectdomain.DecodeSchemaInvalid, Field: path, Line: node.Line, Column: node.Column}) +} + +func joinFieldPath(parent, field string) string { + if parent == "" { + return field + } + return parent + "." + field +} diff --git a/internal/repository/manifest/document.go b/internal/repository/manifest/document.go new file mode 100644 index 0000000..38299dd --- /dev/null +++ b/internal/repository/manifest/document.go @@ -0,0 +1,280 @@ +package manifest + +type document struct { + Version int `yaml:"version"` + Project projectDocument `yaml:"project"` + Env envDocument `yaml:"env"` + Paths pathsDocument `yaml:"paths"` + Sources map[string]sourceDocument `yaml:"sources"` + Exports map[string]exportDocument `yaml:"exports"` + Components componentsDocument `yaml:"components"` + Languages languagesDocument `yaml:"languages"` +} + +type projectDocument struct { + Name string `yaml:"name"` + Language string `yaml:"language"` +} + +type envDocument struct { + Prefix string `yaml:"prefix,omitempty"` + Custom []envGroupDocument `yaml:"custom,omitempty"` +} + +type envGroupDocument struct { + Group string `yaml:"group"` + Vars []envVarDocument `yaml:"vars"` +} + +type envVarDocument struct { + Key string `yaml:"key"` + Type string `yaml:"type,omitempty"` + Default any `yaml:"default,omitempty"` + Secret bool `yaml:"secret,omitempty"` +} + +type pathsDocument struct { + ExternalContracts string `yaml:"external_contracts,omitempty"` +} + +type sourceDocument struct { + Type string `yaml:"type"` + Path string `yaml:"path,omitempty"` + URL string `yaml:"url,omitempty"` + Filename string `yaml:"filename,omitempty"` + AllowInsecureHTTP bool `yaml:"allow_insecure_http,omitempty"` + Repo string `yaml:"repo,omitempty"` + Ref string `yaml:"ref,omitempty"` + Proto sourceProtoDocument `yaml:"proto,omitempty"` +} + +type sourceProtoDocument struct { + BufConfig string `yaml:"buf_config,omitempty"` +} + +type exportDocument struct { + Kind string `yaml:"kind"` + Path string `yaml:"path,omitempty"` + Producer string `yaml:"producer,omitempty"` +} + +type componentsDocument struct { + HTTP *httpDocument `yaml:"http,omitempty"` + GRPC *grpcDocument `yaml:"grpc,omitempty"` + Kafka *kafkaDocument `yaml:"kafka,omitempty"` + Logging *loggingDocument `yaml:"logging,omitempty"` + Health *healthDocument `yaml:"health,omitempty"` + Telemetry *telemetryDocument `yaml:"telemetry,omitempty"` + DB *dbDocument `yaml:"db,omitempty"` + Redis *redisDocument `yaml:"redis,omitempty"` + S3 *s3Document `yaml:"s3,omitempty"` +} + +type redisDocument struct { + Connections []redisConnectionDocument `yaml:"connections,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type redisConnectionDocument struct { + Name string `yaml:"name"` + AddrEnv string `yaml:"addr_env,omitempty"` + AddrDefault string `yaml:"addr_default,omitempty"` +} + +type s3Document struct { + Connections []s3ConnectionDocument `yaml:"connections,omitempty"` + Buckets []s3BucketDocument `yaml:"buckets,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type s3ConnectionDocument struct { + Name string `yaml:"name"` + Credentials string `yaml:"credentials,omitempty"` + Endpoint string `yaml:"endpoint,omitempty"` + Region string `yaml:"region,omitempty"` + PathStyle bool `yaml:"path_style,omitempty"` + AccessKeyEnv string `yaml:"access_key_env,omitempty"` + SecretKeyEnv string `yaml:"secret_key_env,omitempty"` +} + +type s3BucketDocument struct { + Name string `yaml:"name"` + Connection string `yaml:"connection"` + Bucket string `yaml:"bucket,omitempty"` +} + +type kafkaDocument struct { + Consumers []kafkaConsumerDocument `yaml:"consumers,omitempty"` + Producers []kafkaProducerDocument `yaml:"producers,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type kafkaContractDocument struct { + Source string `yaml:"source,omitempty"` + Export string `yaml:"export,omitempty"` + Path string `yaml:"path,omitempty"` + Format string `yaml:"format,omitempty"` + ProtoRoot string `yaml:"proto_root,omitempty"` + Message string `yaml:"message,omitempty"` + Encoding string `yaml:"encoding,omitempty"` +} + +type kafkaConsumerDocument struct { + Name string `yaml:"name"` + Topic string `yaml:"topic"` + GroupEnv string `yaml:"group_env,omitempty"` + Start *startDocument `yaml:"start,omitempty"` + Contract kafkaContractDocument `yaml:"contract,omitempty"` +} +type kafkaProducerDocument struct { + Name string `yaml:"name"` + Topic string `yaml:"topic"` + TopicEnv string `yaml:"topic_env,omitempty"` + Contract kafkaContractDocument `yaml:"contract,omitempty"` +} + +type grpcDocument struct { + Server *grpcServerDocument `yaml:"server,omitempty"` + Clients []grpcClientDocument `yaml:"clients,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type grpcClientDocument struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Export string `yaml:"export,omitempty"` + Path string `yaml:"path,omitempty"` + ProtoRoot string `yaml:"proto_root,omitempty"` + BufGenConfig string `yaml:"buf_gen_config,omitempty"` + AddrEnv string `yaml:"addr_env,omitempty"` +} + +type grpcServerDocument struct { + ProtoRoot string `yaml:"proto_root,omitempty"` + BufConfig string `yaml:"buf_config,omitempty"` + Start *startDocument `yaml:"start,omitempty"` +} + +type componentEnvDocument struct { + System []envVarDocument `yaml:"system,omitempty"` + Custom []envVarDocument `yaml:"custom,omitempty"` +} + +type startDocument struct { + Env string `yaml:"env"` + Default *bool `yaml:"default,omitempty"` +} + +type httpDocument struct { + Server *httpServerDocument `yaml:"server,omitempty"` + Clients []httpClientDocument `yaml:"clients,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type httpServerDocument struct { + OpenAPI string `yaml:"openapi,omitempty"` + Start *startDocument `yaml:"start,omitempty"` +} + +type httpClientDocument struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Export string `yaml:"export,omitempty"` + Path string `yaml:"path,omitempty"` + BaseURLEnv string `yaml:"base_url_env,omitempty"` + OAPIConfig string `yaml:"oapi_config,omitempty"` +} + +type loggingDocument struct { + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type healthDocument struct { + Server *healthServerDocument `yaml:"server,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type healthServerDocument struct { + Start *startDocument `yaml:"start,omitempty"` +} + +type telemetryDocument struct { + Start *startDocument `yaml:"start,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type dbDocument struct { + Connections []dbConnectionDocument `yaml:"connections"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type dbConnectionDocument struct { + Name string `yaml:"name"` + Default string `yaml:"default,omitempty"` + KindEnv string `yaml:"kind_env,omitempty"` + Variants []dbVariantDocument `yaml:"variants"` +} + +type dbVariantDocument struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + DSNEnv string `yaml:"dsn_env,omitempty"` + DSNDefault string `yaml:"dsn_default,omitempty"` + Secret bool `yaml:"secret,omitempty"` + Migrations *dbMigrationsDocument `yaml:"migrations,omitempty"` +} + +type dbMigrationsDocument struct { + Path string `yaml:"path"` + DatabaseEnv string `yaml:"database_env"` + DatabaseDefault string `yaml:"database_default,omitempty"` +} + +type languagesDocument struct { + Go goLanguageDocument `yaml:"go"` +} + +type goLanguageDocument struct { + Module string `yaml:"module"` + Generators goGeneratorsDocument `yaml:"generators,omitempty"` + Components goComponentsDocument `yaml:"components,omitempty"` +} + +type goGeneratorsDocument struct { + Config *configGeneratorDocument `yaml:"config,omitempty"` + HTTP *httpGeneratorDocument `yaml:"http,omitempty"` + GRPC *grpcGeneratorDocument `yaml:"grpc,omitempty"` + Kafka *kafkaGeneratorDocument `yaml:"kafka,omitempty"` +} + +type grpcGeneratorDocument struct { + Out string `yaml:"out,omitempty"` + BufGenConfig string `yaml:"buf_gen_config,omitempty"` +} +type kafkaGeneratorDocument struct { + Out string `yaml:"out,omitempty"` + BufGenConfig string `yaml:"buf_gen_config,omitempty"` +} + +type configGeneratorDocument struct { + Out string `yaml:"out,omitempty"` +} + +type httpGeneratorDocument struct { + OAPIConfig string `yaml:"oapi_config,omitempty"` + ServerOut string `yaml:"server_out,omitempty"` + ClientOut string `yaml:"client_out,omitempty"` +} + +type goComponentsDocument struct { + Pprof *pprofDocument `yaml:"pprof,omitempty"` +} + +type pprofDocument struct { + Server *pprofServerDocument `yaml:"server,omitempty"` + Env componentEnvDocument `yaml:"env,omitempty"` +} + +type pprofServerDocument struct { + Start *startDocument `yaml:"start,omitempty"` +} diff --git a/internal/repository/manifest/mapper.go b/internal/repository/manifest/mapper.go new file mode 100644 index 0000000..a74f074 --- /dev/null +++ b/internal/repository/manifest/mapper.go @@ -0,0 +1,407 @@ +package manifest + +import projectdomain "github.com/devctllabs/devctl/internal/domain/project" + +func toProjectSpec(document document) projectdomain.Manifest { + sources := make(map[string]projectdomain.Source, len(document.Sources)) + for name, source := range document.Sources { + sources[name] = projectdomain.Source{Type: projectdomain.SourceType(source.Type), Path: source.Path, URL: source.URL, Filename: source.Filename, AllowInsecureHTTP: source.AllowInsecureHTTP, Repo: source.Repo, Ref: source.Ref, Proto: projectdomain.SourceProto{BufConfig: source.Proto.BufConfig}} + } + exports := make(map[string]projectdomain.Export, len(document.Exports)) + for name, export := range document.Exports { + exports[name] = projectdomain.Export(export) + } + + return projectdomain.Manifest{ + Version: document.Version, + Project: projectdomain.Identity(document.Project), + Env: mapEnv(document.Env), + Paths: projectdomain.ManifestPaths(document.Paths), + Sources: sources, + Exports: exports, + Components: mapComponents(document.Components), + Languages: mapLanguages(document.Languages), + } +} + +func mapEnv(value envDocument) projectdomain.Env { + groups := make([]projectdomain.EnvGroup, len(value.Custom)) + for index, group := range value.Custom { + groups[index] = projectdomain.EnvGroup{Group: group.Group, Vars: mapEnvVars(group.Vars)} + } + return projectdomain.Env{Prefix: value.Prefix, Custom: groups} +} + +func mapEnvVars(values []envVarDocument) []projectdomain.EnvVar { + result := make([]projectdomain.EnvVar, len(values)) + for index, value := range values { + result[index] = projectdomain.EnvVar(value) + } + return result +} + +func mapComponentEnv(value componentEnvDocument) projectdomain.ComponentEnv { + return projectdomain.ComponentEnv{System: mapEnvVars(value.System), Custom: mapEnvVars(value.Custom)} +} + +func mapStart(value *startDocument) *projectdomain.Start { + if value == nil { + return nil + } + return &projectdomain.Start{Env: value.Env, Default: value.Default} +} + +func mapComponents(value componentsDocument) projectdomain.Components { + return projectdomain.Components{ + HTTP: mapHTTP(value.HTTP), GRPC: mapGRPC(value.GRPC), Kafka: mapKafka(value.Kafka), + Logging: mapLogging(value.Logging), Health: mapHealth(value.Health), Telemetry: mapTelemetry(value.Telemetry), + DB: mapDB(value.DB), Redis: mapRedis(value.Redis), S3: mapS3(value.S3), + } +} + +func mapHTTP(value *httpDocument) *projectdomain.HTTP { + if value == nil { + return nil + } + clients := make([]projectdomain.HTTPClient, len(value.Clients)) + for index, client := range value.Clients { + clients[index] = projectdomain.HTTPClient(client) + } + var server *projectdomain.HTTPServer + if value.Server != nil { + server = &projectdomain.HTTPServer{OpenAPI: value.Server.OpenAPI, Start: mapStart(value.Server.Start)} + } + return &projectdomain.HTTP{Server: server, Clients: clients, Env: mapComponentEnv(value.Env)} +} + +func mapGRPC(value *grpcDocument) *projectdomain.GRPC { + if value == nil { + return nil + } + clients := make([]projectdomain.GRPCClient, len(value.Clients)) + for index, client := range value.Clients { + clients[index] = projectdomain.GRPCClient(client) + } + var server *projectdomain.GRPCServer + if value.Server != nil { + server = &projectdomain.GRPCServer{ProtoRoot: value.Server.ProtoRoot, BufConfig: value.Server.BufConfig, Start: mapStart(value.Server.Start)} + } + return &projectdomain.GRPC{Server: server, Clients: clients, Env: mapComponentEnv(value.Env)} +} + +func mapKafka(value *kafkaDocument) *projectdomain.Kafka { + if value == nil { + return nil + } + consumers := make([]projectdomain.KafkaConsumer, len(value.Consumers)) + for index, consumer := range value.Consumers { + consumers[index] = projectdomain.KafkaConsumer{Name: consumer.Name, Topic: consumer.Topic, GroupEnv: consumer.GroupEnv, Start: mapStart(consumer.Start), Contract: projectdomain.KafkaContract(consumer.Contract)} + } + producers := make([]projectdomain.KafkaProducer, len(value.Producers)) + for index, producer := range value.Producers { + producers[index] = projectdomain.KafkaProducer{Name: producer.Name, Topic: producer.Topic, TopicEnv: producer.TopicEnv, Contract: projectdomain.KafkaContract(producer.Contract)} + } + return &projectdomain.Kafka{Consumers: consumers, Producers: producers, Env: mapComponentEnv(value.Env)} +} + +func mapLogging(value *loggingDocument) *projectdomain.Logging { + if value == nil { + return nil + } + return &projectdomain.Logging{Env: mapComponentEnv(value.Env)} +} + +func mapHealth(value *healthDocument) *projectdomain.Health { + if value == nil { + return nil + } + var server *projectdomain.HealthServer + if value.Server != nil { + server = &projectdomain.HealthServer{Start: mapStart(value.Server.Start)} + } + return &projectdomain.Health{Server: server, Env: mapComponentEnv(value.Env)} +} + +func mapTelemetry(value *telemetryDocument) *projectdomain.Telemetry { + if value == nil { + return nil + } + return &projectdomain.Telemetry{Start: mapStart(value.Start), Env: mapComponentEnv(value.Env)} +} + +func mapDB(value *dbDocument) *projectdomain.DB { + if value == nil { + return nil + } + connections := make([]projectdomain.DBConnection, len(value.Connections)) + for index, connection := range value.Connections { + variants := make([]projectdomain.DBVariant, len(connection.Variants)) + for variantIndex, variant := range connection.Variants { + variants[variantIndex] = mapDBVariant(variant) + } + connections[index] = projectdomain.DBConnection{Name: connection.Name, Default: connection.Default, KindEnv: connection.KindEnv, Variants: variants} + } + return &projectdomain.DB{Connections: connections, Env: mapComponentEnv(value.Env)} +} + +func mapDBVariant(value dbVariantDocument) projectdomain.DBVariant { + variant := projectdomain.DBVariant{Name: value.Name, Kind: value.Kind, DSNEnv: value.DSNEnv, DSNDefault: value.DSNDefault, Secret: value.Secret} + if value.Migrations != nil { + variant.Migrations = &projectdomain.DBMigrations{Path: value.Migrations.Path, DatabaseEnv: value.Migrations.DatabaseEnv, DatabaseDefault: value.Migrations.DatabaseDefault} + } + return variant +} + +func mapRedis(value *redisDocument) *projectdomain.Redis { + if value == nil { + return nil + } + connections := make([]projectdomain.RedisConnection, len(value.Connections)) + for index, connection := range value.Connections { + connections[index] = projectdomain.RedisConnection(connection) + } + return &projectdomain.Redis{Connections: connections, Env: mapComponentEnv(value.Env)} +} + +func mapS3(value *s3Document) *projectdomain.S3 { + if value == nil { + return nil + } + connections := make([]projectdomain.S3Connection, len(value.Connections)) + for index, connection := range value.Connections { + connections[index] = projectdomain.S3Connection(connection) + } + buckets := make([]projectdomain.S3Bucket, len(value.Buckets)) + for index, bucket := range value.Buckets { + buckets[index] = projectdomain.S3Bucket(bucket) + } + return &projectdomain.S3{Connections: connections, Buckets: buckets, Env: mapComponentEnv(value.Env)} +} + +func mapLanguages(value languagesDocument) projectdomain.Languages { + generators := projectdomain.GoGenerators{} + if value.Go.Generators.Config != nil { + config := projectdomain.ConfigGenerator(*value.Go.Generators.Config) + generators.Config = &config + } + if value.Go.Generators.HTTP != nil { + http := projectdomain.HTTPGenerator(*value.Go.Generators.HTTP) + generators.HTTP = &http + } + if value.Go.Generators.GRPC != nil { + grpc := projectdomain.GRPCGenerator(*value.Go.Generators.GRPC) + generators.GRPC = &grpc + } + if value.Go.Generators.Kafka != nil { + kafka := projectdomain.KafkaGenerator(*value.Go.Generators.Kafka) + generators.Kafka = &kafka + } + components := projectdomain.GoComponents{} + if value.Go.Components.Pprof != nil { + var server *projectdomain.PprofServer + if value.Go.Components.Pprof.Server != nil { + server = &projectdomain.PprofServer{Start: mapStart(value.Go.Components.Pprof.Server.Start)} + } + components.Pprof = &projectdomain.Pprof{Server: server, Env: mapComponentEnv(value.Go.Components.Pprof.Env)} + } + return projectdomain.Languages{Go: projectdomain.GoLanguage{Module: value.Go.Module, Generators: generators, Components: components}} +} + +func fromProjectManifest(value projectdomain.Manifest) document { + sources := make(map[string]sourceDocument, len(value.Sources)) + for name, source := range value.Sources { + sources[name] = sourceDocument{Type: string(source.Type), Path: source.Path, URL: source.URL, Filename: source.Filename, AllowInsecureHTTP: source.AllowInsecureHTTP, Repo: source.Repo, Ref: source.Ref, Proto: sourceProtoDocument{BufConfig: source.Proto.BufConfig}} + } + exports := make(map[string]exportDocument, len(value.Exports)) + for name, exported := range value.Exports { + exports[name] = exportDocument(exported) + } + return document{ + Version: value.Version, Project: projectDocument(value.Project), Env: fromProjectEnv(value.Env), + Paths: pathsDocument(value.Paths), Sources: sources, Exports: exports, + Components: fromProjectComponents(value.Components), Languages: fromProjectLanguages(value.Languages), + } +} + +func fromProjectEnv(value projectdomain.Env) envDocument { + groups := make([]envGroupDocument, len(value.Custom)) + for index, group := range value.Custom { + groups[index] = envGroupDocument{Group: group.Group, Vars: fromProjectEnvVars(group.Vars)} + } + return envDocument{Prefix: value.Prefix, Custom: groups} +} + +func fromProjectEnvVars(values []projectdomain.EnvVar) []envVarDocument { + result := make([]envVarDocument, len(values)) + for index, value := range values { + result[index] = envVarDocument(value) + } + return result +} + +func fromProjectComponentEnv(value projectdomain.ComponentEnv) componentEnvDocument { + return componentEnvDocument{System: fromProjectEnvVars(value.System), Custom: fromProjectEnvVars(value.Custom)} +} + +func fromProjectStart(value *projectdomain.Start) *startDocument { + if value == nil { + return nil + } + return &startDocument{Env: value.Env, Default: value.Default} +} + +func fromProjectComponents(value projectdomain.Components) componentsDocument { + return componentsDocument{ + HTTP: fromProjectHTTP(value.HTTP), GRPC: fromProjectGRPC(value.GRPC), Kafka: fromProjectKafka(value.Kafka), + Logging: fromProjectLogging(value.Logging), Health: fromProjectHealth(value.Health), Telemetry: fromProjectTelemetry(value.Telemetry), + DB: fromProjectDB(value.DB), Redis: fromProjectRedis(value.Redis), S3: fromProjectS3(value.S3), + } +} + +func fromProjectHTTP(value *projectdomain.HTTP) *httpDocument { + if value == nil { + return nil + } + clients := make([]httpClientDocument, len(value.Clients)) + for index, client := range value.Clients { + clients[index] = httpClientDocument(client) + } + var server *httpServerDocument + if value.Server != nil { + server = &httpServerDocument{OpenAPI: value.Server.OpenAPI, Start: fromProjectStart(value.Server.Start)} + } + return &httpDocument{Server: server, Clients: clients, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectGRPC(value *projectdomain.GRPC) *grpcDocument { + if value == nil { + return nil + } + clients := make([]grpcClientDocument, len(value.Clients)) + for index, client := range value.Clients { + clients[index] = grpcClientDocument(client) + } + var server *grpcServerDocument + if value.Server != nil { + server = &grpcServerDocument{ProtoRoot: value.Server.ProtoRoot, BufConfig: value.Server.BufConfig, Start: fromProjectStart(value.Server.Start)} + } + return &grpcDocument{Server: server, Clients: clients, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectKafka(value *projectdomain.Kafka) *kafkaDocument { + if value == nil { + return nil + } + consumers := make([]kafkaConsumerDocument, len(value.Consumers)) + for index, consumer := range value.Consumers { + consumers[index] = kafkaConsumerDocument{Name: consumer.Name, Topic: consumer.Topic, GroupEnv: consumer.GroupEnv, Start: fromProjectStart(consumer.Start), Contract: kafkaContractDocument(consumer.Contract)} + } + producers := make([]kafkaProducerDocument, len(value.Producers)) + for index, producer := range value.Producers { + producers[index] = kafkaProducerDocument{Name: producer.Name, Topic: producer.Topic, TopicEnv: producer.TopicEnv, Contract: kafkaContractDocument(producer.Contract)} + } + return &kafkaDocument{Consumers: consumers, Producers: producers, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectLogging(value *projectdomain.Logging) *loggingDocument { + if value == nil { + return nil + } + return &loggingDocument{Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectHealth(value *projectdomain.Health) *healthDocument { + if value == nil { + return nil + } + var server *healthServerDocument + if value.Server != nil { + server = &healthServerDocument{Start: fromProjectStart(value.Server.Start)} + } + return &healthDocument{Server: server, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectTelemetry(value *projectdomain.Telemetry) *telemetryDocument { + if value == nil { + return nil + } + return &telemetryDocument{Start: fromProjectStart(value.Start), Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectDB(value *projectdomain.DB) *dbDocument { + if value == nil { + return nil + } + connections := make([]dbConnectionDocument, len(value.Connections)) + for index, connection := range value.Connections { + variants := make([]dbVariantDocument, len(connection.Variants)) + for variantIndex, variant := range connection.Variants { + variants[variantIndex] = fromProjectDBVariant(variant) + } + connections[index] = dbConnectionDocument{Name: connection.Name, Default: connection.Default, KindEnv: connection.KindEnv, Variants: variants} + } + return &dbDocument{Connections: connections, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectDBVariant(value projectdomain.DBVariant) dbVariantDocument { + variant := dbVariantDocument{Name: value.Name, Kind: value.Kind, DSNEnv: value.DSNEnv, DSNDefault: value.DSNDefault, Secret: value.Secret} + if value.Migrations != nil { + variant.Migrations = &dbMigrationsDocument{Path: value.Migrations.Path, DatabaseEnv: value.Migrations.DatabaseEnv, DatabaseDefault: value.Migrations.DatabaseDefault} + } + return variant +} + +func fromProjectRedis(value *projectdomain.Redis) *redisDocument { + if value == nil { + return nil + } + connections := make([]redisConnectionDocument, len(value.Connections)) + for index, connection := range value.Connections { + connections[index] = redisConnectionDocument(connection) + } + return &redisDocument{Connections: connections, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectS3(value *projectdomain.S3) *s3Document { + if value == nil { + return nil + } + connections := make([]s3ConnectionDocument, len(value.Connections)) + for index, connection := range value.Connections { + connections[index] = s3ConnectionDocument(connection) + } + buckets := make([]s3BucketDocument, len(value.Buckets)) + for index, bucket := range value.Buckets { + buckets[index] = s3BucketDocument(bucket) + } + return &s3Document{Connections: connections, Buckets: buckets, Env: fromProjectComponentEnv(value.Env)} +} + +func fromProjectLanguages(value projectdomain.Languages) languagesDocument { + generators := goGeneratorsDocument{} + if value.Go.Generators.Config != nil { + config := configGeneratorDocument(*value.Go.Generators.Config) + generators.Config = &config + } + if value.Go.Generators.HTTP != nil { + http := httpGeneratorDocument(*value.Go.Generators.HTTP) + generators.HTTP = &http + } + if value.Go.Generators.GRPC != nil { + grpc := grpcGeneratorDocument(*value.Go.Generators.GRPC) + generators.GRPC = &grpc + } + if value.Go.Generators.Kafka != nil { + kafka := kafkaGeneratorDocument(*value.Go.Generators.Kafka) + generators.Kafka = &kafka + } + components := goComponentsDocument{} + if value.Go.Components.Pprof != nil { + var server *pprofServerDocument + if value.Go.Components.Pprof.Server != nil { + server = &pprofServerDocument{Start: fromProjectStart(value.Go.Components.Pprof.Server.Start)} + } + components.Pprof = &pprofDocument{Server: server, Env: fromProjectComponentEnv(value.Go.Components.Pprof.Env)} + } + return languagesDocument{Go: goLanguageDocument{Module: value.Go.Module, Generators: generators, Components: components}} +} diff --git a/internal/repository/manifest/repository.go b/internal/repository/manifest/repository.go new file mode 100644 index 0000000..71e8c80 --- /dev/null +++ b/internal/repository/manifest/repository.go @@ -0,0 +1,109 @@ +package manifest + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + devfs "github.com/devctllabs/go-libs/filesystem" + "gopkg.in/yaml.v3" +) + +// FilesystemRepo maps canonical manifest YAML to and from project domain data. +type FilesystemRepo struct{} + +func NewFilesystemRepo() *FilesystemRepo { return &FilesystemRepo{} } + +// Load decodes selectedPath, returning syntax and type issues as data rather than execution errors. +func (r *FilesystemRepo) Load(ctx context.Context, selectedPath string) (projectdomain.LoadManifestResult, error) { + if err := ctx.Err(); err != nil { + return projectdomain.LoadManifestResult{}, fmt.Errorf("ctx.Err: %w", err) + } + manifestPath := filepath.Clean(selectedPath) + data, err := readManifestFile(manifestPath) + if err != nil { + return projectdomain.LoadManifestResult{}, fmt.Errorf("readManifestFile: %w", err) + } + document, issues, parseErr := parse(data) + project := projectdomain.Project{Root: filepath.Dir(manifestPath), ManifestPath: manifestPath} + if len(issues) > 0 { + return projectdomain.LoadManifestResult{Project: project, Issues: issues}, nil + } + if parseErr != nil { + return projectdomain.LoadManifestResult{}, fmt.Errorf("parse: %w", parseErr) + } + project.Manifest = toProjectSpec(document) + return projectdomain.LoadManifestResult{Project: project}, nil +} + +// Save canonically encodes and atomically publishes project.Manifest at project.ManifestPath. +func (r *FilesystemRepo) Save(ctx context.Context, project projectdomain.Project) (bool, error) { + data, err := encode(project.Manifest) + if err != nil { + return false, fmt.Errorf("encode: %w", err) + } + changed, err := publish(ctx, project.ManifestPath, data) + if err != nil { + return false, fmt.Errorf("publish: %w", err) + } + return changed, nil +} + +func encode(manifest projectdomain.Manifest) ([]byte, error) { + var buffer strings.Builder + encoder := yaml.NewEncoder(&buffer) + encoder.SetIndent(2) + if err := encoder.Encode(fromProjectManifest(manifest)); err != nil { + return nil, fmt.Errorf("encoder.Encode: %w", err) + } + if err := encoder.Close(); err != nil { + return nil, fmt.Errorf("encoder.Close: %w", err) + } + return []byte(buffer.String()), nil +} + +func publish(ctx context.Context, path string, data []byte) (bool, error) { + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o755); err != nil { + return false, fmt.Errorf("os.MkdirAll: %w", err) + } + disk, err := devfs.Open(directory) + if err != nil { + return false, fmt.Errorf("filesystem.Open: %w", err) + } + changed, publishErr := disk.PublishFile(ctx, filepath.Base(path), devfs.File{Content: data, Mode: 0o644}) + if publishErr != nil { + publishErr = fmt.Errorf("filesystem.PublishFile: %w", publishErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return changed, errors.Join(publishErr, closeErr) +} + +func readManifestFile(path string) ([]byte, error) { + rootFS, err := devfs.Open(filepath.Dir(path)) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = rootFS.Close() }() + name := filepath.Base(path) + info, err := rootFS.Lstat(name) + if err != nil { + return nil, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s must be a regular non-symlink file", name) + } + data, err := fs.ReadFile(rootFS, name) + if err != nil { + return nil, fmt.Errorf("filesystem.ReadFile: %w", err) + } + return data, nil +} diff --git a/internal/repository/manifest/repository_test.go b/internal/repository/manifest/repository_test.go new file mode 100644 index 0000000..6fcd3df --- /dev/null +++ b/internal/repository/manifest/repository_test.go @@ -0,0 +1,268 @@ +package manifest_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/repository/manifest" + "github.com/stretchr/testify/require" +) + +func TestFilesystemRepoLoadMinimalManifest(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + err := os.WriteFile(manifestPath, []byte("version: 1\nproject:\n name: example\n language: go\nenv: {}\npaths:\n external_contracts: api/external\nsources: {}\nexports: {}\ncomponents: {}\nlanguages:\n go:\n module: github.com/acme/example\n"), 0o644) + require.NoError(t, err) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), manifestPath) + + require.NoError(t, err) + require.Empty(t, loaded.Issues) + require.Equal(t, "example", loaded.Project.Manifest.Project.Name) + require.Empty(t, loaded.Project.Manifest.Env.Prefix) + require.Equal(t, root, loaded.Project.Root) +} + +func TestFilesystemRepoSavesCanonicalManifestIdempotently(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + project := projectdomain.Project{ + Root: root, ManifestPath: manifestPath, + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{}, + Exports: map[string]projectdomain.Export{}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + repository := manifest.NewFilesystemRepo() + + changed, err := repository.Save(context.Background(), project) + require.NoError(t, err) + require.True(t, changed) + + changed, err = repository.Save(context.Background(), project) + require.NoError(t, err) + require.False(t, changed) + + loaded, err := repository.Load(context.Background(), manifestPath) + require.NoError(t, err) + require.Equal(t, project.Manifest.Project, loaded.Project.Manifest.Project) + require.Equal(t, project.Manifest.Languages.Go.Module, loaded.Project.Manifest.Languages.Go.Module) +} + +func TestFilesystemRepoClassifiesMissingManifest(t *testing.T) { + t.Parallel() + + missing := filepath.Join(t.TempDir(), "missing.yaml") + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), missing) + + require.ErrorIs(t, err, os.ErrNotExist) + require.Empty(t, loaded.Issues) +} + +func TestFilesystemRepoReturnsMalformedYAMLAsDocumentIssue(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte("version: [\n"), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), path) + + require.NoError(t, err) + require.Equal(t, []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeYAMLInvalid}}, loaded.Issues) +} + +func TestFilesystemRepoReturnsTypeMismatchPosition(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte("version: nope\n"), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), path) + + require.NoError(t, err) + require.Equal(t, []projectdomain.DecodeIssue{{ + Kind: projectdomain.DecodeSchemaInvalid, Field: "version", Line: 1, Column: 10, + }}, loaded.Issues) +} + +func TestFilesystemRepoReturnsDuplicateKeyPosition(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte("project:\n name: first\n name: second\n"), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), path) + + require.NoError(t, err) + require.Equal(t, []projectdomain.DecodeIssue{{ + Kind: projectdomain.DecodeDuplicateKey, Field: "project.name", Line: 3, Column: 3, + }}, loaded.Issues) +} + +func TestFilesystemRepoAggregatesUnknownFields(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte("unknown_root: true\nproject:\n unknown_project: true\n"), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), path) + + require.NoError(t, err) + require.Equal(t, []projectdomain.DecodeIssue{ + {Kind: projectdomain.DecodeUnknownField, Field: "unknown_root", Line: 1, Column: 1}, + {Kind: projectdomain.DecodeUnknownField, Field: "project.unknown_project", Line: 3, Column: 3}, + }, loaded.Issues) +} + +func TestLoadManifestMapsYAMLDocumentToProjectSpec(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: example, language: go} +env: {prefix: EXAMPLE_} +paths: {external_contracts: contracts/external} +sources: + remote: + type: url + url: http://example.test/openapi.yaml + filename: remote.yaml + allow_insecure_http: true +exports: {} +components: + http: + clients: + - name: remote + source: remote + path: openapi.yaml + base_url_env: REMOTE_BASE_URL + oapi_config: tools/oapi/remote.yaml + db: + connections: + - name: primary + default: sqlite + kind_env: DB_PRIMARY_KIND + variants: + - name: sqlite + kind: sqlite + dsn_env: DB_PRIMARY_SQLITE_DSN + dsn_default: file:./data/primary.db + migrations: + path: migrations/primary/sqlite + database_env: DB_PRIMARY_SQLITE_MIGRATIONS_URL + database_default: sqlite://./data/primary.db + redis: + connections: + - name: cache + addr_env: REDIS_CACHE_ADDR + addr_default: redis://localhost:6379/1 +languages: + go: + module: github.com/acme/example + generators: + http: + oapi_config: tools/oapi/server.yaml + server_out: gen/server + client_out: gen/client +`), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), manifestPath) + + require.NoError(t, err) + require.Empty(t, loaded.Issues) + require.Equal(t, root, loaded.Project.Root) + require.Equal(t, manifestPath, loaded.Project.ManifestPath) + spec := loaded.Project.Manifest + require.Equal(t, "contracts/external", spec.Paths.ExternalContracts) + require.True(t, spec.Sources["remote"].AllowInsecureHTTP) + require.Equal(t, "REMOTE_BASE_URL", spec.Components.HTTP.Clients[0].BaseURLEnv) + require.Equal(t, "tools/oapi/remote.yaml", spec.Components.HTTP.Clients[0].OAPIConfig) + require.Equal(t, "DB_PRIMARY_KIND", spec.Components.DB.Connections[0].KindEnv) + require.Equal(t, "DB_PRIMARY_SQLITE_DSN", spec.Components.DB.Connections[0].Variants[0].DSNEnv) + require.Equal(t, &projectdomain.DBMigrations{ + Path: "migrations/primary/sqlite", DatabaseEnv: "DB_PRIMARY_SQLITE_MIGRATIONS_URL", + DatabaseDefault: "sqlite://./data/primary.db", + }, spec.Components.DB.Connections[0].Variants[0].Migrations) + require.Equal(t, projectdomain.RedisConnection{ + Name: "cache", AddrEnv: "REDIS_CACHE_ADDR", AddrDefault: "redis://localhost:6379/1", + }, spec.Components.Redis.Connections[0]) + require.Equal(t, "gen/server", spec.Languages.Go.Generators.HTTP.ServerOut) + require.Equal(t, "gen/client", spec.Languages.Go.Generators.HTTP.ClientOut) +} + +func TestFilesystemRepoRejectsRemovedRedisFields(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "devctl.yaml") + require.NoError(t, os.WriteFile(path, []byte(`components: + redis: + instances: [] + connections: + - name: cache + addr_env: REDIS_CACHE_ADDR + default: true +`), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), path) + + require.NoError(t, err) + fields := make([]string, len(loaded.Issues)) + for index, issue := range loaded.Issues { + require.Equal(t, projectdomain.DecodeUnknownField, issue.Kind) + fields[index] = issue.Field + } + require.Equal(t, []string{ + "components.redis.instances", + "components.redis.connections[0].default", + }, fields) +} + +func TestMutationRejectsUnknownExtensionFields(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + original := `version: 1 +project: {name: example, language: go} +env: {} +paths: {external_contracts: api/external} +sources: + users: + # transport note + type: git + repo: old + ref: main + x-owner: contracts +exports: {} +components: + x-component: {keep: true} +languages: + go: {module: github.com/acme/example} +` + require.NoError(t, os.WriteFile(manifestPath, []byte(original), 0o644)) + + loaded, err := manifest.NewFilesystemRepo().Load(context.Background(), manifestPath) + require.NoError(t, err) + require.Equal(t, []projectdomain.DecodeIssue{ + {Kind: projectdomain.DecodeUnknownField, Field: "sources.users.x-owner", Line: 11, Column: 5}, + {Kind: projectdomain.DecodeUnknownField, Field: "components.x-component", Line: 14, Column: 3}, + }, loaded.Issues) + updated, err := os.ReadFile(manifestPath) + require.NoError(t, err) + require.Contains(t, string(updated), "# transport note") + require.Contains(t, string(updated), "x-owner: contracts") + require.Contains(t, string(updated), "x-component: {keep: true}") + require.Contains(t, string(updated), "repo: old") +} diff --git a/internal/repository/workspace/contracts.go b/internal/repository/workspace/contracts.go new file mode 100644 index 0000000..efa51a5 --- /dev/null +++ b/internal/repository/workspace/contracts.go @@ -0,0 +1,160 @@ +package workspace + +import ( + "context" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + devfs "github.com/devctllabs/go-libs/filesystem" + "gopkg.in/yaml.v3" +) + +// ReadContract reads exact bytes from a resolved regular non-symlink contract path. +func (r *FilesystemRepo) ReadContract(ctx context.Context, contractPath string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + info, err := os.Lstat(contractPath) + if err != nil { + return nil, fmt.Errorf("os.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("contract is not a regular non-symlink file: %s", contractPath) + } + data, err := os.ReadFile(contractPath) + if err != nil { + return nil, fmt.Errorf("os.ReadFile: %w", err) + } + return data, nil +} + +// ListProtoFiles returns sorted project-relative Proto files below relativeRoot. +func (r *FilesystemRepo) ListProtoFiles(ctx context.Context, root, relativeRoot string) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + var files []string + err = fs.WalkDir(disk, relativeRoot, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if entry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("symlink in Proto tree: %s", name) + } + if !entry.IsDir() && path.Ext(name) == ".proto" { + files = append(files, path.Clean(name)) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("filesystem.WalkDir: %w", err) + } + sort.Strings(files) + return files, nil +} + +// ResolveContract selects one regular non-symlink entrypoint within location.Root. +// When Entrypoint is empty, resolution succeeds only if exactly one OpenAPI 3.x document is found. +func (r *FilesystemRepo) ResolveContract(ctx context.Context, location contract.Location) (string, error) { + disk, err := devfs.Open(location.Root) + if err != nil { + return "", fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + + relative := location.RelativePath + if location.Local { + if location.Entrypoint != location.RelativePath { + info, statErr := disk.Lstat(relative) + if statErr != nil { + return "", fmt.Errorf("filesystem.Lstat: %w", statErr) + } + if info.IsDir() { + relative = path.Join(relative, location.Entrypoint) + } + } + } else if location.Entrypoint != "" { + relative = path.Join(relative, location.Entrypoint) + } + if location.Entrypoint == "" { + relative, err = findOpenAPI(ctx, disk, relative) + if err != nil { + return "", fmt.Errorf("findOpenAPI: %w", err) + } + } + info, err := disk.Lstat(relative) + if err != nil { + return "", fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return "", fmt.Errorf("contract is not a regular non-symlink file: %s", relative) + } + return filepath.Join(location.Root, filepath.FromSlash(relative)), nil +} + +func findOpenAPI(ctx context.Context, disk *devfs.OS, root string) (string, error) { + var candidates []string + err := fs.WalkDir(disk, root, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if entry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("symlink in materialized target: %s", name) + } + if entry.IsDir() { + return nil + } + data, err := fs.ReadFile(disk, name) + if err != nil { + return fmt.Errorf("filesystem.ReadFile: %w", err) + } + var document yaml.Node + if yaml.Unmarshal(data, &document) == nil && strings.HasPrefix(mappingScalar(documentRoot(&document), "openapi"), "3.") { + candidates = append(candidates, name) + } + return nil + }) + if err != nil { + return "", fmt.Errorf("filesystem.WalkDir: %w", err) + } + if len(candidates) != 1 { + return "", fmt.Errorf("expected exactly one OpenAPI entrypoint in %s, found %d", root, len(candidates)) + } + return candidates[0], nil +} + +func documentRoot(node *yaml.Node) *yaml.Node { + if node != nil && node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return node.Content[0] + } + return node +} + +func mappingScalar(node *yaml.Node, key string) string { + if node == nil || node.Kind != yaml.MappingNode { + return "" + } + for index := 0; index+1 < len(node.Content); index += 2 { + if node.Content[index].Value == key { + return node.Content[index+1].Value + } + } + return "" +} diff --git a/internal/repository/workspace/repository.go b/internal/repository/workspace/repository.go new file mode 100644 index 0000000..f959f25 --- /dev/null +++ b/internal/repository/workspace/repository.go @@ -0,0 +1,441 @@ +package workspace + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "path" + "sort" + "strings" + "sync" + + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/devctllabs/devctl/internal/domain/contract" + devfs "github.com/devctllabs/go-libs/filesystem" +) + +// FilesystemRepo implements contained project workspace mechanics over the operating-system filesystem. +type FilesystemRepo struct { + publication sync.Mutex +} + +func NewFilesystemRepo() *FilesystemRepo { return &FilesystemRepo{} } + +// WorkingDirectory returns the process working directory unless ctx is already cancelled. +func (r *FilesystemRepo) WorkingDirectory(ctx context.Context) (string, error) { + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("ctx.Err: %w", err) + } + directory, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("os.Getwd: %w", err) + } + return directory, nil +} + +// Walk visits entries below root, propagates cancellation, and closes the rooted filesystem before returning. +func (r *FilesystemRepo) Walk(ctx context.Context, root string, visit fs.WalkDirFunc) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return fmt.Errorf("filesystem.Open: %w", err) + } + walkErr := fs.WalkDir(disk, ".", func(name string, entry fs.DirEntry, err error) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if err := visit(name, entry, err); err != nil { + return fmt.Errorf("visit: %w", err) + } + return nil + }) + if walkErr != nil { + walkErr = fmt.Errorf("filesystem.WalkDir: %w", walkErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return errors.Join(walkErr, closeErr) +} + +// Lstat reports contained path metadata without following the final symlink. +func (r *FilesystemRepo) Lstat(ctx context.Context, root, name string) (fs.FileInfo, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + info, statErr := disk.Lstat(name) + if statErr != nil { + statErr = fmt.Errorf("filesystem.Lstat: %w", statErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return info, errors.Join(statErr, closeErr) +} + +// RegularFile reports false for missing paths, symlinks, directories, and other non-regular entries. +func (r *FilesystemRepo) RegularFile(ctx context.Context, root, name string) (bool, error) { + info, err := r.Lstat(ctx, root, name) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("r.Lstat: %w", err) + } + return info.Mode().IsRegular() && info.Mode()&fs.ModeSymlink == 0, nil +} + +// Directory reports false for missing paths, symlinks, and non-directory entries. +func (r *FilesystemRepo) Directory(ctx context.Context, root, name string) (bool, error) { + info, err := r.Lstat(ctx, root, name) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("r.Lstat: %w", err) + } + return info.IsDir() && info.Mode()&fs.ModeSymlink == 0, nil +} + +// ReadBytes reads a path through a filesystem rooted at root and closes that filesystem before returning. +func (r *FilesystemRepo) ReadBytes(ctx context.Context, root, name string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + data, readErr := fs.ReadFile(disk, name) + if readErr != nil { + readErr = fmt.Errorf("filesystem.ReadFile: %w", readErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return data, errors.Join(readErr, closeErr) +} + +// ReadFile returns a regular non-symlink file below root with its relative path and permission bits. +func (r *FilesystemRepo) ReadFile(ctx context.Context, root, name string) (contract.File, error) { + if err := ctx.Err(); err != nil { + return contract.File{}, fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return contract.File{}, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + info, err := disk.Lstat(name) + if err != nil { + return contract.File{}, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + return contract.File{}, fmt.Errorf("path is not a regular non-symlink file: %s", name) + } + content, err := fs.ReadFile(disk, name) + if err != nil { + return contract.File{}, fmt.Errorf("filesystem.ReadFile: %w", err) + } + return contract.File{Path: name, Content: content, Mode: uint32(info.Mode().Perm())}, nil +} + +// ReadTree returns all regular non-symlink files below a contained directory. +func (r *FilesystemRepo) ReadTree(ctx context.Context, root, directory string) ([]contract.File, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + defer func() { _ = disk.Close() }() + + var files []contract.File + err = fs.WalkDir(disk, directory, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if entry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("symlink in contract tree: %s", name) + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return fmt.Errorf("entry.Info: %w", err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("path is not a regular file: %s", name) + } + content, err := fs.ReadFile(disk, name) + if err != nil { + return fmt.Errorf("filesystem.ReadFile: %w", err) + } + files = append(files, contract.File{Path: path.Clean(name), Content: content, Mode: uint32(info.Mode().Perm())}) + return nil + }) + if err != nil { + return nil, fmt.Errorf("filesystem.WalkDir: %w", err) + } + return files, nil +} + +// PublishFile atomically publishes content below root and reports creation, replacement, or equality. +func (r *FilesystemRepo) PublishFile( + ctx context.Context, + root, target string, + content []byte, +) (artifact.PublishResult, error) { + r.publication.Lock() + defer r.publication.Unlock() + + disk, err := devfs.Open(root) + if err != nil { + return artifact.PublishResult{}, fmt.Errorf("filesystem.Open: %w", err) + } + _, statErr := disk.Lstat(target) + exists := statErr == nil + if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) { + _ = disk.Close() + return artifact.PublishResult{}, fmt.Errorf("filesystem.Lstat: %w", statErr) + } + changed, publishErr := disk.PublishFile(ctx, target, devfs.File{Content: content, Mode: 0o644}) + if publishErr != nil { + publishErr = fmt.Errorf("filesystem.PublishFile: %w", publishErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + result := artifact.PublishResult{Action: publishAction(exists, changed)} + return result, errors.Join(publishErr, closeErr) +} + +// PublishDirectory atomically replaces target with the complete tree and reports precise file effects. +func (r *FilesystemRepo) PublishDirectory( + ctx context.Context, + root, target string, + tree artifact.Tree, +) (artifact.PublishResult, error) { + r.publication.Lock() + defer r.publication.Unlock() + + disk, err := devfs.Open(root) + if err != nil { + return artifact.PublishResult{}, fmt.Errorf("filesystem.Open: %w", err) + } + snapshot := make(devfs.Snapshot, len(tree.Files)) + for _, file := range tree.Files { + mode := fs.FileMode(file.Mode) + if mode == 0 { + mode = 0o644 + } + snapshot[file.Path] = devfs.File{Content: file.Content, Mode: mode} + } + previous, exists, inspectErr := inspectPublishedTree(ctx, disk, target) + if inspectErr != nil { + _ = disk.Close() + return artifact.PublishResult{}, fmt.Errorf("inspectPublishedTree: %w", inspectErr) + } + changed, publishErr := disk.PublishDirectory(ctx, target, snapshot) + if publishErr != nil { + publishErr = fmt.Errorf("filesystem.PublishDirectory: %w", publishErr) + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + result := artifact.PublishResult{ + Action: publishAction(exists, changed), + Changes: publicationChanges(previous, snapshot), + } + return result, errors.Join(publishErr, closeErr) +} + +func publishAction(existed, changed bool) artifact.PublishAction { + if !changed { + return artifact.PublishUnchanged + } + if existed { + return artifact.PublishUpdated + } + return artifact.PublishCreated +} + +func inspectPublishedTree( + ctx context.Context, + disk *devfs.OS, + target string, +) (devfs.Snapshot, bool, error) { + info, err := disk.Lstat(target) + if errors.Is(err, fs.ErrNotExist) { + return devfs.Snapshot{}, false, nil + } + if err != nil { + return nil, false, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return nil, true, &fs.PathError{Op: "publishdirectory", Path: target, Err: fs.ErrInvalid} + } + result := devfs.Snapshot{} + err = fs.WalkDir(disk, target, func(name string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("ctx.Err: %w", err) + } + if name == target || entry.IsDir() { + return nil + } + if entry.Type()&fs.ModeSymlink != 0 { + return &fs.PathError{Op: "publishdirectory", Path: name, Err: fs.ErrInvalid} + } + info, err := entry.Info() + if err != nil { + return fmt.Errorf("entry.Info: %w", err) + } + if !info.Mode().IsRegular() { + return &fs.PathError{Op: "publishdirectory", Path: name, Err: fs.ErrInvalid} + } + content, err := fs.ReadFile(disk, name) + if err != nil { + return fmt.Errorf("filesystem.ReadFile: %w", err) + } + relative := strings.TrimPrefix(name, strings.TrimSuffix(target, "/")+"/") + result[relative] = devfs.File{Content: content, Mode: info.Mode().Perm()} + return nil + }) + if err != nil { + return nil, true, fmt.Errorf("filesystem.WalkDir: %w", err) + } + return result, true, nil +} + +func publicationChanges(previous, next devfs.Snapshot) []artifact.PublishChange { + changes := make([]artifact.PublishChange, 0, len(previous)+len(next)) + for name, expected := range next { + action := artifact.PublishCreated + if current, exists := previous[name]; exists { + action = artifact.PublishUpdated + if current.Mode.Perm() == expected.Mode.Perm() && bytes.Equal(current.Content, expected.Content) { + action = artifact.PublishUnchanged + } + } + changes = append(changes, artifact.PublishChange{Path: name, Action: action}) + } + for name := range previous { + if _, exists := next[name]; !exists { + changes = append(changes, artifact.PublishChange{Path: name, Action: artifact.PublishRemoved}) + } + } + sort.Slice(changes, func(i, j int) bool { return changes[i].Path < changes[j].Path }) + return changes +} + +// PruneDirectories removes regular child directories absent from keep and returns their names in lexical order. +func (r *FilesystemRepo) PruneDirectories( + ctx context.Context, + root, parent string, + keep []string, +) ([]string, error) { + r.publication.Lock() + defer r.publication.Unlock() + + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + removed, pruneErr := prunableDirectories(ctx, disk, parent, keep) + if pruneErr == nil { + for _, name := range removed { + if err := disk.RemoveAll(ctx, path.Join(parent, name)); err != nil { + pruneErr = fmt.Errorf("filesystem.RemoveAll: %w", err) + break + } + } + } + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return removed, errors.Join(pruneErr, closeErr) +} + +// PreviewPruneDirectories returns the validated stale child directories without removing them. +func (r *FilesystemRepo) PreviewPruneDirectories( + ctx context.Context, + root, parent string, + keep []string, +) ([]string, error) { + r.publication.Lock() + defer r.publication.Unlock() + + disk, err := devfs.Open(root) + if err != nil { + return nil, fmt.Errorf("filesystem.Open: %w", err) + } + removed, previewErr := prunableDirectories(ctx, disk, parent, keep) + closeErr := disk.Close() + if closeErr != nil { + closeErr = fmt.Errorf("filesystem.Close: %w", closeErr) + } + return removed, errors.Join(previewErr, closeErr) +} + +func prunableDirectories(ctx context.Context, disk *devfs.OS, parent string, keep []string) ([]string, error) { + info, err := disk.Lstat(parent) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return nil, &fs.PathError{Op: "prune", Path: parent, Err: fs.ErrInvalid} + } + entries, err := fs.ReadDir(disk, parent) + if err != nil { + return nil, fmt.Errorf("filesystem.ReadDir: %w", err) + } + kept := make(map[string]struct{}, len(keep)) + for _, name := range keep { + kept[name] = struct{}{} + } + removed := make([]string, 0, len(entries)) + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + if _, ok := kept[entry.Name()]; ok { + continue + } + target := path.Join(parent, entry.Name()) + info, err := disk.Lstat(target) + if err != nil { + return nil, fmt.Errorf("filesystem.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.IsDir() { + return nil, &fs.PathError{Op: "prune", Path: target, Err: fs.ErrInvalid} + } + removed = append(removed, entry.Name()) + } + return removed, nil +} diff --git a/internal/repository/workspace/repository_test.go b/internal/repository/workspace/repository_test.go new file mode 100644 index 0000000..239f167 --- /dev/null +++ b/internal/repository/workspace/repository_test.go @@ -0,0 +1,201 @@ +package workspace_test + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/devctllabs/devctl/internal/domain/contract" + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + "github.com/stretchr/testify/require" +) + +func TestFilesystemRepoPublishesAndPrunesManagedDirectories(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/external/clienthttp/stale"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/external/clienthttp/stale/openapi.yaml"), []byte("stale"), 0o644)) + repository := workspacerepo.NewFilesystemRepo() + + published, err := repository.PublishDirectory(context.Background(), root, "api/external/clienthttp/active", artifact.Tree{Files: []artifact.File{ + {Path: "openapi.yaml", Content: []byte("active")}, + }}) + require.NoError(t, err) + require.Equal(t, artifact.PublishCreated, published.Action) + require.Equal(t, []artifact.PublishChange{{Path: "openapi.yaml", Action: artifact.PublishCreated}}, published.Changes) + + preview, err := repository.PreviewPruneDirectories(context.Background(), root, "api/external/clienthttp", []string{"active"}) + require.NoError(t, err) + require.Equal(t, []string{"stale"}, preview) + require.DirExists(t, filepath.Join(root, "api/external/clienthttp/stale")) + + removed, err := repository.PruneDirectories(context.Background(), root, "api/external/clienthttp", []string{"active"}) + require.NoError(t, err) + require.Equal(t, []string{"stale"}, removed) + require.NoDirExists(t, filepath.Join(root, "api/external/clienthttp/stale")) + require.FileExists(t, filepath.Join(root, "api/external/clienthttp/active/openapi.yaml")) +} + +func TestFilesystemRepoPreviewAndPruneRejectTheSameUnsafeEntries(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("symlink policy requires symlink support") + } + + tests := []struct { + name string + parent string + setup func(*testing.T, string) + }{ + {name: "unsafe parent", parent: "../outside", setup: func(*testing.T, string) {}}, + {name: "parent file", parent: "managed", setup: func(t *testing.T, root string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(root, "managed"), []byte("file"), 0o644)) + }}, + {name: "parent symlink", parent: "managed", setup: func(t *testing.T, root string) { + t.Helper() + outside := t.TempDir() + require.NoError(t, os.Symlink(outside, filepath.Join(root, "managed"))) + }}, + {name: "child file", parent: "managed", setup: func(t *testing.T, root string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(root, "managed"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "managed", "stale"), []byte("file"), 0o644)) + }}, + {name: "child symlink", parent: "managed", setup: func(t *testing.T, root string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(root, "managed"), 0o755)) + require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(root, "managed", "stale"))) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + test.setup(t, root) + repository := workspacerepo.NewFilesystemRepo() + + _, previewErr := repository.PreviewPruneDirectories(context.Background(), root, test.parent, nil) + _, pruneErr := repository.PruneDirectories(context.Background(), root, test.parent, nil) + + require.ErrorIs(t, previewErr, fs.ErrInvalid) + require.ErrorIs(t, pruneErr, fs.ErrInvalid) + }) + } +} + +func TestFilesystemRepoPublishesManagedFileIdempotently(t *testing.T) { + t.Parallel() + + root := t.TempDir() + repository := workspacerepo.NewFilesystemRepo() + + published, err := repository.PublishFile(context.Background(), root, ".env.example", []byte("LOG_LEVEL=info\n")) + require.NoError(t, err) + require.Equal(t, artifact.PublishResult{Action: artifact.PublishCreated}, published) + require.FileExists(t, filepath.Join(root, ".env.example")) + + published, err = repository.PublishFile(context.Background(), root, ".env.example", []byte("LOG_LEVEL=info\n")) + require.NoError(t, err) + require.Equal(t, artifact.PublishResult{Action: artifact.PublishUnchanged}, published) + + published, err = repository.PublishFile(context.Background(), root, ".env.example", []byte("LOG_LEVEL=debug\n")) + require.NoError(t, err) + require.Equal(t, artifact.PublishResult{Action: artifact.PublishUpdated}, published) + content, err := os.ReadFile(filepath.Join(root, ".env.example")) + require.NoError(t, err) + require.Equal(t, []byte("LOG_LEVEL=debug\n"), content) +} + +func TestFilesystemRepoReportsPreciseDirectoryPublicationChanges(t *testing.T) { + t.Parallel() + + root := t.TempDir() + target := filepath.Join(root, "generated") + require.NoError(t, os.MkdirAll(target, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(target, "equal.go"), []byte("equal"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(target, "changed.go"), []byte("before"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(target, "stale.go"), []byte("stale"), 0o644)) + + repository := workspacerepo.NewFilesystemRepo() + desired := artifact.Tree{Files: []artifact.File{ + {Path: "equal.go", Content: []byte("equal"), Mode: 0o644}, + {Path: "changed.go", Content: []byte("after"), Mode: 0o644}, + {Path: "created.go", Content: []byte("created"), Mode: 0o644}, + }} + published, err := repository.PublishDirectory(context.Background(), root, "generated", desired) + + require.NoError(t, err) + require.Equal(t, artifact.PublishUpdated, published.Action) + require.Equal(t, []artifact.PublishChange{ + {Path: "changed.go", Action: artifact.PublishUpdated}, + {Path: "created.go", Action: artifact.PublishCreated}, + {Path: "equal.go", Action: artifact.PublishUnchanged}, + {Path: "stale.go", Action: artifact.PublishRemoved}, + }, published.Changes) + require.NoFileExists(t, filepath.Join(target, "stale.go")) + + published, err = repository.PublishDirectory(context.Background(), root, "generated", desired) + require.NoError(t, err) + require.Equal(t, artifact.PublishUnchanged, published.Action) + require.Equal(t, []artifact.PublishChange{ + {Path: "changed.go", Action: artifact.PublishUnchanged}, + {Path: "created.go", Action: artifact.PublishUnchanged}, + {Path: "equal.go", Action: artifact.PublishUnchanged}, + }, published.Changes) +} + +func TestFilesystemRepoLocatesOneOpenAPIEntrypoint(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/external/clienthttp/remote/spec"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "api/external/clienthttp/remote/spec/openapi.yaml"), []byte("openapi: 3.1.0\n"), 0o644)) + contractPath, err := workspacerepo.NewFilesystemRepo().ResolveContract(context.Background(), contract.Location{Root: root, RelativePath: "api/external/clienthttp/remote"}) + + require.NoError(t, err) + require.Equal(t, filepath.Join(root, "api/external/clienthttp/remote/spec/openapi.yaml"), contractPath) +} + +func TestFilesystemRepoResolvesLocalEntrypointWithoutDuplicatingItsPath(t *testing.T) { + t.Parallel() + + root := t.TempDir() + relative := "api/openapi/swagger.yaml" + require.NoError(t, os.MkdirAll(filepath.Join(root, "api/openapi"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, filepath.FromSlash(relative)), []byte("openapi: 3.1.0\n"), 0o644)) + + contractPath, err := workspacerepo.NewFilesystemRepo().ResolveContract(context.Background(), contract.Location{Root: root, RelativePath: relative, Entrypoint: relative, Local: true}) + + require.NoError(t, err) + require.Equal(t, filepath.Join(root, filepath.FromSlash(relative)), contractPath) +} + +func TestFilesystemRepoListsProtoFilesInProjectOrder(t *testing.T) { + t.Parallel() + + root := t.TempDir() + for _, name := range []string{ + "api/proto/zeta/zeta.service.proto", + "api/proto/alpha/alpha.common_types.proto", + "api/proto/README.md", + } { + filename := filepath.Join(root, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(name), 0o644)) + } + + files, err := workspacerepo.NewFilesystemRepo().ListProtoFiles(context.Background(), root, "api/proto") + + require.NoError(t, err) + require.Equal(t, []string{ + "api/proto/alpha/alpha.common_types.proto", + "api/proto/zeta/zeta.service.proto", + }, files) +} diff --git a/internal/service/contractsnapshot/filesystem_integration_test.go b/internal/service/contractsnapshot/filesystem_integration_test.go new file mode 100644 index 0000000..95fafcd --- /dev/null +++ b/internal/service/contractsnapshot/filesystem_integration_test.go @@ -0,0 +1,160 @@ +package contractsnapshot_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + "github.com/devctllabs/devctl/internal/service/contractsnapshot" + "github.com/stretchr/testify/require" +) + +func TestLoaderUsesFilesystemReaderForCommittedProtoSnapshot(t *testing.T) { + t.Parallel() + + root := t.TempDir() + snapshotRoot := "api/external/grpc/client/billing" + writeSnapshotFile(t, root, snapshotRoot+"/.devctl-contract.json", `{ + "kind": "grpc", + "format": "proto", + "module_root": "api/proto/grpc", + "buf_config": "buf.yaml" +}`) + writeSnapshotFile(t, root, snapshotRoot+"/api/proto/grpc/billing/v1/service.proto", "syntax = \"proto3\";\n") + writeSnapshotFile(t, root, snapshotRoot+"/buf.yaml", "version: v2\n") + writeSnapshotFile(t, root, snapshotRoot+"/buf.lock", "deps: []\n") + + snapshot, err := contractsnapshot.New(workspacerepo.NewFilesystemRepo()).Load( + context.Background(), root, snapshotRoot, + contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + ) + + require.NoError(t, err) + require.Equal(t, "api/proto/grpc", snapshot.ModuleRoot) + require.Empty(t, snapshot.Entrypoint) + require.Equal(t, []string{ + "api/proto/grpc/billing/v1/service.proto", "buf.lock", "buf.yaml", + }, snapshotFilePaths(snapshot)) + require.Equal(t, "buf.yaml", snapshot.Metadata.BufConfig) +} + +func TestLoaderUsesFilesystemReaderForTypedInvalidMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + metadata string + prepare func(*testing.T, string, string) + field string + reason contract.MetadataInvalidReason + expectation contract.MetadataExpectation + }{ + { + name: "missing sidecar", field: ".devctl-contract.json", reason: contract.MetadataRequired, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + { + name: "wrong JSON type", metadata: `{"kind":"kafka","topic":42,"format":"json","entrypoint":"schema.json"}`, + field: "topic", reason: contract.MetadataInvalidType, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + { + name: "missing JSON entrypoint", metadata: `{"kind":"kafka","topic":"sample.events.created.v1","format":"json"}`, + field: "entrypoint", reason: contract.MetadataRequired, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + { + name: "absolute entrypoint", metadata: `{"kind":"kafka","topic":"sample.events.created.v1","format":"json","entrypoint":"/schema.json"}`, + field: "entrypoint", reason: contract.MetadataInvalidPath, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + { + name: "traversing module root", metadata: `{"kind":"grpc","format":"proto","module_root":"../proto","buf_config":"buf.yaml"}`, + field: "module_root", reason: contract.MetadataInvalidPath, + expectation: contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + }, + { + name: "missing referenced file", metadata: `{"kind":"kafka","topic":"sample.events.created.v1","format":"json","entrypoint":"schema.json"}`, + field: "entrypoint", reason: contract.MetadataNotFound, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + { + name: "symlink entrypoint", metadata: `{"kind":"kafka","topic":"sample.events.created.v1","format":"json","entrypoint":"schema.json"}`, + prepare: func(t *testing.T, root, snapshotRoot string) { + t.Helper() + writeSnapshotFile(t, root, snapshotRoot+"/real.json", `{"title":"Event"}`) + link := filepath.Join(root, filepath.FromSlash(snapshotRoot), "schema.json") + require.NoError(t, os.Symlink("real.json", link)) + }, + field: "entrypoint", reason: contract.MetadataNotRegular, + expectation: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + const snapshotRoot = "api/external/kafka/consumer/events" + if test.metadata != "" { + writeSnapshotFile(t, root, snapshotRoot+"/.devctl-contract.json", test.metadata) + } + if test.prepare != nil { + test.prepare(t, root, snapshotRoot) + } + + _, err := contractsnapshot.New(workspacerepo.NewFilesystemRepo()).Load( + context.Background(), root, snapshotRoot, test.expectation, + ) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + var metadataErr *contract.SnapshotMetadataError + require.ErrorAs(t, err, &metadataErr) + require.Equal(t, test.field, metadataErr.Field) + require.Equal(t, test.reason, metadataErr.Reason) + require.Equal(t, "devctl sync", metadataErr.Hint) + }) + } +} + +func TestLoaderUsesFilesystemReaderForRawKafkaWithoutFiles(t *testing.T) { + t.Parallel() + + root := t.TempDir() + const snapshotRoot = "api/external/kafka/consumer/events" + writeSnapshotFile(t, root, snapshotRoot+"/.devctl-contract.json", `{ + "kind":"kafka", + "topic":"sample.events.created.v1", + "format":"raw" +}`) + + snapshot, err := contractsnapshot.New(workspacerepo.NewFilesystemRepo()).Load( + context.Background(), root, snapshotRoot, + contract.MetadataExpectation{Kind: "kafka", Topic: "sample.events.created.v1", Format: "raw"}, + ) + + require.NoError(t, err) + require.Empty(t, snapshot.Files) + require.Empty(t, snapshot.Entrypoint) + require.Empty(t, snapshot.ModuleRoot) +} + +func writeSnapshotFile(t *testing.T, root, name, content string) { + t.Helper() + filename := filepath.Join(root, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(filename), 0o755)) + require.NoError(t, os.WriteFile(filename, []byte(content), 0o644)) +} + +func snapshotFilePaths(snapshot contract.Snapshot) []string { + paths := make([]string, len(snapshot.Files)) + for index, file := range snapshot.Files { + paths[index] = file.Path + } + return paths +} diff --git a/internal/service/contractsnapshot/loader.go b/internal/service/contractsnapshot/loader.go new file mode 100644 index 0000000..9e782ba --- /dev/null +++ b/internal/service/contractsnapshot/loader.go @@ -0,0 +1,126 @@ +package contractsnapshot + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path" + "sort" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" +) + +const metadataFilename = ".devctl-contract.json" + +//go:generate go tool mockgen -destination mocks/loader.go -package mocks -typed . Reader + +// Reader provides the contained committed files needed to reconstruct a Snapshot. +type Reader interface { + // ReadFile returns a regular non-symlink file below root with its relative path and permission bits. + ReadFile(ctx context.Context, root, name string) (contract.File, error) + // ReadTree returns every regular non-symlink file below a contained directory in deterministic order. + ReadTree(ctx context.Context, root, directory string) ([]contract.File, error) +} + +// Loader reconstructs and validates committed Contract Snapshots. +type Loader struct { + reader Reader +} + +func New(reader Reader) *Loader { + return &Loader{reader: reader} +} + +// Load reconstructs the committed Snapshot rooted at treeRoot and validates it against expected. +func (l *Loader) Load( + ctx context.Context, + root string, + treeRoot string, + expected contract.MetadataExpectation, +) (contract.Snapshot, error) { + metadataFile, err := l.reader.ReadFile(ctx, root, path.Join(treeRoot, metadataFilename)) + if err != nil { + reason := contract.MetadataNotRegular + if errors.Is(err, fs.ErrNotExist) { + reason = contract.MetadataRequired + } + return contract.Snapshot{}, metadataError(metadataFilename, reason, err) + } + metadata, err := contract.DecodeMetadata(metadataFile.Content) + if err != nil { + return contract.Snapshot{}, fmt.Errorf("contract.DecodeMetadata: %w", err) + } + if err := contract.ValidateMetadata(metadata, expected); err != nil { + return contract.Snapshot{}, fmt.Errorf("contract.ValidateMetadata: %w", err) + } + if err := l.validateReferences(ctx, root, treeRoot, metadata); err != nil { + return contract.Snapshot{}, err + } + files, err := l.reader.ReadTree(ctx, root, treeRoot) + if err != nil { + return contract.Snapshot{}, metadataError("files", contract.MetadataNotRegular, err) + } + snapshot := contract.Snapshot{ + ModuleRoot: metadata.ModuleRoot, + Entrypoint: metadata.Entrypoint, + Files: rebaseFiles(files, treeRoot), + Metadata: &metadata, + } + if err := contract.ValidateSnapshot(snapshot, expected); err != nil { + return contract.Snapshot{}, fmt.Errorf("contract.ValidateSnapshot: %w", err) + } + return snapshot, nil +} + +func (l *Loader) validateReferences( + ctx context.Context, + root string, + treeRoot string, + metadata contract.Metadata, +) error { + for _, reference := range []struct{ field, name string }{ + {field: "entrypoint", name: metadata.Entrypoint}, + {field: "buf_config", name: metadata.BufConfig}, + } { + if reference.name == "" { + continue + } + if _, err := l.reader.ReadFile(ctx, root, path.Join(treeRoot, reference.name)); err != nil { + return referenceError(reference.field, err) + } + } + if metadata.ModuleRoot == "" { + return nil + } + if _, err := l.reader.ReadTree(ctx, root, path.Join(treeRoot, metadata.ModuleRoot)); err != nil { + return referenceError("module_root", err) + } + return nil +} + +func referenceError(field string, err error) error { + reason := contract.MetadataNotRegular + if errors.Is(err, fs.ErrNotExist) { + reason = contract.MetadataNotFound + } + return metadataError(field, reason, err) +} + +func rebaseFiles(files []contract.File, treeRoot string) []contract.File { + prefix := strings.TrimSuffix(path.Clean(treeRoot), "/") + "/" + rebased := make([]contract.File, 0, len(files)) + for _, file := range files { + file.Path = strings.TrimPrefix(file.Path, prefix) + if file.Path != metadataFilename { + rebased = append(rebased, file) + } + } + sort.Slice(rebased, func(i, j int) bool { return rebased[i].Path < rebased[j].Path }) + return rebased +} + +func metadataError(field string, reason contract.MetadataInvalidReason, cause error) error { + return &contract.SnapshotMetadataError{Field: field, Reason: reason, Hint: "devctl sync", Cause: cause} +} diff --git a/internal/service/contractsnapshot/loader_test.go b/internal/service/contractsnapshot/loader_test.go new file mode 100644 index 0000000..643c8c5 --- /dev/null +++ b/internal/service/contractsnapshot/loader_test.go @@ -0,0 +1,187 @@ +package contractsnapshot_test + +import ( + "context" + "errors" + "io/fs" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/service/contractsnapshot" + "github.com/devctllabs/devctl/internal/service/contractsnapshot/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestLoaderLoadsCommittedProtoSnapshot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockReader(ctrl) + loader := contractsnapshot.New(reader) + const root = "/project" + const treeRoot = "api/external/grpc/client/billing" + metadata := []byte(`{"kind":"grpc","format":"proto","module_root":"api/proto/grpc","buf_config":"buf.yaml"}`) + + reader.EXPECT().ReadFile(gomock.Any(), root, treeRoot+"/.devctl-contract.json").Return( + contract.File{Path: treeRoot + "/.devctl-contract.json", Content: metadata, Mode: 0o644}, nil, + ) + reader.EXPECT().ReadFile(gomock.Any(), root, treeRoot+"/buf.yaml").Return( + contract.File{Path: treeRoot + "/buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, nil, + ) + reader.EXPECT().ReadTree(gomock.Any(), root, treeRoot+"/api/proto/grpc").Return([]contract.File{{ + Path: treeRoot + "/api/proto/grpc/billing/v1/service.proto", + }}, nil) + reader.EXPECT().ReadTree(gomock.Any(), root, treeRoot).Return([]contract.File{ + {Path: treeRoot + "/buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, + {Path: treeRoot + "/.devctl-contract.json", Content: metadata, Mode: 0o644}, + {Path: treeRoot + "/api/proto/grpc/billing/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o600}, + }, nil) + + snapshot, err := loader.Load( + context.Background(), root, treeRoot, + contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + ) + + require.NoError(t, err) + require.Equal(t, "api/proto/grpc", snapshot.ModuleRoot) + require.Empty(t, snapshot.Entrypoint) + require.Equal(t, []contract.File{ + {Path: "api/proto/grpc/billing/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o600}, + {Path: "buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, + }, snapshot.Files) + require.Equal(t, &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: "api/proto/grpc", BufConfig: "buf.yaml", + }, snapshot.Metadata) +} + +func TestLoaderAcceptsRawKafkaMetadataWithoutFiles(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockReader(ctrl) + loader := contractsnapshot.New(reader) + const root = "/project" + const treeRoot = "api/external/kafka/consumer/events" + metadata := []byte(`{"kind":"kafka","topic":"sample.events.created.v1","format":"raw"}`) + reader.EXPECT().ReadFile(gomock.Any(), root, treeRoot+"/.devctl-contract.json").Return( + contract.File{Content: metadata}, nil, + ) + reader.EXPECT().ReadTree(gomock.Any(), root, treeRoot).Return([]contract.File{{ + Path: treeRoot + "/.devctl-contract.json", Content: metadata, Mode: 0o644, + }}, nil) + + snapshot, err := loader.Load(context.Background(), root, treeRoot, contract.MetadataExpectation{ + Kind: "kafka", Topic: "sample.events.created.v1", Format: "raw", + }) + + require.NoError(t, err) + require.Empty(t, snapshot.Files) + require.Empty(t, snapshot.Entrypoint) + require.Empty(t, snapshot.ModuleRoot) +} + +func TestLoaderReportsPreciseMetadataErrors(t *testing.T) { + t.Parallel() + + errNotRegular := errors.New("not a regular file") + tests := []struct { + name string + load func(*mocks.MockReader) + expected contract.MetadataExpectation + field string + reason contract.MetadataInvalidReason + cause error + }{ + { + name: "missing sidecar", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{}, fs.ErrNotExist, + ) + }, + expected: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + field: ".devctl-contract.json", reason: contract.MetadataRequired, cause: fs.ErrNotExist, + }, + { + name: "invalid metadata type", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{Content: []byte(`{"kind":"kafka","topic":42,"format":"json","entrypoint":"schema.json"}`)}, nil, + ) + }, + expected: contract.MetadataExpectation{Kind: "kafka", Format: "json"}, + field: "topic", reason: contract.MetadataInvalidType, + }, + { + name: "missing entrypoint", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{Content: []byte(`{"kind":"kafka","topic":"sample.events.created.v1","format":"json","entrypoint":"schema.json"}`)}, nil, + ) + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/schema.json").Return( + contract.File{}, fs.ErrNotExist, + ) + }, + expected: contract.MetadataExpectation{Kind: "kafka", Topic: "sample.events.created.v1", Format: "json"}, + field: "entrypoint", reason: contract.MetadataNotFound, cause: fs.ErrNotExist, + }, + { + name: "non regular buf config", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{Content: []byte(`{"kind":"grpc","format":"proto","module_root":"proto","buf_config":"buf.yaml"}`)}, nil, + ) + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/buf.yaml").Return( + contract.File{}, errNotRegular, + ) + }, + expected: contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + field: "buf_config", reason: contract.MetadataNotRegular, cause: errNotRegular, + }, + { + name: "missing module root", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{Content: []byte(`{"kind":"grpc","format":"proto","module_root":"proto","buf_config":"buf.yaml"}`)}, nil, + ) + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/buf.yaml").Return(contract.File{}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/project", "contracts/proto").Return(nil, fs.ErrNotExist) + }, + expected: contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + field: "module_root", reason: contract.MetadataNotFound, cause: fs.ErrNotExist, + }, + { + name: "cancelled tree read", + load: func(reader *mocks.MockReader) { + reader.EXPECT().ReadFile(gomock.Any(), "/project", "contracts/.devctl-contract.json").Return( + contract.File{Content: []byte(`{"kind":"kafka","topic":"sample.events.created.v1","format":"raw"}`)}, nil, + ) + reader.EXPECT().ReadTree(gomock.Any(), "/project", "contracts").Return(nil, context.Canceled) + }, + expected: contract.MetadataExpectation{Kind: "kafka", Topic: "sample.events.created.v1", Format: "raw"}, + field: "files", reason: contract.MetadataNotRegular, cause: context.Canceled, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockReader(ctrl) + test.load(reader) + + _, err := contractsnapshot.New(reader).Load(context.Background(), "/project", "contracts", test.expected) + + var metadataErr *contract.SnapshotMetadataError + require.ErrorAs(t, err, &metadataErr) + require.Equal(t, test.field, metadataErr.Field) + require.Equal(t, test.reason, metadataErr.Reason) + require.Equal(t, "devctl sync", metadataErr.Hint) + if test.cause != nil { + require.ErrorIs(t, err, test.cause) + } + }) + } +} diff --git a/internal/service/contractsnapshot/mocks/loader.go b/internal/service/contractsnapshot/mocks/loader.go new file mode 100644 index 0000000..176a0f2 --- /dev/null +++ b/internal/service/contractsnapshot/mocks/loader.go @@ -0,0 +1,120 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/contractsnapshot (interfaces: Reader) +// +// Generated by this command: +// +// mockgen -destination mocks/loader.go -package mocks -typed . Reader +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + contract "github.com/devctllabs/devctl/internal/domain/contract" + gomock "go.uber.org/mock/gomock" +) + +// MockReader is a mock of Reader interface. +type MockReader struct { + ctrl *gomock.Controller + recorder *MockReaderMockRecorder + isgomock struct{} +} + +// MockReaderMockRecorder is the mock recorder for MockReader. +type MockReaderMockRecorder struct { + mock *MockReader +} + +// NewMockReader creates a new mock instance. +func NewMockReader(ctrl *gomock.Controller) *MockReader { + mock := &MockReader{ctrl: ctrl} + mock.recorder = &MockReaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockReader) EXPECT() *MockReaderMockRecorder { + return m.recorder +} + +// ReadFile mocks base method. +func (m *MockReader) ReadFile(ctx context.Context, root, name string) (contract.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadFile", ctx, root, name) + ret0, _ := ret[0].(contract.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadFile indicates an expected call of ReadFile. +func (mr *MockReaderMockRecorder) ReadFile(ctx, root, name any) *MockReaderReadFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockReader)(nil).ReadFile), ctx, root, name) + return &MockReaderReadFileCall{Call: call} +} + +// MockReaderReadFileCall wrap *gomock.Call +type MockReaderReadFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReaderReadFileCall) Return(arg0 contract.File, arg1 error) *MockReaderReadFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReaderReadFileCall) Do(f func(context.Context, string, string) (contract.File, error)) *MockReaderReadFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReaderReadFileCall) DoAndReturn(f func(context.Context, string, string) (contract.File, error)) *MockReaderReadFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ReadTree mocks base method. +func (m *MockReader) ReadTree(ctx context.Context, root, directory string) ([]contract.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadTree", ctx, root, directory) + ret0, _ := ret[0].([]contract.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadTree indicates an expected call of ReadTree. +func (mr *MockReaderMockRecorder) ReadTree(ctx, root, directory any) *MockReaderReadTreeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadTree", reflect.TypeOf((*MockReader)(nil).ReadTree), ctx, root, directory) + return &MockReaderReadTreeCall{Call: call} +} + +// MockReaderReadTreeCall wrap *gomock.Call +type MockReaderReadTreeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReaderReadTreeCall) Return(arg0 []contract.File, arg1 error) *MockReaderReadTreeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReaderReadTreeCall) Do(f func(context.Context, string, string) ([]contract.File, error)) *MockReaderReadTreeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReaderReadTreeCall) DoAndReturn(f func(context.Context, string, string) ([]contract.File, error)) *MockReaderReadTreeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/generate/config.go b/internal/service/generate/config.go new file mode 100644 index 0000000..1c42665 --- /dev/null +++ b/internal/service/generate/config.go @@ -0,0 +1,21 @@ +package generate + +import ( + "fmt" + + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/runtimeconfig" +) + +func generateConfig(manifest projectdomain.Manifest) (generatedomain.Output, error) { + catalog, err := projectdomain.NewRuntimeConfigCatalog(manifest) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("project.NewRuntimeConfigCatalog: %w", err) + } + output, err := runtimeconfig.Render(catalog) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("runtimeconfig.Render: %w", err) + } + return generatedomain.Output{Directory: output.Directory, Files: output.Files}, nil +} diff --git a/internal/service/generate/mocks/service.go b/internal/service/generate/mocks/service.go new file mode 100644 index 0000000..28063d3 --- /dev/null +++ b/internal/service/generate/mocks/service.go @@ -0,0 +1,311 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/generate (interfaces: ProjectRepository,TargetResolver,GeneratorClient,WorkspaceRepository) +// +// Generated by this command: +// +// mockgen -destination mocks/service.go -package mocks -typed . ProjectRepository,TargetResolver,GeneratorClient,WorkspaceRepository +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + artifact "github.com/devctllabs/devctl/internal/domain/artifact" + generate "github.com/devctllabs/devctl/internal/domain/generate" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockProjectRepository is a mock of ProjectRepository interface. +type MockProjectRepository struct { + ctrl *gomock.Controller + recorder *MockProjectRepositoryMockRecorder + isgomock struct{} +} + +// MockProjectRepositoryMockRecorder is the mock recorder for MockProjectRepository. +type MockProjectRepositoryMockRecorder struct { + mock *MockProjectRepository +} + +// NewMockProjectRepository creates a new mock instance. +func NewMockProjectRepository(ctrl *gomock.Controller) *MockProjectRepository { + mock := &MockProjectRepository{ctrl: ctrl} + mock.recorder = &MockProjectRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProjectRepository) EXPECT() *MockProjectRepositoryMockRecorder { + return m.recorder +} + +// LoadProject mocks base method. +func (m *MockProjectRepository) LoadProject(ctx context.Context, manifestPath string) (project.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadProject", ctx, manifestPath) + ret0, _ := ret[0].(project.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadProject indicates an expected call of LoadProject. +func (mr *MockProjectRepositoryMockRecorder) LoadProject(ctx, manifestPath any) *MockProjectRepositoryLoadProjectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadProject", reflect.TypeOf((*MockProjectRepository)(nil).LoadProject), ctx, manifestPath) + return &MockProjectRepositoryLoadProjectCall{Call: call} +} + +// MockProjectRepositoryLoadProjectCall wrap *gomock.Call +type MockProjectRepositoryLoadProjectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockProjectRepositoryLoadProjectCall) Return(arg0 project.Project, arg1 error) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockProjectRepositoryLoadProjectCall) Do(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockProjectRepositoryLoadProjectCall) DoAndReturn(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockTargetResolver is a mock of TargetResolver interface. +type MockTargetResolver struct { + ctrl *gomock.Controller + recorder *MockTargetResolverMockRecorder + isgomock struct{} +} + +// MockTargetResolverMockRecorder is the mock recorder for MockTargetResolver. +type MockTargetResolverMockRecorder struct { + mock *MockTargetResolver +} + +// NewMockTargetResolver creates a new mock instance. +func NewMockTargetResolver(ctrl *gomock.Controller) *MockTargetResolver { + mock := &MockTargetResolver{ctrl: ctrl} + mock.recorder = &MockTargetResolverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTargetResolver) EXPECT() *MockTargetResolverMockRecorder { + return m.recorder +} + +// Resolve mocks base method. +func (m *MockTargetResolver) Resolve(ctx context.Context, selected project.Project, target project.Target) (project.Target, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resolve", ctx, selected, target) + ret0, _ := ret[0].(project.Target) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockTargetResolverMockRecorder) Resolve(ctx, selected, target any) *MockTargetResolverResolveCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockTargetResolver)(nil).Resolve), ctx, selected, target) + return &MockTargetResolverResolveCall{Call: call} +} + +// MockTargetResolverResolveCall wrap *gomock.Call +type MockTargetResolverResolveCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockTargetResolverResolveCall) Return(arg0 project.Target, arg1 error) *MockTargetResolverResolveCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockTargetResolverResolveCall) Do(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockTargetResolverResolveCall) DoAndReturn(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockGeneratorClient is a mock of GeneratorClient interface. +type MockGeneratorClient struct { + ctrl *gomock.Controller + recorder *MockGeneratorClientMockRecorder + isgomock struct{} +} + +// MockGeneratorClientMockRecorder is the mock recorder for MockGeneratorClient. +type MockGeneratorClientMockRecorder struct { + mock *MockGeneratorClient +} + +// NewMockGeneratorClient creates a new mock instance. +func NewMockGeneratorClient(ctrl *gomock.Controller) *MockGeneratorClient { + mock := &MockGeneratorClient{ctrl: ctrl} + mock.recorder = &MockGeneratorClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGeneratorClient) EXPECT() *MockGeneratorClientMockRecorder { + return m.recorder +} + +// Generate mocks base method. +func (m *MockGeneratorClient) Generate(ctx context.Context, arg1 project.Project, target project.Target) (generate.Output, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Generate", ctx, arg1, target) + ret0, _ := ret[0].(generate.Output) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Generate indicates an expected call of Generate. +func (mr *MockGeneratorClientMockRecorder) Generate(ctx, arg1, target any) *MockGeneratorClientGenerateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockGeneratorClient)(nil).Generate), ctx, arg1, target) + return &MockGeneratorClientGenerateCall{Call: call} +} + +// MockGeneratorClientGenerateCall wrap *gomock.Call +type MockGeneratorClientGenerateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGeneratorClientGenerateCall) Return(arg0 generate.Output, arg1 error) *MockGeneratorClientGenerateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGeneratorClientGenerateCall) Do(f func(context.Context, project.Project, project.Target) (generate.Output, error)) *MockGeneratorClientGenerateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGeneratorClientGenerateCall) DoAndReturn(f func(context.Context, project.Project, project.Target) (generate.Output, error)) *MockGeneratorClientGenerateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockWorkspaceRepository is a mock of WorkspaceRepository interface. +type MockWorkspaceRepository struct { + ctrl *gomock.Controller + recorder *MockWorkspaceRepositoryMockRecorder + isgomock struct{} +} + +// MockWorkspaceRepositoryMockRecorder is the mock recorder for MockWorkspaceRepository. +type MockWorkspaceRepositoryMockRecorder struct { + mock *MockWorkspaceRepository +} + +// NewMockWorkspaceRepository creates a new mock instance. +func NewMockWorkspaceRepository(ctrl *gomock.Controller) *MockWorkspaceRepository { + mock := &MockWorkspaceRepository{ctrl: ctrl} + mock.recorder = &MockWorkspaceRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWorkspaceRepository) EXPECT() *MockWorkspaceRepositoryMockRecorder { + return m.recorder +} + +// PublishDirectory mocks base method. +func (m *MockWorkspaceRepository) PublishDirectory(ctx context.Context, root, target string, tree artifact.Tree) (artifact.PublishResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishDirectory", ctx, root, target, tree) + ret0, _ := ret[0].(artifact.PublishResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishDirectory indicates an expected call of PublishDirectory. +func (mr *MockWorkspaceRepositoryMockRecorder) PublishDirectory(ctx, root, target, tree any) *MockWorkspaceRepositoryPublishDirectoryCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishDirectory", reflect.TypeOf((*MockWorkspaceRepository)(nil).PublishDirectory), ctx, root, target, tree) + return &MockWorkspaceRepositoryPublishDirectoryCall{Call: call} +} + +// MockWorkspaceRepositoryPublishDirectoryCall wrap *gomock.Call +type MockWorkspaceRepositoryPublishDirectoryCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPublishDirectoryCall) Return(arg0 artifact.PublishResult, arg1 error) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPublishDirectoryCall) Do(f func(context.Context, string, string, artifact.Tree) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPublishDirectoryCall) DoAndReturn(f func(context.Context, string, string, artifact.Tree) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PublishFile mocks base method. +func (m *MockWorkspaceRepository) PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishFile", ctx, root, target, content) + ret0, _ := ret[0].(artifact.PublishResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishFile indicates an expected call of PublishFile. +func (mr *MockWorkspaceRepositoryMockRecorder) PublishFile(ctx, root, target, content any) *MockWorkspaceRepositoryPublishFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishFile", reflect.TypeOf((*MockWorkspaceRepository)(nil).PublishFile), ctx, root, target, content) + return &MockWorkspaceRepositoryPublishFileCall{Call: call} +} + +// MockWorkspaceRepositoryPublishFileCall wrap *gomock.Call +type MockWorkspaceRepositoryPublishFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPublishFileCall) Return(arg0 artifact.PublishResult, arg1 error) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPublishFileCall) Do(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPublishFileCall) DoAndReturn(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/generate/publish.go b/internal/service/generate/publish.go new file mode 100644 index 0000000..e6519ec --- /dev/null +++ b/internal/service/generate/publish.go @@ -0,0 +1,42 @@ +package generate + +import ( + "context" + "fmt" + "path" + "sort" + + "github.com/devctllabs/devctl/internal/domain/artifact" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +func (s *Service) publish(ctx context.Context, project projectdomain.Project, target projectdomain.Target, output generatedomain.Output) ([]generatedomain.Change, error) { + var changes []generatedomain.Change + published, err := s.workspace.PublishDirectory(ctx, project.Root, target.OutputDir, output.Directory) + if err != nil { + return changes, fmt.Errorf("workspace.PublishDirectory: %w", err) + } + for _, change := range published.Changes { + changes = append(changes, generatedomain.Change{ + Target: target.ID, Path: path.Join(target.OutputDir, change.Path), + Action: generatedomain.ChangeAction(change.Action), + }) + } + for _, file := range sortedArtifacts(output.Files) { + published, err = s.workspace.PublishFile(ctx, project.Root, file.Path, file.Content) + if err != nil { + return changes, fmt.Errorf("workspace.PublishFile: %w", err) + } + changes = append(changes, generatedomain.Change{ + Target: target.ID, Path: file.Path, Action: generatedomain.ChangeAction(published.Action), + }) + } + return changes, nil +} + +func sortedArtifacts(tree artifact.Tree) []artifact.File { + files := append([]artifact.File(nil), tree.Files...) + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + return files +} diff --git a/internal/service/generate/service.go b/internal/service/generate/service.go new file mode 100644 index 0000000..422d829 --- /dev/null +++ b/internal/service/generate/service.go @@ -0,0 +1,137 @@ +package generate + +import ( + "context" + "fmt" + + "github.com/devctllabs/devctl/internal/domain/artifact" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/service.go -package mocks -typed . ProjectRepository,TargetResolver,GeneratorClient,WorkspaceRepository + +// ProjectRepository resolves the valid project selected for generation. +type ProjectRepository interface { + // LoadProject returns a structurally and semantically valid project or an execution error. + LoadProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) +} + +// TargetResolver attaches the concrete input required to execute one Target. +type TargetResolver interface { + // Resolve attaches the concrete input required to execute target in selected Project. + Resolve(ctx context.Context, selected projectdomain.Project, target projectdomain.Target) (projectdomain.Target, error) +} + +// GeneratorClient generates unpublished Managed Output for one supported Target. +type GeneratorClient interface { + // Generate returns unpublished managed output; it must not write into the project workspace. + Generate(ctx context.Context, project projectdomain.Project, target projectdomain.Target) (generatedomain.Output, error) +} + +// WorkspaceRepository atomically publishes managed generation output. +type WorkspaceRepository interface { + // PublishFile atomically publishes one contained auxiliary file and reports whether bytes changed. + PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) + // PublishDirectory atomically replaces one contained target with the complete tree and reports whether content changed. + PublishDirectory(ctx context.Context, root, target string, tree artifact.Tree) (artifact.PublishResult, error) +} + +type Service struct { + logger *zap.Logger + projects ProjectRepository + inputs TargetResolver + generator GeneratorClient + workspace WorkspaceRepository +} + +// Dependencies names the required generation capabilities passed to New. +type Dependencies struct { + Projects ProjectRepository + Inputs TargetResolver + Generator GeneratorClient + Workspace WorkspaceRepository +} + +func New(logger *zap.Logger, dependencies Dependencies) *Service { + return &Service{ + logger: logger, projects: dependencies.Projects, + inputs: dependencies.Inputs, generator: dependencies.Generator, workspace: dependencies.Workspace, + } +} + +// Generate executes selected targets sequentially in deterministic order and publishes each target atomically. +// Dry-run invokes neither generator nor publisher; a failure returns completed changes without rollback. +func (s *Service) Generate(ctx context.Context, command generatedomain.Command) (generatedomain.Result, error) { + result := generatedomain.Result{Targets: []string{}, Changes: []generatedomain.Change{}, DryRun: command.DryRun} + project, err := s.projects.LoadProject(ctx, command.ManifestPath) + if err != nil { + return result, fmt.Errorf("projects.LoadProject: %w", err) + } + targets, err := generationTargets(project.Manifest, command.Family, command.Target) + if err != nil { + return result, fmt.Errorf("generationTargets: %w", err) + } + for _, target := range targets { + changes, targetErr := s.generateOne(ctx, project, target, command.DryRun) + result.Changes = append(result.Changes, changes...) + if targetErr != nil { + return result, targetErr + } + result.Targets = append(result.Targets, target.ID) + } + s.logger.Debug("generation completed", zap.Int("targets", len(result.Targets))) + return result, nil +} + +func (s *Service) generateOne( + ctx context.Context, + project projectdomain.Project, + target projectdomain.Target, + dryRun bool, +) ([]generatedomain.Change, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + if dryRun { + return plannedGeneration(target), nil + } + resolved := target + if target.Family != "config" { + var err error + resolved, err = s.inputs.Resolve(ctx, project, target) + if err != nil { + if target.Family == "http" { + operationErr := &generatedomain.OperationError{Operation: generatedomain.OperationLocateContract, Target: target.ID, Path: target.Location.Entrypoint, Kind: generatedomain.FailureUnavailable, Cause: err} + return nil, fmt.Errorf("inputs.Resolve: %w", operationErr) + } + return nil, fmt.Errorf("inputs.Resolve: %w", err) + } + } + if resolved.Family == "kafka" && resolved.Format == "raw" { + return nil, nil + } + output, err := s.generateTarget(ctx, project, resolved) + if err != nil { + operationErr := &generatedomain.OperationError{Operation: generatedomain.OperationRunGenerator, Target: resolved.ID, Path: resolved.OutputDir, Kind: generatedomain.FailureUnavailable, Cause: err} + return nil, fmt.Errorf("s.generateTarget: %w", operationErr) + } + changes, err := s.publish(ctx, project, resolved, output) + if err != nil { + operationErr := &generatedomain.OperationError{Operation: generatedomain.OperationPublishOutput, Target: resolved.ID, Path: resolved.OutputDir, Kind: generatedomain.FailureUnavailable, Cause: err} + return changes, fmt.Errorf("s.publish: %w", operationErr) + } + return changes, nil +} + +func (s *Service) generateTarget(ctx context.Context, project projectdomain.Project, target projectdomain.Target) (generatedomain.Output, error) { + if target.Family == "config" { + return generateConfig(project.Manifest) + } + output, err := s.generator.Generate(ctx, project, target) + if err != nil { + return generatedomain.Output{}, fmt.Errorf("generator.Generate: %w", err) + } + return output, nil +} diff --git a/internal/service/generate/service_test.go b/internal/service/generate/service_test.go new file mode 100644 index 0000000..770cebe --- /dev/null +++ b/internal/service/generate/service_test.go @@ -0,0 +1,943 @@ +package generate_test + +import ( + "context" + "errors" + "go/ast" + "go/parser" + "go/token" + "os" + "strconv" + "testing" + + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + generateservice "github.com/devctllabs/devctl/internal/service/generate" + "github.com/devctllabs/devctl/internal/service/generate/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestGenServiceOwnsGenerationAndPublicationFlow(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + generator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "checkout", Language: "go"}, + Components: projectdomain.Components{Logging: &projectdomain.Logging{}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/checkout"}}, + }} + target := generationTarget(t, project.Manifest, "config") + projects.EXPECT().LoadProject(gomock.Any(), "custom.yaml").Return(project, nil) + gomock.InOrder( + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, tree artifact.Tree) (artifact.PublishResult, error) { + require.Len(t, tree.Files, 1) + require.Equal(t, "config.gen.go", tree.Files[0].Path) + requireStructFieldTag(t, tree.Files[0].Content, fieldTagExpectation{ + Struct: "LoggingConfig", Field: "Level", Tag: `env:"CHECKOUT_LOG_LEVEL" default:"info"`, + }) + golden, err := os.ReadFile("testdata/config_logging.golden") + require.NoError(t, err) + require.Equal(t, golden, tree.Files[0].Content) + return publishedDirectory(tree, artifact.PublishUpdated), nil + }, + ), + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, ".env.example", []byte("CHECKOUT_LOG_LEVEL=info\n")).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil), + ) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Generator: generator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "custom.yaml", Family: "config"}) + + require.NoError(t, err) + require.Equal(t, []string{"config"}, result.Targets) + require.Equal(t, []generatedomain.Change{ + {Target: "config", Path: "gen/config/config.gen.go", Action: generatedomain.ChangeUpdated}, + {Target: "config", Path: ".env.example", Action: generatedomain.ChangeUnchanged}, + }, result.Changes) +} + +func TestGenHTTPClientReadsCanonicalExternalLayout(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + generator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{"remote": {Type: projectdomain.SourceURL}}, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "catalog", Source: "remote", Path: "openapi.yaml", + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{HTTP: &projectdomain.HTTPGenerator{ + OAPIConfig: "tools/oapi/client.yaml", ClientOut: "gen/http/client", + }}}}, + }} + logical := generationTarget(t, project.Manifest, "http-client:catalog") + target := logical + target.Input = "/project/api/external/http/client/catalog/openapi.yaml" + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + inputs.EXPECT().Resolve(gomock.Any(), project, logical).Return(target, nil) + generator.EXPECT().Generate(gomock.Any(), project, target).Return(generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{Path: "client.gen.go", Content: []byte("package client")}}}}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, gomock.Any()).Return(artifact.PublishResult{ + Action: artifact.PublishUpdated, + Changes: []artifact.PublishChange{{Path: "client.gen.go", Action: artifact.PublishUpdated}}, + }, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, Generator: generator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Target: target.ID}) + + require.NoError(t, err) + require.Equal(t, []string{target.ID}, result.Targets) +} + +func TestGenConfigHonorsGRPCStartPolicy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + start *projectdomain.Start + expectedFields map[string]string + expectedEnv []byte + }{ + { + name: "runtime toggle", + start: &projectdomain.Start{Env: "GRPC_SERVER_ENABLED", Default: boolPointer(false)}, + expectedFields: map[string]string{ + "Address": `env:"SAMPLE_GRPC_ADDR" default:":9090"`, + "Enabled": `env:"SAMPLE_GRPC_SERVER_ENABLED" default:"false"`, + }, + expectedEnv: []byte("SAMPLE_GRPC_ADDR=:9090\nSAMPLE_GRPC_SERVER_ENABLED=false\n"), + }, + { + name: "always active", + start: nil, + expectedFields: map[string]string{ + "Address": `env:"SAMPLE_GRPC_ADDR" default:":9090"`, + }, + expectedEnv: []byte("SAMPLE_GRPC_ADDR=:9090\n"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample"}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{Start: test.start}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Config: &projectdomain.ConfigGenerator{Out: "gen/config"}, + }}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "gen/config", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, tree artifact.Tree) (artifact.PublishResult, error) { + require.Len(t, tree.Files, 1) + require.Equal(t, test.expectedFields, structFieldTags(t, tree.Files[0].Content, "GRPCConfig")) + return publishedDirectory(tree, artifact.PublishUpdated), nil + }, + ) + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, ".env.example", test.expectedEnv).Return(artifact.PublishResult{Action: artifact.PublishUpdated}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Family: "config"}) + + require.NoError(t, err) + }) + } +} + +func TestGenConfigBuildsKafkaRuntimeAndProducerEnv(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample"}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{ + Consumers: []projectdomain.KafkaConsumer{ + {Name: "billing", GroupEnv: "BILLING_GROUP", Start: &projectdomain.Start{Env: "BILLING_KAFKA_ENABLED", Default: boolPointer(false)}}, + {Name: "replay", GroupEnv: "REPLAY_GROUP"}, + }, + Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit_service.audit.events.v1", TopicEnv: "AUDIT_TOPIC", + }}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Config: &projectdomain.ConfigGenerator{Out: "gen/config"}, + }}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "gen/config", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, tree artifact.Tree) (artifact.PublishResult, error) { + tags := structFieldTags(t, tree.Files[0].Content, "KafkaConfig") + require.Subset(t, tags, map[string]string{ + "AuditTopic": `env:"SAMPLE_AUDIT_TOPIC" default:"audit_service.audit.events.v1"`, + "BillingEnabled": `env:"SAMPLE_BILLING_KAFKA_ENABLED" default:"false"`, + "BillingGroup": `env:"SAMPLE_BILLING_GROUP" default:"sample-billing-group"`, + "Brokers": `env:"SAMPLE_KAFKA_BROKERS" default:"localhost:29092"`, + "ReplayGroup": `env:"SAMPLE_REPLAY_GROUP" default:"sample-replay-group"`, + }) + require.Equal(t, `env:"SAMPLE_KAFKA_BILLING_BATCH_MAX_SIZE" default:"1"`, tags["BillingBatchMaxSize"]) + require.Equal(t, `env:"SAMPLE_KAFKA_BILLING_RETRY_MAX_ATTEMPTS" default:"3"`, tags["BillingRetryMaxAttempts"]) + require.Equal(t, `env:"SAMPLE_KAFKA_BILLING_REBALANCE_TIMEOUT" default:"30s"`, tags["BillingRebalanceTimeout"]) + return publishedDirectory(tree, artifact.PublishUpdated), nil + }, + ) + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, ".env.example", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, content []byte) (artifact.PublishResult, error) { + for _, line := range []string{ + "SAMPLE_AUDIT_TOPIC=audit_service.audit.events.v1\n", + "SAMPLE_BILLING_GROUP=sample-billing-group\n", + "SAMPLE_BILLING_KAFKA_ENABLED=false\n", + "SAMPLE_KAFKA_BILLING_BATCH_MAX_SIZE=1\n", + "SAMPLE_KAFKA_BROKERS=localhost:29092\n", + "SAMPLE_REPLAY_GROUP=sample-replay-group\n", + } { + require.Contains(t, string(content), line) + } + return artifact.PublishResult{Action: artifact.PublishUpdated}, nil + }, + ) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Family: "config"}) + + require.NoError(t, err) +} + +func TestGenConfigBuildsRedisAndS3EnvWithoutSecretDefaults(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample"}, + Components: projectdomain.Components{ + Redis: &projectdomain.Redis{Connections: []projectdomain.RedisConnection{ + {Name: "cache", AddrEnv: "REDIS_CACHE_ADDR", AddrDefault: "redis://localhost:6379/1"}, + {Name: "ephemeral", AddrEnv: "REDIS_EPHEMERAL_ADDR"}, + }}, + S3: &projectdomain.S3{ + Connections: []projectdomain.S3Connection{{ + Name: "default", Credentials: "static", Endpoint: "http://localhost:9000", + Region: "us-east-1", PathStyle: true, AccessKeyEnv: "S3_ACCESS_KEY_ID", SecretKeyEnv: "S3_SECRET_ACCESS_KEY", + }}, + Buckets: []projectdomain.S3Bucket{{Name: "media", Connection: "default", Bucket: "media-local"}}, + }, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Config: &projectdomain.ConfigGenerator{Out: "gen/config"}, + }}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "gen/config", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, tree artifact.Tree) (artifact.PublishResult, error) { + require.Equal(t, map[string]string{ + "CacheAddress": `env:"SAMPLE_REDIS_CACHE_ADDR" default:"redis://localhost:6379/1"`, + "EphemeralAddress": `env:"SAMPLE_REDIS_EPHEMERAL_ADDR"`, + }, structFieldTags(t, tree.Files[0].Content, "RedisConfig")) + require.Equal(t, map[string]string{ + "AccessKeyID": `env:"SAMPLE_S3_ACCESS_KEY_ID"`, + "Endpoint": `env:"SAMPLE_S3_ENDPOINT" default:"http://localhost:9000"`, + "ForcePathStyle": `env:"SAMPLE_S3_FORCE_PATH_STYLE" default:"true"`, + "MediaBucket": `env:"SAMPLE_S3_MEDIA_BUCKET" default:"media-local"`, + "Region": `env:"SAMPLE_S3_REGION" default:"us-east-1"`, + "SecretAccessKey": `env:"SAMPLE_S3_SECRET_ACCESS_KEY"`, + }, structFieldTags(t, tree.Files[0].Content, "S3Config")) + return publishedDirectory(tree, artifact.PublishUpdated), nil + }, + ) + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, ".env.example", []byte( + "SAMPLE_REDIS_CACHE_ADDR=redis://localhost:6379/1\n"+ + "SAMPLE_REDIS_EPHEMERAL_ADDR=\n"+ + "SAMPLE_S3_ACCESS_KEY_ID=\n"+ + "SAMPLE_S3_ENDPOINT=http://localhost:9000\n"+ + "SAMPLE_S3_FORCE_PATH_STYLE=true\n"+ + "SAMPLE_S3_MEDIA_BUCKET=media-local\n"+ + "SAMPLE_S3_REGION=us-east-1\n"+ + "SAMPLE_S3_SECRET_ACCESS_KEY=\n", + )).Return(artifact.PublishResult{Action: artifact.PublishUpdated}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Family: "config"}) + + require.NoError(t, err) +} + +func TestGenConfigKeepsMigrationURLsOutOfRuntimeConfig(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample"}, + Components: projectdomain.Components{DB: &projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Default: "sqlite", Variants: []projectdomain.DBVariant{ + {Name: "sqlite", Kind: "sqlite", DSNEnv: "DB_PRIMARY_SQLITE_DSN", DSNDefault: "file:./data/primary.db?_foreign_keys=on", Migrations: &projectdomain.DBMigrations{ + Path: "migrations/primary/sqlite", DatabaseEnv: "DB_PRIMARY_SQLITE_MIGRATIONS_URL", DatabaseDefault: "sqlite://./data/primary.db?_pragma=foreign_keys%281%29", + }}, + {Name: "postgres", Kind: "postgres", DSNEnv: "DB_PRIMARY_POSTGRES_DSN", Secret: true, Migrations: &projectdomain.DBMigrations{ + Path: "migrations/primary/postgres", DatabaseEnv: "DB_PRIMARY_POSTGRES_MIGRATIONS_URL", + }}, + }, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Config: &projectdomain.ConfigGenerator{Out: "gen/config"}, + }}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "gen/config", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, tree artifact.Tree) (artifact.PublishResult, error) { + require.Equal(t, map[string]string{ + "Kind": `env:"SAMPLE_DB_PRIMARY_KIND" default:"sqlite"`, + "PostgresDSN": `env:"SAMPLE_DB_PRIMARY_POSTGRES_DSN"`, + "SqliteDSN": `env:"SAMPLE_DB_PRIMARY_SQLITE_DSN" default:"file:./data/primary.db?_foreign_keys=on"`, + }, structFieldTags(t, tree.Files[0].Content, "DBPrimaryConfig")) + return publishedDirectory(tree, artifact.PublishUpdated), nil + }, + ) + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, ".env.example", []byte( + "SAMPLE_DB_PRIMARY_KIND=sqlite\n"+ + "SAMPLE_DB_PRIMARY_POSTGRES_DSN=\n"+ + "SAMPLE_DB_PRIMARY_POSTGRES_MIGRATIONS_URL=\n"+ + "SAMPLE_DB_PRIMARY_SQLITE_DSN=file:./data/primary.db?_foreign_keys=on\n"+ + "SAMPLE_DB_PRIMARY_SQLITE_MIGRATIONS_URL=sqlite://./data/primary.db?_pragma=foreign_keys%281%29\n", + )).Return(artifact.PublishResult{Action: artifact.PublishUpdated}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Family: "config"}) + + require.NoError(t, err) +} + +func TestGenServiceRoutesGRPCTargetToProtoGeneratorAndPublishesOutput(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + generator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto", BufConfig: "buf.yaml"}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }}}, + }} + target := generationTarget(t, project.Manifest, "grpc-server") + generated := generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{ + Path: "acme/v1/service.pb.go", Content: []byte("generated"), Mode: 0o644, + }}}} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + generator.EXPECT().Generate(gomock.Any(), project, target).Return(generated, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, generated.Directory).Return(publishedDirectory(generated.Directory, artifact.PublishUpdated), nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: generator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Family: "grpc", Target: "grpc-server", + }) + + require.NoError(t, err) + require.Equal(t, []string{"grpc-server"}, result.Targets) + require.Equal(t, []generatedomain.Change{{ + Target: "grpc-server", Path: "gen/grpc/server/acme/v1/service.pb.go", Action: generatedomain.ChangeUpdated, + }}, result.Changes) +} + +func TestGenServiceReportsPreciseChangesInsideOnlyTheSelectedTarget(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + protoGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"local": {Type: projectdomain.SourceLocal, Path: "api/contracts"}}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{}, + Clients: []projectdomain.GRPCClient{{ + Name: "unrelated", Source: "local", Path: "proto/unrelated.proto", ProtoRoot: "proto", + }}, + }}, + }} + target := generationTarget(t, project.Manifest, "grpc-server") + generated := generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{ + {Path: "changed.pb.go", Content: []byte("changed")}, + {Path: "created.pb.go", Content: []byte("created")}, + {Path: "equal.pb.go", Content: []byte("equal")}, + }}} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, target).Return(generated, nil) + workspace.EXPECT().PublishDirectory( + gomock.Any(), project.Root, target.OutputDir, generated.Directory, + ).Return(artifact.PublishResult{Action: artifact.PublishUpdated, Changes: []artifact.PublishChange{ + {Path: "changed.pb.go", Action: artifact.PublishUpdated}, + {Path: "created.pb.go", Action: artifact.PublishCreated}, + {Path: "equal.pb.go", Action: artifact.PublishUnchanged}, + {Path: "stale.pb.go", Action: artifact.PublishRemoved}, + }}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: protoGenerator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Target: target.ID, + }) + + require.NoError(t, err) + require.Equal(t, []string{target.ID}, result.Targets) + require.Equal(t, []generatedomain.Change{ + {Target: target.ID, Path: "gen/grpc/server/changed.pb.go", Action: generatedomain.ChangeUpdated}, + {Target: target.ID, Path: "gen/grpc/server/created.pb.go", Action: generatedomain.ChangeCreated}, + {Target: target.ID, Path: "gen/grpc/server/equal.pb.go", Action: generatedomain.ChangeUnchanged}, + {Target: target.ID, Path: "gen/grpc/server/stale.pb.go", Action: generatedomain.ChangeRemoved}, + }, result.Changes) +} + +func TestGenServiceDefaultsGRPCServerProtoRoot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + protoGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{}, + }}}, + }} + target := generationTarget(t, project.Manifest, "grpc-server") + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, target).Return(generatedomain.Output{}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, artifact.Tree{}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: protoGenerator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Family: "grpc", Target: "grpc-server", + }) + + require.NoError(t, err) + require.Equal(t, []string{"grpc-server"}, result.Targets) +} + +func TestGenServiceResolvesLocalGRPCClientProtoSelection(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + generator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{ + "contracts": {Type: "local", Path: "api/contracts"}, + }, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/acme/billing/v1", + ProtoRoot: "proto", BufGenConfig: "tools/buf/billing.gen.yaml", + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }}}, + }} + target := generationTarget(t, project.Manifest, "grpc-client:billing") + generated := generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{ + Path: "acme/billing/v1/billing.pb.go", Content: []byte("generated"), Mode: 0o644, + }}}} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + generator.EXPECT().Generate(gomock.Any(), project, target).Return(generated, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, generated.Directory).Return(publishedDirectory(generated.Directory, artifact.PublishUpdated), nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: generator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Family: "grpc", Target: "grpc-client:billing", + }) + + require.NoError(t, err) + require.Equal(t, []string{"grpc-client:billing"}, result.Targets) +} + +func TestGenServiceUsesEveryGRPCClientsExplicitConfig(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + protoGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + }, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{ + {Name: "billing", Source: "contracts", Path: "proto/billing.proto", BufGenConfig: "tools/buf/billing.gen.yaml"}, + {Name: "orders", Source: "contracts", Path: "proto/orders.proto", BufGenConfig: "tools/buf/orders.gen.yaml"}, + }}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "gen/grpc"}, + }}}, + }} + billing := generationTarget(t, project.Manifest, "grpc-client:billing") + orders := generationTarget(t, project.Manifest, "grpc-client:orders") + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, billing).Return(generatedomain.Output{}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, billing.OutputDir, artifact.Tree{}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, orders).Return(generatedomain.Output{}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, orders.OutputDir, artifact.Tree{}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: protoGenerator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Family: "grpc", + }) + + require.NoError(t, err) + require.Equal(t, []string{"grpc-client:billing", "grpc-client:orders"}, result.Targets) + require.Equal(t, "tools/buf/billing.gen.yaml", billing.Config) + require.Equal(t, "tools/buf/orders.gen.yaml", orders.Config) +} + +func TestGenServiceUsesCommittedModuleRootForDevctlGRPCClient(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + protoGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceDevctl}}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Export: "billing", + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }}}, + }} + logicalTarget := generationTarget(t, project.Manifest, "grpc-client:billing") + snapshot := contract.Snapshot{ + ModuleRoot: "api/proto/grpc", + Metadata: &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: "api/proto/grpc", BufConfig: "buf.yaml", + }, + } + resolvedTarget := logicalTarget.WithSnapshot(snapshot) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + inputs.EXPECT().Resolve(gomock.Any(), project, logicalTarget).Return(resolvedTarget, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, resolvedTarget).Return(generatedomain.Output{}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, resolvedTarget.OutputDir, artifact.Tree{}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, Generator: protoGenerator, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Target: logicalTarget.ID}) + + require.NoError(t, err) + require.Equal(t, "api/external/grpc/client/billing/api/proto/grpc", resolvedTarget.Input) +} + +func TestGenServiceResolvesKafkaProducerProtoSelection(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + generator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{ + "events": {Type: "local", Path: "api/events"}, + }, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "invoice", Topic: "invoice_service.invoice.events.v1", + Contract: projectdomain.KafkaContract{ + Source: "events", Path: "proto/invoice_service.invoice.events.v1.proto", + Format: "proto", ProtoRoot: "proto", Message: "acme.invoice.v1.Invoice", Encoding: "binary", + }, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Kafka: &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }}}, + }} + target := generationTarget(t, project.Manifest, "kafka-producer:invoice") + generated := generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{ + Path: "acme/invoice/v1/invoice.pb.go", Content: []byte("generated"), Mode: 0o644, + }}}} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + generator.EXPECT().Generate(gomock.Any(), project, target).Return(generated, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, generated.Directory).Return(publishedDirectory(generated.Directory, artifact.PublishUpdated), nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: generator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Family: "kafka", Target: "kafka-producer:invoice", + }) + + require.NoError(t, err) + require.Equal(t, []string{"kafka-producer:invoice"}, result.Targets) +} + +func TestGenServiceResolvesDevctlKafkaProtoFromCommittedMetadata(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + protoGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + const topic = "invoice_service.invoice.events.v1" + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: projectdomain.SourceDevctl}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "invoice", Topic: topic, + Contract: projectdomain.KafkaContract{Source: "events", Export: "invoice", Format: "proto"}, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Kafka: &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }}}, + }} + logical := generationTarget(t, project.Manifest, "kafka-producer:invoice") + snapshot := contract.Snapshot{ + ModuleRoot: "proto", Entrypoint: "proto/event.proto", + Metadata: &contract.Metadata{ + Kind: "kafka", Topic: topic, Format: "proto", + Entrypoint: "proto/event.proto", ModuleRoot: "proto", BufConfig: "buf.yaml", + }, + } + resolved := logical.WithSnapshot(snapshot) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + inputs.EXPECT().Resolve(gomock.Any(), project, logical).Return(resolved, nil) + protoGenerator.EXPECT().Generate(gomock.Any(), project, resolved).Return(generatedomain.Output{}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, resolved.OutputDir, artifact.Tree{}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, Generator: protoGenerator, Workspace: workspace, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Target: logical.ID, + }) + + require.NoError(t, err) + require.Equal(t, "api/external/kafka/producer/invoice/proto", resolved.Input) + require.Equal(t, []string{"event.proto"}, resolved.Paths) +} + +func TestGenKafkaJSONUsesJSONSchemaGenerator(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + jsonGenerator := mocks.NewMockGeneratorClient(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: "local", Path: "api/contracts"}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "audit_service.audit.created.v1", + Contract: projectdomain.KafkaContract{Source: "events", Format: "json", Path: "schemas/audit_service.audit.created.v1.json"}, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Generators: projectdomain.GoGenerators{ + Kafka: &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }}}, + }} + target := generationTarget(t, project.Manifest, "kafka-consumer:audit") + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + jsonGenerator.EXPECT().Generate(gomock.Any(), project, target).Return(generatedomain.Output{Directory: artifact.Tree{Files: []artifact.File{{ + Path: "schema.gen.go", Content: []byte("package audit\n"), + }}}}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, target.OutputDir, gomock.Any()).Return(artifact.PublishResult{ + Action: artifact.PublishUpdated, + Changes: []artifact.PublishChange{{Path: "schema.gen.go", Action: artifact.PublishUpdated}}, + }, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: passthroughTargetResolver(ctrl), Generator: jsonGenerator, Workspace: workspace, + }) + + result, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.Equal(t, []string{"kafka-consumer:audit"}, result.Targets) + require.Equal(t, []generatedomain.Change{{ + Target: "kafka-consumer:audit", Path: "gen/kafka/consumer/audit/schema.gen.go", Action: generatedomain.ChangeUpdated, + }}, result.Changes) +} + +func TestGenServicePreservesTargetInputFailureSemantics(t *testing.T) { + t.Parallel() + + t.Run("HTTP locate operation", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + cause := errors.New("entrypoint unavailable") + selected := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{ + OpenAPI: "api/openapi/swagger.yaml", + }}}, + }} + target := generationTarget(t, selected.Manifest, "http-server") + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(selected, nil) + inputs.EXPECT().Resolve(gomock.Any(), selected, target).Return(target, cause) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Target: target.ID, + }) + + var operationErr *generatedomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, generatedomain.OperationLocateContract, operationErr.Operation) + require.Equal(t, target.ID, operationErr.Target) + require.Equal(t, target.Location.Entrypoint, operationErr.Path) + require.Equal(t, generatedomain.FailureUnavailable, operationErr.Kind) + require.ErrorIs(t, err, cause) + }) + + t.Run("committed metadata category", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + metadataErr := &contract.SnapshotMetadataError{ + Field: "topic", Reason: contract.MetadataMismatch, Hint: "devctl sync", + } + selected := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"upstream": {Type: projectdomain.SourceDevctl}}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "upstream", Export: "billing", + }}}}, + }} + target := generationTarget(t, selected.Manifest, "grpc-client:billing") + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(selected, nil) + inputs.EXPECT().Resolve(gomock.Any(), selected, target).Return(target, metadataErr) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{ + Projects: projects, Inputs: inputs, + }) + + _, err := service.Generate(context.Background(), generatedomain.Command{ + ManifestPath: "devctl.yaml", Target: target.ID, + }) + + require.ErrorIs(t, err, metadataErr) + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + }) +} + +func TestGenServiceAppliesCatalogSelectionContract(t *testing.T) { + t.Parallel() + + manifest := projectdomain.Manifest{Project: projectdomain.Identity{Language: "go"}} + tests := []struct { + name string + command generatedomain.Command + targets []string + category failure.Category + }{ + {name: "known empty family", command: generatedomain.Command{Family: "grpc", DryRun: true}, targets: []string{}}, + {name: "known configured family", command: generatedomain.Command{Family: "config", DryRun: true}, targets: []string{"config"}}, + {name: "unknown family", command: generatedomain.Command{Family: "other", DryRun: true}, category: failure.InvalidInput}, + {name: "unknown target", command: generatedomain.Command{Target: "grpc-client:missing", DryRun: true}, category: failure.NotFound}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(projectdomain.Project{Root: "/project", Manifest: manifest}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{Projects: projects}) + command := test.command + command.ManifestPath = "devctl.yaml" + + result, err := service.Generate(context.Background(), command) + + if test.category != "" { + require.Equal(t, test.category, failure.CategoryOf(err)) + return + } + require.NoError(t, err) + require.Equal(t, test.targets, result.Targets) + }) + } +} + +func TestGenExecutionOrderIsIndependentFromCatalogIDOrder(t *testing.T) { + t.Parallel() + + manifest := projectdomain.Manifest{ + Project: projectdomain.Identity{Language: "go"}, + Sources: map[string]projectdomain.Source{ + "local": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + }, + Components: projectdomain.Components{ + HTTP: &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{}, + Clients: []projectdomain.HTTPClient{{Name: "billing", Source: "local", Path: "openapi.yaml"}}, + }, + GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{}, + Clients: []projectdomain.GRPCClient{{Name: "billing", Source: "local", Path: "proto/billing.proto", ProtoRoot: "proto"}}, + }, + Kafka: &projectdomain.Kafka{ + Consumers: []projectdomain.KafkaConsumer{{Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}}, + Producers: []projectdomain.KafkaProducer{{Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}}, + }, + }, + } + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(projectdomain.Project{Root: "/project", Manifest: manifest}, nil) + service := generateservice.New(zap.NewNop(), generateservice.Dependencies{Projects: projects}) + + result, err := service.Generate(context.Background(), generatedomain.Command{ManifestPath: "devctl.yaml", DryRun: true}) + + require.NoError(t, err) + require.Equal(t, []string{ + "config", + "http-server", "http-client:billing", + "grpc-server", "grpc-client:billing", + "kafka-consumer:audit", "kafka-producer:audit", + }, result.Targets) + require.Equal(t, []string{ + "config", + "grpc-client:billing", "grpc-server", + "http-client:billing", "http-server", + "kafka-consumer:audit", "kafka-producer:audit", + }, targetIDs(projectdomain.NewTargetCatalog(manifest).All())) +} + +func targetIDs(targets []projectdomain.Target) []string { + ids := make([]string, len(targets)) + for index, target := range targets { + ids[index] = target.ID + } + return ids +} + +func passthroughTargetResolver(ctrl *gomock.Controller) *mocks.MockTargetResolver { + resolver := mocks.NewMockTargetResolver(ctrl) + resolver.EXPECT().Resolve(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ projectdomain.Project, target projectdomain.Target) (projectdomain.Target, error) { + return target, nil + }, + ).AnyTimes() + return resolver +} + +func publishedDirectory(tree artifact.Tree, action artifact.PublishAction) artifact.PublishResult { + changes := make([]artifact.PublishChange, len(tree.Files)) + for index, file := range tree.Files { + changes[index] = artifact.PublishChange{Path: file.Path, Action: action} + } + return artifact.PublishResult{Action: action, Changes: changes} +} + +type fieldTagExpectation struct { + Struct string + Field string + Tag string +} + +func generationTarget(t *testing.T, manifest projectdomain.Manifest, id string) projectdomain.Target { + t.Helper() + targets := projectdomain.NewTargetCatalog(manifest).Select(projectdomain.TargetOperationGenerate, "", id) + require.Len(t, targets, 1) + return targets[0] +} + +func requireStructFieldTag(t *testing.T, source []byte, expected fieldTagExpectation) { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "config.gen.go", source, parser.AllErrors) + require.NoError(t, err) + found := false + ast.Inspect(file, func(node ast.Node) bool { + declaration, ok := node.(*ast.TypeSpec) + if !ok || declaration.Name.Name != expected.Struct { + return true + } + structure, ok := declaration.Type.(*ast.StructType) + require.True(t, ok) + for _, field := range structure.Fields.List { + if len(field.Names) == 1 && field.Names[0].Name == expected.Field { + require.NotNil(t, field.Tag) + tag, unquoteErr := strconv.Unquote(field.Tag.Value) + require.NoError(t, unquoteErr) + require.Equal(t, expected.Tag, tag) + found = true + } + } + return false + }) + require.True(t, found, "%s.%s was not generated", expected.Struct, expected.Field) +} + +func structFieldTags(t *testing.T, source []byte, structName string) map[string]string { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "config.gen.go", source, parser.AllErrors) + require.NoError(t, err) + fields := map[string]string{} + ast.Inspect(file, func(node ast.Node) bool { + declaration, ok := node.(*ast.TypeSpec) + if !ok || declaration.Name.Name != structName { + return true + } + structure, ok := declaration.Type.(*ast.StructType) + require.True(t, ok) + for _, field := range structure.Fields.List { + if len(field.Names) != 1 || field.Tag == nil { + continue + } + tag, unquoteErr := strconv.Unquote(field.Tag.Value) + require.NoError(t, unquoteErr) + fields[field.Names[0].Name] = tag + } + return false + }) + return fields +} + +func boolPointer(value bool) *bool { return &value } diff --git a/internal/service/generate/targets.go b/internal/service/generate/targets.go new file mode 100644 index 0000000..a02b315 --- /dev/null +++ b/internal/service/generate/targets.go @@ -0,0 +1,60 @@ +package generate + +import ( + "fmt" + "path" + "sort" + + generatedomain "github.com/devctllabs/devctl/internal/domain/generate" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +func generationTargets(spec projectdomain.Manifest, family, selected string) ([]projectdomain.Target, error) { + targets, err := projectdomain.NewTargetCatalog(spec).Resolve(projectdomain.TargetOperationGenerate, family, selected) + if err != nil { + return nil, fmt.Errorf("catalog.Resolve: %w", err) + } + sort.SliceStable(targets, func(i, j int) bool { + left, right := generationOrder(targets[i]), generationOrder(targets[j]) + if left != right { + return left < right + } + return targets[i].ID < targets[j].ID + }) + return targets, nil +} + +func generationOrder(target projectdomain.Target) int { + switch target.Family { + case "config": + return 0 + case "http": + if target.Role == "server" { + return 10 + } + return 11 + case "grpc": + if target.Role == "server" { + return 20 + } + return 21 + case "kafka": + if target.Role == "consumer" { + return 30 + } + return 31 + default: + return 100 + } +} + +func plannedGeneration(target projectdomain.Target) []generatedomain.Change { + if target.Family == "kafka" && target.Format == "raw" { + return nil + } + changes := []generatedomain.Change{{Target: target.ID, Path: path.Join(target.OutputDir, target.OutputFile), Action: generatedomain.ChangePlannedPublish}} + if target.Family == "config" { + changes = append(changes, generatedomain.Change{Target: target.ID, Path: ".env.example", Action: generatedomain.ChangePlannedPublish}) + } + return changes +} diff --git a/internal/service/generate/testdata/config_logging.golden b/internal/service/generate/testdata/config_logging.golden new file mode 100644 index 0000000..7e7b43f --- /dev/null +++ b/internal/service/generate/testdata/config_logging.golden @@ -0,0 +1,25 @@ +// Code generated by devctl. DO NOT EDIT. + +package config + +import ( + "fmt" + "time" +) + +type Config struct { + Logging LoggingConfig +} + +type LoggingConfig struct { + Level string `env:"CHECKOUT_LOG_LEVEL" default:"info"` +} + +func (c *Config) Validate() error { + if c == nil { + return fmt.Errorf("config is nil") + } + return nil +} + +var _ time.Duration diff --git a/internal/service/lint/mocks/service.go b/internal/service/lint/mocks/service.go new file mode 100644 index 0000000..3098512 --- /dev/null +++ b/internal/service/lint/mocks/service.go @@ -0,0 +1,348 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/lint (interfaces: ProjectRepository,ContractLocator,TargetResolver,ProtoLinter) +// +// Generated by this command: +// +// mockgen -destination mocks/service.go -package mocks -typed . ProjectRepository,ContractLocator,TargetResolver,ProtoLinter +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + contract "github.com/devctllabs/devctl/internal/domain/contract" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockProjectRepository is a mock of ProjectRepository interface. +type MockProjectRepository struct { + ctrl *gomock.Controller + recorder *MockProjectRepositoryMockRecorder + isgomock struct{} +} + +// MockProjectRepositoryMockRecorder is the mock recorder for MockProjectRepository. +type MockProjectRepositoryMockRecorder struct { + mock *MockProjectRepository +} + +// NewMockProjectRepository creates a new mock instance. +func NewMockProjectRepository(ctrl *gomock.Controller) *MockProjectRepository { + mock := &MockProjectRepository{ctrl: ctrl} + mock.recorder = &MockProjectRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProjectRepository) EXPECT() *MockProjectRepositoryMockRecorder { + return m.recorder +} + +// LoadProject mocks base method. +func (m *MockProjectRepository) LoadProject(ctx context.Context, manifestPath string) (project.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadProject", ctx, manifestPath) + ret0, _ := ret[0].(project.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadProject indicates an expected call of LoadProject. +func (mr *MockProjectRepositoryMockRecorder) LoadProject(ctx, manifestPath any) *MockProjectRepositoryLoadProjectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadProject", reflect.TypeOf((*MockProjectRepository)(nil).LoadProject), ctx, manifestPath) + return &MockProjectRepositoryLoadProjectCall{Call: call} +} + +// MockProjectRepositoryLoadProjectCall wrap *gomock.Call +type MockProjectRepositoryLoadProjectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockProjectRepositoryLoadProjectCall) Return(arg0 project.Project, arg1 error) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockProjectRepositoryLoadProjectCall) Do(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockProjectRepositoryLoadProjectCall) DoAndReturn(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockContractLocator is a mock of ContractLocator interface. +type MockContractLocator struct { + ctrl *gomock.Controller + recorder *MockContractLocatorMockRecorder + isgomock struct{} +} + +// MockContractLocatorMockRecorder is the mock recorder for MockContractLocator. +type MockContractLocatorMockRecorder struct { + mock *MockContractLocator +} + +// NewMockContractLocator creates a new mock instance. +func NewMockContractLocator(ctrl *gomock.Controller) *MockContractLocator { + mock := &MockContractLocator{ctrl: ctrl} + mock.recorder = &MockContractLocatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockContractLocator) EXPECT() *MockContractLocatorMockRecorder { + return m.recorder +} + +// ListProtoFiles mocks base method. +func (m *MockContractLocator) ListProtoFiles(ctx context.Context, root, relativeRoot string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListProtoFiles", ctx, root, relativeRoot) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListProtoFiles indicates an expected call of ListProtoFiles. +func (mr *MockContractLocatorMockRecorder) ListProtoFiles(ctx, root, relativeRoot any) *MockContractLocatorListProtoFilesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProtoFiles", reflect.TypeOf((*MockContractLocator)(nil).ListProtoFiles), ctx, root, relativeRoot) + return &MockContractLocatorListProtoFilesCall{Call: call} +} + +// MockContractLocatorListProtoFilesCall wrap *gomock.Call +type MockContractLocatorListProtoFilesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockContractLocatorListProtoFilesCall) Return(arg0 []string, arg1 error) *MockContractLocatorListProtoFilesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockContractLocatorListProtoFilesCall) Do(f func(context.Context, string, string) ([]string, error)) *MockContractLocatorListProtoFilesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockContractLocatorListProtoFilesCall) DoAndReturn(f func(context.Context, string, string) ([]string, error)) *MockContractLocatorListProtoFilesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ReadContract mocks base method. +func (m *MockContractLocator) ReadContract(ctx context.Context, path string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadContract", ctx, path) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadContract indicates an expected call of ReadContract. +func (mr *MockContractLocatorMockRecorder) ReadContract(ctx, path any) *MockContractLocatorReadContractCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadContract", reflect.TypeOf((*MockContractLocator)(nil).ReadContract), ctx, path) + return &MockContractLocatorReadContractCall{Call: call} +} + +// MockContractLocatorReadContractCall wrap *gomock.Call +type MockContractLocatorReadContractCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockContractLocatorReadContractCall) Return(arg0 []byte, arg1 error) *MockContractLocatorReadContractCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockContractLocatorReadContractCall) Do(f func(context.Context, string) ([]byte, error)) *MockContractLocatorReadContractCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockContractLocatorReadContractCall) DoAndReturn(f func(context.Context, string) ([]byte, error)) *MockContractLocatorReadContractCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ResolveContract mocks base method. +func (m *MockContractLocator) ResolveContract(ctx context.Context, location contract.Location) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveContract", ctx, location) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveContract indicates an expected call of ResolveContract. +func (mr *MockContractLocatorMockRecorder) ResolveContract(ctx, location any) *MockContractLocatorResolveContractCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveContract", reflect.TypeOf((*MockContractLocator)(nil).ResolveContract), ctx, location) + return &MockContractLocatorResolveContractCall{Call: call} +} + +// MockContractLocatorResolveContractCall wrap *gomock.Call +type MockContractLocatorResolveContractCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockContractLocatorResolveContractCall) Return(arg0 string, arg1 error) *MockContractLocatorResolveContractCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockContractLocatorResolveContractCall) Do(f func(context.Context, contract.Location) (string, error)) *MockContractLocatorResolveContractCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockContractLocatorResolveContractCall) DoAndReturn(f func(context.Context, contract.Location) (string, error)) *MockContractLocatorResolveContractCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockTargetResolver is a mock of TargetResolver interface. +type MockTargetResolver struct { + ctrl *gomock.Controller + recorder *MockTargetResolverMockRecorder + isgomock struct{} +} + +// MockTargetResolverMockRecorder is the mock recorder for MockTargetResolver. +type MockTargetResolverMockRecorder struct { + mock *MockTargetResolver +} + +// NewMockTargetResolver creates a new mock instance. +func NewMockTargetResolver(ctrl *gomock.Controller) *MockTargetResolver { + mock := &MockTargetResolver{ctrl: ctrl} + mock.recorder = &MockTargetResolverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTargetResolver) EXPECT() *MockTargetResolverMockRecorder { + return m.recorder +} + +// Resolve mocks base method. +func (m *MockTargetResolver) Resolve(ctx context.Context, selected project.Project, target project.Target) (project.Target, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resolve", ctx, selected, target) + ret0, _ := ret[0].(project.Target) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockTargetResolverMockRecorder) Resolve(ctx, selected, target any) *MockTargetResolverResolveCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockTargetResolver)(nil).Resolve), ctx, selected, target) + return &MockTargetResolverResolveCall{Call: call} +} + +// MockTargetResolverResolveCall wrap *gomock.Call +type MockTargetResolverResolveCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockTargetResolverResolveCall) Return(arg0 project.Target, arg1 error) *MockTargetResolverResolveCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockTargetResolverResolveCall) Do(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockTargetResolverResolveCall) DoAndReturn(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockProtoLinter is a mock of ProtoLinter interface. +type MockProtoLinter struct { + ctrl *gomock.Controller + recorder *MockProtoLinterMockRecorder + isgomock struct{} +} + +// MockProtoLinterMockRecorder is the mock recorder for MockProtoLinter. +type MockProtoLinterMockRecorder struct { + mock *MockProtoLinter +} + +// NewMockProtoLinter creates a new mock instance. +func NewMockProtoLinter(ctrl *gomock.Controller) *MockProtoLinter { + mock := &MockProtoLinter{ctrl: ctrl} + mock.recorder = &MockProtoLinterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProtoLinter) EXPECT() *MockProtoLinterMockRecorder { + return m.recorder +} + +// Lint mocks base method. +func (m *MockProtoLinter) Lint(ctx context.Context, arg1 project.Project, target project.Target) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Lint", ctx, arg1, target) + ret0, _ := ret[0].(error) + return ret0 +} + +// Lint indicates an expected call of Lint. +func (mr *MockProtoLinterMockRecorder) Lint(ctx, arg1, target any) *MockProtoLinterLintCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lint", reflect.TypeOf((*MockProtoLinter)(nil).Lint), ctx, arg1, target) + return &MockProtoLinterLintCall{Call: call} +} + +// MockProtoLinterLintCall wrap *gomock.Call +type MockProtoLinterLintCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockProtoLinterLintCall) Return(arg0 error) *MockProtoLinterLintCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockProtoLinterLintCall) Do(f func(context.Context, project.Project, project.Target) error) *MockProtoLinterLintCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockProtoLinterLintCall) DoAndReturn(f func(context.Context, project.Project, project.Target) error) *MockProtoLinterLintCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/lint/service.go b/internal/service/lint/service.go new file mode 100644 index 0000000..a62623b --- /dev/null +++ b/internal/service/lint/service.go @@ -0,0 +1,306 @@ +package lint + +import ( + "bytes" + "context" + "fmt" + "path" + "regexp" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + lintdomain "github.com/devctllabs/devctl/internal/domain/lint" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + platformjsonschema "github.com/devctllabs/devctl/internal/platform/jsonschema" + platformopenapi "github.com/devctllabs/devctl/internal/platform/openapi" + "github.com/santhosh-tekuri/jsonschema/v6" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/service.go -package mocks -typed . ProjectRepository,ContractLocator,TargetResolver,ProtoLinter + +// ProjectRepository resolves the valid project selected for linting. +type ProjectRepository interface { + // LoadProject returns a structurally and semantically valid project or an execution error. + LoadProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) +} + +// ContractLocator resolves and reads contained local or materialized contracts. +type ContractLocator interface { + // ResolveContract returns the contained entrypoint selected by location. + ResolveContract(ctx context.Context, location contract.Location) (string, error) + // ReadContract returns the exact bytes at path without interpreting OpenAPI semantics. + ReadContract(ctx context.Context, path string) ([]byte, error) + // ListProtoFiles returns sorted project-relative Proto files below relativeRoot. + ListProtoFiles(ctx context.Context, root, relativeRoot string) ([]string, error) +} + +// TargetResolver attaches the concrete input required to lint one Target. +type TargetResolver interface { + // Resolve attaches the concrete input required to execute target in selected Project. + Resolve(ctx context.Context, selected projectdomain.Project, target projectdomain.Target) (projectdomain.Target, error) +} + +// ProtoLinter checks one contained Proto target with project-pinned Buf. +type ProtoLinter interface { + // Lint checks target content without modifying project files. + Lint(ctx context.Context, project projectdomain.Project, target projectdomain.Target) error +} + +type Service struct { + logger *zap.Logger + projects ProjectRepository + contracts ContractLocator + inputs TargetResolver + proto ProtoLinter +} + +// Dependencies names the required lint capabilities passed to New. +type Dependencies struct { + Projects ProjectRepository + Contracts ContractLocator + Inputs TargetResolver + Proto ProtoLinter +} + +var protoFilenamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*\.[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*\.proto$`) +var kafkaTopicPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*\.[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*\.[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*\.v[1-9][0-9]*$`) + +func New(logger *zap.Logger, dependencies Dependencies) *Service { + return &Service{ + logger: logger, projects: dependencies.Projects, contracts: dependencies.Contracts, + inputs: dependencies.Inputs, proto: dependencies.Proto, + } +} + +// Lint aggregates findings across configured contracts in deterministic order. +// Findings are normal results; an execution error returns findings collected from earlier contracts. +func (s *Service) Lint(ctx context.Context, command lintdomain.Command) (lintdomain.Result, error) { + result := lintdomain.Result{Valid: true, Contracts: []string{}, Issues: []lintdomain.Issue{}} + project, err := s.projects.LoadProject(ctx, command.ManifestPath) + if err != nil { + return result, fmt.Errorf("projects.LoadProject: %w", err) + } + targets, err := projectdomain.NewTargetCatalog(project.Manifest).Resolve( + projectdomain.TargetOperationLint, command.Family, "", + ) + if err != nil { + return result, fmt.Errorf("catalog.Resolve: %w", err) + } + for _, target := range targets { + result, err = s.lintTarget(ctx, project, result, target) + if err != nil { + return result, err + } + } + result.Valid = len(result.Issues) == 0 + s.logger.Debug("contract lint completed", zap.Bool("valid", result.Valid), zap.Int("contracts", len(result.Contracts))) + return result, nil +} + +func (s *Service) lintTarget( + ctx context.Context, + project projectdomain.Project, + result lintdomain.Result, + target projectdomain.Target, +) (lintdomain.Result, error) { + if err := ctx.Err(); err != nil { + return result, fmt.Errorf("ctx.Err: %w", err) + } + resolved, err := s.inputs.Resolve(ctx, project, target) + if err != nil { + if target.Family == "http" { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationLocateContract, Target: target.ID, Path: target.Reference.Entrypoint, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("inputs.Resolve: %w", operationErr) + } + return result, fmt.Errorf("inputs.Resolve: %w", err) + } + target = resolved + switch target.Family { + case "http": + return s.lintHTTPTarget(ctx, project, result, target) + case "grpc": + return s.lintGRPCTarget(ctx, project, result, target) + case "kafka": + return s.appendKafkaResult(ctx, project, result, target) + default: + return result, nil + } +} + +func (s *Service) lintHTTPTarget( + ctx context.Context, + project projectdomain.Project, + result lintdomain.Result, + target projectdomain.Target, +) (lintdomain.Result, error) { + contractPath := target.Input + result.Contracts = append(result.Contracts, target.ID) + data, err := s.contracts.ReadContract(ctx, contractPath) + if err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationReadContract, Target: target.ID, Path: contractPath, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("contracts.ReadContract: %w", operationErr) + } + result.Issues = append(result.Issues, lintIssues(target, contractPath, platformopenapi.Analyze(data))...) + return result, nil +} + +func (s *Service) appendKafkaResult(ctx context.Context, project projectdomain.Project, result lintdomain.Result, target projectdomain.Target) (lintdomain.Result, error) { + targetID := target.ID + result.Contracts = append(result.Contracts, targetID) + if !kafkaTopicPattern.MatchString(target.Reference.Topic) { + result.Issues = append(result.Issues, lintdomain.Issue{Code: "kafka_topic", Target: targetID}) + } + if localKafkaSchemaMismatch(target) { + result.Issues = append(result.Issues, lintdomain.Issue{Code: "kafka_schema_filename", Target: targetID, Path: target.Reference.Entrypoint}) + } + switch target.Format { + case "json": + return s.lintKafkaJSON(ctx, project, result, target) + case "proto": + return s.lintKafkaProto(ctx, project, result, target) + default: + return result, nil + } +} + +func localKafkaSchemaMismatch(target projectdomain.Target) bool { + if target.Source.Type != projectdomain.SourceLocal || target.Reference.Entrypoint == "" || target.Format != "proto" && target.Format != "json" { + return false + } + return path.Base(target.Reference.Entrypoint) != target.Reference.Topic+"."+target.Format +} + +func (s *Service) lintKafkaJSON(ctx context.Context, project projectdomain.Project, result lintdomain.Result, target projectdomain.Target) (lintdomain.Result, error) { + targetID := target.ID + location := target.Location + location.Root = project.Root + contractPath, err := s.contracts.ResolveContract(ctx, location) + if err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationLocateContract, Target: targetID, Path: target.Reference.Entrypoint, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("contracts.ResolveContract: %w", operationErr) + } + data, err := s.contracts.ReadContract(ctx, contractPath) + if err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationReadContract, Target: targetID, Path: contractPath, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("contracts.ReadContract: %w", operationErr) + } + if jsonSchemaInvalid(data) { + result.Issues = append(result.Issues, lintdomain.Issue{Code: "json_schema", Target: targetID, Path: contractPath}) + return result, nil + } + if jsonSchemaTitleMissing(data) { + result.Issues = append(result.Issues, lintdomain.Issue{ + Code: "json_schema_title", Target: targetID, Path: contractPath, Field: "title", + }) + } + return result, nil +} + +func jsonSchemaInvalid(data []byte) bool { + return compileJSONSchema(data) != nil +} + +func jsonSchemaTitleMissing(data []byte) bool { + _, err := platformjsonschema.RootTitle(data) + return err != nil +} + +func compileJSONSchema(data []byte) error { + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("jsonschema.UnmarshalJSON: %w", err) + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("schema.json", document); err != nil { + return fmt.Errorf("compiler.AddResource: %w", err) + } + _, err = compiler.Compile("schema.json") + if err != nil { + return fmt.Errorf("compiler.Compile: %w", err) + } + return nil +} + +func (s *Service) lintKafkaProto(ctx context.Context, project projectdomain.Project, result lintdomain.Result, target projectdomain.Target) (lintdomain.Result, error) { + if s.proto == nil { + return result, nil + } + if err := s.proto.Lint(ctx, project, target); err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationReadContract, Target: target.ID, Path: target.Input, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("proto.Lint: %w", operationErr) + } + return result, nil +} + +func (s *Service) lintGRPCTarget(ctx context.Context, project projectdomain.Project, result lintdomain.Result, target projectdomain.Target) (lintdomain.Result, error) { + result.Contracts = append(result.Contracts, target.ID) + if target.Role == "server" || target.Location.Local { + files, err := s.contracts.ListProtoFiles(ctx, project.Root, target.Input) + if err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationReadContract, Target: target.ID, Path: target.Input, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("contracts.ListProtoFiles: %w", operationErr) + } + for _, filename := range files { + if !protoFilenamePattern.MatchString(path.Base(filename)) { + result.Issues = append(result.Issues, lintdomain.Issue{Code: "proto_filename", Target: target.ID, Path: filename}) + } + } + } + result.Valid = len(result.Issues) == 0 + if s.proto != nil { + if err := s.proto.Lint(ctx, project, target); err != nil { + operationErr := &lintdomain.OperationError{Operation: lintdomain.OperationReadContract, Target: target.ID, Path: target.Input, Kind: lintdomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("proto.Lint: %w", operationErr) + } + } + return result, nil +} + +func lintIssues(target projectdomain.Target, contractPath string, report platformopenapi.Report) []lintdomain.Issue { + issues := make([]lintdomain.Issue, 0, len(report.Findings)+len(report.Operations)) + for _, finding := range report.Findings { + issues = append(issues, lintdomain.Issue{ + Code: string(finding.Kind), Target: target.ID, Path: contractPath, + Line: finding.Line, Column: finding.Column, Field: finding.Field, + Parameters: &lintdomain.Parameters{ + Type: finding.Type, Subtype: finding.Subtype, SpecPath: finding.SpecPath, + }, + }) + } + if !strings.HasPrefix(report.Version, "3.1.") { + issues = append(issues, lintdomain.Issue{Code: "openapi_version", Target: target.ID, Path: contractPath}) + } + seen := map[string]struct{}{} + for _, operation := range report.Operations { + parameters := &lintdomain.Parameters{OperationID: operation.OperationID, Location: operation.Method + " " + operation.Path} + switch { + case operation.OperationID == "": + issues = append(issues, lintdomain.Issue{Code: "operation_id_missing", Target: target.ID, Path: contractPath, Line: operation.Line, Column: operation.Column, Parameters: parameters}) + case hasOperationID(seen, operation.OperationID): + issues = append(issues, lintdomain.Issue{Code: "operation_id_duplicate", Target: target.ID, Path: contractPath, Line: operation.Line, Column: operation.Column, Parameters: parameters}) + } + if !hasSuccessfulResponse(operation.Responses) { + issues = append(issues, lintdomain.Issue{Code: "response_2xx_missing", Target: target.ID, Path: contractPath, Line: operation.Line, Column: operation.Column, Parameters: parameters}) + } + } + return issues +} + +func hasOperationID(seen map[string]struct{}, operationID string) bool { + _, exists := seen[operationID] + seen[operationID] = struct{}{} + return exists +} + +func hasSuccessfulResponse(responses []string) bool { + for _, response := range responses { + if response == "2XX" { + return true + } + if len(response) == 3 && response[0] == '2' && response[1] >= '0' && response[1] <= '9' && response[2] >= '0' && response[2] <= '9' { + return true + } + } + return false +} diff --git a/internal/service/lint/service_test.go b/internal/service/lint/service_test.go new file mode 100644 index 0000000..90ef002 --- /dev/null +++ b/internal/service/lint/service_test.go @@ -0,0 +1,432 @@ +package lint_test + +import ( + "context" + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + lintdomain "github.com/devctllabs/devctl/internal/domain/lint" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + lintservice "github.com/devctllabs/devctl/internal/service/lint" + "github.com/devctllabs/devctl/internal/service/lint/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestLintServiceOwnsContractCatalogAndOutcome(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{"remote": {Type: "url"}}, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{OpenAPI: "api/openapi/swagger.yaml"}, + Clients: []projectdomain.HTTPClient{ + {Name: "remote", Source: "remote", Path: "openapi.yaml"}, + }, + }}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "custom.yaml").Return(project, nil) + serverContract := "/project/api/openapi/swagger.yaml" + remoteContract := "/project/api/external/http/client/remote/openapi.yaml" + targets := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationLint, "http", "") + remoteTarget, serverTarget := targets[0], targets[1] + resolvedRemote, resolvedServer := remoteTarget, serverTarget + resolvedRemote.Input, resolvedServer.Input = remoteContract, serverContract + gomock.InOrder( + inputs.EXPECT().Resolve(gomock.Any(), project, remoteTarget).Return(resolvedRemote, nil), + contracts.EXPECT().ReadContract(gomock.Any(), remoteContract).Return([]byte(`openapi: 3.1.0 +info: {title: Remote, version: 1.0.0} +paths: + /first: + get: {operationId: duplicate, responses: {"200": {description: ok}}} + /second: + get: {operationId: duplicate, responses: {"200": {description: ok}}} +`), nil), + inputs.EXPECT().Resolve(gomock.Any(), project, serverTarget).Return(resolvedServer, nil), + contracts.EXPECT().ReadContract(gomock.Any(), serverContract).Return([]byte(`openapi: 3.1.0 +info: {title: Server, version: 1.0.0} +paths: + /health: + get: {operationId: health, responses: {"200": {description: ok}}} +`), nil), + ) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: inputs, + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "custom.yaml", Family: "http"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []string{"http-client:remote", "http-server"}, result.Contracts) + require.Len(t, result.Issues, 1) + require.Equal(t, "operation_id_duplicate", result.Issues[0].Code) + require.Equal(t, "http-client:remote", result.Issues[0].Target) +} + +func TestLintServicePreservesHTTPInputFailureSemantics(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + cause := errors.New("entrypoint unavailable") + selected := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{ + OpenAPI: "api/openapi/swagger.yaml", + }}}, + }} + target := projectdomain.NewTargetCatalog(selected.Manifest).Select(projectdomain.TargetOperationLint, "http", "")[0] + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(selected, nil) + inputs.EXPECT().Resolve(gomock.Any(), selected, target).Return(target, cause) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: inputs, + }) + + _, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "http"}) + + var operationErr *lintdomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, lintdomain.OperationLocateContract, operationErr.Operation) + require.Equal(t, target.ID, operationErr.Target) + require.Equal(t, target.Reference.Entrypoint, operationErr.Path) + require.Equal(t, lintdomain.FailureUnavailable, operationErr.Kind) + require.ErrorIs(t, err, cause) +} + +func TestLintServiceReportsInvalidGRPCProtoFilename(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + proto := mocks.NewMockProtoLinter(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto", BufConfig: "buf.yaml"}, + }}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + contracts.EXPECT().ListProtoFiles(gomock.Any(), project.Root, "api/proto").Return([]string{ + "api/proto/acme/v1/sample.common_types.proto", + "api/proto/acme/v1/service.proto", + }, nil) + target := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationLint, "grpc", "")[0] + proto.EXPECT().Lint(gomock.Any(), project, target).Return(nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), Proto: proto, + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "grpc"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []string{"grpc-server"}, result.Contracts) + require.Equal(t, []lintdomain.Issue{{ + Code: "proto_filename", Target: "grpc-server", Path: "api/proto/acme/v1/service.proto", + }}, result.Issues) +} + +func TestLintServiceChecksGRPCClientsWithoutServer(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + proto := mocks.NewMockProtoLinter(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"contracts": {Type: "local", Path: "api/contracts"}}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/acme/billing/v1", ProtoRoot: "proto", + }}}}, + }} + target := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationLint, "grpc", "")[0] + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + contracts.EXPECT().ListProtoFiles(gomock.Any(), project.Root, target.Input).Return([]string{ + "api/contracts/proto/acme/billing/v1/billing_service.invoice_service.proto", + }, nil) + proto.EXPECT().Lint(gomock.Any(), project, target).Return(nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), Proto: proto, + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "grpc"}) + + require.NoError(t, err) + require.True(t, result.Valid) + require.Equal(t, []string{"grpc-client:billing"}, result.Contracts) + require.Empty(t, result.Issues) +} + +func TestLintServiceReportsInvalidKafkaTopic(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit.events", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []string{"kafka-producer:audit"}, result.Contracts) + require.Equal(t, []lintdomain.Issue{{ + Code: "kafka_topic", Target: "kafka-producer:audit", + }}, result.Issues) +} + +func TestLintServiceRejectsCommittedKafkaMetadataThatDoesNotMatchTarget(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{"upstream": {Type: projectdomain.SourceDevctl}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "events", Topic: "downstream_service.domain.events.v1", Contract: projectdomain.KafkaContract{ + Format: "proto", Source: "upstream", Export: "events", + }, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + metadataErr := &contract.SnapshotMetadataError{ + Field: "topic", Reason: contract.MetadataMismatch, Hint: "devctl sync", + } + target := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationLint, "kafka", "")[0] + inputs.EXPECT().Resolve(gomock.Any(), project, target).Return(target, metadataErr) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: inputs, + }) + + _, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.ErrorIs(t, err, metadataErr) + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestLintServiceReportsMismatchedLocalKafkaSchemaFilename(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: "local", Path: "api/events"}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit_service.audit.created.v1", + Contract: projectdomain.KafkaContract{Source: "events", Format: "json", Path: "wrong_name.json"}, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + contracts.EXPECT().ResolveContract(gomock.Any(), contract.Location{Root: "/project", RelativePath: "api/events", Entrypoint: "wrong_name.json", Local: true}).Return("/project/api/events/wrong_name.json", nil) + contracts.EXPECT().ReadContract(gomock.Any(), "/project/api/events/wrong_name.json").Return([]byte(`{"title":"AuditEvent","type":"object"}`), nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []string{"kafka-producer:audit"}, result.Contracts) + require.Equal(t, []lintdomain.Issue{{ + Code: "kafka_schema_filename", Target: "kafka-producer:audit", Path: "wrong_name.json", + }}, result.Issues) +} + +func TestLintServiceCompilesKafkaJSONSchema(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: "local", Path: "api/contracts"}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "audit_service.audit.created.v1", + Contract: projectdomain.KafkaContract{Format: "json", Source: "events", Path: "schemas/audit_service.audit.created.v1.json"}, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + location := contract.Location{Root: project.Root, RelativePath: "api/contracts", Entrypoint: "schemas/audit_service.audit.created.v1.json", Local: true} + contracts.EXPECT().ResolveContract(gomock.Any(), location).Return("/project/api/contracts/schemas/audit_service.audit.created.v1.json", nil) + contracts.EXPECT().ReadContract(gomock.Any(), "/project/api/contracts/schemas/audit_service.audit.created.v1.json").Return([]byte(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":42}`), nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []lintdomain.Issue{{ + Code: "json_schema", Target: "kafka-consumer:audit", Path: "/project/api/contracts/schemas/audit_service.audit.created.v1.json", + }}, result.Issues) +} + +func TestLintServiceResolvesDevctlKafkaJSONFromCommittedMetadata(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + const topic = "audit_service.audit.created.v1" + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: projectdomain.SourceDevctl}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: topic, + Contract: projectdomain.KafkaContract{Format: "json", Source: "events", Export: "audit"}, + }}}}, + }} + target := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationLint, "kafka", "")[0] + snapshot := contract.Snapshot{ + Entrypoint: "schemas/event.json", + Metadata: &contract.Metadata{ + Kind: "kafka", Topic: topic, Format: "json", Entrypoint: "schemas/event.json", + }, + } + resolvedInput := target.WithSnapshot(snapshot) + resolved := resolvedInput + resolved.Location.Root = project.Root + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + inputs.EXPECT().Resolve(gomock.Any(), project, target).Return(resolvedInput, nil) + contracts.EXPECT().ResolveContract(gomock.Any(), resolved.Location).Return( + "/project/api/external/kafka/consumer/audit/schemas/event.json", nil, + ) + contracts.EXPECT().ReadContract( + gomock.Any(), "/project/api/external/kafka/consumer/audit/schemas/event.json", + ).Return([]byte(`{"title":"Event","type":"object"}`), nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: inputs, + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.True(t, result.Valid) +} + +func TestLintServiceRequiresKafkaJSONSchemaTitle(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"events": {Type: "local", Path: "api/contracts"}}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "audit_service.audit.created.v1", + Contract: projectdomain.KafkaContract{Format: "json", Source: "events", Path: "schemas/audit_service.audit.created.v1.json"}, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + location := contract.Location{Root: project.Root, RelativePath: "api/contracts", Entrypoint: "schemas/audit_service.audit.created.v1.json", Local: true} + contracts.EXPECT().ResolveContract(gomock.Any(), location).Return("/project/api/contracts/schemas/audit_service.audit.created.v1.json", nil) + contracts.EXPECT().ReadContract(gomock.Any(), "/project/api/contracts/schemas/audit_service.audit.created.v1.json").Return([]byte(`{"type":"object"}`), nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []lintdomain.Issue{{ + Code: "json_schema_title", Target: "kafka-consumer:audit", + Path: "/project/api/contracts/schemas/audit_service.audit.created.v1.json", Field: "title", + }}, result.Issues) +} + +func TestLintServiceWithoutFamilyIncludesKafkaContracts(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "audit.events", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + service := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }) + + result, err := service.Lint(context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.False(t, result.Valid) + require.Equal(t, []string{"kafka-consumer:audit"}, result.Contracts) + require.Equal(t, []lintdomain.Issue{{Code: "kafka_topic", Target: "kafka-consumer:audit"}}, result.Issues) +} + +func TestLintServiceAppliesCatalogFamilySelectionContract(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + family string + category failure.Category + }{ + {name: "known empty family", family: "grpc"}, + {name: "unknown family", family: "other", category: failure.InvalidInput}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + contracts := mocks.NewMockContractLocator(ctrl) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(projectdomain.Project{ + Root: "/project", Manifest: projectdomain.Manifest{Project: projectdomain.Identity{Language: "go"}}, + }, nil) + + result, err := lintservice.New(zap.NewNop(), lintservice.Dependencies{ + Projects: projects, Contracts: contracts, Inputs: passthroughTargetResolver(ctrl), + }).Lint( + context.Background(), lintdomain.Command{ManifestPath: "devctl.yaml", Family: test.family}, + ) + + if test.category != "" { + require.Equal(t, test.category, failure.CategoryOf(err)) + return + } + require.NoError(t, err) + require.True(t, result.Valid) + require.Empty(t, result.Contracts) + }) + } +} + +func passthroughTargetResolver(ctrl *gomock.Controller) *mocks.MockTargetResolver { + resolver := mocks.NewMockTargetResolver(ctrl) + resolver.EXPECT().Resolve(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ projectdomain.Project, target projectdomain.Target) (projectdomain.Target, error) { + return target, nil + }, + ).AnyTimes() + return resolver +} diff --git a/internal/service/materialize/closure.go b/internal/service/materialize/closure.go new file mode 100644 index 0000000..df33cc5 --- /dev/null +++ b/internal/service/materialize/closure.go @@ -0,0 +1,96 @@ +package materialize + +import ( + "context" + "fmt" + "path" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "gopkg.in/yaml.v3" +) + +// referenceClosure follows contained local $ref values from entrypoint and ignores document fragments and remote references. +func referenceClosure(ctx context.Context, reader FileReader, root, entrypoint string) (contract.Snapshot, error) { + if !safeRelative(entrypoint) { + return contract.Snapshot{}, &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, Path: entrypoint, Kind: materializedomain.FailureInvalid} + } + entrypoint = path.Clean(entrypoint) + queue := []string{entrypoint} + files := make([]contract.File, 0) + seen := make(map[string]struct{}) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + if _, exists := seen[current]; exists { + continue + } + if err := ctx.Err(); err != nil { + return contract.Snapshot{}, fmt.Errorf("ctx.Err: %w", err) + } + file, err := reader.ReadFile(ctx, root, current) + if err != nil { + operationErr := &materializedomain.OperationError{Operation: materializedomain.OperationReadFile, Path: current, Kind: materializedomain.FailureUnavailable, Cause: err} + return contract.Snapshot{}, fmt.Errorf("reader.ReadFile: %w", operationErr) + } + file.Path = current + files = append(files, file) + seen[current] = struct{}{} + references, err := localReferences(current, file.Content) + if err != nil { + return contract.Snapshot{}, err + } + queue = append(queue, references...) + } + return newSnapshot(entrypoint, files) +} + +func localReferences(current string, data []byte) ([]string, error) { + resolved := make([]string, 0) + for _, reference := range collectReferences(data) { + relative := strings.SplitN(reference, "#", 2)[0] + if relative == "" || strings.Contains(relative, "://") { + continue + } + name := path.Clean(path.Join(path.Dir(current), relative)) + if !safeRelative(name) { + return nil, &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, Path: reference, Kind: materializedomain.FailureInvalid} + } + resolved = append(resolved, name) + } + return resolved, nil +} + +func collectReferences(data []byte) []string { + var document yaml.Node + if yaml.Unmarshal(data, &document) != nil { + return nil + } + var references []string + visitYAML(&document, func(key, value *yaml.Node) { + if key.Value == "$ref" && value.Kind == yaml.ScalarNode { + references = append(references, value.Value) + } + }) + return references +} + +func visitYAML(node *yaml.Node, visit func(key, value *yaml.Node)) { + if node.Kind == yaml.MappingNode { + for index := 0; index+1 < len(node.Content); index += 2 { + visit(node.Content[index], node.Content[index+1]) + } + } + for _, child := range node.Content { + visitYAML(child, visit) + } +} + +func safeRelative(name string) bool { + if name == "" || strings.HasPrefix(name, "/") { + return false + } + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") +} diff --git a/internal/service/materialize/devctl.go b/internal/service/materialize/devctl.go new file mode 100644 index 0000000..532db96 --- /dev/null +++ b/internal/service/materialize/devctl.go @@ -0,0 +1,215 @@ +package materialize + +import ( + "context" + "errors" + "fmt" + "path" + "path/filepath" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/contractsnapshot" +) + +const contractMetadataFile = ".devctl-contract.json" + +// DevctlService resolves a named export from a temporary upstream Devctl project checkout. +type DevctlService struct { + client GitClient + manifests ManifestRepository + reader FileReader + snapshots *contractsnapshot.Loader +} + +func NewDevctl(client GitClient, manifests ManifestRepository, reader FileReader) *DevctlService { + return &DevctlService{ + client: client, manifests: manifests, reader: reader, + snapshots: contractsnapshot.New(reader), + } +} + +func (s *DevctlService) SourceType() project.SourceType { return project.SourceDevctl } + +func (s *DevctlService) Materialize(ctx context.Context, request materializedomain.Request) (contract.Snapshot, error) { + var snapshot contract.Snapshot + err := s.client.WithCheckout(ctx, request.Source.Repo, request.Source.Ref, func(root string) error { + var materializeErr error + snapshot, materializeErr = s.materializeCheckout(ctx, root, request) + return materializeErr + }) + if err != nil { + return snapshot, fmt.Errorf("client.WithCheckout: %w", err) + } + return snapshot, nil +} + +func (s *DevctlService) materializeCheckout(ctx context.Context, root string, request materializedomain.Request) (contract.Snapshot, error) { + loaded, err := s.manifests.Load(ctx, filepath.Join(root, "devctl.yaml")) + if err != nil { + return contract.Snapshot{}, &materializedomain.UpstreamManifestError{Repository: request.Source.Repo, Ref: request.Source.Ref, Cause: err} + } + if len(loaded.Issues) > 0 { + return contract.Snapshot{}, &materializedomain.UpstreamManifestError{Repository: request.Source.Repo, Ref: request.Source.Ref} + } + manifest := loaded.Project.Manifest + exported, exists := manifest.Exports[request.Reference.Export] + if !exists { + return contract.Snapshot{}, &materializedomain.ExportNotFoundError{Name: request.Reference.Export} + } + if !manifest.ExportMatchesSurface(exported) { + return contract.Snapshot{}, &materializedomain.InvalidExportError{Name: request.Reference.Export} + } + switch exported.Kind { + case "openapi": + return referenceClosure(ctx, s.reader, root, exported.Path) + case "grpc": + return s.materializeGRPCExport(ctx, root, manifest, exported) + case "kafka": + producer, exists := kafkaProducer(manifest, exported.Producer) + if !exists { + return contract.Snapshot{}, &materializedomain.ExportNotFoundError{Name: exported.Producer} + } + if request.Reference.Topic != "" && request.Reference.Topic != producer.Topic { + return contract.Snapshot{}, &materializedomain.KafkaTopicMismatchError{Expected: producer.Topic, Actual: request.Reference.Topic} + } + format := kafkaFormat(producer.Contract) + if request.Reference.Format != "" && request.Reference.Format != format { + return contract.Snapshot{}, &materializedomain.KafkaFormatMismatchError{Expected: format, Actual: request.Reference.Format} + } + return s.materializeKafkaProducer(ctx, root, manifest, producer) + default: + return contract.Snapshot{}, &materializedomain.UnsupportedExportError{Name: request.Reference.Export, Kind: exported.Kind} + } +} + +func (s *DevctlService) materializeGRPCExport( + ctx context.Context, + root string, + manifest project.Manifest, + exported project.Export, +) (contract.Snapshot, error) { + snapshot, err := s.snapshots.Load( + ctx, root, exported.Path, contract.MetadataExpectation{Kind: "grpc", Format: "proto"}, + ) + if err == nil { + return snapshot, nil + } + if !committedMetadataRequired(err) { + return contract.Snapshot{}, fmt.Errorf("snapshots.Load: %w", err) + } + files, err := s.reader.ReadTree(ctx, root, exported.Path) + if err != nil { + return contract.Snapshot{}, &materializedomain.OperationError{Operation: materializedomain.OperationReadFile, Path: exported.Path, Kind: materializedomain.FailureUnavailable, Cause: err} + } + if len(files) == 0 { + return contract.Snapshot{}, &materializedomain.OperationError{Operation: materializedomain.OperationBuildSnapshot, Path: exported.Path, Kind: materializedomain.FailureNotFound} + } + bufConfig := "buf.yaml" + if manifest.Components.GRPC.Server.BufConfig != "" { + bufConfig = manifest.Components.GRPC.Server.BufConfig + } + files, err = includeBufFiles(ctx, s.reader, bufFilesRequest{ + root: root, files: files, configPath: bufConfig, + }) + if err != nil { + return contract.Snapshot{}, err + } + snapshot, err = newProtoSnapshot(exported.Path, "", files) + if err != nil { + return contract.Snapshot{}, err + } + snapshot.Metadata = &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: exported.Path, BufConfig: bufConfig, + } + return snapshot, nil +} + +func (s *DevctlService) materializeKafkaProducer(ctx context.Context, root string, manifest project.Manifest, producer project.KafkaProducer) (contract.Snapshot, error) { + format := kafkaFormat(producer.Contract) + metadata := &contract.Metadata{Kind: "kafka", Topic: producer.Topic, Format: format} + if format == "raw" { + return contract.Snapshot{Metadata: metadata}, nil + } + source, exists := manifest.Sources[producer.Contract.Source] + if !exists { + return contract.Snapshot{}, &materializedomain.ExportNotFoundError{Name: producer.Contract.Source} + } + if source.Type != project.SourceLocal { + return s.materializeSyncedKafkaProducer(ctx, root, manifest, producer) + } + sourceRoot := filepath.Join(root, filepath.FromSlash(source.Path)) + reference := contract.Reference{ + Entrypoint: producer.Contract.Path, + Format: format, + ProtoRoot: producer.Contract.ProtoRoot, + } + var snapshot contract.Snapshot + var err error + if format == "proto" { + snapshot, err = protoTree(ctx, s.reader, protoTreeRequest{ + root: sourceRoot, reference: reference, bufConfig: source.Proto.BufConfig, + }) + } else { + snapshot, err = referenceClosure(ctx, s.reader, sourceRoot, reference.Entrypoint) + } + if err != nil { + return contract.Snapshot{}, err + } + metadata.Entrypoint = snapshot.Entrypoint + if format == "proto" { + metadata.ModuleRoot = snapshot.ModuleRoot + metadata.BufConfig = source.Proto.BufConfig + } + snapshot.Metadata = metadata + if err := contract.ValidateSnapshot(snapshot, contract.MetadataExpectation{ + Kind: "kafka", Topic: producer.Topic, Format: format, + }); err != nil { + return contract.Snapshot{}, fmt.Errorf("contract.ValidateSnapshot: %w", err) + } + return snapshot, nil +} + +func (s *DevctlService) materializeSyncedKafkaProducer(ctx context.Context, root string, manifest project.Manifest, producer project.KafkaProducer) (contract.Snapshot, error) { + externalRoot := manifest.Paths.ExternalContracts + if externalRoot == "" { + externalRoot = "api/external" + } + treeRoot := path.Join(externalRoot, "kafka", "producer", producer.Name) + snapshot, err := s.snapshots.Load( + ctx, root, treeRoot, + contract.MetadataExpectation{ + Kind: "kafka", Topic: producer.Topic, Format: kafkaFormat(producer.Contract), + }, + ) + if err != nil { + return contract.Snapshot{}, fmt.Errorf("snapshots.Load: %w", err) + } + return snapshot, nil +} + +func committedMetadataRequired(err error) bool { + var metadataErr *contract.SnapshotMetadataError + return errors.As(err, &metadataErr) && + metadataErr.Field == contractMetadataFile && metadataErr.Reason == contract.MetadataRequired +} + +func kafkaFormat(selected project.KafkaContract) string { + if selected.Format == "" { + return "raw" + } + return selected.Format +} + +func kafkaProducer(manifest project.Manifest, name string) (project.KafkaProducer, bool) { + if manifest.Components.Kafka == nil { + return project.KafkaProducer{}, false + } + for _, producer := range manifest.Components.Kafka.Producers { + if producer.Name == name { + return producer, true + } + } + return project.KafkaProducer{}, false +} diff --git a/internal/service/materialize/git.go b/internal/service/materialize/git.go new file mode 100644 index 0000000..1cc5262 --- /dev/null +++ b/internal/service/materialize/git.go @@ -0,0 +1,49 @@ +package materialize + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" +) + +// GitService reads a contract closure from a temporary repository checkout. +type GitService struct { + client GitClient + reader FileReader +} + +func NewGit(client GitClient, reader FileReader) *GitService { + return &GitService{client: client, reader: reader} +} + +func (s *GitService) SourceType() project.SourceType { return project.SourceGit } + +func (s *GitService) Materialize(ctx context.Context, request materializedomain.Request) (contract.Snapshot, error) { + var snapshot contract.Snapshot + err := s.client.WithCheckout(ctx, request.Source.Repo, request.Source.Ref, func(root string) error { + if request.Source.Path != "" { + if !safeRelative(request.Source.Path) { + return &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, Path: request.Source.Path, Kind: materializedomain.FailureInvalid} + } + root = filepath.Join(root, filepath.FromSlash(request.Source.Path)) + } + var materializeErr error + if request.Reference.Format == "proto" { + snapshot, materializeErr = protoTree(ctx, s.reader, protoTreeRequest{ + root: root, reference: request.Reference, bufConfig: request.Source.Proto.BufConfig, + }) + } else { + snapshot, materializeErr = referenceClosure(ctx, s.reader, root, request.Reference.Entrypoint) + } + return materializeErr + }) + if err != nil { + operationErr := &materializedomain.OperationError{Operation: materializedomain.OperationCheckout, SourceType: project.SourceGit, Kind: materializedomain.FailureUnavailable, Cause: err} + return snapshot, fmt.Errorf("client.WithCheckout: %w", operationErr) + } + return snapshot, nil +} diff --git a/internal/service/materialize/local.go b/internal/service/materialize/local.go new file mode 100644 index 0000000..938bac8 --- /dev/null +++ b/internal/service/materialize/local.go @@ -0,0 +1,36 @@ +package materialize + +import ( + "context" + "path/filepath" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" +) + +// LocalService reads a contract closure contained by the current project. +type LocalService struct { + reader FileReader +} + +func NewLocal(reader FileReader) *LocalService { return &LocalService{reader: reader} } + +func (s *LocalService) SourceType() project.SourceType { return project.SourceLocal } + +func (s *LocalService) Materialize(ctx context.Context, request materializedomain.Request) (contract.Snapshot, error) { + if !safeRelative(request.Source.Path) { + return contract.Snapshot{}, &materializedomain.OperationError{ + Operation: materializedomain.OperationValidateSource, + Path: request.Source.Path, + Kind: materializedomain.FailureInvalid, + } + } + sourceRoot := filepath.Join(request.Root, filepath.FromSlash(request.Source.Path)) + if request.Reference.Format == "proto" { + return protoTree(ctx, s.reader, protoTreeRequest{ + root: sourceRoot, reference: request.Reference, bufConfig: request.Source.Proto.BufConfig, + }) + } + return referenceClosure(ctx, s.reader, sourceRoot, request.Reference.Entrypoint) +} diff --git a/internal/service/materialize/mocks/ports.go b/internal/service/materialize/mocks/ports.go new file mode 100644 index 0000000..81f70ea --- /dev/null +++ b/internal/service/materialize/mocks/ports.go @@ -0,0 +1,310 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/materialize (interfaces: FileReader,HTTPClient,GitClient,ManifestRepository) +// +// Generated by this command: +// +// mockgen -destination mocks/ports.go -package mocks -typed . FileReader,HTTPClient,GitClient,ManifestRepository +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + contract "github.com/devctllabs/devctl/internal/domain/contract" + materialize "github.com/devctllabs/devctl/internal/domain/materialize" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockFileReader is a mock of FileReader interface. +type MockFileReader struct { + ctrl *gomock.Controller + recorder *MockFileReaderMockRecorder + isgomock struct{} +} + +// MockFileReaderMockRecorder is the mock recorder for MockFileReader. +type MockFileReaderMockRecorder struct { + mock *MockFileReader +} + +// NewMockFileReader creates a new mock instance. +func NewMockFileReader(ctrl *gomock.Controller) *MockFileReader { + mock := &MockFileReader{ctrl: ctrl} + mock.recorder = &MockFileReaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFileReader) EXPECT() *MockFileReaderMockRecorder { + return m.recorder +} + +// ReadFile mocks base method. +func (m *MockFileReader) ReadFile(ctx context.Context, root, name string) (contract.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadFile", ctx, root, name) + ret0, _ := ret[0].(contract.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadFile indicates an expected call of ReadFile. +func (mr *MockFileReaderMockRecorder) ReadFile(ctx, root, name any) *MockFileReaderReadFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadFile", reflect.TypeOf((*MockFileReader)(nil).ReadFile), ctx, root, name) + return &MockFileReaderReadFileCall{Call: call} +} + +// MockFileReaderReadFileCall wrap *gomock.Call +type MockFileReaderReadFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockFileReaderReadFileCall) Return(arg0 contract.File, arg1 error) *MockFileReaderReadFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockFileReaderReadFileCall) Do(f func(context.Context, string, string) (contract.File, error)) *MockFileReaderReadFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockFileReaderReadFileCall) DoAndReturn(f func(context.Context, string, string) (contract.File, error)) *MockFileReaderReadFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ReadTree mocks base method. +func (m *MockFileReader) ReadTree(ctx context.Context, root, directory string) ([]contract.File, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadTree", ctx, root, directory) + ret0, _ := ret[0].([]contract.File) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadTree indicates an expected call of ReadTree. +func (mr *MockFileReaderMockRecorder) ReadTree(ctx, root, directory any) *MockFileReaderReadTreeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadTree", reflect.TypeOf((*MockFileReader)(nil).ReadTree), ctx, root, directory) + return &MockFileReaderReadTreeCall{Call: call} +} + +// MockFileReaderReadTreeCall wrap *gomock.Call +type MockFileReaderReadTreeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockFileReaderReadTreeCall) Return(arg0 []contract.File, arg1 error) *MockFileReaderReadTreeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockFileReaderReadTreeCall) Do(f func(context.Context, string, string) ([]contract.File, error)) *MockFileReaderReadTreeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockFileReaderReadTreeCall) DoAndReturn(f func(context.Context, string, string) ([]contract.File, error)) *MockFileReaderReadTreeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockHTTPClient is a mock of HTTPClient interface. +type MockHTTPClient struct { + ctrl *gomock.Controller + recorder *MockHTTPClientMockRecorder + isgomock struct{} +} + +// MockHTTPClientMockRecorder is the mock recorder for MockHTTPClient. +type MockHTTPClientMockRecorder struct { + mock *MockHTTPClient +} + +// NewMockHTTPClient creates a new mock instance. +func NewMockHTTPClient(ctrl *gomock.Controller) *MockHTTPClient { + mock := &MockHTTPClient{ctrl: ctrl} + mock.recorder = &MockHTTPClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockHTTPClient) EXPECT() *MockHTTPClientMockRecorder { + return m.recorder +} + +// Fetch mocks base method. +func (m *MockHTTPClient) Fetch(ctx context.Context, request materialize.HTTPFetchRequest) (materialize.HTTPDocument, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Fetch", ctx, request) + ret0, _ := ret[0].(materialize.HTTPDocument) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Fetch indicates an expected call of Fetch. +func (mr *MockHTTPClientMockRecorder) Fetch(ctx, request any) *MockHTTPClientFetchCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fetch", reflect.TypeOf((*MockHTTPClient)(nil).Fetch), ctx, request) + return &MockHTTPClientFetchCall{Call: call} +} + +// MockHTTPClientFetchCall wrap *gomock.Call +type MockHTTPClientFetchCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockHTTPClientFetchCall) Return(arg0 materialize.HTTPDocument, arg1 error) *MockHTTPClientFetchCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockHTTPClientFetchCall) Do(f func(context.Context, materialize.HTTPFetchRequest) (materialize.HTTPDocument, error)) *MockHTTPClientFetchCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockHTTPClientFetchCall) DoAndReturn(f func(context.Context, materialize.HTTPFetchRequest) (materialize.HTTPDocument, error)) *MockHTTPClientFetchCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockGitClient is a mock of GitClient interface. +type MockGitClient struct { + ctrl *gomock.Controller + recorder *MockGitClientMockRecorder + isgomock struct{} +} + +// MockGitClientMockRecorder is the mock recorder for MockGitClient. +type MockGitClientMockRecorder struct { + mock *MockGitClient +} + +// NewMockGitClient creates a new mock instance. +func NewMockGitClient(ctrl *gomock.Controller) *MockGitClient { + mock := &MockGitClient{ctrl: ctrl} + mock.recorder = &MockGitClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGitClient) EXPECT() *MockGitClientMockRecorder { + return m.recorder +} + +// WithCheckout mocks base method. +func (m *MockGitClient) WithCheckout(ctx context.Context, repository, ref string, use func(string) error) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WithCheckout", ctx, repository, ref, use) + ret0, _ := ret[0].(error) + return ret0 +} + +// WithCheckout indicates an expected call of WithCheckout. +func (mr *MockGitClientMockRecorder) WithCheckout(ctx, repository, ref, use any) *MockGitClientWithCheckoutCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WithCheckout", reflect.TypeOf((*MockGitClient)(nil).WithCheckout), ctx, repository, ref, use) + return &MockGitClientWithCheckoutCall{Call: call} +} + +// MockGitClientWithCheckoutCall wrap *gomock.Call +type MockGitClientWithCheckoutCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockGitClientWithCheckoutCall) Return(arg0 error) *MockGitClientWithCheckoutCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockGitClientWithCheckoutCall) Do(f func(context.Context, string, string, func(string) error) error) *MockGitClientWithCheckoutCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockGitClientWithCheckoutCall) DoAndReturn(f func(context.Context, string, string, func(string) error) error) *MockGitClientWithCheckoutCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockManifestRepository is a mock of ManifestRepository interface. +type MockManifestRepository struct { + ctrl *gomock.Controller + recorder *MockManifestRepositoryMockRecorder + isgomock struct{} +} + +// MockManifestRepositoryMockRecorder is the mock recorder for MockManifestRepository. +type MockManifestRepositoryMockRecorder struct { + mock *MockManifestRepository +} + +// NewMockManifestRepository creates a new mock instance. +func NewMockManifestRepository(ctrl *gomock.Controller) *MockManifestRepository { + mock := &MockManifestRepository{ctrl: ctrl} + mock.recorder = &MockManifestRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManifestRepository) EXPECT() *MockManifestRepositoryMockRecorder { + return m.recorder +} + +// Load mocks base method. +func (m *MockManifestRepository) Load(ctx context.Context, manifestPath string) (project.LoadManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", ctx, manifestPath) + ret0, _ := ret[0].(project.LoadManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Load indicates an expected call of Load. +func (mr *MockManifestRepositoryMockRecorder) Load(ctx, manifestPath any) *MockManifestRepositoryLoadCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockManifestRepository)(nil).Load), ctx, manifestPath) + return &MockManifestRepositoryLoadCall{Call: call} +} + +// MockManifestRepositoryLoadCall wrap *gomock.Call +type MockManifestRepositoryLoadCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockManifestRepositoryLoadCall) Return(arg0 project.LoadManifestResult, arg1 error) *MockManifestRepositoryLoadCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockManifestRepositoryLoadCall) Do(f func(context.Context, string) (project.LoadManifestResult, error)) *MockManifestRepositoryLoadCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockManifestRepositoryLoadCall) DoAndReturn(f func(context.Context, string) (project.LoadManifestResult, error)) *MockManifestRepositoryLoadCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/materialize/ports.go b/internal/service/materialize/ports.go new file mode 100644 index 0000000..271593c --- /dev/null +++ b/internal/service/materialize/ports.go @@ -0,0 +1,38 @@ +package materialize + +import ( + "context" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" +) + +//go:generate go tool mockgen -destination mocks/ports.go -package mocks -typed . FileReader,HTTPClient,GitClient,ManifestRepository + +// FileReader reads contained files for snapshot construction. +type FileReader interface { + // ReadFile returns a regular non-symlink file below root with its relative path and permission bits. + ReadFile(ctx context.Context, root, name string) (contract.File, error) + // ReadTree returns every regular non-symlink file below a contained directory in deterministic order. + ReadTree(ctx context.Context, root, directory string) ([]contract.File, error) +} + +// HTTPClient retrieves remote contract bytes. +type HTTPClient interface { + // Fetch retrieves request.URL and returns its effective URL after redirects. + // Request.OriginURL defines the scheme, host, and effective port that every request must retain. + Fetch(ctx context.Context, request materializedomain.HTTPFetchRequest) (materializedomain.HTTPDocument, error) +} + +// GitClient provides a temporary checkout for the lifetime of one callback. +type GitClient interface { + // WithCheckout invokes use with repository at ref and cleans the worktree after use succeeds or fails. + WithCheckout(ctx context.Context, repository, ref string, use func(root string) error) error +} + +// ManifestRepository reads upstream Devctl manifests by exact path. +type ManifestRepository interface { + // Load returns structural manifest issues as data and access failures as errors. + Load(ctx context.Context, manifestPath string) (project.LoadManifestResult, error) +} diff --git a/internal/service/materialize/proto.go b/internal/service/materialize/proto.go new file mode 100644 index 0000000..8be058e --- /dev/null +++ b/internal/service/materialize/proto.go @@ -0,0 +1,123 @@ +package materialize + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" +) + +type protoTreeRequest struct { + root string + reference contract.Reference + bufConfig string +} + +type bufFilesRequest struct { + root string + files []contract.File + configPath string +} + +func protoTree(ctx context.Context, reader FileReader, request protoTreeRequest) (contract.Snapshot, error) { + root := request.root + reference := request.reference + protoRoot := reference.ProtoRoot + if protoRoot == "" { + protoRoot = path.Dir(reference.Entrypoint) + } + if !safeProtoSelection(protoRoot, reference.Entrypoint) { + return contract.Snapshot{}, &materializedomain.OperationError{ + Operation: materializedomain.OperationValidateSource, + Path: reference.Entrypoint, + Kind: materializedomain.FailureInvalid, + } + } + files, err := reader.ReadTree(ctx, root, protoRoot) + if err != nil { + operationErr := &materializedomain.OperationError{Operation: materializedomain.OperationReadFile, Path: protoRoot, Kind: materializedomain.FailureUnavailable, Cause: err} + return contract.Snapshot{}, fmt.Errorf("reader.ReadTree: %w", operationErr) + } + if request.bufConfig != "" { + files, err = includeBufFiles(ctx, reader, bufFilesRequest{ + root: root, files: files, configPath: request.bufConfig, + }) + if err != nil { + return contract.Snapshot{}, err + } + } + return newProtoSnapshot(protoRoot, reference.Entrypoint, files) +} + +func includeBufFiles( + ctx context.Context, + reader FileReader, + request bufFilesRequest, +) ([]contract.File, error) { + root, files, configPath := request.root, request.files, request.configPath + if !safeRelative(configPath) { + return nil, &materializedomain.OperationError{ + Operation: materializedomain.OperationValidateSource, + Path: configPath, + Kind: materializedomain.FailureInvalid, + } + } + config, err := reader.ReadFile(ctx, root, configPath) + if err != nil { + return nil, bufReadError(configPath, err) + } + config.Path = configPath + files = replaceFile(files, config) + + lockPath := path.Join(path.Dir(configPath), "buf.lock") + lock, err := reader.ReadFile(ctx, root, lockPath) + if errors.Is(err, fs.ErrNotExist) { + return files, nil + } + if err != nil { + return nil, bufReadError(lockPath, err) + } + lock.Path = lockPath + return replaceFile(files, lock), nil +} + +func replaceFile(files []contract.File, replacement contract.File) []contract.File { + result := make([]contract.File, 0, len(files)+1) + for _, file := range files { + if cleanSnapshotPath(file.Path) != replacement.Path { + result = append(result, file) + } + } + return append(result, replacement) +} + +func bufReadError(name string, cause error) error { + kind := materializedomain.FailureUnavailable + if errors.Is(cause, fs.ErrNotExist) { + kind = materializedomain.FailureNotFound + } + operationErr := &materializedomain.OperationError{ + Operation: materializedomain.OperationReadFile, + Path: name, + Kind: kind, + Cause: cause, + } + return fmt.Errorf("reader.ReadFile: %w", operationErr) +} + +func safeProtoSelection(root, entrypoint string) bool { + if root != "." && !safeRelative(root) { + return false + } + if !safeRelative(entrypoint) { + return false + } + root = path.Clean(root) + entrypoint = path.Clean(entrypoint) + return root == "." || entrypoint == root || strings.HasPrefix(entrypoint, root+"/") +} diff --git a/internal/service/materialize/service.go b/internal/service/materialize/service.go new file mode 100644 index 0000000..ce22c43 --- /dev/null +++ b/internal/service/materialize/service.go @@ -0,0 +1,54 @@ +package materialize + +import ( + "context" + "fmt" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" +) + +// Strategy materializes exactly one project source type. +type Strategy interface { + // SourceType returns the single source type owned by the strategy. + SourceType() project.SourceType + // Materialize returns the selected exact contract closure without publishing it. + Materialize(ctx context.Context, request materializedomain.Request) (contract.Snapshot, error) +} + +type Service struct { + strategies map[project.SourceType]Strategy +} + +// New builds an immutable strategy router from any subset and rejects duplicate source types. +func New(strategies ...Strategy) (*Service, error) { + byType := make(map[project.SourceType]Strategy, len(strategies)) + for _, strategy := range strategies { + sourceType := strategy.SourceType() + if _, exists := byType[sourceType]; exists { + return nil, &materializedomain.OperationError{Operation: materializedomain.OperationConfigureRouter, SourceType: sourceType, Kind: materializedomain.FailureInvalid} + } + byType[sourceType] = strategy + } + return &Service{strategies: byType}, nil +} + +// Materialize routes request by source type and reports an unsupported source when no strategy was configured. +func (s *Service) Materialize( + ctx context.Context, + root string, + source project.Source, + reference contract.Reference, +) (contract.Snapshot, error) { + request := materializedomain.Request{Root: root, Source: source, Reference: reference} + strategy, exists := s.strategies[source.Type] + if !exists { + return contract.Snapshot{}, &materializedomain.UnsupportedSourceError{SourceType: source.Type} + } + snapshot, err := strategy.Materialize(ctx, request) + if err != nil { + return snapshot, fmt.Errorf("strategy.Materialize: %w", err) + } + return snapshot, nil +} diff --git a/internal/service/materialize/service_test.go b/internal/service/materialize/service_test.go new file mode 100644 index 0000000..ee381f5 --- /dev/null +++ b/internal/service/materialize/service_test.go @@ -0,0 +1,66 @@ +package materialize_test + +import ( + "context" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/materialize" + "github.com/stretchr/testify/require" +) + +type strategy struct { + typeName project.SourceType + called bool +} + +func (s *strategy) SourceType() project.SourceType { return s.typeName } + +func (s *strategy) Materialize(context.Context, materializedomain.Request) (contract.Snapshot, error) { + s.called = true + return contract.Snapshot{Entrypoint: "openapi.yaml", Files: []contract.File{{Path: "openapi.yaml", Content: []byte("openapi: 3.1.0")}}}, nil +} + +func TestRouterAcceptsStrategySubsetAndRoutesBySourceType(t *testing.T) { + t.Parallel() + + local := &strategy{typeName: project.SourceLocal} + service, err := materialize.New(local) + require.NoError(t, err) + + snapshot, err := service.Materialize( + context.Background(), + "", + project.Source{Type: project.SourceLocal}, + contract.Reference{Entrypoint: "openapi.yaml"}, + ) + + require.NoError(t, err) + require.True(t, local.called) + require.Equal(t, "openapi.yaml", snapshot.Entrypoint) +} + +func TestRouterRejectsDuplicateSourceType(t *testing.T) { + t.Parallel() + + _, err := materialize.New(&strategy{typeName: project.SourceURL}, &strategy{typeName: project.SourceURL}) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestRouterReportsMissingStrategyAtCallTime(t *testing.T) { + t.Parallel() + + service, err := materialize.New() + require.NoError(t, err) + + _, err = service.Materialize(context.Background(), "", project.Source{Type: project.SourceGit}, contract.Reference{}) + + require.Equal(t, failure.Unsupported, failure.CategoryOf(err)) + var unsupported *materializedomain.UnsupportedSourceError + require.ErrorAs(t, err, &unsupported) + require.Equal(t, project.SourceGit, unsupported.SourceType) +} diff --git a/internal/service/materialize/snapshot.go b/internal/service/materialize/snapshot.go new file mode 100644 index 0000000..325a5ec --- /dev/null +++ b/internal/service/materialize/snapshot.go @@ -0,0 +1,75 @@ +package materialize + +import ( + "path" + "sort" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" +) + +// newSnapshot copies, normalizes, and sorts files while requiring unique contained paths and a present entrypoint. +func newSnapshot(entrypoint string, files []contract.File) (contract.Snapshot, error) { + normalized, seen, err := normalizeSnapshotFiles(files) + if err != nil { + return contract.Snapshot{}, err + } + entrypoint = cleanSnapshotPath(entrypoint) + if _, exists := seen[entrypoint]; !exists { + return contract.Snapshot{}, invalidSnapshotPath(entrypoint) + } + return contract.Snapshot{Entrypoint: entrypoint, Files: normalized}, nil +} + +func newProtoSnapshot(moduleRoot, entrypoint string, files []contract.File) (contract.Snapshot, error) { + normalized, seen, err := normalizeSnapshotFiles(files) + if err != nil { + return contract.Snapshot{}, err + } + moduleRoot = cleanSnapshotPath(moduleRoot) + if !safeRelativeOrCurrent(moduleRoot) { + return contract.Snapshot{}, invalidSnapshotPath(moduleRoot) + } + if entrypoint != "" { + entrypoint = cleanSnapshotPath(entrypoint) + if _, exists := seen[entrypoint]; !exists { + return contract.Snapshot{}, invalidSnapshotPath(entrypoint) + } + } + return contract.Snapshot{ModuleRoot: moduleRoot, Entrypoint: entrypoint, Files: normalized}, nil +} + +func normalizeSnapshotFiles(files []contract.File) ([]contract.File, map[string]struct{}, error) { + normalized := make([]contract.File, 0, len(files)) + seen := make(map[string]struct{}, len(files)) + for _, file := range files { + name := cleanSnapshotPath(file.Path) + if !safeRelative(name) { + return nil, nil, invalidSnapshotPath(name) + } + if _, exists := seen[name]; exists { + return nil, nil, invalidSnapshotPath(name) + } + seen[name] = struct{}{} + normalized = append(normalized, contract.File{Path: name, Content: append([]byte(nil), file.Content...), Mode: file.Mode}) + } + sort.Slice(normalized, func(i, j int) bool { return normalized[i].Path < normalized[j].Path }) + return normalized, seen, nil +} + +func cleanSnapshotPath(name string) string { + return path.Clean(strings.ReplaceAll(name, "\\", "/")) +} + +func safeRelativeOrCurrent(name string) bool { + return name == "." || safeRelative(name) +} + +func invalidSnapshotPath(name string) error { + return &materializedomain.OperationError{ + Operation: materializedomain.OperationBuildSnapshot, + Path: name, + Kind: materializedomain.FailureInvalid, + } +} diff --git a/internal/service/materialize/strategy_test.go b/internal/service/materialize/strategy_test.go new file mode 100644 index 0000000..5c9e5a3 --- /dev/null +++ b/internal/service/materialize/strategy_test.go @@ -0,0 +1,1019 @@ +package materialize_test + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + "github.com/devctllabs/devctl/internal/service/materialize" + "github.com/devctllabs/devctl/internal/service/materialize/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestLocalMaterializesReferenceClosure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockFileReader(ctrl) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "openapi.yaml").Return(contract.File{ + Content: []byte("openapi: 3.1.0\ncomponents:\n schemas:\n Item:\n $ref: './components.yaml#/Item'\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "components.yaml").Return(contract.File{ + Content: []byte("Item:\n type: object\n"), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewLocal(reader).Materialize(context.Background(), materializedomain.Request{ + Root: "/project", + Source: project.Source{Type: project.SourceLocal, Path: "api/contracts"}, + Reference: contract.Reference{Entrypoint: "openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, "openapi.yaml", snapshot.Entrypoint) + require.Len(t, snapshot.Files, 2) +} + +func TestLocalMaterializesProtoRootTreeAndBufMetadata(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockFileReader(ctrl) + reader.EXPECT().ReadTree(gomock.Any(), "/project/api/contracts", "proto").Return([]contract.File{ + {Path: "proto/acme/v1/common.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644}, + {Path: "proto/acme/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644}, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "buf.yaml").Return(contract.File{ + Path: "buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "buf.lock").Return(contract.File{}, fs.ErrNotExist) + + snapshot, err := materialize.NewLocal(reader).Materialize(context.Background(), materializedomain.Request{ + Root: "/project", + Source: project.Source{Type: project.SourceLocal, Path: "api/contracts", Proto: project.SourceProto{ + BufConfig: "buf.yaml", + }}, + Reference: contract.Reference{Entrypoint: "proto/acme/v1/service.proto", Format: "proto", ProtoRoot: "proto"}, + }) + + require.NoError(t, err) + require.Equal(t, "proto/acme/v1/service.proto", snapshot.Entrypoint) + require.Equal(t, []string{"buf.yaml", "proto/acme/v1/common.proto", "proto/acme/v1/service.proto"}, snapshotPaths(snapshot)) +} + +func TestLocalIncludesBufConfigAndAdjacentLockOnlyOnceWhenTheyAreInsideProtoRoot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + reader := mocks.NewMockFileReader(ctrl) + reader.EXPECT().ReadTree(gomock.Any(), "/project/api/contracts", "proto").Return([]contract.File{ + {Path: "proto/acme/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644}, + {Path: "proto/buf.lock", Content: []byte("deps: []\n"), Mode: 0o644}, + {Path: "proto/buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "proto/buf.yaml").Return(contract.File{ + Content: []byte("version: v2\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/project/api/contracts", "proto/buf.lock").Return(contract.File{ + Content: []byte("deps: []\n"), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewLocal(reader).Materialize(context.Background(), materializedomain.Request{ + Root: "/project", + Source: project.Source{Type: project.SourceLocal, Path: "api/contracts", Proto: project.SourceProto{ + BufConfig: "proto/buf.yaml", + }}, + Reference: contract.Reference{Entrypoint: "proto/acme/v1/service.proto", Format: "proto", ProtoRoot: "proto"}, + }) + + require.NoError(t, err) + require.Equal(t, []string{"proto/acme/v1/service.proto", "proto/buf.lock", "proto/buf.yaml"}, snapshotPaths(snapshot)) +} + +func TestLocalReportsInvalidBufConfigFileAtTheSelectedPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config string + prepare func(*testing.T, string) + category failure.Category + }{ + { + name: "missing", config: "buf/missing.yaml", category: failure.NotFound, + prepare: func(*testing.T, string) {}, + }, + { + name: "directory", config: "buf/config", category: failure.Unavailable, + prepare: func(t *testing.T, root string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(root, "contracts/buf/config"), 0o755)) + }, + }, + { + name: "symlink", config: "buf/link.yaml", category: failure.Unavailable, + prepare: func(t *testing.T, root string) { + t.Helper() + target := filepath.Join(root, "contracts/buf/target.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) + require.NoError(t, os.WriteFile(target, []byte("version: v2\n"), 0o644)) + require.NoError(t, os.Symlink("target.yaml", filepath.Join(root, "contracts/buf/link.yaml"))) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + root := t.TempDir() + proto := filepath.Join(root, "contracts/proto/service.proto") + require.NoError(t, os.MkdirAll(filepath.Dir(proto), 0o755)) + require.NoError(t, os.WriteFile(proto, []byte("syntax = \"proto3\";\n"), 0o644)) + test.prepare(t, root) + + _, err := materialize.NewLocal(workspacerepo.NewFilesystemRepo()).Materialize( + context.Background(), + materializedomain.Request{ + Root: root, + Source: project.Source{Type: project.SourceLocal, Path: "contracts", Proto: project.SourceProto{ + BufConfig: test.config, + }}, + Reference: contract.Reference{Entrypoint: "proto/service.proto", Format: "proto", ProtoRoot: "proto"}, + }, + ) + + require.Equal(t, test.category, failure.CategoryOf(err)) + var operationErr *materializedomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, materializedomain.OperationReadFile, operationErr.Operation) + require.Equal(t, test.config, operationErr.Path) + }) + } +} + +func snapshotPaths(snapshot contract.Snapshot) []string { + paths := make([]string, 0, len(snapshot.Files)) + for _, file := range snapshot.Files { + paths = append(paths, file.Path) + } + return paths +} + +func TestURLRejectsInsecureSourceBeforeClientCall(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: "http://example.test/openapi.yaml"}, + }) + + require.Error(t, err) +} + +func TestURLRejectsCredentialedSourceWithoutLeakingCredentialsOrQuery(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{ + Type: project.SourceURL, + URL: "https://user:password@example.test/openapi.yaml?token=secret", + }, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + var operationErr *materializedomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, "https://example.test/openapi.yaml", operationErr.Path) + require.NotContains(t, err.Error(), "password") + require.NotContains(t, err.Error(), "token=") +} + +func TestURLBuildsSnapshotFromDownloadedDocument(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/openapi.yaml", OriginURL: "https://example.test/openapi.yaml", + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/openapi.yaml", Content: []byte("openapi: 3.1.0\n"), + }, nil) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: "https://example.test/openapi.yaml"}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, "spec/openapi.yaml", snapshot.Entrypoint) + require.Equal(t, []byte("openapi: 3.1.0\n"), snapshot.Files[0].Content) +} + +func TestURLMaterializesRelativeReferenceClosureInFetchAndVirtualPaths(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, + Content: []byte("openapi: 3.1.0\ncomponents:\n schemas:\n Item:\n" + + " $ref: './schemas/common.yaml#/Item'\n"), + }, nil) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/schemas/common.yaml", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/contracts/schemas/common.yaml", + Content: []byte("Item:\n type: object\n"), + }, nil) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, "spec/openapi.yaml", snapshot.Entrypoint) + require.Equal(t, []string{"spec/openapi.yaml", "spec/schemas/common.yaml"}, snapshotPaths(snapshot)) +} + +func TestURLMaterializesJSONSchemaReferenceClosure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/event.json" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, Content: []byte(`{"$ref":"./schemas/payload.json#/$defs/Payload"}`), + }, nil) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/schemas/payload.json", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/contracts/schemas/payload.json", + Content: []byte(`{"$defs":{"Payload":{"type":"object"}}}`), + }, nil) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "schemas/event.json", Format: "json"}, + }) + + require.NoError(t, err) + require.Equal(t, []string{"schemas/event.json", "schemas/schemas/payload.json"}, snapshotPaths(snapshot)) +} + +func TestURLResolvesReferenceFromEffectiveURLAfterRedirect(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/start" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/redirected/openapi.yaml", + Content: []byte("$ref: './schema.yaml'\n"), + }, nil) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/redirected/schema.yaml", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/redirected/schema.yaml", Content: []byte("type: object\n"), + }, nil) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, []string{"spec/openapi.yaml", "spec/schema.yaml"}, snapshotPaths(snapshot)) +} + +func TestURLRejectsReferenceEscapingVirtualSourceRoot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, + Content: []byte("openapi: 3.1.0\ncomponents:\n schemas:\n Item:\n $ref: '../shared.yaml#/Item'\n"), + }, nil) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestURLTerminatesCyclesAndIgnoresAbsoluteReferences(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + gomock.InOrder( + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, + Content: []byte("openapi: 3.1.0\nrefs:\n" + + " - {$ref: './schemas/common.yaml#/Item'}\n" + + " - {$ref: '#/components/schemas/Local'}\n" + + " - {$ref: 'https://example.test/contracts/ignored.yaml'}\n" + + " - {$ref: '/contracts/ignored.yaml'}\n" + + " - {$ref: '//example.test/contracts/ignored.yaml'}\n"), + }, nil), + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/schemas/common.yaml", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/contracts/schemas/common.yaml", + Content: []byte("Item:\n $ref: '../openapi.yaml#/components/schemas/Local'\n"), + }, nil), + ) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, []string{"spec/openapi.yaml", "spec/schemas/common.yaml"}, snapshotPaths(snapshot)) +} + +func TestURLRejectsQueryIdentitiesCollidingOnVirtualPathWithoutLeakingQuery(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, + Content: []byte("refs:\n" + + " - {$ref: './schema.yaml?token=alpha#/Item'}\n" + + " - {$ref: './schema.yaml?token=bravo#/Item'}\n"), + }, nil) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + var operationErr *materializedomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, "spec/schema.yaml", operationErr.Path) + require.NotContains(t, err.Error(), "token=") +} + +func TestURLPreservesDownloadCategoryAndRedactsQueryFromFailureContext(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, Content: []byte("$ref: './missing.yaml?token=secret'\n"), + }, nil) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/missing.yaml?token=secret", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{}, &materializedomain.OperationError{ + Operation: materializedomain.OperationDownload, + Kind: materializedomain.FailureNotFound, + Cause: errors.New("HTTP status 404"), + }) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.NotFound, failure.CategoryOf(err)) + var operationErr *materializedomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, "https://example.test/contracts/missing.yaml", operationErr.Path) + require.NotContains(t, err.Error(), "token=") +} + +func TestURLRejectsClosureExceedingDocumentLimit(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + var refs strings.Builder + refs.WriteString("refs:\n") + for index := 0; index < 64; index++ { + _, _ = fmt.Fprintf(&refs, " - {$ref: './schemas/%02d.yaml'}\n", index) + } + fetches := 0 + client.EXPECT().Fetch(gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( + func(_ context.Context, request materializedomain.HTTPFetchRequest) (materializedomain.HTTPDocument, error) { + fetches++ + if request.URL == sourceURL { + return materializedomain.HTTPDocument{URL: sourceURL, Content: []byte(refs.String())}, nil + } + return materializedomain.HTTPDocument{URL: request.URL, Content: []byte("type: object\n")}, nil + }, + ) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) + require.LessOrEqual(t, fetches, 64) +} + +func TestURLAllowsClosureAtDocumentLimit(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + var refs strings.Builder + refs.WriteString("refs:\n") + for index := 0; index < 63; index++ { + _, _ = fmt.Fprintf(&refs, " - {$ref: './schemas/%02d.yaml'}\n", index) + } + fetches := 0 + client.EXPECT().Fetch(gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( + func(_ context.Context, request materializedomain.HTTPFetchRequest) (materializedomain.HTTPDocument, error) { + fetches++ + if request.URL == sourceURL { + return materializedomain.HTTPDocument{URL: sourceURL, Content: []byte(refs.String())}, nil + } + return materializedomain.HTTPDocument{URL: request.URL, Content: []byte("type: object\n")}, nil + }, + ) + + snapshot, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.NoError(t, err) + require.Len(t, snapshot.Files, 64) + require.Equal(t, 64, fetches) +} + +func TestURLRejectsDocumentExceedingResponseLimit(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, Content: make([]byte, (32<<20)+1), + }, nil) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestURLRejectsClosureExceedingAggregateSizeLimit(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockHTTPClient(ctrl) + const sourceURL = "https://example.test/contracts/openapi.yaml" + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: sourceURL, OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: sourceURL, + Content: []byte("refs:\n - {$ref: './one.yaml'}\n - {$ref: './two.yaml'}\n"), + }, nil) + largeDocument := make([]byte, 32<<20) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/one.yaml", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/contracts/one.yaml", Content: largeDocument, + }, nil) + client.EXPECT().Fetch(gomock.Any(), materializedomain.HTTPFetchRequest{ + URL: "https://example.test/contracts/two.yaml", OriginURL: sourceURL, + }).Return(materializedomain.HTTPDocument{ + URL: "https://example.test/contracts/two.yaml", Content: largeDocument, + }, nil) + + _, err := materialize.NewURL(client).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceURL, URL: sourceURL}, + Reference: contract.Reference{Entrypoint: "spec/openapi.yaml"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestGitMaterializesCheckedOutReferenceClosure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "main", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout/contracts", "openapi.yaml").Return(contract.File{Content: []byte("openapi: 3.1.0\n")}, nil) + + snapshot, err := materialize.NewGit(client, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceGit, Path: "contracts", Repo: "repo", Ref: "main"}, + Reference: contract.Reference{Entrypoint: "openapi.yaml"}, + }) + + require.NoError(t, err) + require.Equal(t, "openapi.yaml", snapshot.Entrypoint) +} + +func TestDevctlMaterializesSelectedExportWithoutUpstreamReadiness(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{ + "public-api": {Kind: "openapi", Path: "api/openapi.yaml"}, + }, + Components: project.Components{HTTP: &project.HTTP{Server: &project.HTTPServer{OpenAPI: "api/openapi.yaml"}}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/openapi.yaml").Return(contract.File{Content: []byte("openapi: 3.1.0\n")}, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "public-api"}, + }) + + require.NoError(t, err) + require.Equal(t, "api/openapi.yaml", snapshot.Entrypoint) +} + +func TestDevctlRejectsSelectedExportThatDoesNotMatchEffectiveSurface(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{ + "public-api": {Kind: "openapi", Path: "api/exported.yaml"}, + }, + Components: project.Components{HTTP: &project.HTTP{Server: &project.HTTPServer{OpenAPI: "api/canonical.yaml"}}}, + }}}, nil) + + _, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "public-api"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestDevctlMaterializesNamedGRPCExportTree(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{ + "billing": {Kind: "grpc", Path: "api/proto/grpc"}, + }, + Components: project.Components{GRPC: &project.GRPC{Server: &project.GRPCServer{ProtoRoot: "api/proto/grpc"}}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/proto/grpc/.devctl-contract.json").Return( + contract.File{}, fs.ErrNotExist, + ) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", "api/proto/grpc").Return([]contract.File{{ + Path: "api/proto/grpc/acme/billing/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644, + }}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "buf.yaml").Return(contract.File{ + Content: []byte("version: v2\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "buf.lock").Return(contract.File{ + Content: []byte("deps: []\n"), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "billing", Format: "proto"}, + }) + + require.NoError(t, err) + require.Empty(t, snapshot.Entrypoint) + require.Equal(t, []string{"api/proto/grpc/acme/billing/v1/service.proto", "buf.lock", "buf.yaml"}, snapshotPaths(snapshot)) + require.Equal(t, &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: "api/proto/grpc", BufConfig: "buf.yaml", + }, snapshot.Metadata) +} + +func TestDevctlMaterializesExplicitGRPCBufConfigAndAdjacentLock(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{"billing": {Kind: "grpc", Path: "api/proto/grpc"}}, + Components: project.Components{GRPC: &project.GRPC{Server: &project.GRPCServer{ + ProtoRoot: "api/proto/grpc", BufConfig: "tools/buf/upstream.yaml", + }}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/proto/grpc/.devctl-contract.json").Return( + contract.File{}, fs.ErrNotExist, + ) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", "api/proto/grpc").Return([]contract.File{{ + Path: "api/proto/grpc/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644, + }}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "tools/buf/upstream.yaml").Return(contract.File{ + Content: []byte("version: v2\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "tools/buf/buf.lock").Return(contract.File{ + Content: []byte("deps: []\n"), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "billing", Format: "proto"}, + }) + + require.NoError(t, err) + require.Equal(t, []string{ + "api/proto/grpc/service.proto", "tools/buf/buf.lock", "tools/buf/upstream.yaml", + }, snapshotPaths(snapshot)) + require.Equal(t, "tools/buf/upstream.yaml", snapshot.Metadata.BufConfig) +} + +func TestDevctlReexportsCommittedGRPCSnapshotWithoutInventingEntrypoint(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + const treeRoot = "api/external/grpc/client/billing" + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{"billing": {Kind: "grpc", Path: treeRoot}}, + Components: project.Components{GRPC: &project.GRPC{Server: &project.GRPCServer{ProtoRoot: treeRoot}}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", treeRoot+"/.devctl-contract.json").Return(contract.File{ + Content: []byte(`{"kind":"grpc","format":"proto","module_root":"api/proto/grpc","buf_config":"buf.yaml"}`), + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", treeRoot+"/buf.yaml").Return(contract.File{}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", treeRoot+"/api/proto/grpc").Return([]contract.File{{ + Path: treeRoot + "/api/proto/grpc/service.proto", + }}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", treeRoot).Return([]contract.File{ + {Path: treeRoot + "/.devctl-contract.json", Content: []byte(`{}`), Mode: 0o644}, + {Path: treeRoot + "/api/proto/grpc/service.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644}, + {Path: treeRoot + "/buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "billing", Format: "proto"}, + }) + + require.NoError(t, err) + require.Equal(t, "api/proto/grpc", snapshot.ModuleRoot) + require.Empty(t, snapshot.Entrypoint) + require.Equal(t, []string{"api/proto/grpc/service.proto", "buf.yaml"}, snapshotPaths(snapshot)) +} + +func TestDevctlDoesNotFallbackWhenCommittedGRPCMetadataIsInvalid(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{"billing": {Kind: "grpc", Path: "api/proto/grpc"}}, + Components: project.Components{GRPC: &project.GRPC{Server: &project.GRPCServer{ + ProtoRoot: "api/proto/grpc", + }}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/proto/grpc/.devctl-contract.json").Return(contract.File{ + Content: []byte(`{"kind":"grpc","format":"proto","buf_config":"buf.yaml"}`), + }, nil) + + _, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "billing", Format: "proto"}, + }) + + var metadataErr *contract.SnapshotMetadataError + require.ErrorAs(t, err, &metadataErr) + require.Equal(t, "module_root", metadataErr.Field) + require.Equal(t, contract.MetadataRequired, metadataErr.Reason) +} + +func TestDevctlReportsMissingNamedExport(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{Exports: map[string]project.Export{}}}}, nil) + + _, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "missing"}, + }) + + require.Equal(t, failure.NotFound, failure.CategoryOf(err)) +} + +func TestDevctlRejectsKafkaExportTopicMismatch(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: "upstream_service.domain.events.v1", Contract: project.KafkaContract{Format: "raw"}, + }}}}, + }}}, nil) + + _, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{Export: "events", Topic: "downstream_service.domain.events.v1"}, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestDevctlRejectsKafkaExportFormatMismatch(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: "upstream_service.domain.events.v1", Contract: project.KafkaContract{Format: "raw"}, + }}}}, + }}}, nil) + + _, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{ + Export: "events", Topic: "upstream_service.domain.events.v1", Format: "json", + }, + }) + + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestDevctlMaterializesKafkaExportContractAndMetadata(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Sources: map[string]project.Source{"contracts": {Type: project.SourceLocal, Path: "api/contracts"}}, + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: "upstream_service.domain.events.v1", Contract: project.KafkaContract{ + Format: "json", Source: "contracts", Path: "schemas/events.json", + }, + }}}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout/api/contracts", "schemas/events.json").Return(contract.File{ + Content: []byte(`{"type":"object"}`), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{ + Export: "events", Topic: "upstream_service.domain.events.v1", Format: "json", + }, + }) + + require.NoError(t, err) + require.Equal(t, "schemas/events.json", snapshot.Entrypoint) + require.Equal(t, &contract.Metadata{ + Kind: "kafka", Topic: "upstream_service.domain.events.v1", Format: "json", Entrypoint: "schemas/events.json", + }, snapshot.Metadata) + require.Equal(t, []string{"schemas/events.json"}, snapshotPaths(snapshot)) +} + +func TestDevctlMaterializesProtoKafkaExportMetadataAndBufFiles(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Sources: map[string]project.Source{"contracts": { + Type: project.SourceLocal, Path: "api/contracts", Proto: project.SourceProto{BufConfig: "buf.yaml"}, + }}, + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: "upstream_service.domain.events.v1", Contract: project.KafkaContract{ + Format: "proto", Source: "contracts", Path: "proto/events.proto", ProtoRoot: "proto", + }, + }}}}, + }}}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout/api/contracts", "proto").Return([]contract.File{{ + Path: "proto/events.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644, + }}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout/api/contracts", "buf.yaml").Return(contract.File{ + Content: []byte("version: v2\n"), Mode: 0o644, + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout/api/contracts", "buf.lock").Return(contract.File{ + Content: []byte("deps: []\n"), Mode: 0o644, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{ + Export: "events", Topic: "upstream_service.domain.events.v1", Format: "proto", + }, + }) + + require.NoError(t, err) + require.Equal(t, "proto", snapshot.ModuleRoot) + require.Equal(t, "proto/events.proto", snapshot.Entrypoint) + require.Equal(t, &contract.Metadata{ + Kind: "kafka", Topic: "upstream_service.domain.events.v1", Format: "proto", + Entrypoint: "proto/events.proto", ModuleRoot: "proto", BufConfig: "buf.yaml", + }, snapshot.Metadata) + require.Equal(t, []string{"buf.lock", "buf.yaml", "proto/events.proto"}, snapshotPaths(snapshot)) +} + +func TestDevctlMaterializesKafkaExportFromUpstreamSyncedTree(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Paths: project.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]project.Source{"contracts": {Type: project.SourceDevctl, Repo: "contracts", Ref: "v1"}}, + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: "upstream_service.domain.events.v1", Contract: project.KafkaContract{ + Format: "json", Source: "contracts", Export: "upstream-events", + }, + }}}}, + }}}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/external/kafka/producer/events/.devctl-contract.json").Return(contract.File{ + Content: []byte(`{"kind":"kafka","topic":"upstream_service.domain.events.v1","format":"json","entrypoint":"schemas/events.json"}`), + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", "api/external/kafka/producer/events/schemas/events.json").Return( + contract.File{}, nil, + ) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", "api/external/kafka/producer/events").Return([]contract.File{ + {Path: "api/external/kafka/producer/events/.devctl-contract.json", Content: []byte(`{}`), Mode: 0o644}, + {Path: "api/external/kafka/producer/events/schemas/events.json", Content: []byte(`{"type":"object"}`), Mode: 0o644}, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{ + Export: "events", Topic: "upstream_service.domain.events.v1", Format: "json", + }, + }) + + require.NoError(t, err) + require.Equal(t, "schemas/events.json", snapshot.Entrypoint) + require.Equal(t, &contract.Metadata{ + Kind: "kafka", Topic: "upstream_service.domain.events.v1", Format: "json", Entrypoint: "schemas/events.json", + }, snapshot.Metadata) + require.Equal(t, []string{"schemas/events.json"}, snapshotPaths(snapshot)) +} + +func TestDevctlReexportsCommittedProtoKafkaSnapshotOffline(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + client := mocks.NewMockGitClient(ctrl) + manifests := mocks.NewMockManifestRepository(ctrl) + reader := mocks.NewMockFileReader(ctrl) + client.EXPECT().WithCheckout(gomock.Any(), "repo", "v1", gomock.Any()).DoAndReturn( + func(_ context.Context, _, _ string, use func(string) error) error { return use("/checkout") }, + ) + const topic = "upstream_service.domain.events.v1" + manifests.EXPECT().Load(gomock.Any(), "/checkout/devctl.yaml").Return(project.LoadManifestResult{Project: project.Project{Manifest: project.Manifest{ + Sources: map[string]project.Source{"contracts": {Type: project.SourceDevctl, Repo: "unavailable", Ref: "v1"}}, + Exports: map[string]project.Export{"events": {Kind: "kafka", Producer: "events"}}, + Components: project.Components{Kafka: &project.Kafka{Producers: []project.KafkaProducer{{ + Name: "events", Topic: topic, Contract: project.KafkaContract{ + Format: "proto", Source: "contracts", Export: "upstream-events", + }, + }}}}, + }}}, nil) + const treeRoot = "api/external/kafka/producer/events" + metadata := `{"kind":"kafka","topic":"upstream_service.domain.events.v1","format":"proto","entrypoint":"proto/events.proto","module_root":"proto","buf_config":"buf.yaml"}` + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", treeRoot+"/.devctl-contract.json").Return(contract.File{ + Content: []byte(metadata), + }, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", treeRoot+"/proto/events.proto").Return(contract.File{}, nil) + reader.EXPECT().ReadFile(gomock.Any(), "/checkout", treeRoot+"/buf.yaml").Return(contract.File{}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", treeRoot+"/proto").Return([]contract.File{{ + Path: treeRoot + "/proto/events.proto", + }}, nil) + reader.EXPECT().ReadTree(gomock.Any(), "/checkout", treeRoot).Return([]contract.File{ + {Path: treeRoot + "/.devctl-contract.json", Content: []byte(metadata), Mode: 0o644}, + {Path: treeRoot + "/proto/events.proto", Content: []byte("syntax = \"proto3\";\n"), Mode: 0o644}, + {Path: treeRoot + "/buf.yaml", Content: []byte("version: v2\n"), Mode: 0o644}, + {Path: treeRoot + "/buf.lock", Content: []byte("deps: []\n"), Mode: 0o644}, + }, nil) + + snapshot, err := materialize.NewDevctl(client, manifests, reader).Materialize(context.Background(), materializedomain.Request{ + Source: project.Source{Type: project.SourceDevctl, Repo: "repo", Ref: "v1"}, + Reference: contract.Reference{ + Export: "events", Topic: topic, Format: "proto", + }, + }) + + require.NoError(t, err) + require.Equal(t, "proto", snapshot.ModuleRoot) + require.Equal(t, "proto/events.proto", snapshot.Entrypoint) + require.Equal(t, []string{"buf.lock", "buf.yaml", "proto/events.proto"}, snapshotPaths(snapshot)) + require.Equal(t, "buf.yaml", snapshot.Metadata.BufConfig) +} diff --git a/internal/service/materialize/url.go b/internal/service/materialize/url.go new file mode 100644 index 0000000..c384498 --- /dev/null +++ b/internal/service/materialize/url.go @@ -0,0 +1,237 @@ +package materialize + +import ( + "context" + "fmt" + "net/url" + "path" + "strings" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + "github.com/devctllabs/devctl/internal/domain/project" +) + +const ( + maxURLDocuments = 64 + maxURLResponseSize = 32 << 20 + maxURLSnapshotSize = 64 << 20 +) + +// URLService materializes a bounded relative-reference closure from a URL Source. +type URLService struct { + client HTTPClient +} + +func NewURL(client HTTPClient) *URLService { return &URLService{client: client} } + +func (s *URLService) SourceType() project.SourceType { return project.SourceURL } + +func (s *URLService) Materialize(ctx context.Context, request materializedomain.Request) (contract.Snapshot, error) { + closure, err := newURLClosure(s.client, request) + if err != nil { + return contract.Snapshot{}, err + } + return closure.materialize(ctx) +} + +type pendingURLDocument struct { + fetchURL string + virtualPath string +} + +type urlClosure struct { + client HTTPClient + source project.Source + entrypoint string + virtualRoot string + queue []pendingURLDocument + seen map[string]struct{} + virtualOwners map[string]string + files []contract.File + aggregateSize int +} + +func newURLClosure(client HTTPClient, request materializedomain.Request) (*urlClosure, error) { + if err := validateSourceURL(request.Source); err != nil { + return nil, err + } + entrypoint := request.Reference.Entrypoint + if entrypoint == "" { + entrypoint = request.Source.Filename + } + if entrypoint == "" { + entrypoint = "openapi.yaml" + } + if !safeRelative(entrypoint) { + return nil, &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: entrypoint, Kind: materializedomain.FailureInvalid} + } + entrypoint = path.Clean(entrypoint) + initial := pendingURLDocument{fetchURL: request.Source.URL, virtualPath: entrypoint} + return &urlClosure{ + client: client, source: request.Source, entrypoint: entrypoint, + virtualRoot: path.Dir(entrypoint), + queue: []pendingURLDocument{initial}, + seen: make(map[string]struct{}), + virtualOwners: map[string]string{ + entrypoint: fetchIdentity(initial.fetchURL), + }, + files: make([]contract.File, 0, 1), + }, nil +} + +func (c *urlClosure) materialize(ctx context.Context) (contract.Snapshot, error) { + for len(c.queue) > 0 { + current := c.pop() + identity := fetchIdentity(current.fetchURL) + if _, exists := c.seen[identity]; exists { + continue + } + if err := c.fetch(ctx, current, identity); err != nil { + return contract.Snapshot{}, err + } + } + return newSnapshot(c.entrypoint, c.files) +} + +func (c *urlClosure) pop() pendingURLDocument { + current := c.queue[0] + c.queue = c.queue[1:] + return current +} + +func (c *urlClosure) fetch(ctx context.Context, current pendingURLDocument, identity string) error { + if len(c.seen) >= maxURLDocuments { + return &materializedomain.OperationError{Operation: materializedomain.OperationBuildSnapshot, SourceType: project.SourceURL, Path: current.virtualPath, Kind: materializedomain.FailureInvalid} + } + document, err := c.client.Fetch(ctx, materializedomain.HTTPFetchRequest{ + URL: current.fetchURL, OriginURL: c.source.URL, + AllowInsecureHTTP: c.source.AllowInsecureHTTP, + }) + if err != nil { + operationErr := &materializedomain.OperationError{Operation: materializedomain.OperationDownload, SourceType: project.SourceURL, Path: redactedURL(current.fetchURL), Kind: downloadFailureKind(err), Cause: err} + return fmt.Errorf("client.Fetch: %w", operationErr) + } + if err := c.add(current, document); err != nil { + return err + } + c.seen[identity] = struct{}{} + return c.enqueueReferences(current, document) +} + +func (c *urlClosure) add(current pendingURLDocument, document materializedomain.HTTPDocument) error { + if len(document.Content) > maxURLResponseSize || c.aggregateSize+len(document.Content) > maxURLSnapshotSize { + return &materializedomain.OperationError{Operation: materializedomain.OperationBuildSnapshot, SourceType: project.SourceURL, Path: current.virtualPath, Kind: materializedomain.FailureInvalid} + } + c.aggregateSize += len(document.Content) + c.files = append(c.files, contract.File{Path: current.virtualPath, Content: document.Content, Mode: 0o644}) + return nil +} + +func (c *urlClosure) enqueueReferences(current pendingURLDocument, document materializedomain.HTTPDocument) error { + base, err := url.Parse(document.URL) + if err != nil { + return &materializedomain.OperationError{Operation: materializedomain.OperationDownload, SourceType: project.SourceURL, Path: redactedURL(current.fetchURL), Kind: materializedomain.FailureUnavailable, Cause: err} + } + for _, rawReference := range collectReferences(document.Content) { + resolved, include, resolveErr := c.resolveReference(current, base, rawReference) + if resolveErr != nil { + return resolveErr + } + if include { + if enqueueErr := c.enqueue(resolved); enqueueErr != nil { + return enqueueErr + } + } + } + return nil +} + +func (c *urlClosure) enqueue(document pendingURLDocument) error { + identity := fetchIdentity(document.fetchURL) + if owner, exists := c.virtualOwners[document.virtualPath]; exists && owner != identity { + return &materializedomain.OperationError{Operation: materializedomain.OperationBuildSnapshot, SourceType: project.SourceURL, Path: document.virtualPath, Kind: materializedomain.FailureInvalid} + } + c.virtualOwners[document.virtualPath] = identity + c.queue = append(c.queue, document) + return nil +} + +func (c *urlClosure) resolveReference(current pendingURLDocument, base *url.URL, rawReference string) (pendingURLDocument, bool, error) { + reference, err := url.Parse(rawReference) + if err != nil { + return pendingURLDocument{}, false, &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: redactedURL(rawReference), Kind: materializedomain.FailureInvalid, Cause: err} + } + if reference.IsAbs() || reference.Host != "" || strings.HasPrefix(reference.Path, "/") { + return pendingURLDocument{}, false, nil + } + reference.Fragment = "" + if reference.Path == "" && reference.RawQuery == "" { + return pendingURLDocument{}, false, nil + } + virtualPath := current.virtualPath + if reference.Path != "" { + virtualPath = path.Clean(path.Join(path.Dir(current.virtualPath), reference.Path)) + } + if !withinVirtualRoot(c.virtualRoot, virtualPath) { + return pendingURLDocument{}, false, &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: redactedURL(rawReference), Kind: materializedomain.FailureInvalid} + } + return pendingURLDocument{fetchURL: base.ResolveReference(reference).String(), virtualPath: virtualPath}, true, nil +} + +func downloadFailureKind(err error) materializedomain.FailureKind { + switch failure.CategoryOf(err) { + case failure.InvalidInput: + return materializedomain.FailureInvalid + case failure.NotFound: + return materializedomain.FailureNotFound + case failure.Unsupported: + return materializedomain.FailureUnsupported + case failure.Conflict, failure.Unavailable, failure.Cancelled, failure.Internal: + return materializedomain.FailureUnavailable + } + return materializedomain.FailureUnavailable +} + +func redactedURL(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return "" + } + parsed.User = nil + parsed.RawQuery = "" + parsed.ForceQuery = false + parsed.Fragment = "" + return parsed.String() +} + +func withinVirtualRoot(root, name string) bool { + if !safeRelative(name) { + return false + } + return root == "." || strings.HasPrefix(name, root+"/") +} + +func fetchIdentity(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + parsed.Fragment = "" + return parsed.String() +} + +func validateSourceURL(source project.Source) error { + parsed, err := url.Parse(source.URL) + if err != nil || parsed.Host == "" { + return &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: redactedURL(source.URL), Kind: materializedomain.FailureInvalid} + } + if parsed.User != nil { + return &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: redactedURL(source.URL), Kind: materializedomain.FailureInvalid} + } + if parsed.Scheme != "https" && (parsed.Scheme != "http" || !source.AllowInsecureHTTP) { + return &materializedomain.OperationError{Operation: materializedomain.OperationValidateSource, SourceType: project.SourceURL, Path: redactedURL(source.URL), Kind: materializedomain.FailureInvalid} + } + return nil +} diff --git a/internal/service/project/errors.go b/internal/service/project/errors.go new file mode 100644 index 0000000..a8ae659 --- /dev/null +++ b/internal/service/project/errors.go @@ -0,0 +1,24 @@ +package project + +import ( + "context" + "errors" + "io/fs" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +func projectOperationError(operation projectdomain.Operation, path string, kind projectdomain.FailureKind, cause error) error { + return &projectdomain.OperationError{Operation: operation, Path: path, Kind: kind, Cause: cause} +} + +func manifestAccessFailure(err error) projectdomain.FailureKind { + switch { + case errors.Is(err, fs.ErrNotExist): + return projectdomain.FailureNotFound + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + return projectdomain.FailureUnavailable + default: + return projectdomain.FailureUnavailable + } +} diff --git a/internal/service/project/init.go b/internal/service/project/init.go new file mode 100644 index 0000000..6441fdc --- /dev/null +++ b/internal/service/project/init.go @@ -0,0 +1,89 @@ +package project + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path/filepath" + "reflect" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +// InitManifest creates a canonical manifest, preserves identical content, and requires Force to replace a conflict. +func (s *Service) InitManifest(ctx context.Context, command projectdomain.InitManifestCommand) (projectdomain.ManifestResult, error) { + if err := validateInitManifest(command); err != nil { + return projectdomain.ManifestResult{}, err + } + destination, err := s.manifestDestination(ctx, command.Destination) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationInitManifest, command.Destination, projectdomain.FailureUnavailable, err) + return projectdomain.ManifestResult{}, fmt.Errorf("s.manifestDestination: %w", operationErr) + } + desired := projectdomain.Project{ + Root: filepath.Dir(destination), ManifestPath: destination, Manifest: initialManifest(command), + } + existing, loadErr := s.manifests.Load(ctx, destination) + existed := loadErr == nil + switch { + case loadErr == nil && len(existing.Issues) == 0 && reflect.DeepEqual(existing.Project.Manifest, desired.Manifest): + case loadErr == nil && !command.Force: + return projectdomain.ManifestResult{Manifest: destination}, projectOperationError(projectdomain.OperationInitManifest, destination, projectdomain.FailureConflict, errors.New("different content")) + case loadErr != nil && !errors.Is(loadErr, fs.ErrNotExist): + operationErr := projectOperationError(projectdomain.OperationLoadManifest, destination, projectdomain.FailureUnavailable, loadErr) + return projectdomain.ManifestResult{Manifest: destination}, fmt.Errorf("manifests.Load: %w", operationErr) + } + changed, err := s.manifests.Save(ctx, desired) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationSaveManifest, destination, projectdomain.FailureUnavailable, err) + return projectdomain.ManifestResult{Manifest: destination}, fmt.Errorf("manifests.Save: %w", operationErr) + } + action := projectdomain.ChangeUnchanged + if !existed { + action = projectdomain.ChangeCreated + } else if changed { + action = projectdomain.ChangeUpdated + } + return projectdomain.ManifestResult{Manifest: destination, Change: action}, nil +} + +func validateInitManifest(command projectdomain.InitManifestCommand) error { + if command.Language != "go" || (command.Preset != "cli" && command.Preset != "http-service") || !kebabCase.MatchString(command.Name) || command.Module == "" { + return projectOperationError(projectdomain.OperationInitManifest, command.Destination, projectdomain.FailureInvalid, errors.New("invalid command")) + } + return nil +} + +func (s *Service) manifestDestination(ctx context.Context, selected string) (string, error) { + if selected != "" && filepath.IsAbs(selected) { + return filepath.Clean(selected), nil + } + directory, err := s.locator.WorkingDirectory(ctx) + if err != nil { + return "", fmt.Errorf("workspace.WorkingDirectory: %w", err) + } + if selected == "" { + selected = manifestFilename + } + return filepath.Join(directory, selected), nil +} + +func initialManifest(command projectdomain.InitManifestCommand) projectdomain.Manifest { + manifest := projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: command.Name, Language: "go"}, + Env: projectdomain.Env{}, Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{}, Exports: map[string]projectdomain.Export{}, + Components: projectdomain.Components{Logging: &projectdomain.Logging{Env: projectdomain.ComponentEnv{System: []projectdomain.EnvVar{{Key: "LOG_LEVEL", Type: "string", Default: "info"}}}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: command.Module}}, + } + if command.Preset == "http-service" { + trueValue, falseValue := true, false + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{OpenAPI: "api/openapi/swagger.yaml", Start: &projectdomain.Start{Env: "HTTP_SERVER_ENABLED", Default: &trueValue}}, Env: projectdomain.ComponentEnv{System: []projectdomain.EnvVar{{Key: "HTTP_ADDR", Type: "string", Default: ":8080"}}}} + manifest.Components.Health = &projectdomain.Health{Server: &projectdomain.HealthServer{Start: &projectdomain.Start{Env: "HEALTH_SERVER_ENABLED", Default: &trueValue}}, Env: projectdomain.ComponentEnv{System: []projectdomain.EnvVar{{Key: "HEALTH_ADDR", Type: "string", Default: ":8081"}}}} + manifest.Components.Telemetry = &projectdomain.Telemetry{Start: &projectdomain.Start{Env: "TELEMETRY_ENABLED", Default: &falseValue}} + manifest.Languages.Go.Components.Pprof = &projectdomain.Pprof{Server: &projectdomain.PprofServer{Start: &projectdomain.Start{Env: "PPROF_ENABLED", Default: &falseValue}}, Env: projectdomain.ComponentEnv{System: []projectdomain.EnvVar{{Key: "PPROF_ADDR", Type: "string", Default: "127.0.0.1:6060"}}}} + manifest.Languages.Go.Generators.HTTP = &projectdomain.HTTPGenerator{OAPIConfig: "tools/oapi/server.yaml", ServerOut: "gen/serverhttp", ClientOut: "gen/clienthttp"} + } + return manifest +} diff --git a/internal/service/project/inspect.go b/internal/service/project/inspect.go new file mode 100644 index 0000000..91ff79c --- /dev/null +++ b/internal/service/project/inspect.go @@ -0,0 +1,140 @@ +package project + +import ( + "context" + "fmt" + "path" + "sort" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +// Inspect returns an effective project view with defaults applied to a valid selected manifest. +func (s *Service) Inspect(ctx context.Context, query projectdomain.InspectQuery) (projectdomain.InspectResult, error) { + selected, err := s.loadValidProject(ctx, query.ManifestPath) + if err != nil { + return projectdomain.InspectResult{}, err + } + targets := projectdomain.NewTargetCatalog(selected.Manifest).All() + inspection, err := inspectProject(selected, targets) + if err != nil { + return projectdomain.InspectResult{}, err + } + inspection.Targets = s.inspectionTargets(ctx, selected, targets) + return projectdomain.InspectResult{Project: inspection}, nil +} + +func inspectProject(selected projectdomain.Project, targets []projectdomain.Target) (projectdomain.Inspection, error) { + manifest := selected.Manifest + catalog, err := projectdomain.NewRuntimeConfigCatalog(manifest) + if err != nil { + return projectdomain.Inspection{}, fmt.Errorf("project.NewRuntimeConfigCatalog: %w", err) + } + return projectdomain.Inspection{ + Root: selected.Root, ManifestPath: selected.ManifestPath, + Name: manifest.Project.Name, Language: manifest.Project.Language, + Module: manifest.Languages.Go.Module, EnvPrefix: catalog.Prefix(), + Paths: effectivePaths(manifest), Targets: inspectionTargets(targets), + Env: effectiveEnv(catalog), Resources: effectiveResources(manifest, catalog.Prefix()), + }, nil +} + +func effectivePaths(manifest projectdomain.Manifest) projectdomain.Paths { + paths := projectdomain.Paths{ExternalContracts: "api/external", ConfigOut: "gen/config", ServerOut: "gen/serverhttp", ClientOut: "gen/clienthttp"} + if manifest.Paths.ExternalContracts != "" { + paths.ExternalContracts = manifest.Paths.ExternalContracts + } + for _, target := range projectdomain.NewTargetCatalog(manifest).All() { + switch { + case target.ID == "config": + paths.ConfigOut = target.OutputDir + case target.ID == "http-server": + paths.ServerOut = target.OutputDir + case target.Family == "http" && target.Role == "client": + paths.ClientOut = path.Dir(target.OutputDir) + } + } + return paths +} + +func inspectionTargets(targets []projectdomain.Target) []projectdomain.InspectionTarget { + result := make([]projectdomain.InspectionTarget, len(targets)) + for index, target := range targets { + result[index] = projectdomain.InspectionTarget{ + ID: target.ID, Family: target.Family, Format: target.Format, + Input: target.Input, Config: target.Config, Output: target.OutputDir, + } + } + return result +} + +func (s *Service) inspectionTargets( + ctx context.Context, + selected projectdomain.Project, + targets []projectdomain.Target, +) []projectdomain.InspectionTarget { + result := inspectionTargets(targets) + for index, target := range targets { + if target.Source.Type != projectdomain.SourceDevctl || target.Family != "grpc" && target.Family != "kafka" { + continue + } + resolved, err := s.inputs.Resolve(ctx, selected, target) + if err == nil { + result[index].ResolvedInput = resolved.Input + } + } + return result +} + +func effectiveResources(manifest projectdomain.Manifest, envPrefix string) projectdomain.InspectionResources { + resources := projectdomain.InspectionResources{} + appendDBResources(&resources, manifest.Components.DB, envPrefix) + if manifest.Components.Redis != nil { + for _, value := range manifest.Components.Redis.Connections { + resources.RedisConnections = append(resources.RedisConnections, value.Name) + } + } + if manifest.Components.S3 != nil { + for _, value := range manifest.Components.S3.Connections { + resources.S3Connections = append(resources.S3Connections, value.Name) + } + for _, value := range manifest.Components.S3.Buckets { + resources.S3Buckets = append(resources.S3Buckets, value.Name) + } + } + sort.Strings(resources.DBConnections) + sort.Strings(resources.RedisConnections) + sort.Strings(resources.S3Connections) + sort.Strings(resources.S3Buckets) + sort.Slice(resources.Migrations, func(i, j int) bool { + left, right := resources.Migrations[i], resources.Migrations[j] + if left.Connection != right.Connection { + return left.Connection < right.Connection + } + return left.Variant < right.Variant + }) + return resources +} + +func appendDBResources(resources *projectdomain.InspectionResources, database *projectdomain.DB, envPrefix string) { + if database == nil { + return + } + for _, connection := range database.Connections { + resources.DBConnections = append(resources.DBConnections, connection.Name) + appendMigrations(resources, connection, envPrefix) + } +} + +func appendMigrations(resources *projectdomain.InspectionResources, connection projectdomain.DBConnection, envPrefix string) { + for _, variant := range connection.Variants { + migrations := variant.Migrations + if migrations == nil { + continue + } + resources.Migrations = append(resources.Migrations, projectdomain.InspectionMigration{ + Connection: connection.Name, Variant: variant.Name, Kind: variant.Kind, + Path: migrations.Path, DatabaseEnv: envPrefix + migrations.DatabaseEnv, + }) + } +} diff --git a/internal/service/project/inspect_env.go b/internal/service/project/inspect_env.go new file mode 100644 index 0000000..5f243d0 --- /dev/null +++ b/internal/service/project/inspect_env.go @@ -0,0 +1,18 @@ +package project + +import projectdomain "github.com/devctllabs/devctl/internal/domain/project" + +func effectiveEnv(catalog projectdomain.RuntimeConfigCatalog) []projectdomain.EffectiveEnv { + fields := catalog.Entries(projectdomain.RuntimeConfigInspect) + result := make([]projectdomain.EffectiveEnv, len(fields)) + for index, field := range fields { + var defaultValue any + if field.HasDefault && !field.Secret { + defaultValue = field.Default + } + result[index] = projectdomain.EffectiveEnv{ + Key: field.Key, Type: string(field.Type), Default: defaultValue, Secret: field.Secret, + } + } + return result +} diff --git a/internal/service/project/load.go b/internal/service/project/load.go new file mode 100644 index 0000000..94a75f9 --- /dev/null +++ b/internal/service/project/load.go @@ -0,0 +1,101 @@ +package project + +import ( + "context" + "fmt" + "io/fs" + "path/filepath" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +func (s *Service) loadValidProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) { + selectedPath, err := s.resolveManifestPath(ctx, manifestPath) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationLoadManifest, manifestPath, manifestAccessFailure(err), err) + return projectdomain.Project{}, fmt.Errorf("s.resolveManifestPath: %w", operationErr) + } + loaded, err := s.manifests.Load(ctx, selectedPath) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationLoadManifest, selectedPath, manifestAccessFailure(err), err) + return projectdomain.Project{}, fmt.Errorf("manifests.Load: %w", operationErr) + } + if len(loaded.Issues) > 0 { + path := selectedManifestPath(loaded.Project.ManifestPath, selectedPath) + return projectdomain.Project{}, &projectdomain.InvalidManifestError{ + Path: path, + Issues: validationIssues(path, loaded.Issues), + } + } + issues := projectdomain.Validate(loaded.Project) + if len(issues) > 0 { + return projectdomain.Project{}, &projectdomain.InvalidManifestError{Path: loaded.Project.ManifestPath, Issues: issues} + } + return loaded.Project, nil +} + +func (s *Service) resolveManifestPath(ctx context.Context, selected string) (string, error) { + if filepath.IsAbs(selected) { + return filepath.Clean(selected), nil + } + directory, err := s.locator.WorkingDirectory(ctx) + if err != nil { + return "", fmt.Errorf("workspace.WorkingDirectory: %w", err) + } + if selected != "" { + return filepath.Join(directory, selected), nil + } + for { + exists, err := s.locator.RegularFile(ctx, directory, manifestFilename) + if err != nil { + return "", fmt.Errorf("workspace.RegularFile: %w", err) + } + if exists { + return filepath.Join(directory, manifestFilename), nil + } + parent := filepath.Dir(directory) + if parent == directory { + return "", fmt.Errorf("%s not found: %w", manifestFilename, fs.ErrNotExist) + } + directory = parent + } +} + +// LoadProject resolves a structurally and semantically valid project for other +// capability services. Readiness checks remain exclusive to Validate. +func (s *Service) LoadProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) { + return s.loadValidProject(ctx, manifestPath) +} + +func validationIssues(path string, documentIssues []projectdomain.DecodeIssue) []projectdomain.Issue { + issues := make([]projectdomain.Issue, 0, len(documentIssues)) + for _, documentIssue := range documentIssues { + issues = append(issues, projectdomain.Issue{ + Code: documentIssueCode(documentIssue.Kind), Path: path, Field: documentIssue.Field, + Line: documentIssue.Line, Column: documentIssue.Column, + }) + } + return issues +} + +func documentIssueCode(kind projectdomain.DecodeIssueKind) projectdomain.IssueCode { + switch kind { + case projectdomain.DecodeYAMLInvalid: + return projectdomain.IssueYAMLInvalid + case projectdomain.DecodeSchemaInvalid: + return projectdomain.IssueSchemaInvalid + case projectdomain.DecodeDuplicateKey: + return projectdomain.IssueYAMLDuplicateKey + case projectdomain.DecodeUnknownField: + return projectdomain.IssueSchemaUnknownField + default: + return projectdomain.IssueSchemaInvalid + } +} + +func selectedManifestPath(selectedPath, requestedPath string) string { + if selectedPath != "" { + return selectedPath + } + return requestedPath +} diff --git a/internal/service/project/mocks/service.go b/internal/service/project/mocks/service.go new file mode 100644 index 0000000..7d986b7 --- /dev/null +++ b/internal/service/project/mocks/service.go @@ -0,0 +1,348 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/project (interfaces: ManifestRepository,ManifestLocator,TargetResolver,ReadinessChecker) +// +// Generated by this command: +// +// mockgen -destination mocks/service.go -package mocks -typed . ManifestRepository,ManifestLocator,TargetResolver,ReadinessChecker +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockManifestRepository is a mock of ManifestRepository interface. +type MockManifestRepository struct { + ctrl *gomock.Controller + recorder *MockManifestRepositoryMockRecorder + isgomock struct{} +} + +// MockManifestRepositoryMockRecorder is the mock recorder for MockManifestRepository. +type MockManifestRepositoryMockRecorder struct { + mock *MockManifestRepository +} + +// NewMockManifestRepository creates a new mock instance. +func NewMockManifestRepository(ctrl *gomock.Controller) *MockManifestRepository { + mock := &MockManifestRepository{ctrl: ctrl} + mock.recorder = &MockManifestRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManifestRepository) EXPECT() *MockManifestRepositoryMockRecorder { + return m.recorder +} + +// Load mocks base method. +func (m *MockManifestRepository) Load(ctx context.Context, manifestPath string) (project.LoadManifestResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", ctx, manifestPath) + ret0, _ := ret[0].(project.LoadManifestResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Load indicates an expected call of Load. +func (mr *MockManifestRepositoryMockRecorder) Load(ctx, manifestPath any) *MockManifestRepositoryLoadCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockManifestRepository)(nil).Load), ctx, manifestPath) + return &MockManifestRepositoryLoadCall{Call: call} +} + +// MockManifestRepositoryLoadCall wrap *gomock.Call +type MockManifestRepositoryLoadCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockManifestRepositoryLoadCall) Return(arg0 project.LoadManifestResult, arg1 error) *MockManifestRepositoryLoadCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockManifestRepositoryLoadCall) Do(f func(context.Context, string) (project.LoadManifestResult, error)) *MockManifestRepositoryLoadCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockManifestRepositoryLoadCall) DoAndReturn(f func(context.Context, string) (project.LoadManifestResult, error)) *MockManifestRepositoryLoadCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Save mocks base method. +func (m *MockManifestRepository) Save(ctx context.Context, arg1 project.Project) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Save", ctx, arg1) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Save indicates an expected call of Save. +func (mr *MockManifestRepositoryMockRecorder) Save(ctx, arg1 any) *MockManifestRepositorySaveCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Save", reflect.TypeOf((*MockManifestRepository)(nil).Save), ctx, arg1) + return &MockManifestRepositorySaveCall{Call: call} +} + +// MockManifestRepositorySaveCall wrap *gomock.Call +type MockManifestRepositorySaveCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockManifestRepositorySaveCall) Return(arg0 bool, arg1 error) *MockManifestRepositorySaveCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockManifestRepositorySaveCall) Do(f func(context.Context, project.Project) (bool, error)) *MockManifestRepositorySaveCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockManifestRepositorySaveCall) DoAndReturn(f func(context.Context, project.Project) (bool, error)) *MockManifestRepositorySaveCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockManifestLocator is a mock of ManifestLocator interface. +type MockManifestLocator struct { + ctrl *gomock.Controller + recorder *MockManifestLocatorMockRecorder + isgomock struct{} +} + +// MockManifestLocatorMockRecorder is the mock recorder for MockManifestLocator. +type MockManifestLocatorMockRecorder struct { + mock *MockManifestLocator +} + +// NewMockManifestLocator creates a new mock instance. +func NewMockManifestLocator(ctrl *gomock.Controller) *MockManifestLocator { + mock := &MockManifestLocator{ctrl: ctrl} + mock.recorder = &MockManifestLocatorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockManifestLocator) EXPECT() *MockManifestLocatorMockRecorder { + return m.recorder +} + +// RegularFile mocks base method. +func (m *MockManifestLocator) RegularFile(ctx context.Context, root, relativePath string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegularFile", ctx, root, relativePath) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RegularFile indicates an expected call of RegularFile. +func (mr *MockManifestLocatorMockRecorder) RegularFile(ctx, root, relativePath any) *MockManifestLocatorRegularFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegularFile", reflect.TypeOf((*MockManifestLocator)(nil).RegularFile), ctx, root, relativePath) + return &MockManifestLocatorRegularFileCall{Call: call} +} + +// MockManifestLocatorRegularFileCall wrap *gomock.Call +type MockManifestLocatorRegularFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockManifestLocatorRegularFileCall) Return(arg0 bool, arg1 error) *MockManifestLocatorRegularFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockManifestLocatorRegularFileCall) Do(f func(context.Context, string, string) (bool, error)) *MockManifestLocatorRegularFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockManifestLocatorRegularFileCall) DoAndReturn(f func(context.Context, string, string) (bool, error)) *MockManifestLocatorRegularFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// WorkingDirectory mocks base method. +func (m *MockManifestLocator) WorkingDirectory(ctx context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WorkingDirectory", ctx) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// WorkingDirectory indicates an expected call of WorkingDirectory. +func (mr *MockManifestLocatorMockRecorder) WorkingDirectory(ctx any) *MockManifestLocatorWorkingDirectoryCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WorkingDirectory", reflect.TypeOf((*MockManifestLocator)(nil).WorkingDirectory), ctx) + return &MockManifestLocatorWorkingDirectoryCall{Call: call} +} + +// MockManifestLocatorWorkingDirectoryCall wrap *gomock.Call +type MockManifestLocatorWorkingDirectoryCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockManifestLocatorWorkingDirectoryCall) Return(arg0 string, arg1 error) *MockManifestLocatorWorkingDirectoryCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockManifestLocatorWorkingDirectoryCall) Do(f func(context.Context) (string, error)) *MockManifestLocatorWorkingDirectoryCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockManifestLocatorWorkingDirectoryCall) DoAndReturn(f func(context.Context) (string, error)) *MockManifestLocatorWorkingDirectoryCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockTargetResolver is a mock of TargetResolver interface. +type MockTargetResolver struct { + ctrl *gomock.Controller + recorder *MockTargetResolverMockRecorder + isgomock struct{} +} + +// MockTargetResolverMockRecorder is the mock recorder for MockTargetResolver. +type MockTargetResolverMockRecorder struct { + mock *MockTargetResolver +} + +// NewMockTargetResolver creates a new mock instance. +func NewMockTargetResolver(ctrl *gomock.Controller) *MockTargetResolver { + mock := &MockTargetResolver{ctrl: ctrl} + mock.recorder = &MockTargetResolverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTargetResolver) EXPECT() *MockTargetResolverMockRecorder { + return m.recorder +} + +// Resolve mocks base method. +func (m *MockTargetResolver) Resolve(ctx context.Context, selected project.Project, target project.Target) (project.Target, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Resolve", ctx, selected, target) + ret0, _ := ret[0].(project.Target) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Resolve indicates an expected call of Resolve. +func (mr *MockTargetResolverMockRecorder) Resolve(ctx, selected, target any) *MockTargetResolverResolveCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockTargetResolver)(nil).Resolve), ctx, selected, target) + return &MockTargetResolverResolveCall{Call: call} +} + +// MockTargetResolverResolveCall wrap *gomock.Call +type MockTargetResolverResolveCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockTargetResolverResolveCall) Return(arg0 project.Target, arg1 error) *MockTargetResolverResolveCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockTargetResolverResolveCall) Do(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockTargetResolverResolveCall) DoAndReturn(f func(context.Context, project.Project, project.Target) (project.Target, error)) *MockTargetResolverResolveCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockReadinessChecker is a mock of ReadinessChecker interface. +type MockReadinessChecker struct { + ctrl *gomock.Controller + recorder *MockReadinessCheckerMockRecorder + isgomock struct{} +} + +// MockReadinessCheckerMockRecorder is the mock recorder for MockReadinessChecker. +type MockReadinessCheckerMockRecorder struct { + mock *MockReadinessChecker +} + +// NewMockReadinessChecker creates a new mock instance. +func NewMockReadinessChecker(ctrl *gomock.Controller) *MockReadinessChecker { + mock := &MockReadinessChecker{ctrl: ctrl} + mock.recorder = &MockReadinessCheckerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockReadinessChecker) EXPECT() *MockReadinessCheckerMockRecorder { + return m.recorder +} + +// Check mocks base method. +func (m *MockReadinessChecker) Check(ctx context.Context, selected project.Project) ([]project.Issue, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Check", ctx, selected) + ret0, _ := ret[0].([]project.Issue) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Check indicates an expected call of Check. +func (mr *MockReadinessCheckerMockRecorder) Check(ctx, selected any) *MockReadinessCheckerCheckCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Check", reflect.TypeOf((*MockReadinessChecker)(nil).Check), ctx, selected) + return &MockReadinessCheckerCheckCall{Call: call} +} + +// MockReadinessCheckerCheckCall wrap *gomock.Call +type MockReadinessCheckerCheckCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockReadinessCheckerCheckCall) Return(arg0 []project.Issue, arg1 error) *MockReadinessCheckerCheckCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockReadinessCheckerCheckCall) Do(f func(context.Context, project.Project) ([]project.Issue, error)) *MockReadinessCheckerCheckCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockReadinessCheckerCheckCall) DoAndReturn(f func(context.Context, project.Project) ([]project.Issue, error)) *MockReadinessCheckerCheckCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/project/mutation.go b/internal/service/project/mutation.go new file mode 100644 index 0000000..6530f27 --- /dev/null +++ b/internal/service/project/mutation.go @@ -0,0 +1,848 @@ +package project + +import ( + "context" + "fmt" + "net" + "net/url" + "path" + "reflect" + "regexp" + "strconv" + "strings" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +var kebabCase = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) +var environmentKey = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +type mutation func(manifest *projectdomain.Manifest) error + +// Enable applies one capability mutation to a structurally and semantically valid manifest. +func (s *Service) Enable(ctx context.Context, command projectdomain.EnableCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { + return enableCapability(manifest, command) + }) +} + +// AddDB adds one database connection using the mutation command's conflict policy. +func (s *Service) AddDB(ctx context.Context, command projectdomain.AddDBCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { + return addDatabase(manifest, command) + }) +} + +// AddSource adds one contract source using the mutation command's conflict policy. +func (s *Service) AddSource(ctx context.Context, command projectdomain.AddSourceCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { + return addSource(manifest, command) + }) +} + +// AddHTTPClient adds one generated HTTP client using the mutation command's conflict policy. +func (s *Service) AddHTTPClient(ctx context.Context, command projectdomain.AddHTTPClientCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { + return addHTTPClient(manifest, command) + }) +} + +func (s *Service) AddGRPCClient(ctx context.Context, command projectdomain.AddGRPCClientCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addGRPCClient(manifest, command) }) +} + +func (s *Service) AddKafkaConsumer(ctx context.Context, command projectdomain.AddKafkaConsumerCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addKafkaConsumer(manifest, command) }) +} + +func (s *Service) AddKafkaProducer(ctx context.Context, command projectdomain.AddKafkaProducerCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addKafkaProducer(manifest, command) }) +} + +func (s *Service) AddRedis(ctx context.Context, command projectdomain.AddRedisCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addRedis(manifest, command) }) +} + +func (s *Service) AddS3Connection(ctx context.Context, command projectdomain.AddS3ConnectionCommand) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addS3Connection(manifest, command) }) +} + +func (s *Service) AddS3(ctx context.Context, command projectdomain.AddS3Command) (projectdomain.ManifestResult, error) { + return s.mutate(ctx, command.ManifestPath, func(manifest *projectdomain.Manifest) error { return addS3(manifest, command) }) +} + +func (s *Service) mutate(ctx context.Context, manifestPath string, apply mutation) (projectdomain.ManifestResult, error) { + selected, err := s.loadValidProject(ctx, manifestPath) + if err != nil { + return projectdomain.ManifestResult{}, fmt.Errorf("s.loadValidProject: %w", err) + } + before := cloneManifest(selected.Manifest) + if err := apply(&selected.Manifest); err != nil { + return projectdomain.ManifestResult{Manifest: selected.ManifestPath}, err + } + if reflect.DeepEqual(before, selected.Manifest) { + return projectdomain.ManifestResult{Manifest: selected.ManifestPath, Change: projectdomain.ChangeUnchanged}, nil + } + if _, err := s.manifests.Save(ctx, selected); err != nil { + operationErr := projectOperationError(projectdomain.OperationSaveManifest, selected.ManifestPath, projectdomain.FailureUnavailable, err) + return projectdomain.ManifestResult{Manifest: selected.ManifestPath}, fmt.Errorf("manifests.Save: %w", operationErr) + } + return projectdomain.ManifestResult{Manifest: selected.ManifestPath, Change: projectdomain.ChangeUpdated}, nil +} + +func cloneManifest(source projectdomain.Manifest) projectdomain.Manifest { + clone := source + clone.Sources = cloneMap(source.Sources) + clone.Exports = cloneMap(source.Exports) + clone.Env.Custom = append([]projectdomain.EnvGroup(nil), source.Env.Custom...) + clone.Components = cloneComponents(source.Components) + clone.Languages = cloneLanguages(source.Languages) + return clone +} + +func cloneMap[K comparable, V any](source map[K]V) map[K]V { + if source == nil { + return nil + } + clone := make(map[K]V, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} + +func cloneComponents(source projectdomain.Components) projectdomain.Components { + clone := source + clone.HTTP = cloneHTTP(source.HTTP) + clone.GRPC = cloneGRPC(source.GRPC) + clone.Kafka = cloneKafka(source.Kafka) + if source.Logging != nil { + value := *source.Logging + clone.Logging = &value + } + clone.Health = cloneHealth(source.Health) + if source.Telemetry != nil { + value := *source.Telemetry + value.Start = cloneStart(source.Telemetry.Start) + clone.Telemetry = &value + } + clone.DB = cloneDB(source.DB) + if source.Redis != nil { + redis := *source.Redis + redis.Connections = append([]projectdomain.RedisConnection(nil), source.Redis.Connections...) + clone.Redis = &redis + } + clone.S3 = cloneS3(source.S3) + return clone +} + +func cloneHTTP(source *projectdomain.HTTP) *projectdomain.HTTP { + if source == nil { + return nil + } + clone := *source + clone.Clients = append([]projectdomain.HTTPClient(nil), source.Clients...) + if source.Server != nil { + server := *source.Server + server.Start = cloneStart(source.Server.Start) + clone.Server = &server + } + return &clone +} + +func cloneGRPC(source *projectdomain.GRPC) *projectdomain.GRPC { + if source == nil { + return nil + } + clone := *source + clone.Clients = append([]projectdomain.GRPCClient(nil), source.Clients...) + if source.Server != nil { + server := *source.Server + server.Start = cloneStart(source.Server.Start) + clone.Server = &server + } + return &clone +} + +func cloneKafka(source *projectdomain.Kafka) *projectdomain.Kafka { + if source == nil { + return nil + } + clone := *source + clone.Consumers = append([]projectdomain.KafkaConsumer(nil), source.Consumers...) + for index := range clone.Consumers { + clone.Consumers[index].Start = cloneStart(clone.Consumers[index].Start) + } + clone.Producers = append([]projectdomain.KafkaProducer(nil), source.Producers...) + return &clone +} + +func cloneHealth(source *projectdomain.Health) *projectdomain.Health { + if source == nil { + return nil + } + clone := *source + if source.Server != nil { + server := *source.Server + server.Start = cloneStart(source.Server.Start) + clone.Server = &server + } + return &clone +} + +func cloneDB(source *projectdomain.DB) *projectdomain.DB { + if source == nil { + return nil + } + clone := *source + clone.Connections = append([]projectdomain.DBConnection(nil), source.Connections...) + for index := range clone.Connections { + clone.Connections[index].Variants = append([]projectdomain.DBVariant(nil), source.Connections[index].Variants...) + for variantIndex := range clone.Connections[index].Variants { + migrations := clone.Connections[index].Variants[variantIndex].Migrations + if migrations != nil { + migrationClone := *migrations + clone.Connections[index].Variants[variantIndex].Migrations = &migrationClone + } + } + } + return &clone +} + +func cloneS3(source *projectdomain.S3) *projectdomain.S3 { + if source == nil { + return nil + } + clone := *source + clone.Connections = append([]projectdomain.S3Connection(nil), source.Connections...) + clone.Buckets = append([]projectdomain.S3Bucket(nil), source.Buckets...) + return &clone +} + +func cloneLanguages(source projectdomain.Languages) projectdomain.Languages { + clone := source + if source.Go.Components.Pprof != nil { + pprof := *source.Go.Components.Pprof + if source.Go.Components.Pprof.Server != nil { + server := *source.Go.Components.Pprof.Server + server.Start = cloneStart(source.Go.Components.Pprof.Server.Start) + pprof.Server = &server + } + clone.Go.Components.Pprof = &pprof + } + return clone +} + +func cloneStart(source *projectdomain.Start) *projectdomain.Start { + if source == nil { + return nil + } + clone := *source + if source.Default != nil { + value := *source.Default + clone.Default = &value + } + return &clone +} + +func enableCapability(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + switch command.Capability { + case "http": + return enableHTTP(manifest, command) + case "grpc": + return enableGRPC(manifest, command) + case "logging": + if command.Always { + return invalidMutation(projectdomain.MutationUnsupportedOption, "always", "true") + } + if manifest.Components.Logging == nil { + manifest.Components.Logging = &projectdomain.Logging{} + } + return nil + case "health": + return enableHealth(manifest, command) + case "telemetry": + return enableTelemetry(manifest, command) + case "pprof": + return enablePprof(manifest, command) + default: + return invalidMutation(projectdomain.MutationUnsupportedValue, "capability", command.Capability) + } +} + +func enableGRPC(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + desired := desiredStart(command.Always, "GRPC_SERVER_ENABLED", true) + if manifest.Components.GRPC == nil { + manifest.Components.GRPC = &projectdomain.GRPC{} + } + if manifest.Components.GRPC.Server == nil { + manifest.Components.GRPC.Server = &projectdomain.GRPCServer{ + ProtoRoot: "api/proto/grpc", + BufConfig: "buf.yaml", + Start: desired, + } + } + if !reflect.DeepEqual(manifest.Components.GRPC.Server.Start, desired) && !command.Force { + return mutationConflict("components.grpc.server.start", "") + } + manifest.Components.GRPC.Server.Start = desired + if manifest.Languages.Go.Generators.GRPC == nil { + manifest.Languages.Go.Generators.GRPC = &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"} + } + return nil +} + +func desiredStart(always bool, environment string, defaultValue bool) *projectdomain.Start { + if always { + return nil + } + return &projectdomain.Start{Env: environment, Default: &defaultValue} +} + +func enableHTTP(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + desired := desiredStart(command.Always, "HTTP_SERVER_ENABLED", true) + if manifest.Components.HTTP == nil { + manifest.Components.HTTP = &projectdomain.HTTP{} + } + if manifest.Components.HTTP.Server == nil { + manifest.Components.HTTP.Server = &projectdomain.HTTPServer{OpenAPI: "api/openapi/swagger.yaml", Start: desired} + } + if !reflect.DeepEqual(manifest.Components.HTTP.Server.Start, desired) && !command.Force { + return mutationConflict("components.http.server.start", "") + } + manifest.Components.HTTP.Server.Start = desired + return nil +} + +func enableHealth(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + desired := desiredStart(command.Always, "HEALTH_SERVER_ENABLED", true) + if manifest.Components.Health == nil { + manifest.Components.Health = &projectdomain.Health{} + } + if manifest.Components.Health.Server == nil { + manifest.Components.Health.Server = &projectdomain.HealthServer{Start: desired} + } + if !reflect.DeepEqual(manifest.Components.Health.Server.Start, desired) && !command.Force { + return mutationConflict("components.health.server.start", "") + } + manifest.Components.Health.Server.Start = desired + return nil +} + +func enableTelemetry(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + desired := desiredStart(command.Always, "TELEMETRY_ENABLED", false) + if manifest.Components.Telemetry == nil { + manifest.Components.Telemetry = &projectdomain.Telemetry{Start: desired} + } + if !reflect.DeepEqual(manifest.Components.Telemetry.Start, desired) && !command.Force { + return mutationConflict("components.telemetry.start", "") + } + manifest.Components.Telemetry.Start = desired + return nil +} + +func enablePprof(manifest *projectdomain.Manifest, command projectdomain.EnableCommand) error { + desired := desiredStart(command.Always, "PPROF_ENABLED", false) + if manifest.Languages.Go.Components.Pprof == nil { + manifest.Languages.Go.Components.Pprof = &projectdomain.Pprof{} + } + if manifest.Languages.Go.Components.Pprof.Server == nil { + manifest.Languages.Go.Components.Pprof.Server = &projectdomain.PprofServer{Start: desired} + } + if !reflect.DeepEqual(manifest.Languages.Go.Components.Pprof.Server.Start, desired) && !command.Force { + return mutationConflict("languages.go.components.pprof.server.start", "") + } + manifest.Languages.Go.Components.Pprof.Server.Start = desired + return nil +} + +func addSource(manifest *projectdomain.Manifest, command projectdomain.AddSourceCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "source.name", command.Name) + } + desired := projectdomain.Source{Type: projectdomain.SourceType(command.Type), Path: command.Path, URL: command.URL, Filename: command.Filename, AllowInsecureHTTP: command.AllowInsecureHTTP, Repo: command.Repo, Ref: command.Ref, Proto: projectdomain.SourceProto{BufConfig: command.BufConfig}} + if err := validateSource(desired); err != nil { + return err + } + if current, exists := manifest.Sources[command.Name]; exists && !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("sources."+command.Name, command.Name) + } + if manifest.Sources == nil { + manifest.Sources = make(map[string]projectdomain.Source) + } + manifest.Sources[command.Name] = desired + return nil +} + +func validateSource(source projectdomain.Source) error { + if source.Proto.BufConfig != "" && !safeRelative(source.Proto.BufConfig) { + return invalidMutation(projectdomain.MutationInvalidOptions, "source.proto.buf_config", source.Proto.BufConfig) + } + switch source.Type { + case projectdomain.SourceLocal: + if !safeRelative(source.Path) || source.URL != "" || source.Repo != "" || source.Ref != "" || source.Filename != "" || source.AllowInsecureHTTP { + return invalidMutation(projectdomain.MutationInvalidOptions, "source", string(source.Type)) + } + case projectdomain.SourceURL: + return validateURLSource(source) + case projectdomain.SourceGit: + if source.Repo == "" || source.Ref == "" || (source.Path != "" && !safeRelative(source.Path)) || source.URL != "" || source.Filename != "" || source.AllowInsecureHTTP { + return invalidMutation(projectdomain.MutationInvalidOptions, "source", string(source.Type)) + } + case projectdomain.SourceDevctl: + if source.Repo == "" || source.Ref == "" || source.Path != "" || source.URL != "" || source.Filename != "" || source.AllowInsecureHTTP { + return invalidMutation(projectdomain.MutationInvalidOptions, "source", string(source.Type)) + } + default: + return invalidMutation(projectdomain.MutationUnsupportedValue, "source.type", string(source.Type)) + } + return nil +} + +func validateURLSource(source projectdomain.Source) error { + parsed, err := url.Parse(source.URL) + if err != nil || parsed.Host == "" || parsed.User != nil { + return invalidMutation(projectdomain.MutationInvalidURL, "source.url", source.URL) + } + if parsed.Scheme != "https" && (parsed.Scheme != "http" || !source.AllowInsecureHTTP) { + return invalidMutation(projectdomain.MutationInsecureURL, "source.url", source.URL) + } + if source.Path != "" || source.Repo != "" || source.Ref != "" { + return invalidMutation(projectdomain.MutationInvalidOptions, "source", string(source.Type)) + } + if source.Filename != "" && strings.Contains(source.Filename, "/") { + return invalidMutation(projectdomain.MutationInvalidOptions, "source.filename", source.Filename) + } + return nil +} + +func addDatabase(manifest *projectdomain.Manifest, command projectdomain.AddDBCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "database.name", command.Name) + } + if command.Kind != "sqlite" && command.Kind != "postgres" && command.Kind != "clickhouse" { + return invalidMutation(projectdomain.MutationUnsupportedValue, "database.kind", command.Kind) + } + if !validMigrationOptions(command) { + return invalidMutation(projectdomain.MutationInvalidOptions, "database.migrations", command.MigrationsPath) + } + if manifest.Components.DB == nil { + manifest.Components.DB = &projectdomain.DB{} + } + connection := findConnection(manifest.Components.DB, command.Name) + variant := defaultDBVariant(command) + if connection == nil { + manifest.Components.DB.Connections = append(manifest.Components.DB.Connections, projectdomain.DBConnection{Name: command.Name, Default: command.Kind, Variants: []projectdomain.DBVariant{variant}}) + return nil + } + addingClickHouseToExisting := command.Kind == "clickhouse" && len(connection.Variants) > 0 + addingTransactionalToClickHouse := command.Kind != "clickhouse" && connectionHasKind(connection, "clickhouse") + if addingClickHouseToExisting || addingTransactionalToClickHouse { + return invalidMutation(projectdomain.MutationInvalidOptions, "database.kind", command.Kind) + } + if err := upsertVariant(connection, variant, command.Force); err != nil { + return err + } + if command.Default || connection.Default == "" { + connection.Default = command.Kind + } + return nil +} + +func validMigrationOptions(command projectdomain.AddDBCommand) bool { + if command.NoMigrations && command.MigrationsPath != "" { + return false + } + return command.MigrationsPath == "" || safeRelative(command.MigrationsPath) +} + +func connectionHasKind(connection *projectdomain.DBConnection, kind string) bool { + for _, variant := range connection.Variants { + if variant.Kind == kind { + return true + } + } + return false +} + +func findConnection(database *projectdomain.DB, name string) *projectdomain.DBConnection { + for index := range database.Connections { + if database.Connections[index].Name == name { + return &database.Connections[index] + } + } + return nil +} + +func upsertVariant(connection *projectdomain.DBConnection, desired projectdomain.DBVariant, force bool) error { + for index, current := range connection.Variants { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !force { + return mutationConflict("database.variant", desired.Name) + } + connection.Variants[index] = desired + return nil + } + connection.Variants = append(connection.Variants, desired) + return nil +} + +func defaultDBVariant(command projectdomain.AddDBCommand) projectdomain.DBVariant { + upper := strings.ToUpper(strings.ReplaceAll(command.Name, "-", "_")) + kindUpper := strings.ToUpper(command.Kind) + variant := projectdomain.DBVariant{Name: command.Kind, Kind: command.Kind, DSNEnv: "DB_" + upper + "_" + kindUpper + "_DSN", Secret: command.Kind == "postgres" || command.Kind == "clickhouse"} + if command.Kind == "sqlite" { + variant.DSNDefault = "file:./data/" + command.Name + ".db?_foreign_keys=on" + } + if command.Kind == "clickhouse" { + variant.DSNDefault = "clickhouse://localhost:9000/default" + } + if !command.NoMigrations { + path := command.MigrationsPath + if path == "" { + path = "migrations/" + command.Name + "/" + command.Kind + } + variant.Migrations = &projectdomain.DBMigrations{Path: path, DatabaseEnv: "DB_" + upper + "_" + kindUpper + "_MIGRATIONS_URL"} + if command.Kind == "sqlite" { + variant.Migrations.DatabaseDefault = "sqlite://./data/" + command.Name + ".db?_pragma=foreign_keys%281%29" + } + } + return variant +} + +func addHTTPClient(manifest *projectdomain.Manifest, command projectdomain.AddHTTPClientCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "http_client.name", command.Name) + } + source, exists := manifest.Sources[command.Source] + if !exists { + return invalidMutation(projectdomain.MutationNotFound, "http_client.source", command.Source) + } + if source.Type == projectdomain.SourceDevctl && (command.Export == "" || command.Path != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "http_client", command.Name) + } + if source.Type != projectdomain.SourceDevctl && (command.Path == "" || command.Export != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "http_client", command.Name) + } + if manifest.Components.HTTP == nil { + manifest.Components.HTTP = &projectdomain.HTTP{} + } + desired := projectdomain.HTTPClient{Name: command.Name, Source: command.Source, Export: command.Export, Path: command.Path, BaseURLEnv: command.BaseURLEnv} + for index, current := range manifest.Components.HTTP.Clients { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("http_client", desired.Name) + } + manifest.Components.HTTP.Clients[index] = desired + return nil + } + manifest.Components.HTTP.Clients = append(manifest.Components.HTTP.Clients, desired) + return nil +} + +func addGRPCClient(manifest *projectdomain.Manifest, command projectdomain.AddGRPCClientCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "grpc_client.name", command.Name) + } + source, exists := manifest.Sources[command.Source] + if !exists { + return invalidMutation(projectdomain.MutationNotFound, "grpc_client.source", command.Source) + } + if source.Type == projectdomain.SourceDevctl && (command.Export == "" || command.Path != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "grpc_client", command.Name) + } + if source.Type != projectdomain.SourceDevctl && (command.Path == "" || command.Export != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "grpc_client", command.Name) + } + desired := projectdomain.GRPCClient{ + Name: command.Name, + Source: command.Source, + Export: command.Export, + Path: command.Path, + ProtoRoot: command.ProtoRoot, + BufGenConfig: command.BufGenConfig, + AddrEnv: command.AddrEnv, + } + if manifest.Components.GRPC == nil { + manifest.Components.GRPC = &projectdomain.GRPC{} + } + for index, current := range manifest.Components.GRPC.Clients { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("grpc_client", desired.Name) + } + manifest.Components.GRPC.Clients[index] = desired + return nil + } + manifest.Components.GRPC.Clients = append(manifest.Components.GRPC.Clients, desired) + if manifest.Languages.Go.Generators.GRPC == nil { + manifest.Languages.Go.Generators.GRPC = &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"} + } + return nil +} + +func kafkaContract(contract projectdomain.KafkaContract) projectdomain.KafkaContract { + if contract.Format == "" { + contract.Format = "raw" + } + if contract.Format == "proto" { + if contract.ProtoRoot == "" { + contract.ProtoRoot = path.Dir(contract.Path) + } + if contract.Encoding == "" { + contract.Encoding = "binary" + } + } + return contract +} + +func validateKafkaContract(manifest *projectdomain.Manifest, contract projectdomain.KafkaContract) error { + if contract.Format != "raw" && contract.Format != "json" && contract.Format != "proto" { + return invalidMutation(projectdomain.MutationUnsupportedValue, "kafka.format", contract.Format) + } + if contract.Format == "raw" && (contract.Source != "" || contract.Export != "" || contract.Path != "" || contract.ProtoRoot != "" || contract.Message != "" || contract.Encoding != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka.contract", contract.Format) + } + if contract.Format == "raw" { + return nil + } + source, exists := manifest.Sources[contract.Source] + if !exists { + return invalidMutation(projectdomain.MutationNotFound, "kafka.source", contract.Source) + } + if source.Type == projectdomain.SourceDevctl && (contract.Export == "" || contract.Path != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka.contract", contract.Format) + } + if source.Type != projectdomain.SourceDevctl && (contract.Path == "" || contract.Export != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka.contract", contract.Format) + } + if contract.Format == "json" && (contract.ProtoRoot != "" || contract.Message != "" || contract.Encoding != "") { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka.contract", contract.Format) + } + if contract.Format == "proto" && (contract.Encoding != "binary" && contract.Encoding != "json" || !pathWithin(contract.ProtoRoot, contract.Path)) { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka.contract", contract.Format) + } + return nil +} + +func addKafkaConsumer(manifest *projectdomain.Manifest, command projectdomain.AddKafkaConsumerCommand) error { + if !kebabCase.MatchString(command.Name) || command.Topic == "" { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka_consumer", command.Name) + } + contract := kafkaContract(projectdomain.KafkaContract{ + Source: command.Source, Export: command.Export, Path: command.Path, Format: command.Format, + ProtoRoot: command.ProtoRoot, Message: command.Message, Encoding: command.Encoding, + }) + if err := validateKafkaContract(manifest, contract); err != nil { + return err + } + desired := projectdomain.KafkaConsumer{ + Name: command.Name, Topic: command.Topic, GroupEnv: command.GroupEnv, + Start: desiredStart(command.Always, "KAFKA_"+strings.ToUpper(strings.ReplaceAll(command.Name, "-", "_"))+"_CONSUMER_ENABLED", false), + Contract: contract, + } + if manifest.Components.Kafka == nil { + manifest.Components.Kafka = &projectdomain.Kafka{} + } + for index, current := range manifest.Components.Kafka.Consumers { + if current.Name == desired.Name { + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("kafka_consumer", desired.Name) + } + manifest.Components.Kafka.Consumers[index] = desired + return nil + } + } + manifest.Components.Kafka.Consumers = append(manifest.Components.Kafka.Consumers, desired) + if contract.Format == "proto" && manifest.Languages.Go.Generators.Kafka == nil { + manifest.Languages.Go.Generators.Kafka = &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"} + } + return nil +} + +func addKafkaProducer(manifest *projectdomain.Manifest, command projectdomain.AddKafkaProducerCommand) error { + if !kebabCase.MatchString(command.Name) || command.Topic == "" { + return invalidMutation(projectdomain.MutationInvalidOptions, "kafka_producer", command.Name) + } + contract := kafkaContract(projectdomain.KafkaContract{ + Source: command.Source, Export: command.Export, Path: command.Path, Format: command.Format, + ProtoRoot: command.ProtoRoot, Message: command.Message, Encoding: command.Encoding, + }) + if err := validateKafkaContract(manifest, contract); err != nil { + return err + } + desired := projectdomain.KafkaProducer{ + Name: command.Name, Topic: command.Topic, TopicEnv: command.TopicEnv, Contract: contract, + } + if manifest.Components.Kafka == nil { + manifest.Components.Kafka = &projectdomain.Kafka{} + } + for index, current := range manifest.Components.Kafka.Producers { + if current.Name == desired.Name { + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("kafka_producer", desired.Name) + } + manifest.Components.Kafka.Producers[index] = desired + return nil + } + } + manifest.Components.Kafka.Producers = append(manifest.Components.Kafka.Producers, desired) + if contract.Format == "proto" && manifest.Languages.Go.Generators.Kafka == nil { + manifest.Languages.Go.Generators.Kafka = &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"} + } + return nil +} + +func addRedis(manifest *projectdomain.Manifest, command projectdomain.AddRedisCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "redis.name", command.Name) + } + addrEnv := command.AddrEnv + if addrEnv == "" { + addrEnv = "REDIS_" + strings.ToUpper(strings.ReplaceAll(command.Name, "-", "_")) + "_ADDR" + } + addrDefault := command.AddrDefault + if addrDefault == "" { + addrDefault = "localhost:6379" + } + if !environmentKey.MatchString(addrEnv) { + return invalidMutation(projectdomain.MutationInvalidOptions, "redis.addr_env", addrEnv) + } + if !validRedisAddress(addrDefault) { + return invalidMutation(projectdomain.MutationInvalidURL, "redis.addr_default", addrDefault) + } + desired := projectdomain.RedisConnection{Name: command.Name, AddrEnv: addrEnv, AddrDefault: addrDefault} + if manifest.Components.Redis == nil { + manifest.Components.Redis = &projectdomain.Redis{} + } + for index, current := range manifest.Components.Redis.Connections { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("redis", desired.Name) + } + manifest.Components.Redis.Connections[index] = desired + return nil + } + manifest.Components.Redis.Connections = append(manifest.Components.Redis.Connections, desired) + return nil +} + +func addS3Connection(manifest *projectdomain.Manifest, command projectdomain.AddS3ConnectionCommand) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "s3_connection.name", command.Name) + } + credentials := command.Credentials + if credentials == "" { + credentials = "ambient" + } + if credentials != "ambient" && credentials != "static" { + return invalidMutation(projectdomain.MutationUnsupportedValue, "s3_connection.credentials", credentials) + } + desired := projectdomain.S3Connection{Name: command.Name, Credentials: credentials} + if manifest.Components.S3 == nil { + manifest.Components.S3 = &projectdomain.S3{} + } + for index, current := range manifest.Components.S3.Connections { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("s3_connection", desired.Name) + } + manifest.Components.S3.Connections[index] = desired + return nil + } + manifest.Components.S3.Connections = append(manifest.Components.S3.Connections, desired) + return nil +} + +func addS3(manifest *projectdomain.Manifest, command projectdomain.AddS3Command) error { + if !kebabCase.MatchString(command.Name) { + return invalidMutation(projectdomain.MutationInvalidName, "s3.name", command.Name) + } + if manifest.Components.S3 == nil { + manifest.Components.S3 = &projectdomain.S3{} + } + connection := command.Connection + if connection == "" { + connection = "default" + if !hasS3Connection(manifest.Components.S3, connection) { + manifest.Components.S3.Connections = append(manifest.Components.S3.Connections, projectdomain.S3Connection{ + Name: connection, Credentials: "static", Endpoint: "http://localhost:9000", + Region: "us-east-1", PathStyle: true, + }) + } + } + if !hasS3Connection(manifest.Components.S3, connection) { + return invalidMutation(projectdomain.MutationNotFound, "s3.connection", connection) + } + desired := projectdomain.S3Bucket{Name: command.Name, Connection: connection, Bucket: command.Name + "-local"} + for index, current := range manifest.Components.S3.Buckets { + if current.Name != desired.Name { + continue + } + if !reflect.DeepEqual(current, desired) && !command.Force { + return mutationConflict("s3", desired.Name) + } + manifest.Components.S3.Buckets[index] = desired + return nil + } + manifest.Components.S3.Buckets = append(manifest.Components.S3.Buckets, desired) + return nil +} + +func hasS3Connection(storage *projectdomain.S3, name string) bool { + for _, connection := range storage.Connections { + if connection.Name == name { + return true + } + } + return false +} + +func safeRelative(name string) bool { + if name == "" || strings.HasPrefix(name, "/") { + return false + } + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") +} + +func pathWithin(root, selected string) bool { + root = path.Clean(strings.ReplaceAll(root, "\\", "/")) + selected = path.Clean(strings.ReplaceAll(selected, "\\", "/")) + if root == "." { + return safeRelative(selected) + } + return selected == root || strings.HasPrefix(selected, root+"/") +} + +func validRedisAddress(value string) bool { + if strings.Contains(value, "://") { + parsed, err := url.Parse(value) + return err == nil && (parsed.Scheme == "redis" || parsed.Scheme == "rediss") && + parsed.Hostname() != "" && parsed.User == nil + } + host, port, err := net.SplitHostPort(value) + if err != nil || host == "" { + return false + } + number, err := strconv.Atoi(port) + return err == nil && number > 0 && number <= 65535 +} + +func invalidMutation(reason projectdomain.MutationReason, field, value string) error { + return &projectdomain.MutationError{Reason: reason, Field: field, Value: value} +} + +func mutationConflict(field, value string) error { + return &projectdomain.MutationError{Reason: projectdomain.MutationExistingConflict, Field: field, Value: value, Conflict: true} +} diff --git a/internal/service/project/mutation_db_test.go b/internal/service/project/mutation_db_test.go new file mode 100644 index 0000000..dabfde7 --- /dev/null +++ b/internal/service/project/mutation_db_test.go @@ -0,0 +1,48 @@ +package project + +import ( + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestDefaultClickHouseVariantPlansIndependentMigrations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + command projectdomain.AddDBCommand + wantMigrations *projectdomain.DBMigrations + }{ + { + name: "default path", + command: projectdomain.AddDBCommand{Name: "analytics", Kind: "clickhouse"}, + wantMigrations: &projectdomain.DBMigrations{ + Path: "migrations/analytics/clickhouse", DatabaseEnv: "DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", + }, + }, + { + name: "explicit path", + command: projectdomain.AddDBCommand{Name: "analytics", Kind: "clickhouse", MigrationsPath: "db/analytics"}, + wantMigrations: &projectdomain.DBMigrations{ + Path: "db/analytics", DatabaseEnv: "DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", + }, + }, + { + name: "opt out", + command: projectdomain.AddDBCommand{Name: "analytics", Kind: "clickhouse", NoMigrations: true}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + variant := defaultDBVariant(test.command) + require.Equal(t, "clickhouse://localhost:9000/default", variant.DSNDefault) + require.Equal(t, "DB_ANALYTICS_CLICKHOUSE_DSN", variant.DSNEnv) + require.True(t, variant.Secret) + require.Equal(t, test.wantMigrations, variant.Migrations) + }) + } +} diff --git a/internal/service/project/service.go b/internal/service/project/service.go new file mode 100644 index 0000000..ccb3213 --- /dev/null +++ b/internal/service/project/service.go @@ -0,0 +1,63 @@ +package project + +import ( + "context" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "go.uber.org/zap" +) + +const manifestFilename = "devctl.yaml" + +//go:generate go tool mockgen -destination mocks/service.go -package mocks -typed . ManifestRepository,ManifestLocator,TargetResolver,ReadinessChecker + +// ManifestRepository persists the canonical manifest selected by the project service. +type ManifestRepository interface { + // Load decodes manifestPath, returning structural issues as data and access failures as errors. + Load(ctx context.Context, manifestPath string) (projectdomain.LoadManifestResult, error) + // Save atomically publishes project.Manifest at project.ManifestPath in canonical form and reports whether bytes changed. + Save(ctx context.Context, project projectdomain.Project) (bool, error) +} + +// ManifestLocator exposes only the filesystem facts needed to locate a Project manifest. +type ManifestLocator interface { + // WorkingDirectory returns the process directory used as the project search origin. + WorkingDirectory(ctx context.Context) (string, error) + // RegularFile reports whether relativePath is a regular non-symlink file below root. + RegularFile(ctx context.Context, root, relativePath string) (bool, error) +} + +// TargetResolver attaches the concrete input used to inspect one Target. +type TargetResolver interface { + // Resolve attaches the concrete input required to execute target in selected Project. + Resolve(ctx context.Context, selected projectdomain.Project, target projectdomain.Target) (projectdomain.Target, error) +} + +// ReadinessChecker evaluates environment-dependent project readiness policy. +type ReadinessChecker interface { + // Check returns every applicable readiness issue in stable order. + Check(ctx context.Context, selected projectdomain.Project) ([]projectdomain.Issue, error) +} + +type Service struct { + logger *zap.Logger + manifests ManifestRepository + locator ManifestLocator + inputs TargetResolver + readiness ReadinessChecker +} + +// Dependencies names the required Project service capabilities passed to New. +type Dependencies struct { + Manifests ManifestRepository + Locator ManifestLocator + Inputs TargetResolver + Readiness ReadinessChecker +} + +func New(logger *zap.Logger, dependencies Dependencies) *Service { + return &Service{ + logger: logger, manifests: dependencies.Manifests, + locator: dependencies.Locator, inputs: dependencies.Inputs, readiness: dependencies.Readiness, + } +} diff --git a/internal/service/project/service_test.go b/internal/service/project/service_test.go new file mode 100644 index 0000000..11a31ac --- /dev/null +++ b/internal/service/project/service_test.go @@ -0,0 +1,702 @@ +package project_test + +import ( + "context" + "errors" + "io/fs" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + domainfailure "github.com/devctllabs/devctl/internal/domain/failure" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceValidateSuccess(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.True(t, result.IsValid()) + require.Empty(t, result.Issues) +} + +func TestServiceValidateRejectsRuntimeConfigConflicts(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Env: projectdomain.Env{Custom: []projectdomain.EnvGroup{ + {Group: "service", Vars: []projectdomain.EnvVar{{Key: "MODE", Type: "string"}}}, + {Group: "worker", Vars: []projectdomain.EnvVar{{Key: "MODE", Type: "bool"}}}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{{ + Code: projectdomain.IssueRuntimeConfigConflict, Path: "/project/devctl.yaml", Field: "env", + }}}, result) +} + +func TestServiceValidateAcceptsGitSourceRoot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{"contracts": { + Type: projectdomain.SourceGit, Path: "contracts", Repo: "example/contracts", Ref: "v1", + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.True(t, result.IsValid()) +} + +func TestServiceValidateDiscoversManifestFromWorkingDirectory(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project/nested", nil), + workspace.EXPECT().RegularFile(gomock.Any(), "/project/nested", "devctl.yaml").Return(false, nil), + workspace.EXPECT().RegularFile(gomock.Any(), "/project", "devctl.yaml").Return(true, nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate(context.Background(), projectdomain.ValidateQuery{}) + + require.NoError(t, err) + require.True(t, result.IsValid()) +} + +func TestServiceValidateReturnsAllManifestIssues(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{Version: 1, Project: projectdomain.Identity{Language: "go"}}, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + readiness := mocks.NewMockReadinessChecker(ctrl) + readiness.EXPECT().Check(gomock.Any(), selected).Return([]projectdomain.Issue{{ + Code: projectdomain.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod", + }}, nil) + result, err := project.New(zap.NewNop(), project.Dependencies{ + Manifests: manifests, Locator: workspace, Readiness: readiness, + }).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{ + {Code: "name_invalid", Path: "/project/devctl.yaml", Field: "project.name"}, + {Code: "go_module_required", Path: "/project/devctl.yaml", Field: "languages.go.module"}, + {Code: "go_mod_missing", Path: "/project/devctl.yaml", Field: "go.mod"}, + }}, result) +} + +func TestServiceValidatePreservesReadinessErrorAfterSemanticIssues(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + readiness := mocks.NewMockReadinessChecker(ctrl) + cause := errors.New("workspace unavailable") + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + readiness.EXPECT().Check(gomock.Any(), selected).Return(nil, &projectdomain.OperationError{ + Operation: projectdomain.OperationInspectFile, + Path: "go.mod", + Kind: projectdomain.FailureUnavailable, + Cause: cause, + }), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{ + Manifests: manifests, Locator: workspace, Readiness: readiness, + }).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}) + + var operationErr *projectdomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, projectdomain.OperationInspectFile, operationErr.Operation) + require.Equal(t, "go.mod", operationErr.Path) + require.Equal(t, projectdomain.FailureUnavailable, operationErr.Kind) + require.ErrorIs(t, err, cause) + require.Equal(t, projectdomain.ValidationResult{}, result) +} + +func TestServiceValidateAppendsInjectedReadinessIssuesAfterSemanticIssues(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + readiness := mocks.NewMockReadinessChecker(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + readinessIssue := projectdomain.Issue{ + Code: projectdomain.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod", + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + readiness.EXPECT().Check(gomock.Any(), selected).Return([]projectdomain.Issue{readinessIssue}, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{ + Manifests: manifests, Locator: workspace, Readiness: readiness, + }).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.Equal(t, []projectdomain.Issue{ + {Code: projectdomain.IssueNameInvalid, Path: selected.ManifestPath, Field: "project.name"}, + readinessIssue, + }, result.Issues) +} + +func TestServiceValidateRejectsGRPCClientWithUnknownSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "missing", Path: "proto/billing", ProtoRoot: "proto", + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{{ + Code: projectdomain.IssueSourceNotFound, Path: "/project/devctl.yaml", + Field: "components.grpc.clients.billing.source", + }}}, result) +} + +func TestServiceInspectReportsGRPCAndKafkaTargets(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{"contracts": {Type: "local", Path: "api/contracts"}}, + Components: projectdomain.Components{ + GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto"}, + Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/billing", ProtoRoot: "proto", + BufGenConfig: "tools/buf/billing.gen.yaml", + }}, + }, + Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit_service.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{ + Module: "example.test/example", + Generators: projectdomain.GoGenerators{GRPC: &projectdomain.GRPCGenerator{ + Out: "generated/grpc", BufGenConfig: "tools/buf/shared.gen.yaml", + }}, + }}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + service := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}) + + result, err := service.Inspect(context.Background(), projectdomain.InspectQuery{ManifestPath: "devctl.yaml"}) + + require.NoError(t, err) + require.Equal(t, []projectdomain.InspectionTarget{ + {ID: "config", Family: "config", Format: "go", Output: "gen/config"}, + {ID: "grpc-client:billing", Family: "grpc", Format: "proto", Input: "api/contracts/proto", Config: "tools/buf/billing.gen.yaml", Output: "generated/grpc/client/billing"}, + {ID: "grpc-server", Family: "grpc", Format: "proto", Input: "api/proto", Config: "tools/buf/shared.gen.yaml", Output: "generated/grpc/server"}, + {ID: "kafka-producer:audit", Family: "kafka", Format: "raw"}, + }, result.Project.Targets) +} + +func TestServiceInspectAddsResolvedInputOnlyFromValidCommittedMetadata(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + inputs := mocks.NewMockTargetResolver(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{ + "upstream": {Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"}, + }, + Components: projectdomain.Components{ + GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "upstream", Export: "billing", + }}}, + Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "audit_service.audit.events.v1", + Contract: projectdomain.KafkaContract{Format: "json", Source: "upstream", Export: "audit"}, + }}}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + grpcSnapshot := contract.Snapshot{ + ModuleRoot: "api/proto/grpc", + Metadata: &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: "api/proto/grpc", BufConfig: "buf.yaml", + }, + } + staleMetadata := &contract.SnapshotMetadataError{ + Field: "entrypoint", Reason: contract.MetadataMismatch, Hint: "devctl sync", + } + targets := projectdomain.NewTargetCatalog(selected.Manifest).All() + grpcTarget, kafkaTarget := targets[1], targets[2] + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + inputs.EXPECT().Resolve(gomock.Any(), selected, grpcTarget).Return(grpcTarget.WithSnapshot(grpcSnapshot), nil) + inputs.EXPECT().Resolve(gomock.Any(), selected, kafkaTarget).Return(kafkaTarget, staleMetadata) + + result, err := project.New(zap.NewNop(), project.Dependencies{ + Manifests: manifests, Locator: workspace, Inputs: inputs, + }).Inspect( + context.Background(), projectdomain.InspectQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, []projectdomain.InspectionTarget{ + {ID: "config", Family: "config", Format: "go", Output: "gen/config"}, + { + ID: "grpc-client:billing", Family: "grpc", Format: "proto", + Input: "api/external/grpc/client/billing", + ResolvedInput: "api/external/grpc/client/billing/api/proto/grpc", + Config: "tools/buf/grpc.gen.yaml", Output: "gen/grpc/client/billing", + }, + { + ID: "kafka-consumer:audit", Family: "kafka", Format: "json", + Input: "api/external/kafka/consumer/audit", Output: "gen/kafka/consumer/audit", + }, + }, result.Project.Targets) +} + +func TestServiceInspectRejectsUnsafeCatalogOutputPathsForEveryGeneratorFamily(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + }, + Components: projectdomain.Components{ + GRPC: &projectdomain.GRPC{Server: &projectdomain.GRPCServer{}}, + Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit_service.audit.events.v1", + Contract: projectdomain.KafkaContract{ + Format: "proto", Source: "contracts", Path: "proto/audit.proto", ProtoRoot: "proto", + }, + }}}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{ + Module: "example.test/example", + Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "/tmp/grpc"}, + Kafka: &projectdomain.KafkaGenerator{Out: "../kafka"}, + }, + }}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + _, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Inspect( + context.Background(), projectdomain.InspectQuery{ManifestPath: "devctl.yaml"}, + ) + + var invalid *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalid) + require.Equal(t, []projectdomain.Issue{ + {Code: projectdomain.IssuePathInvalid, Path: selected.ManifestPath, Field: "languages.go.generators.grpc.out"}, + {Code: projectdomain.IssuePathInvalid, Path: selected.ManifestPath, Field: "languages.go.generators.kafka.out"}, + }, invalid.Issues) +} + +func TestServiceInspectReportsMigrationResourcesAndEnvironment(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{DB: &projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Default: "sqlite", Variants: []projectdomain.DBVariant{ + {Name: "sqlite", Kind: "sqlite", Migrations: &projectdomain.DBMigrations{Path: "migrations/primary/sqlite", DatabaseEnv: "DB_PRIMARY_SQLITE_MIGRATIONS_URL", DatabaseDefault: "sqlite://./data/primary.db"}}, + {Name: "memory", Kind: "sqlite"}, + {Name: "postgres", Kind: "postgres", Migrations: &projectdomain.DBMigrations{Path: "migrations/primary/postgres", DatabaseEnv: "DB_PRIMARY_POSTGRES_MIGRATIONS_URL"}}, + }, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Inspect( + context.Background(), projectdomain.InspectQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, []string{"primary"}, result.Project.Resources.DBConnections) + require.Equal(t, []projectdomain.InspectionMigration{ + {Connection: "primary", Variant: "postgres", Kind: "postgres", Path: "migrations/primary/postgres", DatabaseEnv: "EXAMPLE_DB_PRIMARY_POSTGRES_MIGRATIONS_URL"}, + {Connection: "primary", Variant: "sqlite", Kind: "sqlite", Path: "migrations/primary/sqlite", DatabaseEnv: "EXAMPLE_DB_PRIMARY_SQLITE_MIGRATIONS_URL"}, + }, result.Project.Resources.Migrations) + require.Equal(t, []projectdomain.EffectiveEnv{ + {Key: "EXAMPLE_DB_PRIMARY_KIND", Type: "string", Default: "sqlite"}, + {Key: "EXAMPLE_DB_PRIMARY_MEMORY_DSN", Type: "string"}, + {Key: "EXAMPLE_DB_PRIMARY_POSTGRES_DSN", Type: "string"}, + {Key: "EXAMPLE_DB_PRIMARY_POSTGRES_MIGRATIONS_URL", Type: "string", Secret: true}, + {Key: "EXAMPLE_DB_PRIMARY_SQLITE_DSN", Type: "string"}, + {Key: "EXAMPLE_DB_PRIMARY_SQLITE_MIGRATIONS_URL", Type: "string", Default: "sqlite://./data/primary.db"}, + }, result.Project.Env) +} + +func TestServiceInspectUsesCanonicalRuntimeConfigPolicy(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "sample-api", Language: "go"}, + Components: projectdomain.Components{ + Logging: &projectdomain.Logging{}, + HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{Start: &projectdomain.Start{ + Env: "SERVE_HTTP", + }}}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/sample-api"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Inspect( + context.Background(), projectdomain.InspectQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, []projectdomain.EffectiveEnv{ + {Key: "SAMPLE_API_HTTP_ADDR", Type: "string", Default: ":8080"}, + {Key: "SAMPLE_API_LOG_LEVEL", Type: "string", Default: "info"}, + {Key: "SAMPLE_API_SERVE_HTTP", Type: "bool", Default: false}, + }, result.Project.Env) +} + +func TestServiceValidateReturnsMissingManifestAsExecutionError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/missing.yaml").Return(projectdomain.LoadManifestResult{}, fs.ErrNotExist), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Validate(context.Background(), projectdomain.ValidateQuery{ManifestPath: "missing.yaml"}) + + require.Equal(t, domainfailure.NotFound, domainfailure.CategoryOf(err)) + var operationErr *projectdomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, projectdomain.OperationLoadManifest, operationErr.Operation) + require.Equal(t, "/project/missing.yaml", operationErr.Path) + require.ErrorIs(t, err, fs.ErrNotExist) + require.EqualError(t, err, "manifests.Load: load_manifest failed: file does not exist") + require.Equal(t, projectdomain.ValidationResult{}, result) +} + +func TestServiceValidatePreservesLoadErrorWhenResultAlsoContainsIssues(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + loadErr := errors.New("storage unavailable") + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil) + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{ + Project: projectdomain.Project{ManifestPath: "/project/devctl.yaml"}, + Issues: []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeYAMLInvalid}}, + }, loadErr) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.ErrorIs(t, err, loadErr) + require.Equal(t, domainfailure.Unavailable, domainfailure.CategoryOf(err)) + require.Equal(t, projectdomain.ValidationResult{}, result) +} + +func TestServiceLoadProjectPreservesLoadErrorWhenResultAlsoContainsIssues(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + loadErr := errors.New("storage unavailable") + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil) + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{ + Project: projectdomain.Project{ManifestPath: "/project/devctl.yaml"}, + Issues: []projectdomain.DecodeIssue{{Kind: projectdomain.DecodeYAMLInvalid}}, + }, loadErr) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + require.ErrorIs(t, err, loadErr) + require.Equal(t, domainfailure.Unavailable, domainfailure.CategoryOf(err)) + require.Equal(t, projectdomain.Project{}, result) +} + +func TestServiceLoadProjectUsesAbsoluteManifestPathWithoutWorkingDirectory(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "/project/devctl.yaml") + + require.NoError(t, err) + require.Equal(t, selected, result) +} + +func TestServiceOwnsEnableCommandOutcome(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ManifestPath: "custom.yaml", Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }} + updated := selected + updated.Manifest.Components.Logging = &projectdomain.Logging{} + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/custom.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + manifests.EXPECT().Save(gomock.Any(), updated).Return(true, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Enable(context.Background(), projectdomain.EnableCommand{ + ManifestPath: "custom.yaml", Capability: "logging", + }) + + require.NoError(t, err) + require.Equal(t, projectdomain.ManifestResult{Manifest: "custom.yaml", Change: projectdomain.ChangeUpdated}, result) +} + +func TestServiceReturnsTypedMutationFailure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ManifestPath: "custom.yaml", Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }} + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/custom.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).Enable(context.Background(), projectdomain.EnableCommand{ + ManifestPath: "custom.yaml", Capability: "logging", Always: true, + }) + + var mutationErr *projectdomain.MutationError + require.ErrorAs(t, err, &mutationErr) + require.Equal(t, projectdomain.MutationUnsupportedOption, mutationErr.Reason) + require.Equal(t, "always", mutationErr.Field) + require.Equal(t, domainfailure.InvalidInput, domainfailure.CategoryOf(err)) + require.Equal(t, projectdomain.ManifestResult{Manifest: "custom.yaml"}, result) +} + +func TestServiceAddSourceRejectsPathForDevctlSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ManifestPath: "custom.yaml", Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }} + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/custom.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).AddSource(context.Background(), projectdomain.AddSourceCommand{ + ManifestPath: "custom.yaml", Name: "contracts", Type: "devctl", Path: "contracts", + Repo: "example/contracts", Ref: "v1", + }) + + var mutationErr *projectdomain.MutationError + require.ErrorAs(t, err, &mutationErr) + require.Equal(t, projectdomain.MutationInvalidOptions, mutationErr.Reason) + require.Equal(t, "source", mutationErr.Field) + require.Equal(t, domainfailure.InvalidInput, domainfailure.CategoryOf(err)) + require.Equal(t, projectdomain.ManifestResult{Manifest: "custom.yaml"}, result) +} + +func TestServiceOwnsInitManifestOutcome(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + command := projectdomain.InitManifestCommand{Destination: "/project/custom.yaml", Language: "go", Preset: "cli", Name: "sample", Module: "example.test/sample"} + gomock.InOrder( + manifests.EXPECT().Load(gomock.Any(), "/project/custom.yaml").Return(projectdomain.LoadManifestResult{}, fs.ErrNotExist), + manifests.EXPECT().Save(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, saved projectdomain.Project) (bool, error) { + require.Nil(t, saved.Manifest.Languages.Go.Generators.Config) + return true, nil + }), + ) + + result, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests}).InitManifest(context.Background(), command) + + require.NoError(t, err) + require.Equal(t, projectdomain.ManifestResult{Manifest: "/project/custom.yaml", Change: projectdomain.ChangeCreated}, result) +} diff --git a/internal/service/project/validation.go b/internal/service/project/validation.go new file mode 100644 index 0000000..af0cec4 --- /dev/null +++ b/internal/service/project/validation.go @@ -0,0 +1,35 @@ +package project + +import ( + "context" + "fmt" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +// Validate collects all available structural, semantic, and project-readiness issues. +// Invalid project data is returned as issues; access and persistence failures are errors. +func (s *Service) Validate(ctx context.Context, query projectdomain.ValidateQuery) (projectdomain.ValidationResult, error) { + manifestPath, err := s.resolveManifestPath(ctx, query.ManifestPath) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationLoadManifest, query.ManifestPath, manifestAccessFailure(err), err) + return projectdomain.ValidationResult{}, fmt.Errorf("s.resolveManifestPath: %w", operationErr) + } + loaded, err := s.manifests.Load(ctx, manifestPath) + if err != nil { + operationErr := projectOperationError(projectdomain.OperationLoadManifest, manifestPath, manifestAccessFailure(err), err) + return projectdomain.ValidationResult{}, fmt.Errorf("manifests.Load: %w", operationErr) + } + if len(loaded.Issues) > 0 { + issues := validationIssues(selectedManifestPath(loaded.Project.ManifestPath, manifestPath), loaded.Issues) + return projectdomain.ValidationResult{Issues: issues}, nil + } + + issues := projectdomain.Validate(loaded.Project) + readinessIssues, err := s.readiness.Check(ctx, loaded.Project) + if err != nil { + return projectdomain.ValidationResult{}, fmt.Errorf("readiness.Check: %w", err) + } + issues = append(issues, readinessIssues...) + return projectdomain.ValidationResult{Issues: issues}, nil +} diff --git a/internal/service/project/validation_db_test.go b/internal/service/project/validation_db_test.go new file mode 100644 index 0000000..6bbb2af --- /dev/null +++ b/internal/service/project/validation_db_test.go @@ -0,0 +1,129 @@ +package project_test + +import ( + "context" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestServiceValidateDBShape(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + database projectdomain.DB + issues []projectdomain.Issue + }{ + { + name: "empty connection list", + database: projectdomain.DB{}, + issues: []projectdomain.Issue{{ + Code: projectdomain.IssueDBConnectionInvalid, Path: "/project/devctl.yaml", Field: "components.db.connections", + }}, + }, + { + name: "single variant with mismatched explicit default", + database: projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Default: "postgres", Variants: []projectdomain.DBVariant{{Name: "sqlite", Kind: "sqlite"}}, + }}}, + issues: []projectdomain.Issue{{ + Code: projectdomain.IssueDBDefaultInvalid, Path: "/project/devctl.yaml", Field: "components.db.connections.primary.default", + }}, + }, + { + name: "single variant with inferred default", + database: projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Variants: []projectdomain.DBVariant{{Name: "sqlite", Kind: "sqlite"}}, + }}}, + }, + { + name: "single variant with matching explicit default", + database: projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Default: "sqlite", Variants: []projectdomain.DBVariant{{Name: "sqlite", Kind: "sqlite"}}, + }}}, + }, + { + name: "single clickhouse variant", + database: projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "analytics", Default: "clickhouse", Variants: []projectdomain.DBVariant{{Name: "clickhouse", Kind: "clickhouse"}}, + }}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{DB: &test.database}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), + projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, len(test.issues) == 0, result.IsValid()) + require.Equal(t, test.issues, result.Issues) + }) + } +} + +func TestServiceValidateRejectsInvalidMigrations(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{DB: &projectdomain.DB{Connections: []projectdomain.DBConnection{ + {Name: "primary", Default: "sqlite", Variants: []projectdomain.DBVariant{{ + Name: "sqlite", Kind: "sqlite", Migrations: &projectdomain.DBMigrations{ + Path: "../outside", DatabaseEnv: "bad-env", DatabaseDefault: "postgres://localhost/app", + }, + }}}, + {Name: "analytics", Default: "clickhouse", Variants: []projectdomain.DBVariant{{ + Name: "clickhouse", Kind: "clickhouse", Migrations: &projectdomain.DBMigrations{ + Path: "migrations/analytics/clickhouse", DatabaseEnv: "DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", DatabaseDefault: "postgres://localhost/wrong", + }, + }}}, + }}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, []projectdomain.Issue{ + {Code: "db_migrations_invalid", Path: "/project/devctl.yaml", Field: "components.db.connections.primary.variants.sqlite.migrations"}, + {Code: "db_migrations_invalid", Path: "/project/devctl.yaml", Field: "components.db.connections.analytics.variants.clickhouse.migrations"}, + }, result.Issues) +} diff --git a/internal/service/project/validation_grpc_test.go b/internal/service/project/validation_grpc_test.go new file mode 100644 index 0000000..a36e73a --- /dev/null +++ b/internal/service/project/validation_grpc_test.go @@ -0,0 +1,226 @@ +package project_test + +import ( + "context" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceLoadProjectRejectsInvalidGRPCClients(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sources map[string]projectdomain.Source + clients []projectdomain.GRPCClient + field string + }{ + { + name: "invalid name", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{{Name: "Billing", Source: "contracts", Path: "proto/billing.proto"}}, + field: "components.grpc.clients.Billing", + }, + { + name: "duplicate name", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{ + {Name: "billing", Source: "contracts", Path: "proto/billing.proto"}, + {Name: "billing", Source: "contracts", Path: "proto/billing-v2.proto"}, + }, + field: "components.grpc.clients.billing", + }, + { + name: "devctl source without export", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts"}}, + field: "components.grpc.clients.billing", + }, + { + name: "devctl source with path", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts", Export: "billing", Path: "proto/billing.proto"}}, + field: "components.grpc.clients.billing", + }, + { + name: "git source without path", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts"}}, + field: "components.grpc.clients.billing", + }, + { + name: "git source with export", + sources: map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}}, + clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts", Export: "billing", Path: "proto/billing.proto"}}, + field: "components.grpc.clients.billing", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: test.sources, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: test.clients}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + _, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Equal(t, []projectdomain.Issue{{ + Code: projectdomain.IssueGRPCClientInvalid, Path: "/project/devctl.yaml", Field: test.field, + }}, invalidManifest.Issues) + }) + } +} + +func TestServiceLoadProjectAcceptsValidGRPCClientSelections(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source projectdomain.Source + client projectdomain.GRPCClient + }{ + { + name: "git source path", + source: projectdomain.Source{Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}, + client: projectdomain.GRPCClient{Name: "billing", Source: "contracts", Path: "proto/billing.proto"}, + }, + { + name: "devctl source export", + source: projectdomain.Source{Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"}, + client: projectdomain.GRPCClient{Name: "billing", Source: "contracts", Export: "billing"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{"contracts": test.source}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{test.client}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + loaded, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + require.NoError(t, err) + require.Equal(t, selected, loaded) + }) + } +} + +func TestServiceLoadProjectRejectsUnsafeBufPathsAtTheirManifestFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configure func(*projectdomain.Manifest) + field string + }{ + { + name: "traversing server config", + configure: func(manifest *projectdomain.Manifest) { + manifest.Components.GRPC.Server.BufConfig = "../buf.yaml" + }, + field: "components.grpc.server.buf_config", + }, + { + name: "absolute client config", + configure: func(manifest *projectdomain.Manifest) { + manifest.Components.GRPC.Clients[0].BufGenConfig = "/tmp/client.gen.yaml" + }, + field: "components.grpc.clients.billing.buf_gen_config", + }, + { + name: "traversing shared generator config", + configure: func(manifest *projectdomain.Manifest) { + manifest.Languages.Go.Generators.GRPC.BufGenConfig = "../grpc.gen.yaml" + }, + field: "languages.go.generators.grpc.buf_gen_config", + }, + { + name: "absolute source config", + configure: func(manifest *projectdomain.Manifest) { + source := manifest.Sources["contracts"] + source.Proto.BufConfig = "/tmp/buf.yaml" + manifest.Sources["contracts"] = source + }, + field: "sources.contracts.proto.buf_config", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + manifest := projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{BufConfig: "buf.yaml"}, + Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/billing.proto", + }}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{ + Module: "example.test/example", Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }, + }}, + } + test.configure(&manifest) + selected := projectdomain.Project{Root: "/project", ManifestPath: "/project/devctl.yaml", Manifest: manifest} + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + _, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Contains(t, invalidManifest.Issues, projectdomain.Issue{ + Code: projectdomain.IssuePathInvalid, Path: "/project/devctl.yaml", Field: test.field, + }) + }) + } +} diff --git a/internal/service/project/validation_http_test.go b/internal/service/project/validation_http_test.go new file mode 100644 index 0000000..f1cb7dc --- /dev/null +++ b/internal/service/project/validation_http_test.go @@ -0,0 +1,43 @@ +package project_test + +import ( + "context" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceLoadProjectDoesNotResolveDevctlHTTPExportLocally(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"}, + }, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "billing", Source: "contracts", Export: "billing", + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + loaded, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + require.NoError(t, err) + require.Equal(t, selected, loaded) +} diff --git a/internal/service/project/validation_kafka_test.go b/internal/service/project/validation_kafka_test.go new file mode 100644 index 0000000..89fc274 --- /dev/null +++ b/internal/service/project/validation_kafka_test.go @@ -0,0 +1,77 @@ +package project_test + +import ( + "context" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestServiceValidateRejectsKafkaContractWithUnknownSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "billing", Topic: "billing_service.invoice.events.v1", + Contract: projectdomain.KafkaContract{Source: "missing", Path: "invoice.proto", Format: "proto"}, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{{ + Code: projectdomain.IssueSourceNotFound, Path: "/project/devctl.yaml", + Field: "components.kafka.consumers.billing.contract.source", + }}}, result) +} + +func TestServiceValidateRejectsNonRawKafkaContractWithoutSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "billing", Topic: "billing_service.invoice.events.v1", + Contract: projectdomain.KafkaContract{Path: "invoice.json", Format: "json"}, + }}}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{{ + Code: projectdomain.IssueKafkaContractInvalid, Path: "/project/devctl.yaml", + Field: "components.kafka.consumers.billing.contract", + }}}, result) +} diff --git a/internal/service/project/validation_source_export_test.go b/internal/service/project/validation_source_export_test.go new file mode 100644 index 0000000..21bb41a --- /dev/null +++ b/internal/service/project/validation_source_export_test.go @@ -0,0 +1,164 @@ +package project_test + +import ( + "context" + "fmt" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceLoadProjectRejectsPathOnDevctlSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceDevctl, Path: "contracts", Repo: "example/contracts", Ref: "v1"}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + _, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Equal(t, []projectdomain.Issue{{ + Code: projectdomain.IssueSourceInvalid, Path: "/project/devctl.yaml", Field: "sources.contracts", + }}, invalidManifest.Issues) +} + +func TestServiceLoadProjectRejectsOpenAPIExportPathMismatch(t *testing.T) { + t.Parallel() + + manifest := validManifest() + manifest.Exports = map[string]projectdomain.Export{ + "public-api": {Kind: "openapi", Path: "api/other.yaml"}, + } + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{OpenAPI: "api/openapi.yaml"}} + + err := loadProjectWithManifest(t, manifest) + + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Equal(t, []projectdomain.Issue{{ + Code: projectdomain.IssueExportInvalid, Path: "/project/devctl.yaml", Field: "exports.public-api", + }}, invalidManifest.Issues) +} + +func TestServiceLoadProjectValidatesExportsAgainstEffectiveSurfaces(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + exported projectdomain.Export + components projectdomain.Components + valid bool + }{ + { + name: "OpenAPI exact path", exported: projectdomain.Export{Kind: "openapi", Path: "api/openapi.yaml"}, valid: true, + components: projectdomain.Components{HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{OpenAPI: "api/openapi.yaml"}}}, + }, + { + name: "OpenAPI default path", exported: projectdomain.Export{Kind: "openapi", Path: "api/openapi/swagger.yaml"}, valid: true, + components: projectdomain.Components{HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{}}}, + }, + {name: "OpenAPI missing server", exported: projectdomain.Export{Kind: "openapi", Path: "api/openapi.yaml"}}, + { + name: "gRPC exact root", exported: projectdomain.Export{Kind: "grpc", Path: "api/proto/grpc"}, valid: true, + components: projectdomain.Components{GRPC: &projectdomain.GRPC{Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto/grpc"}}}, + }, + { + name: "gRPC mismatched root", exported: projectdomain.Export{Kind: "grpc", Path: "api/proto/other"}, + components: projectdomain.Components{GRPC: &projectdomain.GRPC{Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto/grpc"}}}, + }, + {name: "gRPC missing server", exported: projectdomain.Export{Kind: "grpc", Path: "api/proto/grpc"}}, + { + name: "Kafka existing producer", exported: projectdomain.Export{Kind: "kafka", Producer: "audit"}, valid: true, + components: projectdomain.Components{Kafka: &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "audit", Topic: "audit.events", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}}}, + }, + {name: "Kafka missing producer", exported: projectdomain.Export{Kind: "kafka", Producer: "audit"}}, + {name: "unknown kind", exported: projectdomain.Export{Kind: "graphql", Path: "api/schema.graphql"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + manifest := validManifest() + manifest.Exports = map[string]projectdomain.Export{"public": test.exported} + manifest.Components = test.components + + err := loadProjectWithManifest(t, manifest) + if test.valid { + require.NoError(t, err) + return + } + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Equal(t, []projectdomain.Issue{{ + Code: projectdomain.IssueExportInvalid, Path: "/project/devctl.yaml", Field: "exports.public", + }}, invalidManifest.Issues) + }) + } +} + +func TestServiceLoadProjectOrdersExportIssuesByName(t *testing.T) { + t.Parallel() + + manifest := validManifest() + manifest.Exports = map[string]projectdomain.Export{ + "zulu": {Kind: "openapi", Path: "api/zulu.yaml"}, + "alpha": {Kind: "grpc", Path: "api/proto/alpha"}, + } + + err := loadProjectWithManifest(t, manifest) + + var invalidManifest *projectdomain.InvalidManifestError + require.ErrorAs(t, err, &invalidManifest) + require.Equal(t, []projectdomain.Issue{ + {Code: projectdomain.IssueExportInvalid, Path: "/project/devctl.yaml", Field: "exports.alpha"}, + {Code: projectdomain.IssueExportInvalid, Path: "/project/devctl.yaml", Field: "exports.zulu"}, + }, invalidManifest.Issues) +} + +func loadProjectWithManifest(t *testing.T, manifest projectdomain.Manifest) error { + t.Helper() + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{Root: "/project", ManifestPath: "/project/devctl.yaml", Manifest: manifest} + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + _, err := project.New(zap.NewNop(), project.Dependencies{Manifests: manifests, Locator: workspace}).LoadProject(context.Background(), "devctl.yaml") + if err != nil { + return fmt.Errorf("service.LoadProject: %w", err) + } + return nil +} + +func validManifest() projectdomain.Manifest { + return projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "example", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + } +} diff --git a/internal/service/project/validation_storage_test.go b/internal/service/project/validation_storage_test.go new file mode 100644 index 0000000..e83c46e --- /dev/null +++ b/internal/service/project/validation_storage_test.go @@ -0,0 +1,77 @@ +package project_test + +import ( + "context" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestServiceValidateRejectsS3BucketWithUnknownConnection(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{S3: &projectdomain.S3{ + Connections: []projectdomain.S3Connection{{Name: "default", Credentials: "static"}}, + Buckets: []projectdomain.S3Bucket{{Name: "media", Connection: "archive", Bucket: "media-local"}}, + }}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, projectdomain.ValidationResult{Issues: []projectdomain.Issue{{ + Code: "s3_connection_not_found", Path: "/project/devctl.yaml", + Field: "components.s3.buckets.media.connection", + }}}, result) +} + +func TestServiceValidateRejectsInvalidRedisAddresses(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + manifests := mocks.NewMockManifestRepository(ctrl) + workspace := mocks.NewMockManifestLocator(ctrl) + selected := projectdomain.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Version: 1, Project: projectdomain.Identity{Name: "example", Language: "go"}, + Components: projectdomain.Components{Redis: &projectdomain.Redis{Connections: []projectdomain.RedisConnection{ + {Name: "cache", AddrEnv: "REDIS_CACHE_ADDR", AddrDefault: "localhost"}, + {Name: "sessions", AddrEnv: "REDIS_SESSIONS_ADDR", AddrDefault: "redis://user:secret@localhost:6379/0"}, + }}}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/example"}}, + }, + } + gomock.InOrder( + workspace.EXPECT().WorkingDirectory(gomock.Any()).Return("/project", nil), + manifests.EXPECT().Load(gomock.Any(), "/project/devctl.yaml").Return(projectdomain.LoadManifestResult{Project: selected}, nil), + ) + + result, err := newValidationService(ctrl, manifests, workspace, selected).Validate( + context.Background(), projectdomain.ValidateQuery{ManifestPath: "devctl.yaml"}, + ) + + require.NoError(t, err) + require.Equal(t, []projectdomain.Issue{ + {Code: "redis_address_invalid", Path: "/project/devctl.yaml", Field: "components.redis.connections.cache.addr_default"}, + {Code: "redis_address_invalid", Path: "/project/devctl.yaml", Field: "components.redis.connections.sessions.addr_default"}, + }, result.Issues) +} diff --git a/internal/service/project/validation_test_helpers_test.go b/internal/service/project/validation_test_helpers_test.go new file mode 100644 index 0000000..7d57715 --- /dev/null +++ b/internal/service/project/validation_test_helpers_test.go @@ -0,0 +1,24 @@ +package project_test + +import ( + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/project/mocks" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func newValidationService( + ctrl *gomock.Controller, + manifests projectservice.ManifestRepository, + workspace projectservice.ManifestLocator, + selected projectdomain.Project, +) *projectservice.Service { + readiness := mocks.NewMockReadinessChecker(ctrl) + readiness.EXPECT().Check(gomock.Any(), selected).Return(nil, nil) + return projectservice.New(zap.NewNop(), projectservice.Dependencies{ + Manifests: manifests, + Locator: workspace, + Readiness: readiness, + }) +} diff --git a/internal/service/projectreadiness/checker.go b/internal/service/projectreadiness/checker.go new file mode 100644 index 0000000..a1586b6 --- /dev/null +++ b/internal/service/projectreadiness/checker.go @@ -0,0 +1,396 @@ +package projectreadiness + +import ( + "context" + "fmt" + "net/url" + "path" + "regexp" + "sort" + "strings" + + "github.com/BurntSushi/toml" + "github.com/devctllabs/devctl/internal/domain/project" + "golang.org/x/mod/modfile" +) + +var environmentKey = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`) + +const ( + oapiCodegenToolPath = "github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen" + bufToolPath = "github.com/bufbuild/buf/cmd/buf" +) + +//go:generate go tool mockgen -destination mocks/checker.go -package mocks -typed . Workspace + +// Workspace exposes only filesystem facts used by Project Readiness policy. +type Workspace interface { + // RegularFile reports whether relativePath is a regular non-symlink file below root. + RegularFile(ctx context.Context, root, relativePath string) (bool, error) + // Directory reports whether relativePath is a real directory below root. + Directory(ctx context.Context, root, relativePath string) (bool, error) + // ReadBytes reads the regular project file at relativePath below root. + ReadBytes(ctx context.Context, root, relativePath string) ([]byte, error) +} + +// Checker evaluates filesystem and tool readiness for a semantically inspected Project. +type Checker struct { + workspace Workspace +} + +func New(workspace Workspace) *Checker { + return &Checker{workspace: workspace} +} + +// Check returns every applicable readiness issue in stable order. +func (c *Checker) Check(ctx context.Context, selected project.Project) ([]project.Issue, error) { + check := readiness{workspace: c.workspace, project: selected} + goModExists, err := check.requireRegular(ctx, "go.mod", project.IssueGoModMissing) + if err != nil { + return nil, err + } + if err := check.sources(ctx); err != nil { + return check.issues, err + } + if err := check.components(ctx, goModExists); err != nil { + return check.issues, err + } + if err := check.migrations(ctx); err != nil { + return check.issues, err + } + return check.issues, nil +} + +func (r *readiness) components(ctx context.Context, goModExists bool) error { + if r.project.Manifest.Components.HTTP != nil { + if err := r.http(ctx, goModExists); err != nil { + return err + } + } + if r.project.Manifest.Components.GRPC != nil { + if err := r.grpc(ctx, goModExists); err != nil { + return err + } + } + if r.project.Manifest.Components.Kafka != nil { + if err := r.kafka(ctx, goModExists); err != nil { + return err + } + } + return nil +} + +func (r *readiness) kafka(ctx context.Context, goModExists bool) error { + manifest := r.project.Manifest + if hasReadyKafkaContractFormat(manifest, "proto") { + configs := make(map[string]struct{}) + for _, target := range project.NewTargetCatalog(manifest).Select(project.TargetOperationGenerate, "kafka", "") { + if target.Format == "proto" { + configs[target.Config] = struct{}{} + } + } + for _, config := range sortedKeys(configs) { + if _, err := r.requireRegular(ctx, config, project.IssueToolConfigMissing); err != nil { + return err + } + } + if goModExists { + if err := r.goTool(ctx, bufToolPath); err != nil { + return err + } + } + } + if hasReadyKafkaContractFormat(manifest, "json") { + return r.miseTools(ctx, "node", "npm:quicktype") + } + return nil +} + +func (r *readiness) miseTools(ctx context.Context, tools ...string) error { + exists, err := r.requireRegular(ctx, ".mise.toml", project.IssueToolConfigMissing) + if err != nil || !exists { + return err + } + content, err := r.workspace.ReadBytes(ctx, r.project.Root, ".mise.toml") + if err != nil { + return fmt.Errorf("workspace.ReadBytes: %w", operationError(project.OperationReadFile, ".mise.toml", err)) + } + configured, valid := decodeMiseTools(content) + if !valid { + r.add(project.IssueToolConfigInvalid, ".mise.toml") + return nil + } + for _, tool := range tools { + if _, exists := configured[tool]; !exists { + r.issues = append(r.issues, project.Issue{ + Code: project.IssueToolMissing, Path: r.project.ManifestPath, Field: ".mise.toml", + Parameters: &project.Parameters{Value: tool}, + }) + } + } + return nil +} + +func decodeMiseTools(content []byte) (map[string]toml.Primitive, bool) { + var config struct { + Tools map[string]toml.Primitive `toml:"tools"` + } + _, err := toml.Decode(string(content), &config) + return config.Tools, err == nil +} + +func hasReadyKafkaContractFormat(manifest project.Manifest, format string) bool { + kafka := manifest.Components.Kafka + if kafka == nil { + return false + } + for _, consumer := range kafka.Consumers { + if kafkaContractReady(manifest, consumer.Contract, format) { + return true + } + } + for _, producer := range kafka.Producers { + if kafkaContractReady(manifest, producer.Contract, format) { + return true + } + } + return false +} + +func kafkaContractReady(manifest project.Manifest, selected project.KafkaContract, format string) bool { + if selected.Format != format || selected.Source == "" { + return false + } + _, exists := manifest.Sources[selected.Source] + return exists +} + +func (r *readiness) grpc(ctx context.Context, goModExists bool) error { + manifest := r.project.Manifest + grpc := manifest.Components.GRPC + if grpc.Server == nil && !hasReadyGRPCClient(manifest, grpc.Clients) { + return nil + } + if grpc.Server != nil { + config := grpc.Server.BufConfig + if config == "" { + config = "buf.yaml" + } + if _, err := r.requireRegular(ctx, config, project.IssueToolConfigMissing); err != nil { + return err + } + } + configs := make(map[string]struct{}) + for _, target := range project.NewTargetCatalog(manifest).Select(project.TargetOperationGenerate, "grpc", "") { + if safeRelative(target.Config) { + configs[target.Config] = struct{}{} + } + } + for _, config := range sortedKeys(configs) { + if _, err := r.requireRegular(ctx, config, project.IssueToolConfigMissing); err != nil { + return err + } + } + if goModExists { + return r.goTool(ctx, bufToolPath) + } + return nil +} + +func hasReadyGRPCClient(manifest project.Manifest, clients []project.GRPCClient) bool { + for _, client := range clients { + if _, exists := manifest.Sources[client.Source]; exists { + return true + } + } + return false +} + +func (r *readiness) http(ctx context.Context, goModExists bool) error { + manifest := r.project.Manifest + targets := project.NewTargetCatalog(manifest).Select(project.TargetOperationGenerate, "http", "") + for _, target := range targets { + if target.Role == "server" && safeRelative(target.Reference.Entrypoint) { + if _, err := r.requireRegular(ctx, target.Reference.Entrypoint, project.IssueOpenAPIMissing); err != nil { + return err + } + } + } + if manifest.Languages.Go.Generators.HTTP == nil { + r.add(project.IssueHTTPGeneratorMissing, "languages.go.generators.http") + } else { + configs := make(map[string]struct{}, len(targets)) + for _, target := range targets { + configs[target.Config] = struct{}{} + } + for _, config := range sortedKeys(configs) { + if _, err := r.requireRegular(ctx, config, project.IssueToolConfigMissing); err != nil { + return err + } + } + } + if goModExists { + return r.goTool(ctx, oapiCodegenToolPath) + } + return nil +} + +func (r *readiness) goTool(ctx context.Context, toolPath string) error { + if _, checked := r.tools[toolPath]; checked { + return nil + } + if r.tools == nil { + r.tools = make(map[string]struct{}) + } + r.tools[toolPath] = struct{}{} + content, err := r.workspace.ReadBytes(ctx, r.project.Root, "go.mod") + if err != nil { + return fmt.Errorf("workspace.ReadBytes: %w", operationError(project.OperationReadFile, "go.mod", err)) + } + parsed, valid := parseGoMod(content) + if !valid { + r.add(project.IssueGoModInvalid, "go.mod") + return nil + } + for _, tool := range parsed.Tool { + if tool.Path == toolPath { + return nil + } + } + r.add(project.IssueToolMissing, "go.mod") + return nil +} + +func parseGoMod(content []byte) (*modfile.File, bool) { + parsed, err := modfile.Parse("go.mod", content, nil) + return parsed, err == nil +} + +type readiness struct { + workspace Workspace + project project.Project + issues []project.Issue + tools map[string]struct{} +} + +func (r *readiness) sources(ctx context.Context) error { + for _, name := range sortedKeys(r.project.Manifest.Sources) { + source := r.project.Manifest.Sources[name] + if source.Type != project.SourceLocal || !safeRelative(source.Path) { + continue + } + exists, err := r.directory(ctx, source.Path) + if err != nil { + return err + } + if !exists { + r.add(project.IssueSourceMissing, source.Path) + continue + } + if source.Proto.BufConfig != "" && safeRelative(source.Proto.BufConfig) { + if _, err := r.requireRegular(ctx, path.Join(source.Path, source.Proto.BufConfig), project.IssueToolConfigMissing); err != nil { + return err + } + } + } + return nil +} + +func (r *readiness) migrations(ctx context.Context) error { + if r.project.Manifest.Components.DB == nil { + return nil + } + var paths []string + for _, connection := range r.project.Manifest.Components.DB.Connections { + for _, variant := range connection.Variants { + if migrationsReady(variant.Kind, variant.Migrations) { + paths = append(paths, variant.Migrations.Path) + } + } + } + sort.Strings(paths) + for _, migrationPath := range paths { + exists, err := r.directory(ctx, migrationPath) + if err != nil { + return err + } + if !exists { + r.add(project.IssueMigrationPathMissing, migrationPath) + } + } + return nil +} + +func (r *readiness) directory(ctx context.Context, relativePath string) (bool, error) { + exists, err := r.workspace.Directory(ctx, r.project.Root, relativePath) + if err != nil { + return false, fmt.Errorf("workspace.Directory: %w", operationError(project.OperationInspectFile, relativePath, err)) + } + return exists, nil +} + +func migrationsReady(kind string, migrations *project.DBMigrations) bool { + if migrations == nil { + return false + } + validKind := kind == "sqlite" || kind == "postgres" || kind == "clickhouse" + if !validKind || !safeRelative(migrations.Path) || !environmentKey.MatchString(migrations.DatabaseEnv) { + return false + } + if migrations.DatabaseDefault == "" { + return true + } + parsed, err := url.Parse(migrations.DatabaseDefault) + if err != nil { + return false + } + switch kind { + case "sqlite": + return parsed.Scheme == "sqlite" + case "clickhouse": + return parsed.Scheme == "clickhouse" + default: + return parsed.Scheme == "postgres" || parsed.Scheme == "postgresql" + } +} + +func (r *readiness) requireRegular(ctx context.Context, relativePath string, code project.IssueCode) (bool, error) { + exists, err := r.workspace.RegularFile(ctx, r.project.Root, relativePath) + if err != nil { + return false, fmt.Errorf("workspace.RegularFile: %w", operationError(project.OperationInspectFile, relativePath, err)) + } + if !exists { + r.add(code, relativePath) + } + return exists, nil +} + +func operationError(operation project.Operation, selectedPath string, cause error) error { + return &project.OperationError{ + Operation: operation, + Path: selectedPath, + Kind: project.FailureUnavailable, + Cause: cause, + } +} + +func (r *readiness) add(code project.IssueCode, field string) { + r.issues = append(r.issues, project.Issue{Code: code, Path: r.project.ManifestPath, Field: field}) +} + +func safeRelative(name string) bool { + if name == "" || strings.HasPrefix(name, "/") { + return false + } + clean := path.Clean(strings.ReplaceAll(name, "\\", "/")) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") +} + +func sortedKeys[Value any](values map[string]Value) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/service/projectreadiness/checker_test.go b/internal/service/projectreadiness/checker_test.go new file mode 100644 index 0000000..53833c3 --- /dev/null +++ b/internal/service/projectreadiness/checker_test.go @@ -0,0 +1,617 @@ +package projectreadiness_test + +import ( + "context" + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/projectreadiness" + "github.com/devctllabs/devctl/internal/service/projectreadiness/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestCheckerReportsMissingGoModule(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{Root: "/project", ManifestPath: "/project/devctl.yaml"} + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(false, nil) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{{ + Code: project.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod", + }}, issues) +} + +func TestCheckerPreservesGoModuleInspectionError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{Root: "/project", ManifestPath: "/project/devctl.yaml"} + cause := errors.New("permission denied") + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(false, cause) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.Empty(t, issues) + require.ErrorIs(t, err, cause) + var operationErr *project.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, project.OperationInspectFile, operationErr.Operation) + require.Equal(t, "go.mod", operationErr.Path) + require.Equal(t, project.FailureUnavailable, operationErr.Kind) +} + +func TestCheckerChecksLocalSourcesInNameOrder(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Sources: map[string]project.Source{ + "bravo": {Type: project.SourceLocal, Path: "contracts/bravo"}, + "alpha": { + Type: project.SourceLocal, Path: "contracts/alpha", + Proto: project.SourceProto{BufConfig: "buf.yaml"}, + }, + }}, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "contracts/alpha").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "contracts/alpha/buf.yaml").Return(false, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "contracts/bravo").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "contracts/alpha/buf.yaml"}, + {Code: project.IssueSourceMissing, Path: selected.ManifestPath, Field: "contracts/bravo"}, + }, issues) +} + +func TestCheckerPreservesLocalSourceInspectionError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Sources: map[string]project.Source{ + "contracts": {Type: project.SourceLocal, Path: "api/contracts"}, + }}, + } + cause := errors.New("stat failed") + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil) + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "api/contracts").Return(false, cause) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.Empty(t, issues) + require.ErrorIs(t, err, cause) + var operationErr *project.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, project.OperationInspectFile, operationErr.Operation) + require.Equal(t, "api/contracts", operationErr.Path) + require.Equal(t, project.FailureUnavailable, operationErr.Kind) +} + +func TestCheckerChecksValidMigrationDirectoriesInPathOrder(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Components: project.Components{DB: &project.DB{ + Connections: []project.DBConnection{{Variants: []project.DBVariant{ + {Kind: "postgres", Migrations: &project.DBMigrations{Path: "migrations/zeta", DatabaseEnv: "DATABASE_URL"}}, + {Kind: "sqlite", Migrations: &project.DBMigrations{Path: "../unsafe", DatabaseEnv: "DATABASE_URL"}}, + {Kind: "clickhouse", Migrations: &project.DBMigrations{Path: "migrations/alpha", DatabaseEnv: "DATABASE_URL"}}, + }}}, + }}}, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/alpha").Return(false, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/zeta").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueMigrationPathMissing, Path: selected.ManifestPath, Field: "migrations/alpha"}, + {Code: project.IssueMigrationPathMissing, Path: selected.ManifestPath, Field: "migrations/zeta"}, + }, issues) +} + +func TestCheckerChecksMigrationDirectoriesWithMatchingDatabaseDefaults(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Components: project.Components{DB: &project.DB{ + Connections: []project.DBConnection{{Variants: []project.DBVariant{ + {Kind: "sqlite", Migrations: &project.DBMigrations{Path: "migrations/sqlite", DatabaseEnv: "SQLITE_URL", DatabaseDefault: "sqlite://data/app.db"}}, + {Kind: "postgres", Migrations: &project.DBMigrations{Path: "migrations/postgres", DatabaseEnv: "POSTGRES_URL", DatabaseDefault: "postgresql://localhost/app"}}, + {Kind: "clickhouse", Migrations: &project.DBMigrations{Path: "migrations/clickhouse", DatabaseEnv: "CLICKHOUSE_URL", DatabaseDefault: "clickhouse://localhost/app"}}, + {Kind: "postgres", Migrations: &project.DBMigrations{Path: "migrations/mismatch", DatabaseEnv: "MISMATCH_URL", DatabaseDefault: "sqlite://data/app.db"}}, + }}}, + }}}, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/clickhouse").Return(true, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/postgres").Return(true, nil), + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/sqlite").Return(true, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestCheckerPreservesMigrationDirectoryInspectionError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Components: project.Components{DB: &project.DB{ + Connections: []project.DBConnection{{Variants: []project.DBVariant{{ + Kind: "postgres", Migrations: &project.DBMigrations{Path: "migrations/app", DatabaseEnv: "DATABASE_URL"}, + }}}}, + }}}, + } + cause := errors.New("migration stat failed") + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil) + workspace.EXPECT().Directory(gomock.Any(), selected.Root, "migrations/app").Return(false, cause) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.Empty(t, issues) + require.ErrorIs(t, err, cause) + var operationErr *project.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, project.OperationInspectFile, operationErr.Operation) + require.Equal(t, "migrations/app", operationErr.Path) +} + +func TestCheckerReportsHTTPFilesAndGeneratorAfterGoModule(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{Components: project.Components{HTTP: &project.HTTP{ + Server: &project.HTTPServer{}, + }}}, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(false, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "api/openapi/swagger.yaml").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod"}, + {Code: project.IssueOpenAPIMissing, Path: selected.ManifestPath, Field: "api/openapi/swagger.yaml"}, + {Code: project.IssueHTTPGeneratorMissing, Path: selected.ManifestPath, Field: "languages.go.generators.http"}, + }, issues) +} + +func TestCheckerChecksHTTPConfigsAndOAPICodegenTool(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{HTTP: &project.HTTP{ + Server: &project.HTTPServer{OpenAPI: "api/service.yaml"}, + Clients: []project.HTTPClient{ + {Name: "zeta", Source: "contracts", Path: "zeta.yaml", OAPIConfig: "tools/oapi/zeta.yaml"}, + {Name: "alpha", Source: "contracts", Path: "alpha.yaml", OAPIConfig: "tools/oapi/alpha.yaml"}, + }, + }}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + HTTP: &project.HTTPGenerator{OAPIConfig: "tools/oapi/server.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "api/service.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/oapi/alpha.yaml").Return(false, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/oapi/server.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/oapi/zeta.yaml").Return(false, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte("module example.test/example\n"), nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "tools/oapi/alpha.yaml"}, + {Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "tools/oapi/zeta.yaml"}, + {Code: project.IssueToolMissing, Path: selected.ManifestPath, Field: "go.mod"}, + }, issues) +} + +func TestCheckerReportsInvalidGoModuleWhenHTTPNeedsTool(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Components: project.Components{HTTP: &project.HTTP{}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + HTTP: &project.HTTPGenerator{}, + }}}, + }, + } + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil) + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte("module [invalid"), nil) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{{ + Code: project.IssueGoModInvalid, Path: selected.ManifestPath, Field: "go.mod", + }}, issues) +} + +func TestCheckerPreservesGoModuleReadErrorWhenHTTPNeedsTool(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Components: project.Components{HTTP: &project.HTTP{}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + HTTP: &project.HTTPGenerator{}, + }}}, + }, + } + cause := errors.New("read failed") + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil) + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return(nil, cause) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.Empty(t, issues) + require.ErrorIs(t, err, cause) + var operationErr *project.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, project.OperationReadFile, operationErr.Operation) + require.Equal(t, "go.mod", operationErr.Path) +} + +func TestCheckerChecksGRPCModuleAndGeneratorConfigsWithoutGoModule(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Components: project.Components{GRPC: &project.GRPC{ + Server: &project.GRPCServer{BufConfig: "buf.yaml"}, + }}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + GRPC: &project.GRPCGenerator{BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(false, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "buf.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/grpc.gen.yaml").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod"}, + {Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "tools/buf/grpc.gen.yaml"}, + }, issues) +} + +func TestCheckerChecksGRPCClientOnlyGeneratorAndBufTool(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{GRPC: &project.GRPC{Clients: []project.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/billing.proto", ProtoRoot: "proto", + }}}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + GRPC: &project.GRPCGenerator{BufGenConfig: "tools/buf/clients.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/clients.gen.yaml").Return(true, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte( + "module example.test/example\n\ntool github.com/bufbuild/buf/cmd/buf\n", + ), nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestCheckerChecksEveryDistinctGRPCConfigInSortedOrder(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{GRPC: &project.GRPC{Clients: []project.GRPCClient{ + {Name: "zeta", Source: "contracts", Path: "proto/zeta.proto", BufGenConfig: "tools/buf/zeta.gen.yaml"}, + {Name: "alpha", Source: "contracts", Path: "proto/alpha.proto", BufGenConfig: "tools/buf/alpha.gen.yaml"}, + {Name: "alpha-v2", Source: "contracts", Path: "proto/alpha-v2.proto", BufGenConfig: "tools/buf/alpha.gen.yaml"}, + }}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + GRPC: &project.GRPCGenerator{BufGenConfig: "tools/buf/default.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(false, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/alpha.gen.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/zeta.gen.yaml").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{ + {Code: project.IssueGoModMissing, Path: selected.ManifestPath, Field: "go.mod"}, + {Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "tools/buf/zeta.gen.yaml"}, + }, issues) +} + +func TestCheckerDoesNotTreatGoModuleCommentAsToolDirective(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Components: project.Components{GRPC: &project.GRPC{Server: &project.GRPCServer{}}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + GRPC: &project.GRPCGenerator{BufGenConfig: "tools/buf/grpc.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "buf.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/grpc.gen.yaml").Return(true, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte( + "module example.test/example\n\n// tool github.com/bufbuild/buf/cmd/buf\n", + ), nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{{ + Code: project.IssueToolMissing, Path: selected.ManifestPath, Field: "go.mod", + }}, issues) +} + +func TestCheckerChecksKafkaProtoConfigAndBufTool(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{Kafka: &project.Kafka{Consumers: []project.KafkaConsumer{{ + Name: "billing", Contract: project.KafkaContract{ + Source: "contracts", Path: "proto/billing.proto", Format: "proto", ProtoRoot: "proto", + }, + }}}}, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + Kafka: &project.KafkaGenerator{BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/kafka.gen.yaml").Return(false, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte( + "module example.test/example\n\ntool github.com/bufbuild/buf/cmd/buf\n", + ), nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{{ + Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: "tools/buf/kafka.gen.yaml", + }}, issues) +} + +func TestCheckerChecksSharedBufToolOnceForGRPCAndKafka(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{ + GRPC: &project.GRPC{Server: &project.GRPCServer{BufConfig: "buf.yaml"}}, + Kafka: &project.Kafka{Producers: []project.KafkaProducer{{Contract: project.KafkaContract{ + Source: "contracts", Path: "proto/event.proto", Format: "proto", ProtoRoot: "proto", + }}}}, + }, + Languages: project.Languages{Go: project.GoLanguage{Generators: project.GoGenerators{ + GRPC: &project.GRPCGenerator{BufGenConfig: "tools/buf/grpc.gen.yaml"}, + Kafka: &project.KafkaGenerator{BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }}}, + }, + } + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "buf.yaml").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/grpc.gen.yaml").Return(true, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, "go.mod").Return([]byte( + "module example.test/example\n\ntool github.com/bufbuild/buf/cmd/buf\n", + ), nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "tools/buf/kafka.gen.yaml").Return(true, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Empty(t, issues) +} + +func TestCheckerRequiresMiseConfigForKafkaJSON(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := kafkaJSONProject() + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, ".mise.toml").Return(false, nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, []project.Issue{{ + Code: project.IssueToolConfigMissing, Path: selected.ManifestPath, Field: ".mise.toml", + }}, issues) +} + +func TestCheckerValidatesKafkaJSONMiseToolDeclarations(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + expected []project.Issue + }{ + {name: "valid", content: "[tools]\nnode = \"24\"\n\"npm:quicktype\" = \"26.0.0\"\n"}, + {name: "missing", content: "[tools]\ngo = \"1.26\"\n", expected: []project.Issue{ + {Code: project.IssueToolMissing, Path: "/project/devctl.yaml", Field: ".mise.toml", Parameters: &project.Parameters{Value: "node"}}, + {Code: project.IssueToolMissing, Path: "/project/devctl.yaml", Field: ".mise.toml", Parameters: &project.Parameters{Value: "npm:quicktype"}}, + }}, + {name: "invalid", content: "[tools\n", expected: []project.Issue{{ + Code: project.IssueToolConfigInvalid, Path: "/project/devctl.yaml", Field: ".mise.toml", + }}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := kafkaJSONProject() + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, ".mise.toml").Return(true, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, ".mise.toml").Return([]byte(test.content), nil), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.NoError(t, err) + require.Equal(t, test.expected, issues) + }) + } +} + +func TestCheckerPreservesMiseConfigReadError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + workspace := mocks.NewMockWorkspace(ctrl) + selected := kafkaJSONProject() + cause := errors.New("mise read failed") + gomock.InOrder( + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, "go.mod").Return(true, nil), + workspace.EXPECT().RegularFile(gomock.Any(), selected.Root, ".mise.toml").Return(true, nil), + workspace.EXPECT().ReadBytes(gomock.Any(), selected.Root, ".mise.toml").Return(nil, cause), + ) + + issues, err := projectreadiness.New(workspace).Check(context.Background(), selected) + + require.Empty(t, issues) + require.ErrorIs(t, err, cause) + var operationErr *project.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, project.OperationReadFile, operationErr.Operation) + require.Equal(t, ".mise.toml", operationErr.Path) +} + +func kafkaJSONProject() project.Project { + return project.Project{ + Root: "/project", ManifestPath: "/project/devctl.yaml", + Manifest: project.Manifest{ + Sources: map[string]project.Source{ + "contracts": {Type: project.SourceGit, Repo: "example/contracts", Ref: "v1"}, + }, + Components: project.Components{Kafka: &project.Kafka{Consumers: []project.KafkaConsumer{{ + Name: "billing", Contract: project.KafkaContract{ + Source: "contracts", Path: "json/billing.json", Format: "json", + }, + }}}}, + }, + } +} diff --git a/internal/service/projectreadiness/mocks/checker.go b/internal/service/projectreadiness/mocks/checker.go new file mode 100644 index 0000000..3106e93 --- /dev/null +++ b/internal/service/projectreadiness/mocks/checker.go @@ -0,0 +1,158 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/projectreadiness (interfaces: Workspace) +// +// Generated by this command: +// +// mockgen -destination mocks/checker.go -package mocks -typed . Workspace +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockWorkspace is a mock of Workspace interface. +type MockWorkspace struct { + ctrl *gomock.Controller + recorder *MockWorkspaceMockRecorder + isgomock struct{} +} + +// MockWorkspaceMockRecorder is the mock recorder for MockWorkspace. +type MockWorkspaceMockRecorder struct { + mock *MockWorkspace +} + +// NewMockWorkspace creates a new mock instance. +func NewMockWorkspace(ctrl *gomock.Controller) *MockWorkspace { + mock := &MockWorkspace{ctrl: ctrl} + mock.recorder = &MockWorkspaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWorkspace) EXPECT() *MockWorkspaceMockRecorder { + return m.recorder +} + +// Directory mocks base method. +func (m *MockWorkspace) Directory(ctx context.Context, root, relativePath string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Directory", ctx, root, relativePath) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Directory indicates an expected call of Directory. +func (mr *MockWorkspaceMockRecorder) Directory(ctx, root, relativePath any) *MockWorkspaceDirectoryCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Directory", reflect.TypeOf((*MockWorkspace)(nil).Directory), ctx, root, relativePath) + return &MockWorkspaceDirectoryCall{Call: call} +} + +// MockWorkspaceDirectoryCall wrap *gomock.Call +type MockWorkspaceDirectoryCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceDirectoryCall) Return(arg0 bool, arg1 error) *MockWorkspaceDirectoryCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceDirectoryCall) Do(f func(context.Context, string, string) (bool, error)) *MockWorkspaceDirectoryCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceDirectoryCall) DoAndReturn(f func(context.Context, string, string) (bool, error)) *MockWorkspaceDirectoryCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ReadBytes mocks base method. +func (m *MockWorkspace) ReadBytes(ctx context.Context, root, relativePath string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadBytes", ctx, root, relativePath) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadBytes indicates an expected call of ReadBytes. +func (mr *MockWorkspaceMockRecorder) ReadBytes(ctx, root, relativePath any) *MockWorkspaceReadBytesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadBytes", reflect.TypeOf((*MockWorkspace)(nil).ReadBytes), ctx, root, relativePath) + return &MockWorkspaceReadBytesCall{Call: call} +} + +// MockWorkspaceReadBytesCall wrap *gomock.Call +type MockWorkspaceReadBytesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceReadBytesCall) Return(arg0 []byte, arg1 error) *MockWorkspaceReadBytesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceReadBytesCall) Do(f func(context.Context, string, string) ([]byte, error)) *MockWorkspaceReadBytesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceReadBytesCall) DoAndReturn(f func(context.Context, string, string) ([]byte, error)) *MockWorkspaceReadBytesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// RegularFile mocks base method. +func (m *MockWorkspace) RegularFile(ctx context.Context, root, relativePath string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RegularFile", ctx, root, relativePath) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RegularFile indicates an expected call of RegularFile. +func (mr *MockWorkspaceMockRecorder) RegularFile(ctx, root, relativePath any) *MockWorkspaceRegularFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegularFile", reflect.TypeOf((*MockWorkspace)(nil).RegularFile), ctx, root, relativePath) + return &MockWorkspaceRegularFileCall{Call: call} +} + +// MockWorkspaceRegularFileCall wrap *gomock.Call +type MockWorkspaceRegularFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRegularFileCall) Return(arg0 bool, arg1 error) *MockWorkspaceRegularFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRegularFileCall) Do(f func(context.Context, string, string) (bool, error)) *MockWorkspaceRegularFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRegularFileCall) DoAndReturn(f func(context.Context, string, string) (bool, error)) *MockWorkspaceRegularFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/runtimeconfig/render.go b/internal/service/runtimeconfig/render.go new file mode 100644 index 0000000..6cd768a --- /dev/null +++ b/internal/service/runtimeconfig/render.go @@ -0,0 +1,122 @@ +package runtimeconfig + +import ( + "bytes" + _ "embed" + "fmt" + "go/format" + "sort" + "strconv" + "strings" + "text/template" + + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +//go:embed templates/config.go.gotmpl +var configTemplate string + +// Output contains the config package and project-root files owned by Runtime Config. +type Output struct { + Directory artifact.Tree + Files artifact.Tree +} + +// Render converts a canonical catalog into deterministic Managed Output. +func Render(catalog projectdomain.RuntimeConfigCatalog) (Output, error) { + configFile, err := renderConfig(catalog.Entries(projectdomain.RuntimeConfigRuntime)) + if err != nil { + return Output{}, err + } + envFile := renderEnv(catalog.Entries(projectdomain.RuntimeConfigExample)) + return Output{ + Directory: artifact.Tree{Files: []artifact.File{{Path: "config.gen.go", Content: configFile, Mode: 0o644}}}, + Files: artifact.Tree{Files: []artifact.File{{Path: ".env.example", Content: envFile, Mode: 0o644}}}, + }, nil +} + +type templateGroup struct { + Name string + Fields []templateField +} + +type templateField struct { + Name string + Type string + Tag string +} + +func renderConfig(fields []projectdomain.RuntimeConfigField) ([]byte, error) { + grouped := make(map[string][]projectdomain.RuntimeConfigField) + for _, field := range fields { + grouped[field.Group] = append(grouped[field.Group], field) + } + groupNames := make([]string, 0, len(grouped)) + for name := range grouped { + groupNames = append(groupNames, name) + } + sort.Strings(groupNames) + + groups := make([]templateGroup, 0, len(groupNames)) + for _, groupName := range groupNames { + groupFields := grouped[groupName] + sort.Slice(groupFields, func(i, j int) bool { return groupFields[i].Name < groupFields[j].Name }) + group := templateGroup{Name: groupName, Fields: make([]templateField, 0, len(groupFields))} + for _, field := range groupFields { + group.Fields = append(group.Fields, templateField{Name: field.Name, Type: goType(field.Type), Tag: fieldTag(field)}) + } + groups = append(groups, group) + } + + parsed, err := template.New("config.go").Parse(configTemplate) + if err != nil { + return nil, fmt.Errorf("template.Parse: %w", err) + } + var rendered bytes.Buffer + if err := parsed.Execute(&rendered, groups); err != nil { + return nil, fmt.Errorf("parsed.Execute: %w", err) + } + formatted, err := format.Source(rendered.Bytes()) + if err != nil { + return nil, fmt.Errorf("format.Source: %w", err) + } + return formatted, nil +} + +func fieldTag(field projectdomain.RuntimeConfigField) string { + tag := "`env:" + strconv.Quote(field.Key) + if field.HasDefault && !field.Secret { + tag += " default:" + strconv.Quote(fmt.Sprint(field.Default)) + } + return tag + "`" +} + +func goType(value projectdomain.RuntimeConfigType) string { + switch value { + case projectdomain.RuntimeConfigBool: + return "bool" + case projectdomain.RuntimeConfigInt: + return "int" + case projectdomain.RuntimeConfigDuration: + return "time.Duration" + case projectdomain.RuntimeConfigStringList: + return "[]string" + case projectdomain.RuntimeConfigString: + return "string" + default: + return "string" + } +} + +func renderEnv(fields []projectdomain.RuntimeConfigField) []byte { + var builder strings.Builder + for _, field := range fields { + value := "" + if field.HasDefault && !field.Secret { + value = fmt.Sprint(field.Default) + } + fmt.Fprintf(&builder, "%s=%s\n", field.Key, value) + } + return []byte(builder.String()) +} diff --git a/internal/service/runtimeconfig/render_test.go b/internal/service/runtimeconfig/render_test.go new file mode 100644 index 0000000..902cc84 --- /dev/null +++ b/internal/service/runtimeconfig/render_test.go @@ -0,0 +1,46 @@ +package runtimeconfig_test + +import ( + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/runtimeconfig" + "github.com/stretchr/testify/require" +) + +func TestRenderProducesCanonicalManagedOutput(t *testing.T) { + t.Parallel() + + manifest := projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample", Language: "go"}, + Components: projectdomain.Components{ + HTTP: &projectdomain.HTTP{Server: &projectdomain.HTTPServer{Start: &projectdomain.Start{}}}, + Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{Name: "billing"}}}, + }, + } + catalog, err := projectdomain.NewRuntimeConfigCatalog(manifest) + require.NoError(t, err) + + output, err := runtimeconfig.Render(catalog) + + require.NoError(t, err) + require.Len(t, output.Directory.Files, 1) + require.Equal(t, "config.gen.go", output.Directory.Files[0].Path) + source := string(output.Directory.Files[0].Content) + require.Regexp(t, `HTTP\s+HTTPConfig`, source) + require.Regexp(t, `Address\s+string\s+`+"`"+`env:"SAMPLE_HTTP_ADDR" default:":8080"`+"`", source) + require.Regexp(t, `Enabled\s+bool\s+`+"`"+`env:"SAMPLE_HTTP_SERVER_ENABLED" default:"false"`+"`", source) + require.Regexp(t, `Brokers\s+\[\]string\s+`+"`"+`env:"SAMPLE_KAFKA_BROKERS" default:"localhost:29092"`+"`", source) + require.Len(t, output.Files.Files, 1) + require.Equal(t, ".env.example", output.Files.Files[0].Path) + environment := string(output.Files.Files[0].Content) + for _, line := range []string{ + "SAMPLE_HTTP_ADDR=:8080\n", "SAMPLE_HTTP_SERVER_ENABLED=false\n", + "SAMPLE_KAFKA_BILLING_GROUP=sample-billing-group\n", "SAMPLE_KAFKA_BILLING_TOPIC=\n", + "SAMPLE_KAFKA_BILLING_BATCH_MAX_SIZE=1\n", "SAMPLE_KAFKA_BILLING_RETRY_MAX_ATTEMPTS=3\n", + "SAMPLE_KAFKA_BILLING_REBALANCE_TIMEOUT=30s\n", "SAMPLE_KAFKA_BILLING_SHUTDOWN_TIMEOUT=30s\n", + "SAMPLE_KAFKA_BROKERS=localhost:29092\n", + } { + require.Contains(t, environment, line) + } +} diff --git a/internal/service/runtimeconfig/templates/config.go.gotmpl b/internal/service/runtimeconfig/templates/config.go.gotmpl new file mode 100644 index 0000000..e9d2cae --- /dev/null +++ b/internal/service/runtimeconfig/templates/config.go.gotmpl @@ -0,0 +1,29 @@ +// Code generated by devctl. DO NOT EDIT. + +package config + +import ( + "fmt" + "time" +) + +type Config struct { +{{- range . }} + {{ .Name }} {{ .Name }}Config +{{- end }} +} +{{ range . }} +type {{ .Name }}Config struct { +{{- range .Fields }} + {{ .Name }} {{ .Type }} {{ .Tag }} +{{- end }} +} +{{ end }} +func (c *Config) Validate() error { + if c == nil { + return fmt.Errorf("config is nil") + } + return nil +} + +var _ time.Duration diff --git a/internal/service/scaffold/integration_test.go b/internal/service/scaffold/integration_test.go new file mode 100644 index 0000000..d4ae4ac --- /dev/null +++ b/internal/service/scaffold/integration_test.go @@ -0,0 +1,216 @@ +package scaffold_test + +import ( + "context" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + manifestrepo "github.com/devctllabs/devctl/internal/repository/manifest" + workspacerepo "github.com/devctllabs/devctl/internal/repository/workspace" + projectservice "github.com/devctllabs/devctl/internal/service/project" + "github.com/devctllabs/devctl/internal/service/scaffold" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestFilesystemRepoPreflightsAndPublishesRenderedArtifacts(t *testing.T) { + t.Parallel() + + root := t.TempDir() + repository := workspacerepo.NewFilesystemRepo() + service := scaffold.New(zap.NewNop(), fixedProjectRepository{project: projectdomain.Project{Root: root, Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample", Language: "go"}, Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/sample"}}, + }}}, repository) + changes, err := service.Scaffold(context.Background(), scaffolddomain.Command{}) + require.NoError(t, err) + require.NotEmpty(t, changes.Files) + requireGoldenFile(t, filepath.Join(root, "cmd/sample/main.go"), "testdata/minimal/cmd/sample/main.go") + requireGoldenFile(t, filepath.Join(root, "go.mod"), "testdata/minimal/go.mod") + changes, err = service.Scaffold(context.Background(), scaffolddomain.Command{}) + require.NoError(t, err) + for _, change := range changes.Files { + require.Equal(t, scaffolddomain.FileUnchanged, change.Action) + } +} + +func requireGoldenFile(t *testing.T, actualPath, goldenPath string) { + t.Helper() + actual, err := os.ReadFile(actualPath) + require.NoError(t, err) + golden, err := os.ReadFile(goldenPath) + require.NoError(t, err) + require.Equal(t, golden, actual) +} + +func TestFilesystemRepoCreatesCLIFoundation(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte("version: 1\nproject: {name: sample-cli, language: go}\nenv: {}\npaths: {external_contracts: api/external}\nsources: {}\nexports: {}\ncomponents:\n logging: {}\nlanguages:\n go:\n module: github.com/acme/sample-cli\n generators:\n config: {out: gen/config}\n"), 0o644)) + + result, err := scaffoldProject(context.Background(), manifestPath) + + require.NoError(t, err) + require.NotEmpty(t, result.Files) + for _, path := range []string{"go.mod", ".mise.toml", ".golangci.yml", "cmd/sample-cli/main.go", "internal/deps/container.gen.go"} { + _, err := os.Stat(filepath.Join(root, path)) + require.NoError(t, err, path) + } +} + +func TestFilesystemRepoRefreshPreservesApplicationAndAddsConsumerSeeds(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + initialManifest := []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: {} +exports: {} +components: {} +languages: + go: {module: example.test/sample} +`) + require.NoError(t, os.WriteFile(manifestPath, initialManifest, 0o644)) + _, err := scaffoldProject(context.Background(), manifestPath) + require.NoError(t, err) + applicationPath := filepath.Join(root, "internal/deps/application.go") + require.NoError(t, os.WriteFile(applicationPath, []byte("package deps\n\n// user composition\n"), 0o644)) + + withConsumer := []byte(`version: 1 +project: {name: sample, language: go} +env: {} +paths: {external_contracts: api/external} +sources: {} +exports: {} +components: + kafka: + consumers: + - name: audit + topic: sample.audit.events.v1 + contract: {format: raw} +languages: + go: {module: example.test/sample} +`) + require.NoError(t, os.WriteFile(manifestPath, withConsumer, 0o644)) + result, err := scaffoldProject(context.Background(), manifestPath) + require.NoError(t, err) + + application, err := os.ReadFile(applicationPath) + require.NoError(t, err) + require.Equal(t, "package deps\n\n// user composition\n", string(application)) + requireFileChange(t, result, "internal/deps/application.go", scaffolddomain.FileUnchanged) + requireFileChange(t, result, "internal/deps/consumer_audit.go", scaffolddomain.FileCreated) + _, err = os.Stat(filepath.Join(root, "internal/transport/consumerkafka/audit/handler.go")) + require.NoError(t, err) +} + +func requireFileChange(t *testing.T, result scaffolddomain.Result, path string, action scaffolddomain.FileAction) { + t.Helper() + for _, change := range result.Files { + if change.Path == path { + require.Equal(t, action, change.Action) + return + } + } + require.Fail(t, "scaffold change missing", path) +} + +func TestFilesystemRepoCreatesGoLibsDBProviders(t *testing.T) { + t.Parallel() + + root := t.TempDir() + manifestPath := filepath.Join(root, "devctl.yaml") + require.NoError(t, os.WriteFile(manifestPath, []byte(`version: 1 +project: {name: sample-api, language: go} +env: {prefix: SAMPLE_} +paths: {external_contracts: api/external} +sources: {} +exports: {} +components: + health: {server: {start: {env: HEALTH_ENABLED, default: true}}} + telemetry: {start: {env: TELEMETRY_ENABLED, default: false}} + db: + connections: + - name: primary + default: sqlite + variants: + - {name: sqlite, kind: sqlite, dsn_default: 'file:./data/app.db?_foreign_keys=on'} + - {name: postgres, kind: postgres, secret: true} +languages: + go: {module: github.com/acme/sample-api} +`), 0o644)) + + result, err := scaffoldProject(context.Background(), manifestPath) + require.NoError(t, err) + require.NotEmpty(t, result.Files) + apiSource, err := os.ReadFile(filepath.Join(root, "cmd/sample-api/internal/api.go")) + require.NoError(t, err) + for _, declaration := range []string{ + "func NewCmdAPI() *cli.Command", "deps.NewAPI(ctx)", "scenario.Run(ctx)", + } { + require.Contains(t, string(apiSource), declaration) + } + + storage, err := os.ReadFile(filepath.Join(root, "internal/deps/storage_primary.gen.go")) + require.NoError(t, err) + selectors := selectorNames(t, storage) + for _, expected := range []string{ + "sqlitedb.Open", "postgresdb.Open", "di.ProvideNamedResource", "di.ProvideNamed[txmanager.Managers]", + } { + if expected == "di.ProvideNamed[txmanager.Managers]" { + expected = "di.ProvideNamed" + } + require.Contains(t, selectors, expected) + } + _, err = os.Stat(filepath.Join(root, "data/.gitkeep")) + require.NoError(t, err) +} + +func selectorNames(t *testing.T, source []byte) map[string]struct{} { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "generated.go", source, parser.AllErrors) + require.NoError(t, err) + selectors := make(map[string]struct{}) + ast.Inspect(file, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + identifier, ok := selector.X.(*ast.Ident) + if ok { + selectors[identifier.Name+"."+selector.Sel.Name] = struct{}{} + } + return true + }) + return selectors +} + +func scaffoldProject(ctx context.Context, manifestPath string) (scaffolddomain.Result, error) { + adapter := workspacerepo.NewFilesystemRepo() + projects := projectservice.New(zap.NewNop(), projectservice.Dependencies{ + Manifests: manifestrepo.NewFilesystemRepo(), Locator: adapter, + }) + service := scaffold.New(zap.NewNop(), projects, adapter) + result, err := service.Scaffold(ctx, scaffolddomain.Command{ManifestPath: manifestPath}) + if err != nil { + return result, fmt.Errorf("service.Scaffold: %w", err) + } + return result, nil +} + +type fixedProjectRepository struct{ project projectdomain.Project } + +func (r fixedProjectRepository) LoadProject(context.Context, string) (projectdomain.Project, error) { + return r.project, nil +} diff --git a/internal/service/scaffold/mocks/ports.go b/internal/service/scaffold/mocks/ports.go new file mode 100644 index 0000000..d853583 --- /dev/null +++ b/internal/service/scaffold/mocks/ports.go @@ -0,0 +1,262 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/scaffold (interfaces: ProjectRepository,WorkspaceRepository) +// +// Generated by this command: +// +// mockgen -destination mocks/ports.go -package mocks -typed . ProjectRepository,WorkspaceRepository +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + fs "io/fs" + reflect "reflect" + + artifact "github.com/devctllabs/devctl/internal/domain/artifact" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockProjectRepository is a mock of ProjectRepository interface. +type MockProjectRepository struct { + ctrl *gomock.Controller + recorder *MockProjectRepositoryMockRecorder + isgomock struct{} +} + +// MockProjectRepositoryMockRecorder is the mock recorder for MockProjectRepository. +type MockProjectRepositoryMockRecorder struct { + mock *MockProjectRepository +} + +// NewMockProjectRepository creates a new mock instance. +func NewMockProjectRepository(ctrl *gomock.Controller) *MockProjectRepository { + mock := &MockProjectRepository{ctrl: ctrl} + mock.recorder = &MockProjectRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProjectRepository) EXPECT() *MockProjectRepositoryMockRecorder { + return m.recorder +} + +// LoadProject mocks base method. +func (m *MockProjectRepository) LoadProject(ctx context.Context, manifestPath string) (project.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadProject", ctx, manifestPath) + ret0, _ := ret[0].(project.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadProject indicates an expected call of LoadProject. +func (mr *MockProjectRepositoryMockRecorder) LoadProject(ctx, manifestPath any) *MockProjectRepositoryLoadProjectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadProject", reflect.TypeOf((*MockProjectRepository)(nil).LoadProject), ctx, manifestPath) + return &MockProjectRepositoryLoadProjectCall{Call: call} +} + +// MockProjectRepositoryLoadProjectCall wrap *gomock.Call +type MockProjectRepositoryLoadProjectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockProjectRepositoryLoadProjectCall) Return(arg0 project.Project, arg1 error) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockProjectRepositoryLoadProjectCall) Do(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockProjectRepositoryLoadProjectCall) DoAndReturn(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockWorkspaceRepository is a mock of WorkspaceRepository interface. +type MockWorkspaceRepository struct { + ctrl *gomock.Controller + recorder *MockWorkspaceRepositoryMockRecorder + isgomock struct{} +} + +// MockWorkspaceRepositoryMockRecorder is the mock recorder for MockWorkspaceRepository. +type MockWorkspaceRepositoryMockRecorder struct { + mock *MockWorkspaceRepository +} + +// NewMockWorkspaceRepository creates a new mock instance. +func NewMockWorkspaceRepository(ctrl *gomock.Controller) *MockWorkspaceRepository { + mock := &MockWorkspaceRepository{ctrl: ctrl} + mock.recorder = &MockWorkspaceRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWorkspaceRepository) EXPECT() *MockWorkspaceRepositoryMockRecorder { + return m.recorder +} + +// Lstat mocks base method. +func (m *MockWorkspaceRepository) Lstat(ctx context.Context, root, name string) (fs.FileInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Lstat", ctx, root, name) + ret0, _ := ret[0].(fs.FileInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Lstat indicates an expected call of Lstat. +func (mr *MockWorkspaceRepositoryMockRecorder) Lstat(ctx, root, name any) *MockWorkspaceRepositoryLstatCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Lstat", reflect.TypeOf((*MockWorkspaceRepository)(nil).Lstat), ctx, root, name) + return &MockWorkspaceRepositoryLstatCall{Call: call} +} + +// MockWorkspaceRepositoryLstatCall wrap *gomock.Call +type MockWorkspaceRepositoryLstatCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryLstatCall) Return(arg0 fs.FileInfo, arg1 error) *MockWorkspaceRepositoryLstatCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryLstatCall) Do(f func(context.Context, string, string) (fs.FileInfo, error)) *MockWorkspaceRepositoryLstatCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryLstatCall) DoAndReturn(f func(context.Context, string, string) (fs.FileInfo, error)) *MockWorkspaceRepositoryLstatCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PublishFile mocks base method. +func (m *MockWorkspaceRepository) PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishFile", ctx, root, target, content) + ret0, _ := ret[0].(artifact.PublishResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishFile indicates an expected call of PublishFile. +func (mr *MockWorkspaceRepositoryMockRecorder) PublishFile(ctx, root, target, content any) *MockWorkspaceRepositoryPublishFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishFile", reflect.TypeOf((*MockWorkspaceRepository)(nil).PublishFile), ctx, root, target, content) + return &MockWorkspaceRepositoryPublishFileCall{Call: call} +} + +// MockWorkspaceRepositoryPublishFileCall wrap *gomock.Call +type MockWorkspaceRepositoryPublishFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPublishFileCall) Return(arg0 artifact.PublishResult, arg1 error) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPublishFileCall) Do(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPublishFileCall) DoAndReturn(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// ReadBytes mocks base method. +func (m *MockWorkspaceRepository) ReadBytes(ctx context.Context, root, name string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadBytes", ctx, root, name) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadBytes indicates an expected call of ReadBytes. +func (mr *MockWorkspaceRepositoryMockRecorder) ReadBytes(ctx, root, name any) *MockWorkspaceRepositoryReadBytesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadBytes", reflect.TypeOf((*MockWorkspaceRepository)(nil).ReadBytes), ctx, root, name) + return &MockWorkspaceRepositoryReadBytesCall{Call: call} +} + +// MockWorkspaceRepositoryReadBytesCall wrap *gomock.Call +type MockWorkspaceRepositoryReadBytesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryReadBytesCall) Return(arg0 []byte, arg1 error) *MockWorkspaceRepositoryReadBytesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryReadBytesCall) Do(f func(context.Context, string, string) ([]byte, error)) *MockWorkspaceRepositoryReadBytesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryReadBytesCall) DoAndReturn(f func(context.Context, string, string) ([]byte, error)) *MockWorkspaceRepositoryReadBytesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Walk mocks base method. +func (m *MockWorkspaceRepository) Walk(ctx context.Context, root string, visit fs.WalkDirFunc) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Walk", ctx, root, visit) + ret0, _ := ret[0].(error) + return ret0 +} + +// Walk indicates an expected call of Walk. +func (mr *MockWorkspaceRepositoryMockRecorder) Walk(ctx, root, visit any) *MockWorkspaceRepositoryWalkCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Walk", reflect.TypeOf((*MockWorkspaceRepository)(nil).Walk), ctx, root, visit) + return &MockWorkspaceRepositoryWalkCall{Call: call} +} + +// MockWorkspaceRepositoryWalkCall wrap *gomock.Call +type MockWorkspaceRepositoryWalkCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryWalkCall) Return(arg0 error) *MockWorkspaceRepositoryWalkCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryWalkCall) Do(f func(context.Context, string, fs.WalkDirFunc) error) *MockWorkspaceRepositoryWalkCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryWalkCall) DoAndReturn(f func(context.Context, string, fs.WalkDirFunc) error) *MockWorkspaceRepositoryWalkCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/scaffold/planner.go b/internal/service/scaffold/planner.go new file mode 100644 index 0000000..8744683 --- /dev/null +++ b/internal/service/scaffold/planner.go @@ -0,0 +1,471 @@ +package scaffold + +import ( + "bytes" + "context" + "fmt" + "go/format" + "io/fs" + "path/filepath" + "sort" + "strings" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +// Artifact is one rendered scaffold file and its replacement policy. +type Artifact struct { + Path string + Mode fs.FileMode + Content []byte + // CreateOnly preserves different existing content on every refresh. + CreateOnly bool +} + +type conflict struct { + code string + path string +} + +type preflightInspector struct { + workspace WorkspaceRepository + root string + planned map[string]Artifact + conflicts []conflict +} + +type preflightRequest struct { + root string + artifacts []Artifact +} + +// preflight collects all detectable workspace conflicts before the first scaffold publication. +func preflight( + ctx context.Context, + workspace WorkspaceRepository, + request preflightRequest, +) ([]conflict, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("ctx.Err: %w", err) + } + planned := make(map[string]Artifact, len(request.artifacts)) + for _, artifact := range request.artifacts { + planned[canonicalArtifactPath(artifact.Path)] = artifact + } + inspector := preflightInspector{workspace: workspace, root: request.root, planned: planned} + err := workspace.Walk(ctx, request.root, func(path string, entry fs.DirEntry, walkErr error) error { + return inspector.inspectPath(ctx, path, entry, walkErr) + }) + if err != nil { + return nil, fmt.Errorf("workspace.Walk: %w", err) + } + return inspector.conflicts, nil +} + +// plan returns the complete rendered artifact set in deterministic path order with formatted Go sources. +func plan(m projectdomain.Manifest) ([]Artifact, error) { + projection := compileScaffoldProjection(m) + artifacts, err := baseArtifacts(projection) + if err != nil { + return nil, fmt.Errorf("baseArtifacts: %w", err) + } + server, err := serverArtifacts(projection) + if err != nil { + return nil, fmt.Errorf("serverArtifacts: %w", err) + } + artifacts = append(artifacts, server...) + http, err := httpArtifacts(projection.http) + if err != nil { + return nil, fmt.Errorf("httpArtifacts: %w", err) + } + artifacts = append(artifacts, http...) + proto, err := protoArtifacts(projection.proto) + if err != nil { + return nil, fmt.Errorf("protoArtifacts: %w", err) + } + artifacts = append(artifacts, proto...) + database, err := dbArtifacts(projection) + if err != nil { + return nil, fmt.Errorf("dbArtifacts: %w", err) + } + artifacts = append(artifacts, database...) + components, err := componentArtifacts(projection.components) + if err != nil { + return nil, fmt.Errorf("componentArtifacts: %w", err) + } + artifacts = append(artifacts, components...) + applyArtifactOwnership(projection, artifacts) + if err := ensureUniqueArtifactPaths(artifacts); err != nil { + return nil, fmt.Errorf("ensureUniqueArtifactPaths: %w", err) + } + sort.SliceStable(artifacts, func(i, j int) bool { return artifacts[i].Path < artifacts[j].Path }) + if err := formatGoArtifacts(artifacts); err != nil { + return nil, fmt.Errorf("formatGoArtifacts: %w", err) + } + return artifacts, nil +} + +func applyArtifactOwnership(projection scaffoldProjection, artifacts []Artifact) { + for index := range artifacts { + artifacts[index].CreateOnly = projection.scaffoldSeed(artifacts[index].Path) + } +} + +func componentArtifacts(projection componentProjection) ([]Artifact, error) { + builders := []func(componentProjection) ([]Artifact, error){ + grpcComponentArtifacts, + httpClientArtifacts, + resourceComponentArtifacts, + kafkaComponentArtifacts, + } + var artifacts []Artifact + for _, build := range builders { + group, err := build(projection) + if err != nil { + return nil, err + } + artifacts = append(artifacts, group...) + } + return artifacts, nil +} + +func grpcComponentArtifacts(projection componentProjection) ([]Artifact, error) { + if !projection.grpcEnabled { + return nil, nil + } + grpc, err := renderedTemplateArtifact("internal/deps/grpc.gen.go", "grpc.go.gotmpl", nil) + if err != nil { + return nil, err + } + artifacts := []Artifact{grpc} + if len(projection.grpcClients) == 0 { + return artifacts, nil + } + clients, err := renderedTemplateArtifact("internal/deps/grpc_clients.gen.go", "grpc_clients.go.gotmpl", projection.grpcClients) + if err != nil { + return nil, err + } + return append(artifacts, clients), nil +} + +func httpClientArtifacts(projection componentProjection) ([]Artifact, error) { + if len(projection.httpClients) == 0 { + return nil, nil + } + artifact, err := renderedTemplateArtifact("internal/deps/http_clients.gen.go", "http_clients.go.gotmpl", projection.httpClients) + return artifactSlice(artifact, err) +} + +func resourceComponentArtifacts(projection componentProjection) ([]Artifact, error) { + var artifacts []Artifact + if projection.redisEnabled { + artifact, err := renderedTemplateArtifact("internal/deps/redis.gen.go", "redis.go.gotmpl", projection.redisConnections) + if err != nil { + return nil, err + } + artifacts = append(artifacts, artifact) + } + if projection.s3 != nil { + artifact, err := renderedTemplateArtifact("internal/deps/s3.gen.go", "s3.go.gotmpl", projection.s3) + if err != nil { + return nil, err + } + artifacts = append(artifacts, artifact) + } + return artifacts, nil +} + +func kafkaComponentArtifacts(projection componentProjection) ([]Artifact, error) { + if projection.kafka == nil { + return nil, nil + } + kafka := projection.kafka + definitions := []struct { + path string + template string + data any + }{ + {"internal/deps/kafka_broker.gen.go", "kafka_broker.go.gotmpl", nil}, + {"internal/deps/kafka_consumers.gen.go", "kafka_consumers.go.gotmpl", struct { + Module string + Consumers []kafkaConsumerTemplateData + }{kafka.module, kafka.consumers}}, + {"internal/deps/kafka_producers.gen.go", "kafka_producers.go.gotmpl", kafka.producers}, + {filepath.ToSlash(filepath.Join("cmd", kafka.projectName, "internal", "consumer.go")), "consumer.go.gotmpl", struct{ Module string }{kafka.module}}, + } + artifacts := make([]Artifact, 0, len(definitions)+2*len(kafka.consumers)) + for _, definition := range definitions { + artifact, err := renderedTemplateArtifact(definition.path, definition.template, definition.data) + if err != nil { + return nil, err + } + artifacts = append(artifacts, artifact) + } + consumerSeeds, err := kafkaConsumerSeedArtifacts(kafka.consumerSeedFacts) + if err != nil { + return nil, err + } + return append(artifacts, consumerSeeds...), nil +} + +func kafkaConsumerSeedArtifacts(facts []kafkaConsumerSeedFact) ([]Artifact, error) { + artifacts := make([]Artifact, 0, 2*len(facts)) + for _, fact := range facts { + binding, err := renderedTemplateArtifact(filepath.ToSlash(filepath.Join("internal", "deps", "consumer_"+fact.packageName+".go")), "kafka_consumer_binding.go.gotmpl", fact.data) + if err != nil { + return nil, err + } + handler, err := renderedTemplateArtifact(filepath.ToSlash(filepath.Join("internal", "transport", "consumerkafka", fact.packageName, "handler.go")), "kafka_handler.go.gotmpl", struct{ Package string }{fact.packageName}) + if err != nil { + return nil, err + } + artifacts = append(artifacts, binding, handler) + } + return artifacts, nil +} + +func renderedTemplateArtifact(output, templateName string, data any) (Artifact, error) { + content, err := executeTemplate(templateName, data) + if err != nil { + return Artifact{}, err + } + return Artifact{Path: output, Mode: 0o644, Content: []byte(content)}, nil +} + +func artifactSlice(artifact Artifact, err error) ([]Artifact, error) { + if err != nil { + return nil, err + } + return []Artifact{artifact}, nil +} + +type kafkaConsumerTemplateData struct { + Name string + Topic string + Toggle bool + Format string + Module string +} + +func protoArtifacts(projection protoProjection) ([]Artifact, error) { + if !projection.enabled { + return nil, nil + } + generated, err := readTemplateAsset("buf-go.gen.yaml") + if err != nil { + return nil, fmt.Errorf("readTemplateAsset: %w", err) + } + artifacts := make([]Artifact, 0, len(projection.configPaths)+1) + for _, configPath := range projection.configPaths { + artifacts = append(artifacts, Artifact{configPath, 0o644, generated, false}) + } + moduleArtifact, err := grpcModuleArtifact(projection.grpcModule) + if err != nil { + return nil, err + } + if moduleArtifact != nil { + artifacts = append(artifacts, *moduleArtifact) + } + return artifacts, nil +} + +func grpcModuleArtifact(module *grpcModuleProjection) (*Artifact, error) { + if module == nil { + return nil, nil + } + moduleConfig, err := executeTemplate("buf.yaml.gotmpl", struct{ ProtoRoot string }{ProtoRoot: module.protoRoot}) + if err != nil { + return nil, fmt.Errorf("executeTemplate: %w", err) + } + artifact := Artifact{module.path, 0o644, []byte(moduleConfig), false} + return &artifact, nil +} + +func valueOrDefault(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func ensureUniqueArtifactPaths(artifacts []Artifact) error { + seen := make(map[string]struct{}, len(artifacts)) + for _, artifact := range artifacts { + path := canonicalArtifactPath(artifact.Path) + if _, exists := seen[path]; exists { + return fmt.Errorf("duplicate artifact path %q", path) + } + seen[path] = struct{}{} + } + return nil +} + +func canonicalArtifactPath(path string) string { + return filepath.ToSlash(filepath.Clean(filepath.FromSlash(path))) +} + +func baseArtifacts(projection scaffoldProjection) ([]Artifact, error) { + goMod, err := renderGoMod(projection.goMod) + if err != nil { + return nil, fmt.Errorf("renderGoMod: %w", err) + } + mainFile, err := renderMain(projection.main) + if err != nil { + return nil, fmt.Errorf("renderMain: %w", err) + } + configArtifacts, err := runtimeConfigArtifacts(projection.config) + if err != nil { + return nil, fmt.Errorf("runtimeConfigArtifacts: %w", err) + } + containerFile, err := renderContainer(projection.container) + if err != nil { + return nil, fmt.Errorf("renderContainer: %w", err) + } + applicationFile, err := renderApplication(projection.application) + if err != nil { + return nil, fmt.Errorf("renderApplication: %w", err) + } + projectReadme, err := executeTemplate("project-readme.md.gotmpl", struct{ Project string }{Project: projection.projectName}) + if err != nil { + return nil, fmt.Errorf("executeTemplate project README: %w", err) + } + mise, err := renderMise(projection.mise) + if err != nil { + return nil, fmt.Errorf("renderMise: %w", err) + } + golangci, err := readTemplateAsset("golangci.yml") + if err != nil { + return nil, fmt.Errorf("readTemplateAsset: %w", err) + } + artifacts := []Artifact{ + {"go.mod", 0o644, []byte(goMod), false}, + {"README.md", 0o644, []byte(projectReadme), false}, + {".mise.toml", 0o644, []byte(mise), false}, + {".golangci.yml", 0o644, golangci, false}, + {filepath.ToSlash(filepath.Join("cmd", projection.projectName, "main.go")), 0o644, []byte(mainFile), false}, + {"internal/deps/container.gen.go", 0o644, []byte(containerFile), false}, + {"internal/deps/application.go", 0o644, []byte(applicationFile), false}, + } + artifacts = append(artifacts, configArtifacts...) + return artifacts, nil +} + +func serverArtifacts(projection scaffoldProjection) ([]Artifact, error) { + if !projection.hasServer { + return nil, nil + } + runtimeFile, err := renderRuntime(projection.runtime) + if err != nil { + return nil, fmt.Errorf("renderRuntime: %w", err) + } + apiFile, err := renderAPI(projection.module) + if err != nil { + return nil, fmt.Errorf("renderAPI: %w", err) + } + return []Artifact{ + {"internal/deps/runtime.gen.go", 0o644, []byte(runtimeFile), false}, + {filepath.ToSlash(filepath.Join("cmd", projection.projectName, "internal", "api.go")), 0o644, []byte(apiFile), false}, + }, nil +} + +func httpArtifacts(projection httpProjection) ([]Artifact, error) { + var artifacts []Artifact + if !projection.enabled { + return artifacts, nil + } + for _, target := range projection.targets { + templateName := "oapi-client.yaml" + if target.Role == "server" { + seed, err := readTemplateAsset("openapi.yaml") + if err != nil { + return nil, fmt.Errorf("readTemplateAsset: %w", err) + } + artifacts = append(artifacts, Artifact{target.Reference.Entrypoint, 0o644, seed, false}) + templateName = "oapi-server.yaml" + } + config, err := readTemplateAsset(templateName) + if err != nil { + return nil, fmt.Errorf("readTemplateAsset: %w", err) + } + artifacts = append(artifacts, Artifact{target.Config, 0o644, config, false}) + } + return artifacts, nil +} + +func formatGoArtifacts(artifacts []Artifact) error { + for index := range artifacts { + artifact := &artifacts[index] + if strings.HasSuffix(artifact.Path, ".gen.go") && !bytes.HasPrefix(artifact.Content, []byte("// Code generated by devctl. DO NOT EDIT.")) { + artifact.Content = append([]byte("// Code generated by devctl. DO NOT EDIT.\n\n"), artifact.Content...) + } + if strings.HasSuffix(artifact.Path, ".go") { + formatted, formatErr := format.Source(artifact.Content) + if formatErr != nil { + return fmt.Errorf("format.Source: %s: %w", artifact.Path, formatErr) + } + artifact.Content = formatted + } + } + return nil +} + +func dbArtifacts(projection scaffoldProjection) ([]Artifact, error) { + if len(projection.storages) == 0 { + return nil, nil + } + artifacts := make([]Artifact, 0, len(projection.storages)+1) + for _, storage := range projection.storages { + storageFile, err := renderStorage(storage.template) + if err != nil { + return nil, fmt.Errorf("renderStorage: %w", err) + } + artifacts = append(artifacts, Artifact{storage.path, 0o644, []byte(storageFile), false}) + for _, migrationPath := range storage.migrationPaths { + artifacts = append(artifacts, Artifact{ + Path: filepath.ToSlash(filepath.Join(migrationPath, ".gitkeep")), + Mode: 0o644, Content: []byte{}, CreateOnly: false, + }) + } + } + if projection.hasSQLite { + artifacts = append(artifacts, Artifact{"data/.gitkeep", 0o644, []byte{}, false}) + } + return artifacts, nil +} + +func (i *preflightInspector) inspectPath(ctx context.Context, path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative := filepath.ToSlash(path) + if relative == "." { + return nil + } + if strings.HasPrefix(relative, ".git/") { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + clean := filepath.ToSlash(filepath.Clean(relative)) + if artifact, exists := i.planned[clean]; exists { + return i.inspectArtifact(ctx, artifact, clean, relative) + } + return nil +} + +func (i *preflightInspector) inspectArtifact(ctx context.Context, artifact Artifact, clean, relative string) error { + info, err := i.workspace.Lstat(ctx, i.root, clean) + if err != nil { + return fmt.Errorf("workspace.Lstat: %w", err) + } + if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() { + i.conflicts = append(i.conflicts, conflict{code: "wrong_file_kind", path: relative}) + return nil + } + _, err = i.workspace.ReadBytes(ctx, i.root, clean) + if err != nil { + return fmt.Errorf("workspace.ReadBytes: %w", err) + } + return nil +} diff --git a/internal/service/scaffold/planner_characterization_test.go b/internal/service/scaffold/planner_characterization_test.go new file mode 100644 index 0000000..c88f5e6 --- /dev/null +++ b/internal/service/scaffold/planner_characterization_test.go @@ -0,0 +1,177 @@ +package scaffold + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "testing" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" +) + +func TestPlanMatchesGoldenTrees(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + manifest projectdomain.Manifest + seeds []string + }{ + {name: "minimal", manifest: minimalScaffoldManifest(), seeds: []string{ + "README.md", "cmd/sample/main.go", "internal/deps/application.go", + }}, + {name: "full", manifest: fullCharacterizationManifest(), seeds: []string{ + "README.md", + "api/openapi/swagger.yaml", + "cmd/sample-api/internal/api.go", + "cmd/sample-api/internal/consumer.go", + "cmd/sample-api/main.go", + "data/.gitkeep", + "internal/deps/application.go", + "internal/deps/consumer_audit.go", + "internal/deps/consumer_invoice.go", + "internal/transport/consumerkafka/audit/handler.go", + "internal/transport/consumerkafka/invoice/handler.go", + "migrations/analytics/clickhouse/.gitkeep", + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + artifacts, err := plan(test.manifest) + require.NoError(t, err) + goldenRoot := filepath.Join("testdata", test.name) + if os.Getenv("UPDATE_GOLDEN") == "1" { + writeGoldenTree(t, goldenRoot, artifacts) + } + expected := readGoldenTree(t, goldenRoot) + + actual := make(map[string]string, len(artifacts)) + actualPaths := make([]string, 0, len(artifacts)) + actualSeeds := make([]string, 0, len(test.seeds)) + for _, artifact := range artifacts { + require.NotContains(t, actual, artifact.Path, "duplicate artifact path") + require.Equal(t, fs.FileMode(0o644), artifact.Mode, artifact.Path) + actual[artifact.Path] = string(artifact.Content) + actualPaths = append(actualPaths, artifact.Path) + if artifact.CreateOnly { + actualSeeds = append(actualSeeds, artifact.Path) + } + } + + expectedPaths := sortedPaths(expected) + require.Equal(t, expectedPaths, actualPaths, "artifact paths and deterministic position") + require.Equal(t, test.seeds, actualSeeds, "artifact ownership") + for _, path := range expectedPaths { + require.Equal(t, expected[path], actual[path], path) + } + }) + } +} + +func writeGoldenTree(t *testing.T, root string, artifacts []Artifact) { + t.Helper() + require.NoError(t, os.RemoveAll(root)) + for _, artifact := range artifacts { + path := filepath.Join(root, filepath.FromSlash(artifact.Path)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, artifact.Content, artifact.Mode)) + } +} + +func readGoldenTree(t *testing.T, root string) map[string]string { + t.Helper() + + contents := make(map[string]string) + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + + relativePath, err := filepath.Rel(root, path) + if err != nil { + return fmt.Errorf("filepath.Rel: %w", err) + } + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("os.ReadFile: %w", err) + } + contents[filepath.ToSlash(relativePath)] = string(content) + return nil + }) + require.NoError(t, err) + return contents +} + +func sortedPaths(contents map[string]string) []string { + paths := make([]string, 0, len(contents)) + for path := range contents { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +func fullCharacterizationManifest() projectdomain.Manifest { + return projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "sample-api", Language: "go"}, + Env: projectdomain.Env{Prefix: "SAMPLE_"}, + Sources: map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + }, + Components: projectdomain.Components{ + Logging: &projectdomain.Logging{}, + Health: &projectdomain.Health{}, + Telemetry: &projectdomain.Telemetry{}, + HTTP: &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{OpenAPI: "api/openapi/swagger.yaml"}, + Clients: []projectdomain.HTTPClient{{Name: "catalog", Source: "contracts", Path: "catalog.yaml", BaseURLEnv: "CATALOG_BASE_URL"}}, + }, + GRPC: &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto", BufConfig: "buf.yaml"}, + Clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts", Path: "billing.proto", AddrEnv: "BILLING_GRPC_ADDR"}}, + }, + Kafka: &projectdomain.Kafka{ + Consumers: []projectdomain.KafkaConsumer{ + {Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}, + {Name: "invoice", Topic: "sample.invoice.events.v1", Contract: projectdomain.KafkaContract{Format: "proto", Source: "contracts", Path: "invoice.proto"}}, + }, + Producers: []projectdomain.KafkaProducer{{Name: "events", Topic: "sample.events.v1", Contract: projectdomain.KafkaContract{Format: "json", Source: "contracts", Path: "events.json"}}}, + }, + Redis: &projectdomain.Redis{Connections: []projectdomain.RedisConnection{{Name: "cache", AddrDefault: "localhost:6379"}}}, + S3: &projectdomain.S3{ + Connections: []projectdomain.S3Connection{{Name: "default", Region: "us-east-1"}}, + Buckets: []projectdomain.S3Bucket{{Name: "media", Connection: "default", Bucket: "media-local"}}, + }, + DB: &projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Default: "sqlite", + Variants: []projectdomain.DBVariant{ + {Name: "sqlite", Kind: "sqlite", DSNDefault: "file:./data/app.db?_foreign_keys=on"}, + {Name: "postgres", Kind: "postgres", Secret: true}, + }, + }, { + Name: "analytics", Default: "clickhouse", + Variants: []projectdomain.DBVariant{{Name: "clickhouse", Kind: "clickhouse", DSNDefault: "clickhouse://localhost:9000/default", Secret: true, Migrations: &projectdomain.DBMigrations{ + Path: "migrations/analytics/clickhouse", DatabaseEnv: "DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL", + }}}, + }}}, + }, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{ + Module: "example.test/sample-api", + Generators: projectdomain.GoGenerators{ + GRPC: &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"}, + Kafka: &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"}, + }, + Components: projectdomain.GoComponents{Pprof: &projectdomain.Pprof{}}, + }}, + } +} diff --git a/internal/service/scaffold/planner_test.go b/internal/service/scaffold/planner_test.go new file mode 100644 index 0000000..b3ce2c5 --- /dev/null +++ b/internal/service/scaffold/planner_test.go @@ -0,0 +1,746 @@ +package scaffold + +import ( + "go/parser" + "go/token" + "io/fs" + "strings" + "testing" + + "github.com/BurntSushi/toml" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/stretchr/testify/require" + "golang.org/x/mod/modfile" + "gopkg.in/yaml.v3" +) + +func TestPlanAddsPinnedBufToolingForGRPC(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.GRPC = &projectdomain.GRPC{Server: &projectdomain.GRPCServer{ + ProtoRoot: "api/proto", BufConfig: "buf.yaml", + }} + manifest.Languages.Go.Generators.GRPC = &projectdomain.GRPCGenerator{Out: "gen/grpc", BufGenConfig: "tools/buf/grpc.gen.yaml"} + artifacts := plannedArtifacts(t, manifest) + + goMod, err := modfile.Parse("go.mod", artifacts["go.mod"].Content, nil) + require.NoError(t, err) + tools := make([]string, 0, len(goMod.Tool)) + for _, tool := range goMod.Tool { + tools = append(tools, tool.Path) + } + require.ElementsMatch(t, []string{ + "github.com/bufbuild/buf/cmd/buf", + "google.golang.org/protobuf/cmd/protoc-gen-go", + "google.golang.org/grpc/cmd/protoc-gen-go-grpc", + }, tools) + + var moduleConfig struct { + Modules []struct { + Path string `yaml:"path"` + } `yaml:"modules"` + Lint struct { + Use []string `yaml:"use"` + Except []string `yaml:"except"` + } `yaml:"lint"` + } + require.NoError(t, yaml.Unmarshal(artifacts["buf.yaml"].Content, &moduleConfig)) + require.Equal(t, "api/proto", moduleConfig.Modules[0].Path) + require.Equal(t, []string{"STANDARD"}, moduleConfig.Lint.Use) + require.Equal(t, []string{"FILE_LOWER_SNAKE_CASE"}, moduleConfig.Lint.Except) + + var generationConfig struct { + Plugins []struct { + Local []string `yaml:"local"` + } `yaml:"plugins"` + } + require.NoError(t, yaml.Unmarshal(artifacts["tools/buf/grpc.gen.yaml"].Content, &generationConfig)) + require.Equal(t, [][]string{ + {"go", "tool", "protoc-gen-go"}, + {"go", "tool", "protoc-gen-go-grpc"}, + }, [][]string{generationConfig.Plugins[0].Local, generationConfig.Plugins[1].Local}) +} + +func TestPlanManagesOnlyCanonicalBufGenerationConfigs(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}, + } + manifest.Components.GRPC = &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{ + {Name: "billing", Source: "contracts", Path: "proto/billing.proto", BufGenConfig: "tools/buf/billing.gen.yaml"}, + {Name: "catalog", Source: "contracts", Path: "proto/catalog.proto", BufGenConfig: "tools/buf/grpc.gen.yaml"}, + {Name: "orders", Source: "contracts", Path: "proto/orders.proto", BufGenConfig: "tools/buf/orders.gen.yaml"}, + }} + + artifacts := plannedArtifacts(t, manifest) + + require.Contains(t, artifacts, "tools/buf/grpc.gen.yaml") + require.NotContains(t, artifacts, "tools/buf/billing.gen.yaml") + require.NotContains(t, artifacts, "tools/buf/orders.gen.yaml") +} + +func TestPlanDoesNotCreateUnusedCanonicalBufConfigForCustomOnlyGRPCTargets(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}, + } + manifest.Components.GRPC = &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{ + {Name: "billing", Source: "contracts", Path: "proto/billing.proto", BufGenConfig: "tools/buf/billing.gen.yaml"}, + {Name: "orders", Source: "contracts", Path: "proto/orders.proto", BufGenConfig: "tools/buf/orders.gen.yaml"}, + }} + + artifacts := plannedArtifacts(t, manifest) + + require.NotContains(t, artifacts, "tools/buf/grpc.gen.yaml") + require.NotContains(t, artifacts, "tools/buf/billing.gen.yaml") + require.NotContains(t, artifacts, "tools/buf/orders.gen.yaml") +} + +func TestPlanAddsPinnedBufToolingForKafkaProto(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.Kafka = &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "events", Topic: "sample.event.events.v1", + Contract: projectdomain.KafkaContract{Format: "proto", Source: "contracts", Path: "proto/sample.event.events.v1.proto", ProtoRoot: "proto"}, + }}} + manifest.Languages.Go.Generators.Kafka = &projectdomain.KafkaGenerator{Out: "gen/kafka", BufGenConfig: "tools/buf/kafka.gen.yaml"} + artifacts := plannedArtifacts(t, manifest) + + require.Contains(t, artifacts, "tools/buf/kafka.gen.yaml") + goMod, err := modfile.Parse("go.mod", artifacts["go.mod"].Content, nil) + require.NoError(t, err) + tools := make([]string, 0, len(goMod.Tool)) + for _, tool := range goMod.Tool { + tools = append(tools, tool.Path) + } + require.Contains(t, tools, "github.com/bufbuild/buf/cmd/buf") + require.NotContains(t, string(artifacts["go.mod"].Content), "github.com/devctllabs/go-libs/kafkaproto") +} + +func TestPlanAddsKafkaProtoDecoderOnlyForProtoConsumer(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.Kafka = &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "events", Topic: "sample.event.events.v1", + Contract: projectdomain.KafkaContract{Format: "proto", Source: "contracts", Path: "proto/sample.event.events.v1.proto"}, + }}} + artifacts := plannedArtifacts(t, manifest) + + requireArtifactContains(t, artifacts, "go.mod", "github.com/devctllabs/go-libs/kafkaproto v0.1.0") + requireArtifactContains(t, artifacts, "internal/deps/kafka_consumers.gen.go", "kafkaproto.NewDecoder", "protoKafkaDecoder") +} + +func TestPlanAddsPinnedQuicktypeToolingForKafkaJSON(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.Kafka = &projectdomain.Kafka{Producers: []projectdomain.KafkaProducer{{ + Name: "events", Topic: "sample.event.events.v1", + Contract: projectdomain.KafkaContract{Format: "json", Source: "contracts", Path: "json/sample.event.events.v1.json"}, + }}} + artifacts := plannedArtifacts(t, manifest) + + require.NotContains(t, string(artifacts["go.mod"].Content), "go-jsonschema") + var mise struct { + Tools map[string]any `toml:"tools"` + } + _, err := toml.Decode(string(artifacts[".mise.toml"].Content), &mise) + require.NoError(t, err) + require.Equal(t, "24", mise.Tools["node"]) + require.Equal(t, "26.0.0", mise.Tools["npm:quicktype"]) +} + +func TestPlanPinsPublishedGoLibVersionsByModule(t *testing.T) { + t.Parallel() + + artifacts := plannedArtifacts(t, fullCharacterizationManifest()) + goMod := string(artifacts["go.mod"].Content) + + for _, dependency := range []string{ + "github.com/devctllabs/go-libs/lifecycle v0.2.0", + "github.com/devctllabs/go-libs/log v0.2.0", + "github.com/devctllabs/go-libs/oapivalidator v0.2.0", + "github.com/devctllabs/go-libs/postgresdb v0.2.0", + "github.com/devctllabs/go-libs/config v0.1.0", + "github.com/devctllabs/go-libs/di v0.1.0", + "github.com/devctllabs/go-libs/kafka v0.1.0", + "github.com/devctllabs/go-libs/kafkaproto v0.1.0", + "github.com/devctllabs/go-libs/retry v0.1.0", + "github.com/devctllabs/go-libs/sqlitedb v0.1.0", + "github.com/devctllabs/go-libs/txmanager v0.1.0", + "github.com/devctllabs/go-libs/telemetry v0.1.0", + "github.com/devctllabs/go-libs/health v0.1.0", + "github.com/devctllabs/go-libs/healthserver v0.1.0", + "github.com/devctllabs/go-libs/debugserver v0.1.0", + } { + require.Contains(t, goMod, dependency) + } + require.NotContains(t, goMod, "replace ") +} + +func TestPlanIncludesEveryV1ComponentFoundation(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.GRPC = &projectdomain.GRPC{Server: &projectdomain.GRPCServer{ProtoRoot: "api/proto/grpc", BufConfig: "buf.yaml"}} + manifest.Components.Kafka = &projectdomain.Kafka{ + Consumers: []projectdomain.KafkaConsumer{{Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}}, + Producers: []projectdomain.KafkaProducer{{Name: "events", Topic: "sample.event.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}}, + } + manifest.Components.Redis = &projectdomain.Redis{Connections: []projectdomain.RedisConnection{{Name: "cache"}}} + manifest.Components.S3 = &projectdomain.S3{Connections: []projectdomain.S3Connection{{Name: "default"}}, Buckets: []projectdomain.S3Bucket{{Name: "uploads", Connection: "default"}}} + artifacts := plannedArtifacts(t, manifest) + + for _, filename := range []string{ + "internal/deps/grpc.gen.go", + "internal/deps/kafka_broker.gen.go", + "internal/deps/kafka_consumers.gen.go", + "internal/deps/kafka_producers.gen.go", + "internal/deps/redis.gen.go", + "internal/deps/s3.gen.go", + "cmd/sample/internal/consumer.go", + "internal/transport/consumerkafka/audit/handler.go", + } { + artifact, exists := artifacts[filename] + require.True(t, exists, filename) + _, err := parser.ParseFile(token.NewFileSet(), filename, artifact.Content, parser.AllErrors) + require.NoError(t, err, filename) + } + require.True(t, artifacts["cmd/sample/internal/consumer.go"].CreateOnly) + require.True(t, artifacts["internal/transport/consumerkafka/audit/handler.go"].CreateOnly) +} + +func TestPlanAppliesS3ConnectionRuntimeConfig(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.S3 = &projectdomain.S3{ + Connections: []projectdomain.S3Connection{{ + Name: "archive", Credentials: "static", Endpoint: "http://localhost:9000", + Region: "us-east-1", PathStyle: true, + }}, + Buckets: []projectdomain.S3Bucket{{Name: "media", Connection: "archive", Bucket: "media-local"}}, + } + artifacts := plannedArtifacts(t, manifest) + + requireArtifactContains(t, artifacts, "internal/deps/s3.gen.go", + `s3ArchiveKey = "s3-connection:archive"`, + "credentials.NewStaticCredentialsProvider", + "cfg.S3.ArchiveAccessKeyID", "cfg.S3.ArchiveSecretAccessKey", + "cfg.S3.ArchiveEndpoint", "options.UsePathStyle = cfg.S3.ArchiveForcePathStyle", + `s3MediaBucketKey = "s3-bucket:media"`, `s3ArchiveKey`, "cfg.S3.MediaBucket", + ) +} + +func TestPlanMakesGeneratedGoOwnershipExplicit(t *testing.T) { + t.Parallel() + + manifest := fullCharacterizationManifest() + manifest.Components.GRPC = &projectdomain.GRPC{Server: &projectdomain.GRPCServer{}} + manifest.Components.Kafka = &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}} + manifest.Components.Redis = &projectdomain.Redis{Connections: []projectdomain.RedisConnection{{Name: "cache"}}} + manifest.Components.S3 = &projectdomain.S3{Connections: []projectdomain.S3Connection{{Name: "default"}}} + + for path, artifact := range plannedArtifacts(t, manifest) { + if !strings.HasSuffix(path, ".go") { + continue + } + if artifact.CreateOnly { + require.NotContains(t, path, ".gen.go", path) + require.NotContains(t, string(artifact.Content), "Code generated by devctl", path) + continue + } + require.True(t, strings.HasSuffix(path, ".gen.go"), path) + require.True(t, strings.HasPrefix(string(artifact.Content), "// Code generated by devctl. DO NOT EDIT.\n"), path) + } +} + +func TestPlanCreatesProjectReadmeWithRefreshChecklist(t *testing.T) { + t.Parallel() + + artifact := plannedArtifacts(t, fullCharacterizationManifest())["README.md"] + + require.True(t, artifact.CreateOnly) + require.Contains(t, string(artifact.Content), "go run ./cmd/sample-api --help") + require.Contains(t, string(artifact.Content), "devctl sync") + require.Contains(t, string(artifact.Content), "devctl init scaffold") + require.Contains(t, string(artifact.Content), "devctl gen") + require.Contains(t, string(artifact.Content), "*.gen.go") + require.Contains(t, string(artifact.Content), "internal/deps/application.go") +} + +func TestPlanSelectsRuntimeRootComponents(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configure func(*projectdomain.Manifest) + dependency string + configField string + runtimeField string + unexpectedFields []string + artifactCount int + }{ + { + name: "HTTP server", + configure: func(manifest *projectdomain.Manifest) { + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{}} + }, + dependency: "github.com/labstack/echo/v5 v5.3.1", configField: "HTTPConfig", runtimeField: "http *http.Server", + unexpectedFields: []string{"httpEnabled bool", "healthEnabled bool", "pprofEnabled bool"}, artifactCount: 14, + }, + { + name: "health server", + configure: func(manifest *projectdomain.Manifest) { + manifest.Components.Health = &projectdomain.Health{} + }, + dependency: "github.com/devctllabs/go-libs/healthserver v0.1.0", configField: "HealthConfig", runtimeField: "health *healthserverlib.Server", + unexpectedFields: []string{"httpEnabled bool", "healthEnabled bool", "pprofEnabled bool"}, artifactCount: 12, + }, + { + name: "pprof server", + configure: func(manifest *projectdomain.Manifest) { + manifest.Languages.Go.Components.Pprof = &projectdomain.Pprof{} + }, + dependency: "github.com/devctllabs/go-libs/debugserver v0.1.0", configField: "PprofConfig", runtimeField: "pprof *debugserverlib.Server", + unexpectedFields: []string{"httpEnabled bool", "healthEnabled bool", "pprofEnabled bool"}, artifactCount: 12, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + test.configure(&manifest) + artifacts := plannedArtifacts(t, manifest) + + require.Len(t, artifacts, test.artifactCount) + require.Contains(t, artifacts, "cmd/sample/internal/api.go") + requireArtifactContains(t, artifacts, "go.mod", "github.com/devctllabs/go-libs/lifecycle v0.2.0", test.dependency) + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", test.configField) + requireArtifactContains(t, artifacts, "internal/deps/runtime.gen.go", test.runtimeField) + for _, field := range test.unexpectedFields { + require.NotContains(t, string(artifacts["internal/deps/runtime.gen.go"].Content), field) + } + }) + } +} + +func TestPlanRendersLazyRuntimeScenariosAndFailClosedConsumers(t *testing.T) { + t.Parallel() + + disabled := false + enabled := true + manifest := minimalScaffoldManifest() + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{}} + manifest.Components.GRPC = &projectdomain.GRPC{Server: &projectdomain.GRPCServer{Start: &projectdomain.Start{}}} + manifest.Components.Health = &projectdomain.Health{Server: &projectdomain.HealthServer{Start: &projectdomain.Start{Default: &enabled}}} + manifest.Components.Kafka = &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{ + {Name: "audit", Topic: "sample.audit.events.v1", Contract: projectdomain.KafkaContract{Format: "raw"}}, + {Name: "invoice", Topic: "sample.invoice.events.v1", Start: &projectdomain.Start{Default: &disabled}, Contract: projectdomain.KafkaContract{Format: "raw"}}, + }} + artifacts := plannedArtifacts(t, manifest) + + container := string(artifacts["internal/deps/container.gen.go"].Content) + require.Contains(t, container, "func NewAPI(ctx context.Context) (*Scenario, error)") + require.Contains(t, container, "func NewConsumer(ctx context.Context, name string) (*Scenario, error)") + require.Contains(t, container, `case "audit":`) + require.Contains(t, container, `case "invoice":`) + require.Contains(t, container, `if !(cfg.Kafka.InvoiceEnabled)`) + require.Contains(t, container, `di.ResolveNamed[scenarioRunner](graph, kafkaConsumerKey(name))`) + consumerSource := container[strings.Index(container, "func NewConsumer"):] + require.Less(t, strings.Index(consumerSource, "switch name"), strings.Index(consumerSource, "graph := di.New()")) + require.Contains(t, container, "graph.Shutdown(context.WithoutCancel(ctx))") + + runtime := string(artifacts["internal/deps/runtime.gen.go"].Content) + require.Contains(t, runtime, `lifecycle.Task{Name: "http"`) + require.Contains(t, runtime, "if r.grpcEnabled") + require.Contains(t, runtime, "if r.healthEnabled") + require.Contains(t, runtime, "r.grpc.Stop()") + require.Contains(t, runtime, "<-ctx.Done()") + + kafka := string(artifacts["internal/deps/kafka_consumers.gen.go"].Content) + for _, expected := range []string{ + "config.CommitRetry = &config.Retry", "kafka.RejectStop", + "RebalanceTimeout:", "RebalanceDrainTimeout:", "ShutdownTimeout:", + } { + require.Contains(t, kafka, expected) + } + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", + `env:"SAMPLE_KAFKA_AUDIT_TOPIC" default:"sample.audit.events.v1"`, + `env:"SAMPLE_KAFKA_AUDIT_RETRY_MAX_ATTEMPTS" default:"3"`, + `env:"SAMPLE_KAFKA_INVOICE_CONSUMER_ENABLED" default:"false"`, + ) + requireArtifactContains(t, artifacts, "internal/transport/consumerkafka/audit/handler.go", + "retry.Permanent(ErrNotImplemented)", "must not retain batch data", + ) +} + +func TestPlanKeepsTelemetryOutOfServerRoot(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.Telemetry = &projectdomain.Telemetry{} + artifacts := plannedArtifacts(t, manifest) + + require.Len(t, artifacts, 10) + require.NotContains(t, artifacts, "internal/deps/runtime.gen.go") + require.NotContains(t, artifacts, "cmd/sample/internal/api.go") + requireArtifactContains(t, artifacts, "go.mod", "github.com/devctllabs/go-libs/telemetry v0.1.0") + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", "TelemetryConfig") + requireArtifactContains(t, artifacts, "internal/deps/container.gen.go", "telemetrylib.Open", "cfg.Telemetry.ServiceVersion") + require.NotContains(t, string(artifacts["go.mod"].Content), "github.com/devctllabs/go-libs/lifecycle") +} + +func TestPlanAppliesHTTPArtifactPolicies(t *testing.T) { + t.Parallel() + + t.Run("server and clients", func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + } + manifest.Components.HTTP = &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{OpenAPI: "contracts/service.yaml"}, + Clients: []projectdomain.HTTPClient{ + {Name: "orders", Source: "contracts", Path: "orders.yaml", OAPIConfig: "tools/oapi/orders.yaml"}, + {Name: "catalog", Source: "contracts", Path: "catalog.yaml"}, + }, + } + manifest.Languages.Go.Generators.HTTP = &projectdomain.HTTPGenerator{OAPIConfig: "tools/oapi/custom-server.yaml"} + artifacts := plannedArtifacts(t, manifest) + + require.True(t, artifacts["contracts/service.yaml"].CreateOnly) + require.False(t, artifacts["tools/oapi/custom-server.yaml"].CreateOnly) + require.False(t, artifacts["tools/oapi/clients.catalog.yaml"].CreateOnly) + require.False(t, artifacts["tools/oapi/orders.yaml"].CreateOnly) + require.NotContains(t, artifacts, "tools/oapi/server.yaml") + require.NotContains(t, artifacts, "api/openapi/swagger.yaml") + requireArtifactContains(t, artifacts, "go.mod", "tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen") + }) + + t.Run("client only", func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + } + manifest.Components.HTTP = &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "catalog", Source: "contracts", Path: "catalog.yaml", + }}} + artifacts := plannedArtifacts(t, manifest) + + require.Contains(t, artifacts, "tools/oapi/clients.catalog.yaml") + require.NotContains(t, artifacts, "tools/oapi/server.yaml") + require.NotContains(t, artifacts, "api/openapi/swagger.yaml") + require.NotContains(t, artifacts, "internal/deps/runtime.gen.go") + require.NotContains(t, artifacts, "cmd/sample/internal/api.go") + requireArtifactContains(t, artifacts, "go.mod", "tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen") + }) +} + +func TestPlanExposesRegistrarsAndRawOutboundClients(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Sources = map[string]projectdomain.Source{"contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}} + manifest.Components.HTTP = &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{}, + Clients: []projectdomain.HTTPClient{{Name: "billing", Source: "contracts", Path: "billing.yaml"}}, + } + manifest.Components.GRPC = &projectdomain.GRPC{ + Server: &projectdomain.GRPCServer{}, + Clients: []projectdomain.GRPCClient{{Name: "billing", Source: "contracts", Path: "billing.proto"}}, + } + artifacts := plannedArtifacts(t, manifest) + + requireArtifactContains(t, artifacts, "internal/deps/runtime.gen.go", + "type HTTPRegistrar interface", "RegisterHTTP(*echo.Echo)", + "type GRPCRegistrar interface", "RegisterGRPC(*grpc.Server)", + "httpRegistrar.RegisterHTTP", "grpcRegistrar.RegisterGRPC", + ) + requireArtifactContains(t, artifacts, "internal/deps/http_clients.gen.go", + `httpClientBillingKey = "http-client:billing"`, + "func BillingHTTPTransport(resolver di.Resolver) (*http.Client, error)", + "func BillingHTTPBaseURL(resolver di.Resolver) (string, error)", + ) + requireArtifactContains(t, artifacts, "internal/deps/grpc_clients.gen.go", + `grpcClientBillingKey = "grpc-client:billing"`, + "func BillingGRPCConn(resolver di.Resolver) (*grpc.ClientConn, error)", + ) + require.NotContains(t, string(artifacts["internal/deps/http_clients.gen.go"].Content), "ClientWithResponses") + require.NotContains(t, string(artifacts["internal/deps/grpc_clients.gen.go"].Content), "NewBillingClient") +} + +func TestPlanRendersDatabaseDecisions(t *testing.T) { + t.Parallel() + + t.Run("multiple kinds with telemetry", func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Env.Prefix = "APP_" + manifest.Components.Telemetry = &projectdomain.Telemetry{} + manifest.Components.DB = &projectdomain.DB{Connections: []projectdomain.DBConnection{ + { + Name: "read-model", KindEnv: "DATABASE_DRIVER", + Variants: []projectdomain.DBVariant{{Name: "postgres", Kind: "postgres", DSNEnv: "READ_DATABASE_URL", Secret: true}}, + }, + { + Name: "primary", Default: "local", + Variants: []projectdomain.DBVariant{ + {Name: "local", Kind: "sqlite", DSNDefault: "file:./data/app.db"}, + {Name: "remote", Kind: "postgres", Secret: true}, + }, + }, + }} + artifacts := plannedArtifacts(t, manifest) + + require.Contains(t, artifacts, "internal/deps/storage_primary.gen.go") + require.Contains(t, artifacts, "internal/deps/storage_read_model.gen.go") + require.True(t, artifacts["data/.gitkeep"].CreateOnly) + goMod := string(artifacts["go.mod"].Content) + require.Equal(t, 1, strings.Count(goMod, "github.com/devctllabs/go-libs/sqlitedb v0.1.0")) + require.Equal(t, 1, strings.Count(goMod, "github.com/devctllabs/go-libs/postgresdb v0.2.0")) + require.Equal(t, 1, strings.Count(goMod, "github.com/devctllabs/go-libs/txmanager v0.1.0")) + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", + `env:"APP_DATABASE_DRIVER" default:"postgres"`, + `env:"APP_READ_DATABASE_URL"`, + "DBPrimary DBPrimaryConfig", + `env:"APP_DB_PRIMARY_KIND" default:"local"`, + ) + requireArtifactContains(t, artifacts, "internal/deps/storage_primary.gen.go", `case "local":`, `case "remote":`, "cfg.DBPrimary.Kind", "telemetryRuntime.TracerProvider()") + requireArtifactContains(t, artifacts, "internal/deps/application.go", "provideStorageReadModel", "provideStoragePrimary") + }) + + t.Run("postgres does not create sqlite data directory", func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.DB = &projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "primary", Variants: []projectdomain.DBVariant{{Name: "postgres", Kind: "postgres", Secret: true}}, + }}} + artifacts := plannedArtifacts(t, manifest) + + require.NotContains(t, artifacts, "data/.gitkeep") + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", `default:"postgres"`, `env:"SAMPLE_DB_PRIMARY_POSTGRES_DSN"`) + require.NotContains(t, string(artifacts["internal/deps/storage_primary.gen.go"].Content), "sqlitedb") + }) + + t.Run("native clickhouse keeps telemetry out and uses namespaced health key", func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.Health = &projectdomain.Health{} + manifest.Components.Telemetry = &projectdomain.Telemetry{} + manifest.Components.DB = &projectdomain.DB{Connections: []projectdomain.DBConnection{{ + Name: "analytics", Default: "clickhouse", + Variants: []projectdomain.DBVariant{{Name: "clickhouse", Kind: "clickhouse", DSNDefault: "clickhouse://localhost:9000/default"}}, + }}} + artifacts := plannedArtifacts(t, manifest) + + storage := string(artifacts["internal/deps/storage_analytics.gen.go"].Content) + require.Contains(t, storage, `storageAnalyticsConnectionName = "db-connection:analytics"`) + for _, operation := range []string{"clickhouse.ParseDSN", "clickhouse.Open", "connection.Ping", "connection.Close"} { + require.Contains(t, storage, operation) + } + require.NotContains(t, storage, "telemetrylib") + require.NotContains(t, storage, "txmanager") + requireArtifactContains(t, artifacts, "internal/deps/runtime.gen.go", + `di.ResolveNamed[dbChecker](resolver, "db-connection:analytics")`, + ) + }) +} + +func TestPlanScaffoldsMigrationTargetsAndMiseTasks(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Env.Prefix = "APP_" + manifest.Components.DB = &projectdomain.DB{Connections: []projectdomain.DBConnection{ + {Name: "archive", Variants: []projectdomain.DBVariant{{ + Name: "postgres", Kind: "postgres", Migrations: &projectdomain.DBMigrations{ + Path: "db/archive", DatabaseEnv: "ARCHIVE_MIGRATIONS_URL", + }, + }}}, + {Name: "primary", Variants: []projectdomain.DBVariant{{ + Name: "sqlite", Kind: "sqlite", Migrations: &projectdomain.DBMigrations{ + Path: "migrations/primary/sqlite", DatabaseEnv: "DB_PRIMARY_SQLITE_MIGRATIONS_URL", + DatabaseDefault: "sqlite://./data/primary.db?_pragma=foreign_keys%281%29", + }, + }}}, + }} + artifacts := plannedArtifacts(t, manifest) + + require.True(t, artifacts["db/archive/.gitkeep"].CreateOnly) + require.True(t, artifacts["migrations/primary/sqlite/.gitkeep"].CreateOnly) + var mise struct { + Tools map[string]toml.Primitive `toml:"tools"` + Tasks map[string]struct { + Run string `toml:"run"` + Usage string `toml:"usage"` + Confirm any `toml:"confirm"` + } `toml:"tasks"` + } + metadata, err := toml.Decode(string(artifacts[".mise.toml"].Content), &mise) + require.NoError(t, err) + var migrateTool struct { + Version string `toml:"version"` + Tags []string `toml:"tags"` + } + require.NoError(t, metadata.PrimitiveDecode(mise.Tools["go:github.com/golang-migrate/migrate/v4/cmd/migrate"], &migrateTool)) + require.Equal(t, "v4.19.1", migrateTool.Version) + require.Equal(t, []string{"postgres", "sqlite"}, migrateTool.Tags) + require.Equal(t, `arg "" help="Migration name"`, mise.Tasks["migrate:primary:sqlite:create"].Usage) + require.Contains(t, mise.Tasks["migrate:primary:sqlite:create"].Run, `-format "20060102150405" "${usage_name?}"`) + require.Contains(t, mise.Tasks["migrate:primary:sqlite:up"].Run, `${APP_DB_PRIMARY_SQLITE_MIGRATIONS_URL:-sqlite://./data/primary.db?_pragma=foreign_keys%281%29}`) + require.Equal(t, `arg "[steps]" default="1" help="Number of migrations"`, mise.Tasks["migrate:primary:sqlite:down"].Usage) + require.NotNil(t, mise.Tasks["migrate:primary:sqlite:down"].Confirm) + require.Contains(t, mise.Tasks["migrate:archive:postgres:up"].Run, `${APP_ARCHIVE_MIGRATIONS_URL:?set APP_ARCHIVE_MIGRATIONS_URL}`) + require.NotContains(t, mise.Tasks["migrate:archive:postgres:down"].Run, "-all") +} + +func TestPlanUsesFallbackEnvironmentPrefix(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Project.Name = "sample-api" + manifest.Components.Logging = &projectdomain.Logging{} + artifacts := plannedArtifacts(t, manifest) + + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", `env:"SAMPLE_API_LOG_LEVEL"`) +} + +func TestPlanUsesCanonicalGeneratedRuntimeConfig(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + manifest.Components.HTTP = &projectdomain.HTTP{ + Server: &projectdomain.HTTPServer{Start: &projectdomain.Start{}}, + Env: projectdomain.ComponentEnv{System: []projectdomain.EnvVar{{ + Key: "HTTP_ADDR", Type: "string", Default: ":8080", + }}}, + } + artifacts := plannedArtifacts(t, manifest) + + requireArtifactContains(t, artifacts, "gen/config/config.gen.go", + "type Config struct", "HTTP HTTPConfig", "Enabled bool", "Address string", + `env:"SAMPLE_HTTP_SERVER_ENABLED" default:"false"`, + `env:"SAMPLE_HTTP_ADDR" default:":8080"`, + ) + requireArtifactContains(t, artifacts, ".env.example", + "SAMPLE_HTTP_ADDR=:8080", "SAMPLE_HTTP_SERVER_ENABLED=false", + ) + requireArtifactContains(t, artifacts, "internal/deps/config.gen.go", + `generatedconfig "example.test/sample/gen/config"`, + "type Config = generatedconfig.Config", + ) + requireArtifactContains(t, artifacts, "internal/deps/runtime.gen.go", + "cfg.HTTP.Address", "cfg.HTTP.Enabled", + ) +} + +func TestPlanRejectsDuplicateArtifactPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configure func(*projectdomain.Manifest) + path string + }{ + { + name: "OpenAPI seed collides with base artifact", + configure: func(manifest *projectdomain.Manifest) { + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{OpenAPI: "api/../go.mod"}} + }, + path: "go.mod", + }, + { + name: "client generator configs collide", + configure: func(manifest *projectdomain.Manifest) { + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + } + manifest.Components.HTTP = &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{ + {Name: "catalog", Source: "contracts", Path: "catalog.yaml", OAPIConfig: "tools/oapi/shared.yaml"}, + {Name: "orders", Source: "contracts", Path: "orders.yaml", OAPIConfig: "tools/oapi/shared.yaml"}, + }} + }, + path: "tools/oapi/shared.yaml", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + manifest := minimalScaffoldManifest() + test.configure(&manifest) + + artifacts, err := plan(manifest) + + require.ErrorContains(t, err, "duplicate artifact path") + require.ErrorContains(t, err, test.path) + require.Nil(t, artifacts) + }) + } +} + +func minimalScaffoldManifest() projectdomain.Manifest { + return projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "sample", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/sample"}}, + } +} + +func TestRenderPolicies(t *testing.T) { + t.Parallel() + + t.Run("Go names", func(t *testing.T) { + t.Parallel() + require.Equal(t, "Primary", goName("primary")) + require.Equal(t, "ReadModel", goName("read-model")) + }) + +} + +func plannedArtifacts(t *testing.T, manifest projectdomain.Manifest) map[string]Artifact { + t.Helper() + + planned, err := plan(manifest) + require.NoError(t, err) + artifacts := make(map[string]Artifact, len(planned)) + for index, artifact := range planned { + if index > 0 { + require.Less(t, planned[index-1].Path, artifact.Path, "artifact paths must be sorted") + } + require.NotContains(t, artifacts, artifact.Path, "duplicate artifact path") + require.Equal(t, fs.FileMode(0o644), artifact.Mode, artifact.Path) + artifacts[artifact.Path] = artifact + } + return artifacts +} + +func requireArtifactContains(t *testing.T, artifacts map[string]Artifact, path string, values ...string) { + t.Helper() + + artifact, exists := artifacts[path] + require.True(t, exists, path) + for _, value := range values { + require.Contains(t, string(artifact.Content), value, path) + } +} diff --git a/internal/service/scaffold/ports.go b/internal/service/scaffold/ports.go new file mode 100644 index 0000000..7206eae --- /dev/null +++ b/internal/service/scaffold/ports.go @@ -0,0 +1,29 @@ +package scaffold + +import ( + "context" + "io/fs" + + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +//go:generate go tool mockgen -destination mocks/ports.go -package mocks -typed . ProjectRepository,WorkspaceRepository + +// ProjectRepository resolves the valid project selected for scaffolding. +type ProjectRepository interface { + // LoadProject returns a structurally and semantically valid project or an execution error. + LoadProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) +} + +// WorkspaceRepository exposes contained file facts and per-file atomic publication. +type WorkspaceRepository interface { + // Walk visits project entries below root without escaping its containment boundary. + Walk(ctx context.Context, root string, visit fs.WalkDirFunc) error + // Lstat reports file metadata without following the final symlink named by name. + Lstat(ctx context.Context, root, name string) (fs.FileInfo, error) + // ReadBytes reads a contained project file below root. + ReadBytes(ctx context.Context, root, name string) ([]byte, error) + // PublishFile atomically publishes content at target below root and reports whether bytes changed. + PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) +} diff --git a/internal/service/scaffold/projection.go b/internal/service/scaffold/projection.go new file mode 100644 index 0000000..2936501 --- /dev/null +++ b/internal/service/scaffold/projection.go @@ -0,0 +1,627 @@ +package scaffold + +import ( + "path" + "sort" + "strings" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +type scaffoldProjection struct { + projectName string + module string + hasServer bool + container containerTemplateData + application applicationTemplateData + runtime runtimeTemplateData + config configProjection + http httpProjection + proto protoProjection + components componentProjection + storages []storageArtifactProjection + hasSQLite bool + goMod goModTemplateData + mise miseTemplateData + main mainTemplateData + seedPaths map[string]struct{} +} + +func compileScaffoldProjection(manifest projectdomain.Manifest) scaffoldProjection { + hasServer := manifestHasServer(manifest) + kafkaProto := manifestHasKafkaFormat(manifest, "proto") + kafkaJSON := manifestHasKafkaFormat(manifest, "json") + targets := projectdomain.NewTargetCatalog(manifest) + runtimeCatalog, runtimeErr := projectdomain.NewRuntimeConfigCatalog(manifest) + configTargets := targets.Select(projectdomain.TargetOperationGenerate, "config", "") + config := configProjection{catalog: runtimeCatalog, catalogErr: runtimeErr} + if len(configTargets) == 1 { + config.targetAvailable = true + config.target = configTargets[0] + config.importPath = path.Join(manifest.Languages.Go.Module, configTargets[0].OutputDir) + } + httpTargets := targets.Select(projectdomain.TargetOperationGenerate, "http", "") + proto := compileProtoProjection(manifest, targets, kafkaProto) + components := compileComponentProjection(manifest) + storages, hasSQLite := compileStorageProjections(manifest) + projection := scaffoldProjection{ + projectName: manifest.Project.Name, + module: manifest.Languages.Go.Module, + hasServer: hasServer, + container: compileContainerTemplate(manifest, hasServer), + application: compileApplicationTemplate(manifest, hasServer), + runtime: compileRuntimeTemplate(manifest), + config: config, + http: httpProjection{enabled: manifest.Components.HTTP != nil, targets: httpTargets}, + proto: proto, + components: components, + storages: storages, + hasSQLite: hasSQLite, + goMod: compileGoModTemplate(manifest, hasServer, kafkaProto), + mise: compileMiseTemplate(manifest, kafkaJSON), + main: mainTemplateData{ + Project: manifest.Project.Name, Module: manifest.Languages.Go.Module, + Server: hasServer, Kafka: manifest.Components.Kafka != nil, + }, + } + projection.seedPaths = compileSeedPaths(projection) + return projection +} + +func compileSeedPaths(projection scaffoldProjection) map[string]struct{} { + paths := map[string]struct{}{ + "README.md": {}, + "internal/deps/application.go": {}, + path.Join("cmd", projection.projectName, "main.go"): {}, + } + if projection.hasServer { + paths[path.Join("cmd", projection.projectName, "internal", "api.go")] = struct{}{} + } + for _, target := range projection.http.targets { + if target.Role == "server" { + paths[target.Reference.Entrypoint] = struct{}{} + } + } + if kafka := projection.components.kafka; kafka != nil { + paths[path.Join("cmd", kafka.projectName, "internal", "consumer.go")] = struct{}{} + for _, fact := range kafka.consumerSeedFacts { + paths[path.Join("internal", "deps", "consumer_"+fact.packageName+".go")] = struct{}{} + paths[path.Join("internal", "transport", "consumerkafka", fact.packageName, "handler.go")] = struct{}{} + } + } + for _, storage := range projection.storages { + for _, migrationPath := range storage.migrationPaths { + paths[path.Join(migrationPath, ".gitkeep")] = struct{}{} + } + } + if projection.hasSQLite { + paths["data/.gitkeep"] = struct{}{} + } + return paths +} + +func (p scaffoldProjection) scaffoldSeed(artifactPath string) bool { + _, exists := p.seedPaths[canonicalArtifactPath(artifactPath)] + return exists +} + +type componentProjection struct { + grpcEnabled bool + grpcClients []projectdomain.GRPCClient + httpClients []projectdomain.HTTPClient + redisEnabled bool + redisConnections []projectdomain.RedisConnection + s3 *projectdomain.S3 + kafka *kafkaProjection +} + +type kafkaProjection struct { + module string + projectName string + consumers []kafkaConsumerTemplateData + producers []projectdomain.KafkaProducer + consumerSeedFacts []kafkaConsumerSeedFact +} + +type kafkaConsumerSeedFact struct { + packageName string + data kafkaConsumerTemplateData +} + +func compileComponentProjection(manifest projectdomain.Manifest) componentProjection { + projection := componentProjection{ + grpcEnabled: manifest.Components.GRPC != nil, + redisEnabled: manifest.Components.Redis != nil, + } + if manifest.Components.GRPC != nil { + projection.grpcClients = append([]projectdomain.GRPCClient(nil), manifest.Components.GRPC.Clients...) + } + if manifest.Components.HTTP != nil { + projection.httpClients = append([]projectdomain.HTTPClient(nil), manifest.Components.HTTP.Clients...) + } + if manifest.Components.Redis != nil { + projection.redisConnections = append([]projectdomain.RedisConnection(nil), manifest.Components.Redis.Connections...) + } + if manifest.Components.S3 != nil { + storage := *manifest.Components.S3 + storage.Connections = append([]projectdomain.S3Connection(nil), storage.Connections...) + storage.Buckets = append([]projectdomain.S3Bucket(nil), storage.Buckets...) + projection.s3 = &storage + } + if manifest.Components.Kafka != nil { + kafka := &kafkaProjection{ + module: manifest.Languages.Go.Module, + projectName: manifest.Project.Name, + producers: append([]projectdomain.KafkaProducer(nil), manifest.Components.Kafka.Producers...), + } + kafka.consumers = make([]kafkaConsumerTemplateData, len(manifest.Components.Kafka.Consumers)) + kafka.consumerSeedFacts = make([]kafkaConsumerSeedFact, len(manifest.Components.Kafka.Consumers)) + for index, consumer := range manifest.Components.Kafka.Consumers { + data := kafkaConsumerTemplateData{ + Name: consumer.Name, Topic: consumer.Topic, Toggle: consumer.Start != nil, + Format: consumer.Contract.Format, Module: manifest.Languages.Go.Module, + } + kafka.consumers[index] = data + kafka.consumerSeedFacts[index] = kafkaConsumerSeedFact{ + packageName: strings.ReplaceAll(consumer.Name, "-", "_"), data: data, + } + } + projection.kafka = kafka + } + return projection +} + +type storageArtifactProjection struct { + path string + template storageTemplateData + migrationPaths []string +} + +func compileStorageProjections(manifest projectdomain.Manifest) ([]storageArtifactProjection, bool) { + if manifest.Components.DB == nil { + return nil, false + } + connections := append([]projectdomain.DBConnection(nil), manifest.Components.DB.Connections...) + sort.Slice(connections, func(i, j int) bool { return connections[i].Name < connections[j].Name }) + projections := make([]storageArtifactProjection, 0, len(connections)) + hasSQLite := false + for _, connection := range connections { + projection := storageArtifactProjection{ + path: "internal/deps/storage_" + strings.ReplaceAll(connection.Name, "-", "_") + ".gen.go", + template: compileStorageTemplate(connection, manifest.Components.Telemetry != nil), + } + for _, variant := range connection.Variants { + hasSQLite = hasSQLite || variant.Kind == "sqlite" + if variant.Migrations != nil { + projection.migrationPaths = append(projection.migrationPaths, variant.Migrations.Path) + } + } + projections = append(projections, projection) + } + return projections, hasSQLite +} + +func compileStorageTemplate(connection projectdomain.DBConnection, telemetry bool) storageTemplateData { + data := storageTemplateData{Name: goName(connection.Name), Connection: connection.Name, Telemetry: telemetry} + seenKinds := make(map[string]bool, len(connection.Variants)) + data.Variants = make([]storageVariant, 0, len(connection.Variants)) + for _, variant := range connection.Variants { + if variant.Kind == "clickhouse" { + data.ClickHouse = true + data.ClickHouseConfig = goName(variant.Name) + "DSN" + data.Variants = append(data.Variants, storageVariant{ + Name: variant.Name, Kind: variant.Kind, + ConfigField: goName(variant.Name) + "DSN", ClickHouse: true, + }) + continue + } + field := strings.ToLower(variant.Kind[:1]) + variant.Kind[1:] + if !seenKinds[variant.Kind] { + data.Kinds = append(data.Kinds, storageKind{Name: variant.Kind, Field: field}) + seenKinds[variant.Kind] = true + } + data.Variants = append(data.Variants, storageVariant{ + Name: variant.Name, Kind: variant.Kind, Field: field, + ConfigField: goName(variant.Name) + "DSN", + SQLite: variant.Kind == "sqlite", + }) + } + return data +} + +type httpProjection struct { + enabled bool + targets []projectdomain.Target +} + +type protoProjection struct { + enabled bool + configPaths []string + grpcModule *grpcModuleProjection +} + +type grpcModuleProjection struct { + path string + protoRoot string +} + +func compileProtoProjection( + manifest projectdomain.Manifest, + targets projectdomain.TargetCatalog, + kafkaProto bool, +) protoProjection { + projection := protoProjection{enabled: manifest.Components.GRPC != nil || kafkaProto} + seen := make(map[string]struct{}, 2) + for _, target := range targets.Select(projectdomain.TargetOperationGenerate, "grpc", "") { + if target.Config == "tools/buf/grpc.gen.yaml" { + seen[target.Config] = struct{}{} + } + } + for _, target := range targets.Select(projectdomain.TargetOperationGenerate, "kafka", "") { + if target.Format == "proto" && target.Config == "tools/buf/kafka.gen.yaml" { + seen[target.Config] = struct{}{} + } + } + projection.configPaths = make([]string, 0, len(seen)) + for configPath := range seen { + projection.configPaths = append(projection.configPaths, configPath) + } + sort.Strings(projection.configPaths) + if manifest.Components.GRPC != nil && manifest.Components.GRPC.Server != nil { + server := manifest.Components.GRPC.Server + projection.grpcModule = &grpcModuleProjection{ + path: valueOrDefault(server.BufConfig, "buf.yaml"), + protoRoot: valueOrDefault(server.ProtoRoot, "api/proto/grpc"), + } + } + return projection +} + +func compileGoModTemplate( + manifest projectdomain.Manifest, + hasServer bool, + kafkaProto bool, +) goModTemplateData { + requires := []string{ + "github.com/devctllabs/go-libs/config v0.1.0", + "github.com/devctllabs/go-libs/di v0.1.0", + "github.com/urfave/cli/v3 v3.10.1", + } + if manifest.Components.Logging != nil { + requires = append(requires, "github.com/devctllabs/go-libs/log v0.2.0", "go.uber.org/zap v1.28.0") + } + if hasServer { + requires = append(requires, "github.com/devctllabs/go-libs/lifecycle v0.2.0") + } + if manifest.Components.HTTP != nil && manifest.Components.HTTP.Server != nil { + requires = append(requires, "github.com/devctllabs/go-libs/oapivalidator v0.2.0", "github.com/labstack/echo/v5 v5.3.1") + } + if manifest.Components.Health != nil { + requires = append(requires, "github.com/devctllabs/go-libs/health v0.1.0", "github.com/devctllabs/go-libs/healthserver v0.1.0") + } + if manifest.Components.Telemetry != nil { + requires = append(requires, "github.com/devctllabs/go-libs/telemetry v0.1.0") + } + if manifest.Languages.Go.Components.Pprof != nil { + requires = append(requires, "github.com/devctllabs/go-libs/debugserver v0.1.0") + } + if manifest.Components.GRPC != nil { + requires = append(requires, + "github.com/bufbuild/buf v1.72.0", + "google.golang.org/grpc v1.83.2", + "google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2", + "google.golang.org/protobuf v1.36.12", + ) + } + if manifest.Components.Kafka != nil { + requires = append(requires, + "github.com/devctllabs/go-libs/kafka v0.1.0", + "github.com/devctllabs/go-libs/retry v0.1.0", + "github.com/twmb/franz-go v1.21.6", + ) + if manifestHasKafkaConsumerFormat(manifest, "proto") { + requires = append(requires, "github.com/devctllabs/go-libs/kafkaproto v0.1.0") + } + } + if manifest.Components.Redis != nil { + requires = append(requires, "github.com/redis/go-redis/v9 v9.22.0") + } + if manifest.Components.S3 != nil { + requires = append(requires, + "github.com/aws/aws-sdk-go-v2 v1.45.1", + "github.com/aws/aws-sdk-go-v2/config v1.33.1", + "github.com/aws/aws-sdk-go-v2/credentials v1.20.1", + "github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1", + ) + } + if manifest.Components.DB != nil { + for _, connection := range manifest.Components.DB.Connections { + for _, variant := range connection.Variants { + requires = append(requires, databaseVariantRequirements(variant.Kind)...) + } + } + } + sort.Strings(requires) + return goModTemplateData{ + Module: manifest.Languages.Go.Module, Requires: unique(requires), + HTTP: manifest.Components.HTTP != nil, Proto: manifest.Components.GRPC != nil || kafkaProto, + } +} + +func databaseVariantRequirements(kind string) []string { + if kind == "clickhouse" { + return []string{"github.com/ClickHouse/clickhouse-go/v2 v2.48.0"} + } + version := "v0.1.0" + if kind == "postgres" { + version = "v0.2.0" + } + return []string{ + "github.com/devctllabs/go-libs/txmanager v0.1.0", + "github.com/devctllabs/go-libs/" + kind + "db " + version, + } +} + +func compileMiseTemplate(manifest projectdomain.Manifest, kafkaJSON bool) miseTemplateData { + prefix := manifest.Env.Prefix + if prefix == "" { + prefix = strings.ToUpper(strings.ReplaceAll(manifest.Project.Name, "-", "_")) + "_" + } + migrations, tags := compileMigrationTasks(manifest.Components.DB, prefix) + return miseTemplateData{JSON: kafkaJSON, Migrations: migrations, MigrationTags: tags} +} + +func compileMigrationTasks(database *projectdomain.DB, prefix string) ([]migrationTask, []string) { + if database == nil { + return nil, nil + } + var migrations []migrationTask + migrationKinds := make(map[string]struct{}) + for _, connection := range database.Connections { + connectionTasks := compileConnectionMigrationTasks(connection, prefix) + migrations = append(migrations, connectionTasks...) + for _, task := range connectionTasks { + migrationKinds[task.Kind] = struct{}{} + } + } + sort.Slice(migrations, func(i, j int) bool { return migrations[i].TaskPrefix < migrations[j].TaskPrefix }) + tags := sortedKeys(migrationKinds) + return migrations, tags +} + +func compileConnectionMigrationTasks(connection projectdomain.DBConnection, prefix string) []migrationTask { + var tasks []migrationTask + for _, variant := range connection.Variants { + if task, exists := compileMigrationTask(connection.Name, variant, prefix); exists { + tasks = append(tasks, task) + } + } + return tasks +} + +func compileMigrationTask( + connectionName string, + variant projectdomain.DBVariant, + prefix string, +) (migrationTask, bool) { + if variant.Migrations == nil { + return migrationTask{}, false + } + environment := prefix + variant.Migrations.DatabaseEnv + expression := "${" + environment + ":?set " + environment + "}" + if variant.Migrations.DatabaseDefault != "" { + expression = "${" + environment + ":-" + variant.Migrations.DatabaseDefault + "}" + } + return migrationTask{ + TaskPrefix: "migrate:" + connectionName + ":" + variant.Name, + Path: variant.Migrations.Path, DatabaseExpression: expression, Kind: variant.Kind, + }, true +} + +func sortedKeys[Value any](values map[string]Value) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func manifestHasKafkaFormat(manifest projectdomain.Manifest, format string) bool { + if manifest.Components.Kafka == nil { + return false + } + for _, consumer := range manifest.Components.Kafka.Consumers { + if consumer.Contract.Format == format { + return true + } + } + for _, producer := range manifest.Components.Kafka.Producers { + if producer.Contract.Format == format { + return true + } + } + return false +} + +func manifestHasKafkaConsumerFormat(manifest projectdomain.Manifest, format string) bool { + if manifest.Components.Kafka == nil { + return false + } + for _, consumer := range manifest.Components.Kafka.Consumers { + if consumer.Contract.Format == format { + return true + } + } + return false +} + +func compileContainerTemplate(manifest projectdomain.Manifest, hasServer bool) containerTemplateData { + data := containerTemplateData{ + Project: manifest.Project.Name, + Logging: manifest.Components.Logging != nil, + Telemetry: manifest.Components.Telemetry != nil, + Database: manifest.Components.DB != nil, + Server: hasServer, + Kafka: manifest.Components.Kafka != nil, + } + if manifest.Components.Telemetry != nil { + data.TelemetryToggle = manifest.Components.Telemetry.Start != nil + } + if manifest.Components.DB != nil { + data.RuntimeUsesResolver = manifest.Components.Health != nil && len(manifest.Components.DB.Connections) > 0 + data.Connections = make([]containerConnection, 0, len(manifest.Components.DB.Connections)) + for _, connection := range manifest.Components.DB.Connections { + data.Connections = append(data.Connections, containerConnection{ + Name: goName(connection.Name), Connection: connection.Name, + }) + } + } + if manifest.Components.Kafka != nil { + for _, consumer := range manifest.Components.Kafka.Consumers { + enabled := "true" + if consumer.Start != nil { + enabled = "cfg.Kafka." + goName(consumer.Name) + "Enabled" + } + data.Consumers = append(data.Consumers, containerConsumer{Name: consumer.Name, Enabled: enabled}) + } + } + return data +} + +func compileApplicationTemplate(manifest projectdomain.Manifest, hasServer bool) applicationTemplateData { + return applicationTemplateData{ + HTTP: manifest.Components.HTTP != nil && manifest.Components.HTTP.Server != nil, + GRPC: manifest.Components.GRPC != nil && manifest.Components.GRPC.Server != nil, + Calls: applicationProviderCalls(manifest, hasServer), + } +} + +func applicationProviderCalls(manifest projectdomain.Manifest, hasServer bool) []string { + var calls []string + if manifest.Components.Logging != nil { + calls = append(calls, "provideLogging") + } + if manifest.Components.Telemetry != nil { + calls = append(calls, "provideTelemetry") + } + calls = append(calls, databaseProviderCalls(manifest.Components.DB)...) + calls = append(calls, kafkaProviderCalls(manifest.Components.Kafka)...) + calls = append(calls, httpClientProviderCalls(manifest.Components.HTTP)...) + calls = append(calls, grpcClientProviderCalls(manifest.Components.GRPC)...) + calls = append(calls, redisProviderCalls(manifest.Components.Redis)...) + calls = append(calls, s3ProviderCalls(manifest.Components.S3)...) + if hasServer { + calls = append(calls, "provideRuntime") + } + return calls +} + +func databaseProviderCalls(database *projectdomain.DB) []string { + if database == nil { + return nil + } + calls := make([]string, 0, len(database.Connections)) + for _, connection := range database.Connections { + calls = append(calls, "provideStorage"+goName(connection.Name)) + } + return calls +} + +func kafkaProviderCalls(broker *projectdomain.Kafka) []string { + if broker == nil { + return nil + } + calls := make([]string, 0, len(broker.Consumers)+len(broker.Producers)) + for _, consumer := range broker.Consumers { + calls = append(calls, "provide"+goName(consumer.Name)+"Consumer") + } + for _, producer := range broker.Producers { + calls = append(calls, "provide"+goName(producer.Name)+"KafkaProducer") + } + return calls +} + +func httpClientProviderCalls(http *projectdomain.HTTP) []string { + if http == nil { + return nil + } + calls := make([]string, 0, len(http.Clients)) + for _, client := range http.Clients { + calls = append(calls, "provide"+goName(client.Name)+"HTTPClient") + } + return calls +} + +func grpcClientProviderCalls(grpc *projectdomain.GRPC) []string { + if grpc == nil { + return nil + } + calls := make([]string, 0, len(grpc.Clients)) + for _, client := range grpc.Clients { + calls = append(calls, "provide"+goName(client.Name)+"GRPCClient") + } + return calls +} + +func redisProviderCalls(redis *projectdomain.Redis) []string { + if redis == nil { + return nil + } + calls := make([]string, 0, len(redis.Connections)) + for _, connection := range redis.Connections { + calls = append(calls, "provideRedis"+goName(connection.Name)) + } + return calls +} + +func s3ProviderCalls(storage *projectdomain.S3) []string { + if storage == nil { + return nil + } + calls := make([]string, 0, len(storage.Connections)+len(storage.Buckets)) + for _, connection := range storage.Connections { + calls = append(calls, "provideS3"+goName(connection.Name)) + } + for _, bucket := range storage.Buckets { + calls = append(calls, "provideS3"+goName(bucket.Name)+"Bucket") + } + return calls +} + +func compileRuntimeTemplate(manifest projectdomain.Manifest) runtimeTemplateData { + data := runtimeTemplateData{ + HTTP: manifest.Components.HTTP != nil && manifest.Components.HTTP.Server != nil, + GRPC: manifest.Components.GRPC != nil && manifest.Components.GRPC.Server != nil, + Health: manifest.Components.Health != nil, + Pprof: manifest.Languages.Go.Components.Pprof != nil, + } + if data.HTTP { + data.HTTPToggle = manifest.Components.HTTP.Server.Start != nil + } + if data.GRPC { + data.GRPCToggle = manifest.Components.GRPC.Server.Start != nil + } + if data.Health && manifest.Components.Health.Server != nil { + data.HealthToggle = manifest.Components.Health.Server.Start != nil + } + if data.Pprof && manifest.Languages.Go.Components.Pprof.Server != nil { + data.PprofToggle = manifest.Languages.Go.Components.Pprof.Server.Start != nil + } + if data.Health && manifest.Components.DB != nil { + data.HealthConnections = make([]runtimeHealthConnection, 0, len(manifest.Components.DB.Connections)) + for _, connection := range manifest.Components.DB.Connections { + data.HealthConnections = append(data.HealthConnections, runtimeHealthConnection{ + Connection: "db-connection:" + connection.Name, + Probe: "db." + connection.Name, + }) + } + } + return data +} + +func manifestHasServer(manifest projectdomain.Manifest) bool { + return manifest.Components.HTTP != nil && manifest.Components.HTTP.Server != nil || + manifest.Components.GRPC != nil && manifest.Components.GRPC.Server != nil || + manifest.Components.Health != nil || manifest.Languages.Go.Components.Pprof != nil +} diff --git a/internal/service/scaffold/render.go b/internal/service/scaffold/render.go new file mode 100644 index 0000000..e75fbac --- /dev/null +++ b/internal/service/scaffold/render.go @@ -0,0 +1,118 @@ +package scaffold + +import ( + "bytes" + "embed" + "fmt" + "strings" + "text/template" + "unicode" +) + +//go:embed templates/* +var scaffoldTemplates embed.FS + +func readTemplateAsset(name string) ([]byte, error) { + content, err := scaffoldTemplates.ReadFile("templates/" + name) + if err != nil { + return nil, fmt.Errorf("scaffoldTemplates.ReadFile: %w", err) + } + return content, nil +} + +func executeTemplate(name string, data any) (string, error) { + parsed, err := template.New(name).Funcs(template.FuncMap{ + "goName": goName, + "replace": func(value string) string { return strings.ReplaceAll(value, "-", "_") }, + "hasProto": func(consumers []kafkaConsumerTemplateData) bool { + for _, consumer := range consumers { + if consumer.Format == "proto" { + return true + } + } + return false + }, + }).ParseFS(scaffoldTemplates, "templates/"+name) + if err != nil { + return "", fmt.Errorf("template.ParseFS: %w", err) + } + var output bytes.Buffer + if err := parsed.Execute(&output, data); err != nil { + return "", fmt.Errorf("parsed.Execute: %w", err) + } + return output.String(), nil +} + +type goModTemplateData struct { + Module string + Requires []string + HTTP bool + Proto bool +} + +func renderGoMod(data goModTemplateData) (string, error) { + return executeTemplate("go.mod.gotmpl", data) +} + +type migrationTask struct { + TaskPrefix string + Path string + DatabaseExpression string + Kind string +} + +type miseTemplateData struct { + JSON bool + Migrations []migrationTask + MigrationTags []string +} + +func renderMise(data miseTemplateData) (string, error) { + rendered, err := executeTemplate("mise.toml", data) + if err != nil { + return "", err + } + return strings.TrimRight(rendered, "\n") + "\n", nil +} + +type mainTemplateData struct { + Project string + Module string + Server bool + Kafka bool +} + +func renderMain(data mainTemplateData) (string, error) { + return executeTemplate("main.go.gotmpl", data) +} + +func renderAPI(module string) (string, error) { + return executeTemplate("api.go.gotmpl", struct{ Module string }{Module: module}) +} + +func unique(values []string) []string { + result := values[:0] + for _, value := range values { + if len(result) == 0 || result[len(result)-1] != value { + result = append(result, value) + } + } + return result +} + +func goName(value string) string { + var builder strings.Builder + upper := true + for _, char := range value { + if char == '-' || char == '_' { + upper = true + continue + } + if upper { + char = unicode.ToUpper(char) + upper = false + } + builder.WriteRune(char) + } + return builder.String() +} diff --git a/internal/service/scaffold/render_config.go b/internal/service/scaffold/render_config.go new file mode 100644 index 0000000..17f375b --- /dev/null +++ b/internal/service/scaffold/render_config.go @@ -0,0 +1,49 @@ +package scaffold + +import ( + "fmt" + "io/fs" + "path" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/runtimeconfig" +) + +type configLoaderTemplateData struct { + ImportPath string +} + +type configProjection struct { + catalog projectdomain.RuntimeConfigCatalog + catalogErr error + target projectdomain.Target + targetAvailable bool + importPath string +} + +func runtimeConfigArtifacts(projection configProjection) ([]Artifact, error) { + if projection.catalogErr != nil { + return nil, fmt.Errorf("project.NewRuntimeConfigCatalog: %w", projection.catalogErr) + } + output, err := runtimeconfig.Render(projection.catalog) + if err != nil { + return nil, fmt.Errorf("runtimeconfig.Render: %w", err) + } + if !projection.targetAvailable { + return nil, fmt.Errorf("effective config target is unavailable") + } + loader, err := executeTemplate("config.go.gotmpl", configLoaderTemplateData{ + ImportPath: projection.importPath, + }) + if err != nil { + return nil, fmt.Errorf("executeTemplate: %w", err) + } + artifacts := []Artifact{{Path: "internal/deps/config.gen.go", Mode: 0o644, Content: []byte(loader)}} + for _, file := range output.Directory.Files { + artifacts = append(artifacts, Artifact{Path: path.Join(projection.target.OutputDir, file.Path), Mode: fs.FileMode(file.Mode), Content: file.Content}) + } + for _, file := range output.Files.Files { + artifacts = append(artifacts, Artifact{Path: file.Path, Mode: fs.FileMode(file.Mode), Content: file.Content}) + } + return artifacts, nil +} diff --git a/internal/service/scaffold/render_container.go b/internal/service/scaffold/render_container.go new file mode 100644 index 0000000..2c20bf7 --- /dev/null +++ b/internal/service/scaffold/render_container.go @@ -0,0 +1,38 @@ +package scaffold + +type containerTemplateData struct { + Project string + Logging bool + Telemetry bool + TelemetryToggle bool + Database bool + Server bool + Kafka bool + RuntimeUsesResolver bool + Connections []containerConnection + Consumers []containerConsumer +} + +type containerConnection struct { + Name string + Connection string +} + +type containerConsumer struct { + Name string + Enabled string +} + +func renderContainer(data containerTemplateData) (string, error) { + return executeTemplate("container.go.gotmpl", data) +} + +type applicationTemplateData struct { + HTTP bool + GRPC bool + Calls []string +} + +func renderApplication(data applicationTemplateData) (string, error) { + return executeTemplate("application.go.gotmpl", data) +} diff --git a/internal/service/scaffold/render_runtime.go b/internal/service/scaffold/render_runtime.go new file mode 100644 index 0000000..be8c21b --- /dev/null +++ b/internal/service/scaffold/render_runtime.go @@ -0,0 +1,22 @@ +package scaffold + +type runtimeTemplateData struct { + HTTP bool + HTTPToggle bool + GRPC bool + GRPCToggle bool + Health bool + HealthToggle bool + Pprof bool + PprofToggle bool + HealthConnections []runtimeHealthConnection +} + +type runtimeHealthConnection struct { + Connection string + Probe string +} + +func renderRuntime(data runtimeTemplateData) (string, error) { + return executeTemplate("runtime.go.gotmpl", data) +} diff --git a/internal/service/scaffold/render_storage.go b/internal/service/scaffold/render_storage.go new file mode 100644 index 0000000..db346e4 --- /dev/null +++ b/internal/service/scaffold/render_storage.go @@ -0,0 +1,29 @@ +package scaffold + +type storageTemplateData struct { + Name string + Connection string + Telemetry bool + ClickHouse bool + ClickHouseConfig string + Kinds []storageKind + Variants []storageVariant +} + +type storageKind struct { + Name string + Field string +} + +type storageVariant struct { + Name string + Kind string + Field string + ConfigField string + SQLite bool + ClickHouse bool +} + +func renderStorage(data storageTemplateData) (string, error) { + return executeTemplate("storage.go.gotmpl", data) +} diff --git a/internal/service/scaffold/service.go b/internal/service/scaffold/service.go new file mode 100644 index 0000000..c9cf46d --- /dev/null +++ b/internal/service/scaffold/service.go @@ -0,0 +1,94 @@ +package scaffold + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + "go.uber.org/zap" +) + +type Service struct { + logger *zap.Logger + projects ProjectRepository + workspace WorkspaceRepository +} + +func New(logger *zap.Logger, projects ProjectRepository, workspace WorkspaceRepository) *Service { + return &Service{logger: logger, projects: projects, workspace: workspace} +} + +// Scaffold preflights the complete artifact plan, then publishes files in deterministic order. +// A publication error returns changes for earlier files without rolling them back. +func (s *Service) Scaffold(ctx context.Context, command scaffolddomain.Command) (scaffolddomain.Result, error) { + result := scaffolddomain.Result{Files: []scaffolddomain.FileChange{}} + project, err := s.projects.LoadProject(ctx, command.ManifestPath) + if err != nil { + return result, fmt.Errorf("projects.LoadProject: %w", err) + } + artifacts, err := plan(project.Manifest) + if err != nil { + return result, &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPlan, Kind: scaffolddomain.FailureInternal, Cause: err} + } + conflicts, err := preflight(ctx, s.workspace, preflightRequest{root: project.Root, artifacts: artifacts}) + if err != nil { + operationErr := &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPreflight, Kind: scaffolddomain.FailureUnavailable, Cause: err} + return result, fmt.Errorf("preflight: %w", operationErr) + } + if len(conflicts) > 0 { + return result, &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPreflight, Path: conflicts[0].path, Kind: scaffolddomain.FailureConflict} + } + result.Files, err = s.publish(ctx, project.Root, artifacts) + if err != nil { + if ctx.Err() != nil { + return result, errors.Join(fmt.Errorf("ctx.Err: %w", ctx.Err()), err) + } + return result, err + } + s.logger.Debug("scaffold completed", zap.Int("files", len(result.Files))) + return result, nil +} + +func (s *Service) publish(ctx context.Context, root string, artifacts []Artifact) ([]scaffolddomain.FileChange, error) { + changes := make([]scaffolddomain.FileChange, 0, len(artifacts)) + for _, artifact := range artifacts { + change, err := s.publishArtifact(ctx, root, artifact) + if err != nil { + return changes, err + } + changes = append(changes, change) + } + return changes, nil +} + +func (s *Service) publishArtifact(ctx context.Context, root string, artifact Artifact) (scaffolddomain.FileChange, error) { + if err := ctx.Err(); err != nil { + return scaffolddomain.FileChange{}, fmt.Errorf("ctx.Err: %w", err) + } + info, statErr := s.workspace.Lstat(ctx, root, artifact.Path) + exists := statErr == nil + if statErr != nil && !errors.Is(statErr, fs.ErrNotExist) { + operationErr := &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPreflight, Path: artifact.Path, Kind: scaffolddomain.FailureUnavailable, Cause: statErr} + return scaffolddomain.FileChange{}, fmt.Errorf("workspace.Lstat: %w", operationErr) + } + if exists && info.Mode().IsRegular() { + existing, readErr := s.workspace.ReadBytes(ctx, root, artifact.Path) + if readErr != nil { + operationErr := &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPreflight, Path: artifact.Path, Kind: scaffolddomain.FailureUnavailable, Cause: readErr} + return scaffolddomain.FileChange{}, fmt.Errorf("workspace.ReadBytes: %w", operationErr) + } + if bytes.Equal(existing, artifact.Content) || artifact.CreateOnly { + return scaffolddomain.FileChange{Path: artifact.Path, Action: scaffolddomain.FileUnchanged}, nil + } + } + published, err := s.workspace.PublishFile(ctx, root, artifact.Path, artifact.Content) + if err != nil { + operationErr := &scaffolddomain.OperationError{Operation: scaffolddomain.OperationPublish, Path: artifact.Path, Kind: scaffolddomain.FailureUnavailable, Cause: err} + return scaffolddomain.FileChange{}, fmt.Errorf("workspace.PublishFile: %w", operationErr) + } + action := scaffolddomain.FileAction(published.Action) + return scaffolddomain.FileChange{Path: artifact.Path, Action: action}, nil +} diff --git a/internal/service/scaffold/service_preflight_test.go b/internal/service/scaffold/service_preflight_test.go new file mode 100644 index 0000000..5eb6e03 --- /dev/null +++ b/internal/service/scaffold/service_preflight_test.go @@ -0,0 +1,312 @@ +package scaffold_test + +import ( + "context" + "errors" + "io/fs" + "testing" + "testing/fstest" + + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + "github.com/devctllabs/devctl/internal/service/scaffold" + "github.com/devctllabs/devctl/internal/service/scaffold/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceRejectsPreflightConflictsBeforePublishing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + entryKind string + different bool + }{ + {name: "planned symlink", path: "go.mod", entryKind: "symlink"}, + {name: "planned directory", path: "go.mod", entryKind: "directory"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + fixture := newPreflightFixture(t, minimalServiceManifest()) + info := scaffoldFileInfo(t, test.path, test.entryKind) + fixture.expectWalkEntry(test.path, info) + if test.path == "go.mod" { + fixture.workspace.EXPECT().Lstat(gomock.Any(), fixture.root, test.path).Return(info, nil) + } + if test.different { + fixture.workspace.EXPECT().ReadBytes(gomock.Any(), fixture.root, test.path).Return([]byte("different"), nil) + } + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + var operationErr *scaffolddomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, scaffolddomain.OperationPreflight, operationErr.Operation) + require.Equal(t, scaffolddomain.FailureConflict, operationErr.Kind) + require.Equal(t, test.path, operationErr.Path) + require.Empty(t, result.Files) + }) + } +} + +func TestServiceReportsPreflightFailuresAsUnavailable(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stage string + }{ + {name: "cancelled context", stage: "context"}, + {name: "walk failure", stage: "walk"}, + {name: "lstat failure", stage: "lstat"}, + {name: "read failure", stage: "read"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + failure := errors.New(test.stage + " failed") + fixture := newPreflightFixture(t, minimalServiceManifest()) + ctx := context.Background() + switch test.stage { + case "context": + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + failure = context.Canceled + case "walk": + fixture.workspace.EXPECT().Walk(gomock.Any(), fixture.root, gomock.Any()).Return(failure) + case "lstat", "read": + info := scaffoldFileInfo(t, "go.mod", "regular") + fixture.expectWalkEntry("go.mod", info) + if test.stage == "lstat" { + fixture.workspace.EXPECT().Lstat(gomock.Any(), fixture.root, "go.mod").Return(nil, failure) + } else { + fixture.workspace.EXPECT().Lstat(gomock.Any(), fixture.root, "go.mod").Return(info, nil) + fixture.workspace.EXPECT().ReadBytes(gomock.Any(), fixture.root, "go.mod").Return(nil, failure) + } + } + + result, err := fixture.service.Scaffold(ctx, scaffolddomain.Command{}) + + var operationErr *scaffolddomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, scaffolddomain.OperationPreflight, operationErr.Operation) + require.Equal(t, scaffolddomain.FailureUnavailable, operationErr.Kind) + require.ErrorIs(t, err, failure) + require.Empty(t, result.Files) + }) + } +} + +func TestServiceRefreshesManagedFilesAndPreservesSeeds(t *testing.T) { + t.Parallel() + + t.Run("refresh updates a different managed file", func(t *testing.T) { + t.Parallel() + + fixture := newPreflightFixture(t, minimalServiceManifest()) + info := scaffoldFileInfo(t, "go.mod", "regular") + fixture.expectWalkEntry("go.mod", info) + fixture.allowPublication("go.mod", info, []byte("different"), "") + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + require.NoError(t, err) + requireFileAction(t, result, "go.mod", scaffolddomain.FileUpdated) + }) + + t.Run("refresh preserves a different user-owned seed", func(t *testing.T) { + t.Parallel() + + manifest := minimalServiceManifest() + manifest.Components.HTTP = &projectdomain.HTTP{Server: &projectdomain.HTTPServer{}} + fixture := newPreflightFixture(t, manifest) + path := "api/openapi/swagger.yaml" + info := scaffoldFileInfo(t, path, "regular") + fixture.expectWalkEntry(path, info) + fixture.allowPublication(path, info, []byte("user-owned OpenAPI"), "") + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + require.NoError(t, err) + requireFileAction(t, result, path, scaffolddomain.FileUnchanged) + }) +} + +func TestServiceRefreshesManagedFilesAndPreservesUserOwnedEntrypoints(t *testing.T) { + t.Parallel() + + manifest := minimalServiceManifest() + fixture := newPreflightFixture(t, manifest) + mainPath := "cmd/sample/main.go" + mainInfo := scaffoldFileInfo(t, mainPath, "regular") + fixture.expectWalkEntry(mainPath, mainInfo) + fixture.allowPublication(mainPath, mainInfo, []byte("package main\n// user changes\n"), mainPath) + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + require.NoError(t, err) + requireFileAction(t, result, mainPath, scaffolddomain.FileUnchanged) +} + +func TestServiceRefreshNeverReadsOrPublishesCustomBufGenerationConfig(t *testing.T) { + t.Parallel() + + manifest := minimalServiceManifest() + manifest.Sources = map[string]projectdomain.Source{ + "contracts": {Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1"}, + } + const customConfig = "tools/buf/billing.gen.yaml" + manifest.Components.GRPC = &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/billing.proto", BufGenConfig: customConfig, + }}} + fixture := newPreflightFixture(t, manifest) + fixture.expectWalkEntry(customConfig, scaffoldFileInfo(t, customConfig, "regular")) + fixture.workspace.EXPECT().Lstat(gomock.Any(), fixture.root, gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, target string) (fs.FileInfo, error) { + require.NotEqual(t, customConfig, target) + return nil, fs.ErrNotExist + }, + ).AnyTimes() + fixture.workspace.EXPECT().PublishFile(gomock.Any(), fixture.root, gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, target string, _ []byte) (artifact.PublishResult, error) { + require.NotEqual(t, customConfig, target) + return artifact.PublishResult{Action: artifact.PublishCreated}, nil + }, + ).AnyTimes() + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + require.NoError(t, err) + for _, change := range result.Files { + require.NotEqual(t, customConfig, change.Path) + } +} + +func TestServiceIgnoresGitMetadataDuringPreflight(t *testing.T) { + t.Parallel() + + fixture := newPreflightFixture(t, minimalServiceManifest()) + path := ".git/hooks/generated.go" + fixture.expectWalkEntry(path, scaffoldFileInfo(t, path, "regular")) + fixture.workspace.EXPECT().Lstat(gomock.Any(), fixture.root, gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, name string) (fs.FileInfo, error) { + require.NotEqual(t, path, name) + return nil, fs.ErrNotExist + }, + ).AnyTimes() + fixture.workspace.EXPECT().PublishFile(gomock.Any(), fixture.root, gomock.Any(), gomock.Any()).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil).AnyTimes() + + result, err := fixture.service.Scaffold(context.Background(), scaffolddomain.Command{}) + + require.NoError(t, err) + require.NotEmpty(t, result.Files) +} + +type preflightFixture struct { + t *testing.T + root string + workspace *mocks.MockWorkspaceRepository + service *scaffold.Service +} + +func newPreflightFixture(t *testing.T, manifest projectdomain.Manifest) *preflightFixture { + t.Helper() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: manifest} + projects.EXPECT().LoadProject(gomock.Any(), "").Return(project, nil) + return &preflightFixture{ + t: t, + root: project.Root, + workspace: workspace, + service: scaffold.New(zap.NewNop(), projects, workspace), + } +} + +func (f *preflightFixture) expectWalkEntry(path string, info fs.FileInfo) { + f.workspace.EXPECT().Walk(gomock.Any(), f.root, gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, visit fs.WalkDirFunc) error { + return visit(path, fs.FileInfoToDirEntry(info), nil) + }, + ) +} + +func (f *preflightFixture) allowPublication( + existingPath string, + info fs.FileInfo, + existing []byte, + forbiddenPublishPath string, +) { + f.t.Helper() + + f.workspace.EXPECT().Lstat(gomock.Any(), f.root, gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, name string) (fs.FileInfo, error) { + if name == existingPath { + return info, nil + } + return nil, fs.ErrNotExist + }, + ).AnyTimes() + f.workspace.EXPECT().ReadBytes(gomock.Any(), f.root, existingPath).Return(existing, nil).AnyTimes() + f.workspace.EXPECT().PublishFile(gomock.Any(), f.root, gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, _ string, target string, _ []byte) (artifact.PublishResult, error) { + require.NotEqual(f.t, forbiddenPublishPath, target) + action := artifact.PublishCreated + if target == existingPath { + action = artifact.PublishUpdated + } + return artifact.PublishResult{Action: action}, nil + }, + ).AnyTimes() +} + +func scaffoldFileInfo(t *testing.T, name, kind string) fs.FileInfo { + t.Helper() + + files := fstest.MapFS{} + switch kind { + case "regular": + files[name] = &fstest.MapFile{Data: []byte("existing"), Mode: 0o644} + case "symlink": + files[name] = &fstest.MapFile{Mode: fs.ModeSymlink | 0o777} + case "directory": + files[name+"/child"] = &fstest.MapFile{Data: []byte("child"), Mode: 0o644} + default: + require.FailNow(t, "unknown file kind", kind) + } + info, err := fs.Stat(files, name) + require.NoError(t, err) + return info +} + +func minimalServiceManifest() projectdomain.Manifest { + return projectdomain.Manifest{ + Version: 1, + Project: projectdomain.Identity{Name: "sample", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/sample"}}, + } +} + +func requireFileAction(t *testing.T, result scaffolddomain.Result, path string, action scaffolddomain.FileAction) { + t.Helper() + + for _, change := range result.Files { + if change.Path == path { + require.Equal(t, action, change.Action) + return + } + } + require.Fail(t, "file change not found", path) +} diff --git a/internal/service/scaffold/service_test.go b/internal/service/scaffold/service_test.go new file mode 100644 index 0000000..f9f91c0 --- /dev/null +++ b/internal/service/scaffold/service_test.go @@ -0,0 +1,41 @@ +package scaffold_test + +import ( + "context" + "io/fs" + "testing" + + "github.com/devctllabs/devctl/internal/domain/artifact" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + scaffolddomain "github.com/devctllabs/devctl/internal/domain/scaffold" + "github.com/devctllabs/devctl/internal/service/scaffold" + "github.com/devctllabs/devctl/internal/service/scaffold/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceOwnsScaffoldWorkflowAndOutcome(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Project: projectdomain.Identity{Name: "sample", Language: "go"}, + Languages: projectdomain.Languages{Go: projectdomain.GoLanguage{Module: "example.test/sample"}}, + }} + gomock.InOrder( + projects.EXPECT().LoadProject(gomock.Any(), "custom.yaml").Return(project, nil), + workspace.EXPECT().Walk(gomock.Any(), project.Root, gomock.Any()).Return(nil), + ) + workspace.EXPECT().Lstat(gomock.Any(), project.Root, gomock.Any()).Return(nil, fs.ErrNotExist).AnyTimes() + workspace.EXPECT().PublishFile(gomock.Any(), project.Root, gomock.Any(), gomock.Any()).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil).AnyTimes() + service := scaffold.New(zap.NewNop(), projects, workspace) + + result, err := service.Scaffold(context.Background(), scaffolddomain.Command{ManifestPath: "custom.yaml"}) + + require.NoError(t, err) + require.NotEmpty(t, result.Files) + require.Equal(t, scaffolddomain.FileChange{Path: ".env.example", Action: scaffolddomain.FileCreated}, result.Files[0]) +} diff --git a/internal/service/scaffold/templates/api.go.gotmpl b/internal/service/scaffold/templates/api.go.gotmpl new file mode 100644 index 0000000..5240850 --- /dev/null +++ b/internal/service/scaffold/templates/api.go.gotmpl @@ -0,0 +1,23 @@ +package internal + +import ( + "context" + "fmt" + + "{{ .Module }}/internal/deps" + "github.com/urfave/cli/v3" +) + +// NewCmdAPI constructs the API server command. +func NewCmdAPI() *cli.Command { + return &cli.Command{ + Name: "api", + Usage: "Run API servers", + Action: func(ctx context.Context, _ *cli.Command) error { + scenario, err := deps.NewAPI(ctx) + if err != nil { return fmt.Errorf("deps.NewAPI: %w", err) } + if err := scenario.Run(ctx); err != nil { return fmt.Errorf("scenario.Run: %w", err) } + return nil + }, + } +} diff --git a/internal/service/scaffold/templates/application.go.gotmpl b/internal/service/scaffold/templates/application.go.gotmpl new file mode 100644 index 0000000..4f6d5f1 --- /dev/null +++ b/internal/service/scaffold/templates/application.go.gotmpl @@ -0,0 +1,46 @@ +package deps + +import ( + "context" +{{- if or .HTTP .GRPC .Calls }} + "fmt" +{{- end }} + + "github.com/devctllabs/go-libs/di" +{{- if .HTTP }} + "github.com/labstack/echo/v5" +{{- end }} +{{- if .GRPC }} + "google.golang.org/grpc" +{{- end }} +) + +// application is the user-owned composition root. Add application dependencies here. +type application struct{} + +{{- if .HTTP }} +func (*application) RegisterHTTP(*echo.Echo) {} +{{- end }} +{{- if .GRPC }} +func (*application) RegisterGRPC(*grpc.Server) {} +{{- end }} + +// provideApplication is created once. Add newly scaffolded provider calls manually. +func provideApplication(ctx context.Context, graph *di.Container, cfg *Config) error { +{{- if .HTTP }} + if err := di.Provide[HTTPRegistrar](graph, func(di.Resolver) (HTTPRegistrar, error) { return &application{}, nil }); err != nil { + return fmt.Errorf("di.Provide HTTPRegistrar: %w", err) + } +{{- end }} +{{- if .GRPC }} + if err := di.Provide[GRPCRegistrar](graph, func(di.Resolver) (GRPCRegistrar, error) { return &application{}, nil }); err != nil { + return fmt.Errorf("di.Provide GRPCRegistrar: %w", err) + } +{{- end }} +{{- range .Calls }} + if err := {{ . }}(ctx, graph, cfg); err != nil { + return fmt.Errorf("{{ . }}: %w", err) + } +{{- end }} + return nil +} diff --git a/internal/service/scaffold/templates/buf-go.gen.yaml b/internal/service/scaffold/templates/buf-go.gen.yaml new file mode 100644 index 0000000..9483aef --- /dev/null +++ b/internal/service/scaffold/templates/buf-go.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +plugins: + - local: [go, tool, protoc-gen-go] + out: . + opt: + - paths=source_relative + - local: [go, tool, protoc-gen-go-grpc] + out: . + opt: + - paths=source_relative diff --git a/internal/service/scaffold/templates/buf.yaml.gotmpl b/internal/service/scaffold/templates/buf.yaml.gotmpl new file mode 100644 index 0000000..42a0973 --- /dev/null +++ b/internal/service/scaffold/templates/buf.yaml.gotmpl @@ -0,0 +1,11 @@ +version: v2 +modules: + - path: {{ .ProtoRoot }} +lint: + use: + - STANDARD + except: + - FILE_LOWER_SNAKE_CASE +breaking: + use: + - FILE diff --git a/internal/service/scaffold/templates/config.go.gotmpl b/internal/service/scaffold/templates/config.go.gotmpl new file mode 100644 index 0000000..b168f09 --- /dev/null +++ b/internal/service/scaffold/templates/config.go.gotmpl @@ -0,0 +1,21 @@ +package deps + +import ( + "context" + "fmt" + + configlib "github.com/devctllabs/go-libs/config" + generatedconfig "{{ .ImportPath }}" +) + +// Config is the canonical generated runtime configuration. +type Config = generatedconfig.Config + +func loadConfig(ctx context.Context) (*Config, error) { + var cfg Config + loader := configlib.Chain(configlib.Defaults(), configlib.OSEnv()) + if err := loader.Load(ctx, &cfg); err != nil { + return nil, fmt.Errorf("loader.Load: %w", err) + } + return &cfg, nil +} diff --git a/internal/service/scaffold/templates/consumer.go.gotmpl b/internal/service/scaffold/templates/consumer.go.gotmpl new file mode 100644 index 0000000..f78d60a --- /dev/null +++ b/internal/service/scaffold/templates/consumer.go.gotmpl @@ -0,0 +1,23 @@ +package internal + +import ( + "context" + "fmt" + + "{{ .Module }}/internal/deps" + "github.com/urfave/cli/v3" +) + +// NewCmdConsumer constructs the named Kafka consumer command. +func NewCmdConsumer() *cli.Command { + return &cli.Command{ + Name: "consumer", + Arguments: []cli.Argument{&cli.StringArg{Name: "consumer-name"}}, + Action: func(ctx context.Context, command *cli.Command) error { + scenario, err := deps.NewConsumer(ctx, command.StringArg("consumer-name")) + if err != nil { return fmt.Errorf("deps.NewConsumer: %w", err) } + if err := scenario.Run(ctx); err != nil { return fmt.Errorf("scenario.Run: %w", err) } + return nil + }, + } +} diff --git a/internal/service/scaffold/templates/container.go.gotmpl b/internal/service/scaffold/templates/container.go.gotmpl new file mode 100644 index 0000000..825c30c --- /dev/null +++ b/internal/service/scaffold/templates/container.go.gotmpl @@ -0,0 +1,144 @@ +package deps + +{{- if or .Database .Logging .Telemetry .Server .Kafka }} +import ( + "context" +{{- if or .Server .Kafka }} + "errors" + "fmt" + "time" +{{- else if or .Logging .Telemetry }} + "fmt" +{{- end }} + +{{- if or .Logging .Telemetry .Server .Kafka }} + "github.com/devctllabs/go-libs/di" +{{- end }} +{{- if or .Server .Kafka }} + "github.com/devctllabs/go-libs/lifecycle" +{{- end }} +{{- if .Logging }} + loglib "github.com/devctllabs/go-libs/log" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +{{- end }} +{{- if .Telemetry }} + telemetrylib "github.com/devctllabs/go-libs/telemetry" +{{- end }} +) +{{- end }} + +{{- if .Database }} +type dbChecker interface { Check(context.Context) error } +{{- end }} + +{{- if or .Server .Kafka }} +type scenarioRunner interface { Run(context.Context) error } + +// Scenario owns one lazily resolved runnable branch and its dependency graph. +type Scenario struct { + graph *di.Container + tasks []lifecycle.Task +} + +// Run coordinates the selected branch until cancellation or failure. +func (s *Scenario) Run(ctx context.Context) error { + return lifecycle.Run(ctx, lifecycle.Config{ + ShutdownTimeout: 30 * time.Second, + Shutdown: s.Shutdown, + Tasks: s.tasks, + }) +} + +// Shutdown closes only resources constructed by this Scenario. +func (s *Scenario) Shutdown(ctx context.Context) error { + if s == nil || s.graph == nil { return nil } + if err := s.graph.Shutdown(ctx); err != nil { return fmt.Errorf("graph.Shutdown: %w", err) } + return nil +} + +func newScenarioGraph(ctx context.Context) (*di.Container, *Config, error) { + cfg, err := loadConfig(ctx) + if err != nil { return nil, nil, fmt.Errorf("loadConfig: %w", err) } + graph := di.New() + if err := di.ProvideValue(graph, cfg); err != nil { return nil, nil, fmt.Errorf("di.ProvideValue: %w", err) } + if err := provideApplication(ctx, graph, cfg); err != nil { + shutdownErr := graph.Shutdown(context.WithoutCancel(ctx)) + return nil, nil, errors.Join(fmt.Errorf("provideApplication: %w", err), shutdownErr) + } + return graph, cfg, nil +} +{{- end }} + +{{- if .Logging }} +func provideLogging(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*zap.Logger, error) { + level := zapcore.InfoLevel + if err := level.Set(cfg.Logging.Level); err != nil { return nil, fmt.Errorf("logging level: %w", err) } + return loglib.New(level, false).Named({{ printf "%q" .Project }}), nil + }, func(_ context.Context, value *zap.Logger) error { _ = value.Sync(); return nil }) +} +{{- end }} + +{{- if .Telemetry }} +func provideTelemetry(ctx context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*telemetrylib.Runtime, error) { + value, err := telemetrylib.Open(ctx, telemetrylib.Config{ + Enabled: {{ if .TelemetryToggle }}cfg.Telemetry.Enabled{{ else }}true{{ end }}, + ServiceName: {{ printf "%q" .Project }}, + ServiceVersion: cfg.Telemetry.ServiceVersion, + DeploymentEnvironment: cfg.Telemetry.DeploymentEnvironment, + }) + if err != nil { return nil, fmt.Errorf("telemetrylib.Open: %w", err) } + return value, nil + }, func(ctx context.Context, value *telemetrylib.Runtime) error { return value.Shutdown(ctx) }) +} +{{- end }} + +{{- if .Server }} +// NewAPI resolves only the API Runtime branch. +func NewAPI(ctx context.Context) (*Scenario, error) { + graph, _, err := newScenarioGraph(ctx) + if err != nil { return nil, err } + runtime, err := di.Resolve[*Runtime](graph) + if err != nil { + return nil, errors.Join(fmt.Errorf("di.Resolve Runtime: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + return &Scenario{graph: graph, tasks: runtime.Tasks()}, nil +} +{{- end }} + +{{- if .Kafka }} +// ConsumerSelectionError reports an unknown or disabled selected consumer. +type ConsumerSelectionError struct { Name string; Reason string } + +func (e *ConsumerSelectionError) Error() string { + return fmt.Sprintf("Kafka consumer %q is %s", e.Name, e.Reason) +} + +// NewConsumer validates selection before resolving the selected consumer branch. +func NewConsumer(ctx context.Context, name string) (*Scenario, error) { + cfg, err := loadConfig(ctx) + if err != nil { return nil, fmt.Errorf("loadConfig: %w", err) } + switch name { +{{- range .Consumers }} + case {{ printf "%q" .Name }}: + if !({{ .Enabled }}) { return nil, &ConsumerSelectionError{Name: name, Reason: "disabled"} } +{{- end }} + default: + return nil, &ConsumerSelectionError{Name: name, Reason: "unknown"} + } + graph := di.New() + if err := di.ProvideValue(graph, cfg); err != nil { return nil, fmt.Errorf("di.ProvideValue: %w", err) } + if err := provideApplication(ctx, graph, cfg); err != nil { + return nil, errors.Join(fmt.Errorf("provideApplication: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + runner, err := di.ResolveNamed[scenarioRunner](graph, kafkaConsumerKey(name)) + if err != nil { + return nil, errors.Join(fmt.Errorf("di.ResolveNamed consumer: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + return &Scenario{graph: graph, tasks: []lifecycle.Task{ + {Name: "kafka-consumer:" + name, Run: runner.Run}, + }}, nil +} +{{- end }} diff --git a/internal/service/scaffold/templates/go.mod.gotmpl b/internal/service/scaffold/templates/go.mod.gotmpl new file mode 100644 index 0000000..bbbc305 --- /dev/null +++ b/internal/service/scaffold/templates/go.mod.gotmpl @@ -0,0 +1,21 @@ +module {{ .Module }} + +go 1.26.0 + +require ( +{{- range .Requires }} + {{ . }} +{{- end }} +) +{{ if .HTTP }} +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + +require github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 +{{ end -}} +{{ if .Proto }} +tool ( + github.com/bufbuild/buf/cmd/buf + google.golang.org/grpc/cmd/protoc-gen-go-grpc + google.golang.org/protobuf/cmd/protoc-gen-go +) +{{ end -}} diff --git a/internal/service/scaffold/templates/golangci.yml b/internal/service/scaffold/templates/golangci.yml new file mode 100644 index 0000000..a01c843 --- /dev/null +++ b/internal/service/scaffold/templates/golangci.yml @@ -0,0 +1,76 @@ +version: "2" +run: + relative-path-mode: gomod + tests: true + modules-download-mode: readonly +linters: + default: none + enable: + - asasalint + - bidichk + - bodyclose + - containedctx + - contextcheck + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - fatcontext + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - gocognit + - govet + - inamedparam + - ineffassign + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - predeclared + - reassign + - recvcheck + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - testifylint + - thelper + - tparallel + - unconvert + - unused + - usetesting + - wastedassign + - wrapcheck + settings: + gocognit: + min-complexity: 20 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + paralleltest: + ignore-missing: false + ignore-missing-subtests: false + check-cleanup: true + revive: + rules: + - name: argument-limit + arguments: [4] + - name: function-result-limit + arguments: [3] + exclusions: + generated: strict + paths: ["^gen/"] +formatters: + enable: [gofmt] + exclusions: + generated: strict + paths: ["^gen/"] diff --git a/internal/service/scaffold/templates/grpc.go.gotmpl b/internal/service/scaffold/templates/grpc.go.gotmpl new file mode 100644 index 0000000..902de1d --- /dev/null +++ b/internal/service/scaffold/templates/grpc.go.gotmpl @@ -0,0 +1,6 @@ +package deps + +import "google.golang.org/grpc" + +// newGRPCServer constructs the handwritten gRPC runtime boundary. +func newGRPCServer() *grpc.Server { return grpc.NewServer() } diff --git a/internal/service/scaffold/templates/grpc_clients.go.gotmpl b/internal/service/scaffold/templates/grpc_clients.go.gotmpl new file mode 100644 index 0000000..c420adc --- /dev/null +++ b/internal/service/scaffold/templates/grpc_clients.go.gotmpl @@ -0,0 +1,23 @@ +package deps + +import ( + "context" + + "github.com/devctllabs/go-libs/di" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +{{- range . }} +const grpcClient{{ goName .Name }}Key = "grpc-client:{{ .Name }}" + +func provide{{ goName .Name }}GRPCClient(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, grpcClient{{ goName .Name }}Key, func(di.Resolver) (*grpc.ClientConn, error) { + return grpc.NewClient(cfg.GRPCClients.{{ goName .Name }}Address, grpc.WithTransportCredentials(insecure.NewCredentials())) + }, func(_ context.Context, connection *grpc.ClientConn) error { return connection.Close() }) +} + +func {{ goName .Name }}GRPCConn(resolver di.Resolver) (*grpc.ClientConn, error) { + return di.ResolveNamed[*grpc.ClientConn](resolver, grpcClient{{ goName .Name }}Key) +} +{{- end }} diff --git a/internal/service/scaffold/templates/http_clients.go.gotmpl b/internal/service/scaffold/templates/http_clients.go.gotmpl new file mode 100644 index 0000000..d6bc01f --- /dev/null +++ b/internal/service/scaffold/templates/http_clients.go.gotmpl @@ -0,0 +1,28 @@ +package deps + +import ( + "context" + "net/http" + + "github.com/devctllabs/go-libs/di" +) + +type httpBaseURL string + +{{- range . }} +const httpClient{{ goName .Name }}Key = "http-client:{{ .Name }}" + +func provide{{ goName .Name }}HTTPClient(_ context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedValue(graph, httpClient{{ goName .Name }}Key, &http.Client{}); err != nil { return err } + return di.ProvideNamedValue(graph, httpClient{{ goName .Name }}Key, httpBaseURL(cfg.HTTPClients.{{ goName .Name }}BaseURL)) +} + +func {{ goName .Name }}HTTPTransport(resolver di.Resolver) (*http.Client, error) { + return di.ResolveNamed[*http.Client](resolver, httpClient{{ goName .Name }}Key) +} + +func {{ goName .Name }}HTTPBaseURL(resolver di.Resolver) (string, error) { + value, err := di.ResolveNamed[httpBaseURL](resolver, httpClient{{ goName .Name }}Key) + return string(value), err +} +{{- end }} diff --git a/internal/service/scaffold/templates/kafka_broker.go.gotmpl b/internal/service/scaffold/templates/kafka_broker.go.gotmpl new file mode 100644 index 0000000..fb59562 --- /dev/null +++ b/internal/service/scaffold/templates/kafka_broker.go.gotmpl @@ -0,0 +1,5 @@ +package deps + +func kafkaConsumerKey(name string) string { return "kafka-consumer:" + name } + +func kafkaProducerKey(name string) string { return "kafka-producer:" + name } diff --git a/internal/service/scaffold/templates/kafka_consumer_binding.go.gotmpl b/internal/service/scaffold/templates/kafka_consumer_binding.go.gotmpl new file mode 100644 index 0000000..f798763 --- /dev/null +++ b/internal/service/scaffold/templates/kafka_consumer_binding.go.gotmpl @@ -0,0 +1,35 @@ +package deps + +import ( + "context" + "fmt" + + {{ replace .Name }}consumer "{{ .Module }}/internal/transport/consumerkafka/{{ replace .Name }}" + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +// provide{{ goName .Name }}Consumer is user-owned: change []byte and the decoder for generated schema types. +func provide{{ goName .Name }}Consumer(ctx context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey({{ printf "%q" .Name }}) + if err := provide{{ goName .Name }}ConsumerConfig(ctx, graph, cfg); err != nil { + return fmt.Errorf("provide consumer config: %w", err) + } + if err := di.ProvideNamedValue[kafka.Decoder[[]byte]](graph, key, rawKafkaDecoder()); err != nil { + return fmt.Errorf("provide consumer decoder: %w", err) + } + if err := di.ProvideNamedValue[kafka.BatchHandler[[]byte]](graph, key, {{ replace .Name }}consumer.NewHandler()); err != nil { + return fmt.Errorf("provide consumer handler: %w", err) + } + if err := di.ProvideNamed[retry.Policy](graph, key, func(di.Resolver) (retry.Policy, error) { + return retry.NewExponential(retry.ExponentialConfig{ + InitialDelay: cfg.Kafka.{{ goName .Name }}RetryInitialDelay, + MaxDelay: cfg.Kafka.{{ goName .Name }}RetryMaxDelay, + Multiplier: 2, + }) + }); err != nil { + return fmt.Errorf("provide consumer retry policy: %w", err) + } + return provideConsumer[[]byte](graph, key) +} diff --git a/internal/service/scaffold/templates/kafka_consumers.go.gotmpl b/internal/service/scaffold/templates/kafka_consumers.go.gotmpl new file mode 100644 index 0000000..e4c5c70 --- /dev/null +++ b/internal/service/scaffold/templates/kafka_consumers.go.gotmpl @@ -0,0 +1,61 @@ +package deps + +import ( + "context" + "fmt" + + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" +{{- if hasProto .Consumers }} + kafkaproto "github.com/devctllabs/go-libs/kafkaproto" +{{- end }} + retry "github.com/devctllabs/go-libs/retry" +) + +func rawKafkaDecoder() kafka.Decoder[[]byte] { + // The consumer owns the record bytes until Handle returns; handlers must not retain a batch. + return kafka.DecoderFunc[[]byte](func(_ context.Context, value []byte) ([]byte, error) { return value, nil }) +} + +func jsonKafkaDecoder[T any]() kafka.Decoder[T] { return kafka.NewJSONDecoder[T]() } + +{{- if hasProto .Consumers }} +func protoKafkaDecoder[T any, PT kafkaproto.ProtoPtr[T]]() kafka.Decoder[PT] { + return kafkaproto.NewDecoder[T, PT]() +} +{{- end }} + +func provideConsumer[T any](graph *di.Container, key string) error { + return di.ProvideNamed[scenarioRunner](graph, key, func(resolver di.Resolver) (scenarioRunner, error) { + config, err := di.ResolveNamed[kafka.ConsumerConfig](resolver, key) + if err != nil { return nil, fmt.Errorf("resolve consumer config: %w", err) } + decoder, err := di.ResolveNamed[kafka.Decoder[T]](resolver, key) + if err != nil { return nil, fmt.Errorf("resolve consumer decoder: %w", err) } + handler, err := di.ResolveNamed[kafka.BatchHandler[T]](resolver, key) + if err != nil { return nil, fmt.Errorf("resolve consumer handler: %w", err) } + policy, err := di.ResolveNamed[retry.Policy](resolver, key) + if err != nil { return nil, fmt.Errorf("resolve consumer retry policy: %w", err) } + config.Retry.Policy = policy + config.CommitRetry = &config.Retry + consumer, err := kafka.NewConsumer(config, decoder, handler) + if err != nil { return nil, fmt.Errorf("kafka.NewConsumer: %w", err) } + return consumer, nil + }) +} + +{{- range .Consumers }} +func provide{{ goName .Name }}ConsumerConfig(_ context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey({{ printf "%q" .Name }}) + return di.ProvideNamedValue(graph, key, kafka.ConsumerConfig{ + Brokers: cfg.Kafka.Brokers, + Group: cfg.Kafka.{{ goName .Name }}Group, + Topics: []string{cfg.Kafka.{{ goName .Name }}Topic}, + Batch: kafka.BatchConfig{MaxSize: cfg.Kafka.{{ goName .Name }}BatchMaxSize, FlushInterval: cfg.Kafka.{{ goName .Name }}BatchFlushInterval}, + Retry: kafka.RetryConfig{MaxAttempts: uint(cfg.Kafka.{{ goName .Name }}RetryMaxAttempts), MaxElapsedTime: cfg.Kafka.{{ goName .Name }}RetryMaxElapsedTime}, + OnReject: kafka.RejectStop, + RebalanceTimeout: cfg.Kafka.{{ goName .Name }}RebalanceTimeout, + RebalanceDrainTimeout: cfg.Kafka.{{ goName .Name }}RebalanceDrainTimeout, + ShutdownTimeout: cfg.Kafka.{{ goName .Name }}ShutdownTimeout, + }) +} +{{- end }} diff --git a/internal/service/scaffold/templates/kafka_handler.go.gotmpl b/internal/service/scaffold/templates/kafka_handler.go.gotmpl new file mode 100644 index 0000000..f4ce9e3 --- /dev/null +++ b/internal/service/scaffold/templates/kafka_handler.go.gotmpl @@ -0,0 +1,20 @@ +package {{ .Package }} + +import ( + "context" + "errors" + + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +var ErrNotImplemented = errors.New("Kafka consumer handler is not implemented") + +type Handler struct{} + +func NewHandler() *Handler { return &Handler{} } + +// Handle processes the batch synchronously. It must not retain batch data after returning. +func (h *Handler) Handle(context.Context, *kafka.Batch[[]byte]) error { + return retry.Permanent(ErrNotImplemented) +} diff --git a/internal/service/scaffold/templates/kafka_producers.go.gotmpl b/internal/service/scaffold/templates/kafka_producers.go.gotmpl new file mode 100644 index 0000000..23f337d --- /dev/null +++ b/internal/service/scaffold/templates/kafka_producers.go.gotmpl @@ -0,0 +1,19 @@ +package deps + +import ( + "context" + "fmt" + + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" +) + +{{- range . }} +func provide{{ goName .Name }}KafkaProducer(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, kafkaProducerKey({{ printf "%q" .Name }}), func(di.Resolver) (*kafka.Producer[[]byte], error) { + producer, err := kafka.NewProducer(kafka.ProducerConfig{Brokers: cfg.Kafka.Brokers}, kafka.NewBytesEncoder()) + if err != nil { return nil, fmt.Errorf("kafka.NewProducer: %w", err) } + return producer, nil + }, func(ctx context.Context, producer *kafka.Producer[[]byte]) error { return producer.Close(ctx) }) +} +{{- end }} diff --git a/internal/service/scaffold/templates/main.go.gotmpl b/internal/service/scaffold/templates/main.go.gotmpl new file mode 100644 index 0000000..5936364 --- /dev/null +++ b/internal/service/scaffold/templates/main.go.gotmpl @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/urfave/cli/v3" +{{- if or .Server .Kafka }} + + appcmd "{{ .Module }}/cmd/{{ .Project }}/internal" +{{- end }} +) + +func main() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signals) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + select { + case <-signals: + cancel() + case <-ctx.Done(): + } + }() + root := &cli.Command{ + Name: "{{ .Project }}", + Usage: "Run {{ .Project }}", +{{- if or .Server .Kafka }} + Commands: []*cli.Command{ +{{- if .Server }} + appcmd.NewCmdAPI(), +{{- end }} +{{- if .Kafka }} + appcmd.NewCmdConsumer(), +{{- end }} + }, +{{- end }} + } + if err := root.Run(ctx, os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/internal/service/scaffold/templates/mise.toml b/internal/service/scaffold/templates/mise.toml new file mode 100644 index 0000000..b8d73de --- /dev/null +++ b/internal/service/scaffold/templates/mise.toml @@ -0,0 +1,57 @@ +[tools] +go = "1.26.0" +golangci-lint = "2.12.2" +{{- if .JSON }} +node = "24" +"npm:quicktype" = "26.0.0" +{{- end }} +{{- if .Migrations }} +"go:github.com/golang-migrate/migrate/v4/cmd/migrate" = { version = "v4.19.1", tags = [{{- range $index, $tag := .MigrationTags }}{{ if $index }}, {{ end }}{{ printf "%q" $tag }}{{- end }}] } +{{- end }} + +[tasks.fmt] +run = "golangci-lint fmt" +[tasks."fmt:check"] +run = "golangci-lint fmt --diff" +[tasks."lint:contracts"] +run = "devctl lint" +[tasks."lint:go"] +run = "golangci-lint run" +[tasks.lint] +depends = ["lint:contracts", "lint:go"] +[tasks.test] +run = "go test ./..." +[tasks.gen] +run = "devctl gen" +[tasks."gen:http"] +run = "devctl gen http" +[tasks."gen:grpc"] +run = "devctl gen grpc" +[tasks."gen:kafka"] +run = "devctl gen kafka" +[tasks.check] +depends = ["fmt:check", "lint", "test"] +{{ range .Migrations }} +[tasks."{{ .TaskPrefix }}:create"] +description = "Create timestamped migration files in {{ .Path }}" +usage = 'arg "" help="Migration name"' +run = ''' +migrate create -ext sql -dir "{{ .Path }}" -format "20060102150405" "${usage_name?}" +''' + +[tasks."{{ .TaskPrefix }}:up"] +description = "Apply migrations from {{ .Path }}" +run = ''' +database_url="{{ .DatabaseExpression }}" +migrate -path "{{ .Path }}" -database "$database_url" up +''' + +[tasks."{{ .TaskPrefix }}:down"] +description = "Roll back migrations from {{ .Path }}" +usage = 'arg "[steps]" default="1" help="Number of migrations"' +confirm = "Roll back {{ .TaskPrefix }} migrations?" +run = ''' +database_url="{{ .DatabaseExpression }}" +migrate -path "{{ .Path }}" -database "$database_url" down "${usage_steps?}" +''' +{{ end }} diff --git a/internal/service/scaffold/templates/oapi-client.yaml b/internal/service/scaffold/templates/oapi-client.yaml new file mode 100644 index 0000000..3334bfb --- /dev/null +++ b/internal/service/scaffold/templates/oapi-client.yaml @@ -0,0 +1,4 @@ +package: clienthttp +generate: + models: true + client: true diff --git a/internal/service/scaffold/templates/oapi-server.yaml b/internal/service/scaffold/templates/oapi-server.yaml new file mode 100644 index 0000000..45c9a22 --- /dev/null +++ b/internal/service/scaffold/templates/oapi-server.yaml @@ -0,0 +1,6 @@ +package: serverhttp +generate: + models: true + echo5-server: true + strict-server: true + embedded-spec: true diff --git a/internal/service/scaffold/templates/openapi.yaml b/internal/service/scaffold/templates/openapi.yaml new file mode 100644 index 0000000..aa356d4 --- /dev/null +++ b/internal/service/scaffold/templates/openapi.yaml @@ -0,0 +1,5 @@ +openapi: 3.1.0 +info: + title: API + version: 0.0.0 +paths: {} diff --git a/internal/service/scaffold/templates/project-readme.md.gotmpl b/internal/service/scaffold/templates/project-readme.md.gotmpl new file mode 100644 index 0000000..7b9def9 --- /dev/null +++ b/internal/service/scaffold/templates/project-readme.md.gotmpl @@ -0,0 +1,30 @@ +# {{ .Project }} + +This project foundation is scaffolded by Devctl. + +## Bootstrap + +```sh +mise install +go mod download all +go mod tidy +devctl lint +devctl gen +go mod tidy +mise run check +``` + +Inspect the application commands with `go run ./cmd/{{ .Project }} --help`. + +## Updating the foundation + +- Run `devctl sync` after changing remote sources. +- Run `devctl init scaffold` after changing components in `devctl.yaml`. +- Run `devctl gen` after changing API or schema contracts. + +Devctl replaces files ending in `*.gen.go`. Ordinary `.go` files and this +README are created once, so application code and local notes are preserved. + +When a component adds a provider seed, review it and call the provider from +`internal/deps/application.go`. That file is the user-owned composition root; +Devctl does not rewrite its provider list. diff --git a/internal/service/scaffold/templates/redis.go.gotmpl b/internal/service/scaffold/templates/redis.go.gotmpl new file mode 100644 index 0000000..6648814 --- /dev/null +++ b/internal/service/scaffold/templates/redis.go.gotmpl @@ -0,0 +1,18 @@ +package deps + +import ( + "context" + + "github.com/devctllabs/go-libs/di" + redis "github.com/redis/go-redis/v9" +) + +{{- range . }} +const redis{{ goName .Name }}Key = "redis-connection:{{ .Name }}" + +func provideRedis{{ goName .Name }}(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, redis{{ goName .Name }}Key, func(di.Resolver) (*redis.Client, error) { + return redis.NewClient(&redis.Options{Addr: cfg.Redis.{{ goName .Name }}Address}), nil + }, func(ctx context.Context, client *redis.Client) error { return client.Close() }) +} +{{- end }} diff --git a/internal/service/scaffold/templates/runtime.go.gotmpl b/internal/service/scaffold/templates/runtime.go.gotmpl new file mode 100644 index 0000000..35f1fe7 --- /dev/null +++ b/internal/service/scaffold/templates/runtime.go.gotmpl @@ -0,0 +1,243 @@ +package deps + +import ( + "context" + "errors" + "fmt" + + "github.com/devctllabs/go-libs/di" + "github.com/devctllabs/go-libs/lifecycle" +{{- if .HTTP }} + "net/http" + "time" + "github.com/labstack/echo/v5" +{{- end }} +{{- if .GRPC }} + "net" + "google.golang.org/grpc" +{{- end }} +{{- if .Health }} + healthlib "github.com/devctllabs/go-libs/health" + healthserverlib "github.com/devctllabs/go-libs/healthserver" +{{- end }} +{{- if .Pprof }} + debugserverlib "github.com/devctllabs/go-libs/debugserver" +{{- end }} +) + +{{- if .HTTP }} +// HTTPRegistrar is implemented by user-owned application composition. +type HTTPRegistrar interface { RegisterHTTP(*echo.Echo) } +{{- end }} +{{- if .GRPC }} +// GRPCRegistrar is implemented by user-owned application composition. +type GRPCRegistrar interface { RegisterGRPC(*grpc.Server) } +{{- end }} + +// Runtime owns the optional long-lived components selected by configuration. +type Runtime struct { +{{- if .HTTP }} + http *http.Server +{{- if .HTTPToggle }} + httpEnabled bool +{{- end }} +{{- end }} +{{- if .GRPC }} + grpc *grpc.Server + grpcAddress string +{{- if .GRPCToggle }} + grpcEnabled bool +{{- end }} +{{- end }} +{{- if .Health }} + health *healthserverlib.Server +{{- if .HealthToggle }} + healthEnabled bool +{{- end }} +{{- end }} +{{- if .Pprof }} + pprof *debugserverlib.Server +{{- if .PprofToggle }} + pprofEnabled bool +{{- end }} +{{- end }} +} + +func newRuntime(resolver di.Resolver, cfg *Config) (*Runtime, error) { +{{- if .HTTP }} + httpRegistrar, err := di.Resolve[HTTPRegistrar](resolver) + if err != nil { return nil, fmt.Errorf("di.Resolve HTTPRegistrar: %w", err) } +{{- end }} +{{- if .GRPC }} + grpcRegistrar, err := di.Resolve[GRPCRegistrar](resolver) + if err != nil { return nil, fmt.Errorf("di.Resolve GRPCRegistrar: %w", err) } +{{- end }} +{{- if .HealthConnections }} + options := make([]healthlib.Option, 0, {{ len .HealthConnections }}) +{{- range .HealthConnections }} + { + checker, err := di.ResolveNamed[dbChecker](resolver, {{ printf "%q" .Connection }}) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + options = append(options, healthlib.NonCritical({{ printf "%q" .Probe }}, checker)) + } +{{- end }} +{{- end }} + return NewRuntime(cfg{{ if .HTTP }}, httpRegistrar{{ end }}{{ if .GRPC }}, grpcRegistrar{{ end }}{{ if .HealthConnections }}, options...{{ end }}) +} + +func NewRuntime(cfg *Config{{ if .HTTP }}, httpRegistrar HTTPRegistrar{{ end }}{{ if .GRPC }}, grpcRegistrar GRPCRegistrar{{ end }}{{ if .HealthConnections }}, options ...healthlib.Option{{ end }}) (*Runtime, error) { + if cfg == nil { + return nil, errors.New("config is nil") + } + runtime := &Runtime{} +{{- if .HTTP }} + httpRouter := echo.New() + httpRegistrar.RegisterHTTP(httpRouter) + runtime.http = &http.Server{Addr: cfg.HTTP.Address, Handler: httpRouter, ReadHeaderTimeout: 2 * time.Second, IdleTimeout: 30 * time.Second} +{{- if .HTTPToggle }} + runtime.httpEnabled = cfg.HTTP.Enabled +{{- end }} +{{- end }} +{{- if .GRPC }} + runtime.grpc = newGRPCServer() + grpcRegistrar.RegisterGRPC(runtime.grpc) + runtime.grpcAddress = cfg.GRPC.Address +{{- if .GRPCToggle }} + runtime.grpcEnabled = cfg.GRPC.Enabled +{{- end }} +{{- end }} +{{- if .Health }} + probes, err := healthlib.New({{ if .HealthConnections }}options...{{ end }}) + if err != nil { + return nil, fmt.Errorf("healthlib.New: %w", err) + } + runtime.health, err = healthserverlib.NewServer(probes, healthserverlib.WithAddress(cfg.Health.Address)) + if err != nil { + return nil, fmt.Errorf("healthserverlib.NewServer: %w", err) + } +{{- if .HealthToggle }} + runtime.healthEnabled = cfg.Health.Enabled +{{- end }} +{{- end }} +{{- if .Pprof }} + pprofServer, err := debugserverlib.NewServer(debugserverlib.WithAddress(cfg.Pprof.Address)) + if err != nil { + return nil, fmt.Errorf("debugserverlib.NewServer: %w", err) + } + runtime.pprof = pprofServer +{{- if .PprofToggle }} + runtime.pprofEnabled = cfg.Pprof.Enabled +{{- end }} +{{- end }} + return runtime, nil +} + +func provideRuntime(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(resolver di.Resolver) (*Runtime, error) { + return newRuntime(resolver, cfg) + }, func(ctx context.Context, value *Runtime) error { return value.Shutdown(ctx) }) +} + +// Tasks returns enabled runtime roots in deterministic startup order. +func (r *Runtime) Tasks() []lifecycle.Task { + tasks := make([]lifecycle.Task, 0, 3) +{{- if .HTTP }} +{{- if .HTTPToggle }} + if r.httpEnabled { +{{- end }} + tasks = append(tasks, lifecycle.Task{Name: "http", Run: func(context.Context) error { + if err := r.http.ListenAndServe(); err != nil { + return fmt.Errorf("r.http.ListenAndServe: %w", err) + } + return nil + }}) +{{- if .HTTPToggle }} + } +{{- end }} +{{- end }} +{{- if .GRPC }} +{{- if .GRPCToggle }} + if r.grpcEnabled { +{{- end }} + tasks = append(tasks, lifecycle.Task{Name: "grpc", Run: func(context.Context) error { + listener, err := net.Listen("tcp", r.grpcAddress) + if err != nil { return fmt.Errorf("net.Listen: %w", err) } + if err := r.grpc.Serve(listener); err != nil { return fmt.Errorf("r.grpc.Serve: %w", err) } + return nil + }}) +{{- if .GRPCToggle }} + } +{{- end }} +{{- end }} +{{- if .Health }} +{{- if .HealthToggle }} + if r.healthEnabled { +{{- end }} + tasks = append(tasks, lifecycle.Task{Name: "health", Run: func(context.Context) error { + if err := r.health.ListenAndServe(); err != nil { + return fmt.Errorf("r.health.ListenAndServe: %w", err) + } + return nil + }}) +{{- if .HealthToggle }} + } +{{- end }} +{{- end }} +{{- if .Pprof }} +{{- if .PprofToggle }} + if r.pprofEnabled { +{{- end }} + tasks = append(tasks, lifecycle.Task{Name: "pprof", Run: func(context.Context) error { + if err := r.pprof.ListenAndServe(); err != nil { + return fmt.Errorf("r.pprof.ListenAndServe: %w", err) + } + return nil + }}) +{{- if .PprofToggle }} + } +{{- end }} +{{- end }} + return tasks +} + +// Shutdown stops every constructed runtime component and joins cleanup failures. +func (r *Runtime) Shutdown(ctx context.Context) error { + var shutdownErrors []error +{{- if .HTTP }} + if r.http != nil { + if err := r.http.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.http.Shutdown: %w", err)) + } + } +{{- end }} +{{- if .GRPC }} + if r.grpc != nil { + stopped := make(chan struct{}) + go func() { r.grpc.GracefulStop(); close(stopped) }() + select { + case <-stopped: + case <-ctx.Done(): + r.grpc.Stop() + <-stopped + shutdownErrors = append(shutdownErrors, fmt.Errorf("grpc shutdown: %w", ctx.Err())) + } + } +{{- end }} +{{- if .Health }} + if r.health != nil { + if err := r.health.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.health.Shutdown: %w", err)) + } + } +{{- end }} +{{- if .Pprof }} + if r.pprof != nil { + if err := r.pprof.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.pprof.Shutdown: %w", err)) + } + } +{{- end }} + return errors.Join(shutdownErrors...) +} diff --git a/internal/service/scaffold/templates/s3.go.gotmpl b/internal/service/scaffold/templates/s3.go.gotmpl new file mode 100644 index 0000000..88a7d11 --- /dev/null +++ b/internal/service/scaffold/templates/s3.go.gotmpl @@ -0,0 +1,65 @@ +package deps + +import ( + "context" +{{- if .Buckets }} + "fmt" +{{- end }} + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/devctllabs/go-libs/di" + awsconfig "github.com/aws/aws-sdk-go-v2/config" +{{- $static := false }} +{{- range .Connections }}{{ if eq .Credentials "static" }}{{ $static = true }}{{ end }}{{ end }} +{{- if $static }} + "github.com/aws/aws-sdk-go-v2/credentials" +{{- end }} + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +{{- if .Buckets }} +// S3Bucket binds one configured bucket name to its connection. +type S3Bucket struct { + Client *s3.Client + Name string +} +{{- end }} + +{{- range .Connections }} +const s3{{ goName .Name }}Key = "s3-connection:{{ .Name }}" + +func provideS3{{ goName .Name }}(ctx context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, s3{{ goName .Name }}Key, func(di.Resolver) (*s3.Client, error) { + loadOptions := []func(*awsconfig.LoadOptions) error{ + awsconfig.WithRegion(cfg.S3.{{ if and .Name (ne .Name "default") }}{{ goName .Name }}{{ end }}Region), + } +{{- if eq .Credentials "static" }} + loadOptions = append(loadOptions, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + cfg.S3.{{ if and .Name (ne .Name "default") }}{{ goName .Name }}{{ end }}AccessKeyID, + cfg.S3.{{ if and .Name (ne .Name "default") }}{{ goName .Name }}{{ end }}SecretAccessKey, + "", + ))) +{{- end }} + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...) + if err != nil { return nil, err } + return s3.NewFromConfig(awsCfg, func(options *s3.Options) { + if endpoint := cfg.S3.{{ if and .Name (ne .Name "default") }}{{ goName .Name }}{{ end }}Endpoint; endpoint != "" { + options.BaseEndpoint = aws.String(endpoint) + } + options.UsePathStyle = cfg.S3.{{ if and .Name (ne .Name "default") }}{{ goName .Name }}{{ end }}ForcePathStyle + }), nil + }, func(context.Context, *s3.Client) error { return nil }) +} +{{- end }} + +{{- range .Buckets }} +const s3{{ goName .Name }}BucketKey = "s3-bucket:{{ .Name }}" + +func provideS3{{ goName .Name }}Bucket(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamed[S3Bucket](graph, s3{{ goName .Name }}BucketKey, func(resolver di.Resolver) (S3Bucket, error) { + client, err := di.ResolveNamed[*s3.Client](resolver, s3{{ goName .Connection }}Key) + if err != nil { return S3Bucket{}, fmt.Errorf("resolve S3 connection {{ .Connection }}: %w", err) } + return S3Bucket{Client: client, Name: cfg.S3.{{ goName .Name }}Bucket}, nil + }) +} +{{- end }} diff --git a/internal/service/scaffold/templates/storage.go.gotmpl b/internal/service/scaffold/templates/storage.go.gotmpl new file mode 100644 index 0000000..6512437 --- /dev/null +++ b/internal/service/scaffold/templates/storage.go.gotmpl @@ -0,0 +1,133 @@ +package deps + +import ( + "context" + "errors" + "fmt" + + "github.com/devctllabs/go-libs/di" +{{- if not .ClickHouse }} + "github.com/devctllabs/go-libs/txmanager" +{{- end }} +{{- if and .Telemetry (not .ClickHouse) }} + telemetrylib "github.com/devctllabs/go-libs/telemetry" +{{- end }} +{{- if .ClickHouse }} + "github.com/ClickHouse/clickhouse-go/v2" + clickhousedriver "github.com/ClickHouse/clickhouse-go/v2/lib/driver" +{{- end }} +{{- range .Kinds }} + {{ .Name }}db "github.com/devctllabs/go-libs/{{ .Name }}db" +{{- end }} +) + +const storage{{ .Name }}ConnectionName = "db-connection:{{ .Connection }}" + +{{- if .ClickHouse }} +type clickHouse{{ .Name }}Checker struct { connection clickhousedriver.Conn } + +func (c clickHouse{{ .Name }}Checker) Check(ctx context.Context) error { return c.connection.Ping(ctx) } + +func provideStorage{{ .Name }}(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedResource[clickhousedriver.Conn](graph, storage{{ .Name }}ConnectionName, func(di.Resolver) (clickhousedriver.Conn, error) { + options, err := clickhouse.ParseDSN(cfg.DB{{ .Name }}.{{ .ClickHouseConfig }}) + if err != nil { return nil, fmt.Errorf("clickhouse.ParseDSN: %w", err) } + connection, err := clickhouse.Open(options) + if err != nil { return nil, fmt.Errorf("clickhouse.Open: %w", err) } + if err := connection.Ping(ctx); err != nil { + return nil, errors.Join(fmt.Errorf("connection.Ping: %w", err), connection.Close()) + } + return connection, nil + }, func(_ context.Context, connection clickhousedriver.Conn) error { return connection.Close() }); err != nil { + return fmt.Errorf("di.ProvideNamedResource: %w", err) + } + return di.ProvideNamed[dbChecker](graph, storage{{ .Name }}ConnectionName, func(resolver di.Resolver) (dbChecker, error) { + connection, err := di.ResolveNamed[clickhousedriver.Conn](resolver, storage{{ .Name }}ConnectionName) + if err != nil { return nil, fmt.Errorf("di.ResolveNamed: %w", err) } + return clickHouse{{ .Name }}Checker{connection: connection}, nil + }) +} +{{- else }} +type storage{{ .Name }} struct { +{{- range .Kinds }} + {{ .Field }} *{{ .Name }}db.DB +{{- end }} +} + +func provideStorage{{ .Name }}(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedResource[*storage{{ .Name }}](graph, storage{{ .Name }}ConnectionName, func(resolver di.Resolver) (*storage{{ .Name }}, error) { + return openStorage{{ .Name }}(ctx, resolver, cfg) + }, func(_ context.Context, value *storage{{ .Name }}) error { return value.close() }); err != nil { + return fmt.Errorf("di.ProvideNamedResource: %w", err) + } +{{- range .Kinds }} + if err := di.ProvideNamed[*{{ .Name }}db.Endpoint](graph, storage{{ $.Name }}ConnectionName+".reader", func(resolver di.Resolver) (*{{ .Name }}db.Endpoint, error) { + storage, err := di.ResolveNamed[*storage{{ $.Name }}](resolver, storage{{ $.Name }}ConnectionName) + if err != nil { return nil, fmt.Errorf("di.ResolveNamed: %w", err) } + if storage.{{ .Field }} == nil { return nil, fmt.Errorf("storage {{ $.Connection }} does not use {{ .Name }}") } + return storage.{{ .Field }}.Reader(), nil + }); err != nil { return fmt.Errorf("di.ProvideNamed reader: %w", err) } + if err := di.ProvideNamed[*{{ .Name }}db.Endpoint](graph, storage{{ $.Name }}ConnectionName+".writer", func(resolver di.Resolver) (*{{ .Name }}db.Endpoint, error) { + storage, err := di.ResolveNamed[*storage{{ $.Name }}](resolver, storage{{ $.Name }}ConnectionName) + if err != nil { return nil, fmt.Errorf("di.ResolveNamed: %w", err) } + if storage.{{ .Field }} == nil { return nil, fmt.Errorf("storage {{ $.Connection }} does not use {{ .Name }}") } + return storage.{{ .Field }}.Writer(), nil + }); err != nil { return fmt.Errorf("di.ProvideNamed writer: %w", err) } +{{- end }} + if err := di.ProvideNamed[txmanager.Managers](graph, storage{{ .Name }}ConnectionName, func(resolver di.Resolver) (txmanager.Managers, error) { + storage, err := di.ResolveNamed[*storage{{ .Name }}](resolver, storage{{ .Name }}ConnectionName) + if err != nil { return nil, fmt.Errorf("di.ResolveNamed: %w", err) } + return storage.managers(), nil + }); err != nil { return fmt.Errorf("di.ProvideNamed tx managers: %w", err) } + return di.ProvideNamed[dbChecker](graph, storage{{ .Name }}ConnectionName, func(resolver di.Resolver) (dbChecker, error) { + storage, err := di.ResolveNamed[*storage{{ .Name }}](resolver, storage{{ .Name }}ConnectionName) + if err != nil { return nil, fmt.Errorf("di.ResolveNamed: %w", err) } + return storage.checker(), nil + }) +} + +func openStorage{{ .Name }}(ctx context.Context, resolver di.Resolver, cfg *Config) (*storage{{ .Name }}, error) { +{{- if .Telemetry }} + telemetryRuntime, err := di.Resolve[*telemetrylib.Runtime](resolver) + if err != nil { return nil, fmt.Errorf("di.Resolve: %w", err) } +{{- end }} + switch cfg.DB{{ .Name }}.Kind { +{{- range .Variants }} + case {{ printf "%q" .Name }}: +{{- if .SQLite }} + db, err := {{ .Kind }}db.Open(ctx, {{ .Kind }}db.Config{DSN: cfg.DB{{ $.Name }}.{{ .ConfigField }}{{ if $.Telemetry }}, Telemetry: {{ .Kind }}db.Telemetry{TracerProvider: telemetryRuntime.TracerProvider(), MeterProvider: telemetryRuntime.MeterProvider()}{{ end }}}) +{{- else }} + db, err := {{ .Kind }}db.Open(ctx, {{ .Kind }}db.Config{Writer: {{ .Kind }}db.EndpointConfig{DSN: cfg.DB{{ $.Name }}.{{ .ConfigField }}}{{ if $.Telemetry }}, Telemetry: {{ .Kind }}db.Telemetry{TracerProvider: telemetryRuntime.TracerProvider(), MeterProvider: telemetryRuntime.MeterProvider()}{{ end }}}) +{{- end }} + if err != nil { return nil, fmt.Errorf("{{ .Kind }}db.Open: %w", err) } + return &storage{{ $.Name }}{ {{- .Field }}: db}, nil +{{- end }} + default: + return nil, fmt.Errorf("unsupported {{ .Connection }} database kind %q", cfg.DB{{ .Name }}.Kind) + } +} + +func (s *storage{{ .Name }}) managers() txmanager.Managers { +{{- range .Kinds }} + if s.{{ .Field }} != nil { return s.{{ .Field }}.TxManagers() } +{{- end }} + return nil +} + +func (s *storage{{ .Name }}) checker() dbChecker { +{{- range .Kinds }} + if s.{{ .Field }} != nil { return s.{{ .Field }}.Writer() } +{{- end }} + return nil +} + +func (s *storage{{ .Name }}) close() error { + var closeErrors []error +{{- range .Kinds }} + if s.{{ .Field }} != nil { + if err := s.{{ .Field }}.Close(); err != nil { closeErrors = append(closeErrors, fmt.Errorf("close {{ .Name }}: %w", err)) } + } +{{- end }} + return errors.Join(closeErrors...) +} +{{- end }} diff --git a/internal/service/scaffold/testdata/full/.env.example b/internal/service/scaffold/testdata/full/.env.example new file mode 100644 index 0000000..54a143b --- /dev/null +++ b/internal/service/scaffold/testdata/full/.env.example @@ -0,0 +1,44 @@ +SAMPLE_BILLING_GRPC_ADDR= +SAMPLE_CATALOG_BASE_URL= +SAMPLE_DB_ANALYTICS_CLICKHOUSE_DSN= +SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL= +SAMPLE_DB_ANALYTICS_KIND=clickhouse +SAMPLE_DB_PRIMARY_KIND=sqlite +SAMPLE_DB_PRIMARY_POSTGRES_DSN= +SAMPLE_DB_PRIMARY_SQLITE_DSN=file:./data/app.db?_foreign_keys=on +SAMPLE_DEPLOYMENT_ENVIRONMENT=development +SAMPLE_GRPC_ADDR=:9090 +SAMPLE_HEALTH_ADDR=:8081 +SAMPLE_HTTP_ADDR=:8080 +SAMPLE_KAFKA_AUDIT_BATCH_FLUSH_INTERVAL=1s +SAMPLE_KAFKA_AUDIT_BATCH_MAX_SIZE=1 +SAMPLE_KAFKA_AUDIT_GROUP=sample-api-audit-group +SAMPLE_KAFKA_AUDIT_REBALANCE_DRAIN_TIMEOUT=20s +SAMPLE_KAFKA_AUDIT_REBALANCE_TIMEOUT=30s +SAMPLE_KAFKA_AUDIT_RETRY_INITIAL_DELAY=1s +SAMPLE_KAFKA_AUDIT_RETRY_MAX_ATTEMPTS=3 +SAMPLE_KAFKA_AUDIT_RETRY_MAX_DELAY=30s +SAMPLE_KAFKA_AUDIT_RETRY_MAX_ELAPSED_TIME=0s +SAMPLE_KAFKA_AUDIT_SHUTDOWN_TIMEOUT=30s +SAMPLE_KAFKA_AUDIT_TOPIC=sample.audit.events.v1 +SAMPLE_KAFKA_BROKERS=localhost:29092 +SAMPLE_KAFKA_EVENTS_TOPIC=sample.events.v1 +SAMPLE_KAFKA_INVOICE_BATCH_FLUSH_INTERVAL=1s +SAMPLE_KAFKA_INVOICE_BATCH_MAX_SIZE=1 +SAMPLE_KAFKA_INVOICE_GROUP=sample-api-invoice-group +SAMPLE_KAFKA_INVOICE_REBALANCE_DRAIN_TIMEOUT=20s +SAMPLE_KAFKA_INVOICE_REBALANCE_TIMEOUT=30s +SAMPLE_KAFKA_INVOICE_RETRY_INITIAL_DELAY=1s +SAMPLE_KAFKA_INVOICE_RETRY_MAX_ATTEMPTS=3 +SAMPLE_KAFKA_INVOICE_RETRY_MAX_DELAY=30s +SAMPLE_KAFKA_INVOICE_RETRY_MAX_ELAPSED_TIME=0s +SAMPLE_KAFKA_INVOICE_SHUTDOWN_TIMEOUT=30s +SAMPLE_KAFKA_INVOICE_TOPIC=sample.invoice.events.v1 +SAMPLE_LOG_LEVEL=info +SAMPLE_PPROF_ADDR=127.0.0.1:6060 +SAMPLE_REDIS_CACHE_ADDR=localhost:6379 +SAMPLE_S3_ENDPOINT= +SAMPLE_S3_FORCE_PATH_STYLE=false +SAMPLE_S3_MEDIA_BUCKET=media-local +SAMPLE_S3_REGION=us-east-1 +SAMPLE_SERVICE_VERSION=dev diff --git a/internal/service/scaffold/testdata/full/.golangci.yml b/internal/service/scaffold/testdata/full/.golangci.yml new file mode 100644 index 0000000..a01c843 --- /dev/null +++ b/internal/service/scaffold/testdata/full/.golangci.yml @@ -0,0 +1,76 @@ +version: "2" +run: + relative-path-mode: gomod + tests: true + modules-download-mode: readonly +linters: + default: none + enable: + - asasalint + - bidichk + - bodyclose + - containedctx + - contextcheck + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - fatcontext + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - gocognit + - govet + - inamedparam + - ineffassign + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - predeclared + - reassign + - recvcheck + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - testifylint + - thelper + - tparallel + - unconvert + - unused + - usetesting + - wastedassign + - wrapcheck + settings: + gocognit: + min-complexity: 20 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + paralleltest: + ignore-missing: false + ignore-missing-subtests: false + check-cleanup: true + revive: + rules: + - name: argument-limit + arguments: [4] + - name: function-result-limit + arguments: [3] + exclusions: + generated: strict + paths: ["^gen/"] +formatters: + enable: [gofmt] + exclusions: + generated: strict + paths: ["^gen/"] diff --git a/internal/service/scaffold/testdata/full/.mise.toml b/internal/service/scaffold/testdata/full/.mise.toml new file mode 100644 index 0000000..30c9c17 --- /dev/null +++ b/internal/service/scaffold/testdata/full/.mise.toml @@ -0,0 +1,52 @@ +[tools] +go = "1.26.0" +golangci-lint = "2.12.2" +node = "24" +"npm:quicktype" = "26.0.0" +"go:github.com/golang-migrate/migrate/v4/cmd/migrate" = { version = "v4.19.1", tags = ["clickhouse"] } + +[tasks.fmt] +run = "golangci-lint fmt" +[tasks."fmt:check"] +run = "golangci-lint fmt --diff" +[tasks."lint:contracts"] +run = "devctl lint" +[tasks."lint:go"] +run = "golangci-lint run" +[tasks.lint] +depends = ["lint:contracts", "lint:go"] +[tasks.test] +run = "go test ./..." +[tasks.gen] +run = "devctl gen" +[tasks."gen:http"] +run = "devctl gen http" +[tasks."gen:grpc"] +run = "devctl gen grpc" +[tasks."gen:kafka"] +run = "devctl gen kafka" +[tasks.check] +depends = ["fmt:check", "lint", "test"] + +[tasks."migrate:analytics:clickhouse:create"] +description = "Create timestamped migration files in migrations/analytics/clickhouse" +usage = 'arg "" help="Migration name"' +run = ''' +migrate create -ext sql -dir "migrations/analytics/clickhouse" -format "20060102150405" "${usage_name?}" +''' + +[tasks."migrate:analytics:clickhouse:up"] +description = "Apply migrations from migrations/analytics/clickhouse" +run = ''' +database_url="${SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL:?set SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL}" +migrate -path "migrations/analytics/clickhouse" -database "$database_url" up +''' + +[tasks."migrate:analytics:clickhouse:down"] +description = "Roll back migrations from migrations/analytics/clickhouse" +usage = 'arg "[steps]" default="1" help="Number of migrations"' +confirm = "Roll back migrate:analytics:clickhouse migrations?" +run = ''' +database_url="${SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL:?set SAMPLE_DB_ANALYTICS_CLICKHOUSE_MIGRATIONS_URL}" +migrate -path "migrations/analytics/clickhouse" -database "$database_url" down "${usage_steps?}" +''' diff --git a/internal/service/scaffold/testdata/full/README.md b/internal/service/scaffold/testdata/full/README.md new file mode 100644 index 0000000..af2c059 --- /dev/null +++ b/internal/service/scaffold/testdata/full/README.md @@ -0,0 +1,30 @@ +# sample-api + +This project foundation is scaffolded by Devctl. + +## Bootstrap + +```sh +mise install +go mod download all +go mod tidy +devctl lint +devctl gen +go mod tidy +mise run check +``` + +Inspect the application commands with `go run ./cmd/sample-api --help`. + +## Updating the foundation + +- Run `devctl sync` after changing remote sources. +- Run `devctl init scaffold` after changing components in `devctl.yaml`. +- Run `devctl gen` after changing API or schema contracts. + +Devctl replaces files ending in `*.gen.go`. Ordinary `.go` files and this +README are created once, so application code and local notes are preserved. + +When a component adds a provider seed, review it and call the provider from +`internal/deps/application.go`. That file is the user-owned composition root; +Devctl does not rewrite its provider list. diff --git a/internal/service/scaffold/testdata/full/api/openapi/swagger.yaml b/internal/service/scaffold/testdata/full/api/openapi/swagger.yaml new file mode 100644 index 0000000..aa356d4 --- /dev/null +++ b/internal/service/scaffold/testdata/full/api/openapi/swagger.yaml @@ -0,0 +1,5 @@ +openapi: 3.1.0 +info: + title: API + version: 0.0.0 +paths: {} diff --git a/internal/service/scaffold/testdata/full/buf.yaml b/internal/service/scaffold/testdata/full/buf.yaml new file mode 100644 index 0000000..30947eb --- /dev/null +++ b/internal/service/scaffold/testdata/full/buf.yaml @@ -0,0 +1,11 @@ +version: v2 +modules: + - path: api/proto +lint: + use: + - STANDARD + except: + - FILE_LOWER_SNAKE_CASE +breaking: + use: + - FILE diff --git a/internal/service/scaffold/testdata/full/cmd/sample-api/internal/api.go b/internal/service/scaffold/testdata/full/cmd/sample-api/internal/api.go new file mode 100644 index 0000000..d12ec7a --- /dev/null +++ b/internal/service/scaffold/testdata/full/cmd/sample-api/internal/api.go @@ -0,0 +1,27 @@ +package internal + +import ( + "context" + "fmt" + + "example.test/sample-api/internal/deps" + "github.com/urfave/cli/v3" +) + +// NewCmdAPI constructs the API server command. +func NewCmdAPI() *cli.Command { + return &cli.Command{ + Name: "api", + Usage: "Run API servers", + Action: func(ctx context.Context, _ *cli.Command) error { + scenario, err := deps.NewAPI(ctx) + if err != nil { + return fmt.Errorf("deps.NewAPI: %w", err) + } + if err := scenario.Run(ctx); err != nil { + return fmt.Errorf("scenario.Run: %w", err) + } + return nil + }, + } +} diff --git a/internal/service/scaffold/testdata/full/cmd/sample-api/internal/consumer.go b/internal/service/scaffold/testdata/full/cmd/sample-api/internal/consumer.go new file mode 100644 index 0000000..5cfbc41 --- /dev/null +++ b/internal/service/scaffold/testdata/full/cmd/sample-api/internal/consumer.go @@ -0,0 +1,27 @@ +package internal + +import ( + "context" + "fmt" + + "example.test/sample-api/internal/deps" + "github.com/urfave/cli/v3" +) + +// NewCmdConsumer constructs the named Kafka consumer command. +func NewCmdConsumer() *cli.Command { + return &cli.Command{ + Name: "consumer", + Arguments: []cli.Argument{&cli.StringArg{Name: "consumer-name"}}, + Action: func(ctx context.Context, command *cli.Command) error { + scenario, err := deps.NewConsumer(ctx, command.StringArg("consumer-name")) + if err != nil { + return fmt.Errorf("deps.NewConsumer: %w", err) + } + if err := scenario.Run(ctx); err != nil { + return fmt.Errorf("scenario.Run: %w", err) + } + return nil + }, + } +} diff --git a/internal/service/scaffold/testdata/full/cmd/sample-api/main.go b/internal/service/scaffold/testdata/full/cmd/sample-api/main.go new file mode 100644 index 0000000..2b3f732 --- /dev/null +++ b/internal/service/scaffold/testdata/full/cmd/sample-api/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/urfave/cli/v3" + + appcmd "example.test/sample-api/cmd/sample-api/internal" +) + +func main() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signals) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + select { + case <-signals: + cancel() + case <-ctx.Done(): + } + }() + root := &cli.Command{ + Name: "sample-api", + Usage: "Run sample-api", + Commands: []*cli.Command{ + appcmd.NewCmdAPI(), + appcmd.NewCmdConsumer(), + }, + } + if err := root.Run(ctx, os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/internal/service/scaffold/testdata/full/data/.gitkeep b/internal/service/scaffold/testdata/full/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/service/scaffold/testdata/full/gen/config/config.gen.go b/internal/service/scaffold/testdata/full/gen/config/config.gen.go new file mode 100644 index 0000000..1021cfd --- /dev/null +++ b/internal/service/scaffold/testdata/full/gen/config/config.gen.go @@ -0,0 +1,115 @@ +// Code generated by devctl. DO NOT EDIT. + +package config + +import ( + "fmt" + "time" +) + +type Config struct { + DBAnalytics DBAnalyticsConfig + DBPrimary DBPrimaryConfig + GRPC GRPCConfig + GRPCClients GRPCClientsConfig + HTTP HTTPConfig + HTTPClients HTTPClientsConfig + Health HealthConfig + Kafka KafkaConfig + Logging LoggingConfig + Pprof PprofConfig + Redis RedisConfig + S3 S3Config + Telemetry TelemetryConfig +} + +type DBAnalyticsConfig struct { + ClickhouseDSN string `env:"SAMPLE_DB_ANALYTICS_CLICKHOUSE_DSN"` + Kind string `env:"SAMPLE_DB_ANALYTICS_KIND" default:"clickhouse"` +} + +type DBPrimaryConfig struct { + Kind string `env:"SAMPLE_DB_PRIMARY_KIND" default:"sqlite"` + PostgresDSN string `env:"SAMPLE_DB_PRIMARY_POSTGRES_DSN"` + SqliteDSN string `env:"SAMPLE_DB_PRIMARY_SQLITE_DSN" default:"file:./data/app.db?_foreign_keys=on"` +} + +type GRPCConfig struct { + Address string `env:"SAMPLE_GRPC_ADDR" default:":9090"` +} + +type GRPCClientsConfig struct { + BillingAddress string `env:"SAMPLE_BILLING_GRPC_ADDR"` +} + +type HTTPConfig struct { + Address string `env:"SAMPLE_HTTP_ADDR" default:":8080"` +} + +type HTTPClientsConfig struct { + CatalogBaseURL string `env:"SAMPLE_CATALOG_BASE_URL"` +} + +type HealthConfig struct { + Address string `env:"SAMPLE_HEALTH_ADDR" default:":8081"` +} + +type KafkaConfig struct { + AuditBatchFlushInterval time.Duration `env:"SAMPLE_KAFKA_AUDIT_BATCH_FLUSH_INTERVAL" default:"1s"` + AuditBatchMaxSize int `env:"SAMPLE_KAFKA_AUDIT_BATCH_MAX_SIZE" default:"1"` + AuditGroup string `env:"SAMPLE_KAFKA_AUDIT_GROUP" default:"sample-api-audit-group"` + AuditRebalanceDrainTimeout time.Duration `env:"SAMPLE_KAFKA_AUDIT_REBALANCE_DRAIN_TIMEOUT" default:"20s"` + AuditRebalanceTimeout time.Duration `env:"SAMPLE_KAFKA_AUDIT_REBALANCE_TIMEOUT" default:"30s"` + AuditRetryInitialDelay time.Duration `env:"SAMPLE_KAFKA_AUDIT_RETRY_INITIAL_DELAY" default:"1s"` + AuditRetryMaxAttempts int `env:"SAMPLE_KAFKA_AUDIT_RETRY_MAX_ATTEMPTS" default:"3"` + AuditRetryMaxDelay time.Duration `env:"SAMPLE_KAFKA_AUDIT_RETRY_MAX_DELAY" default:"30s"` + AuditRetryMaxElapsedTime time.Duration `env:"SAMPLE_KAFKA_AUDIT_RETRY_MAX_ELAPSED_TIME" default:"0s"` + AuditShutdownTimeout time.Duration `env:"SAMPLE_KAFKA_AUDIT_SHUTDOWN_TIMEOUT" default:"30s"` + AuditTopic string `env:"SAMPLE_KAFKA_AUDIT_TOPIC" default:"sample.audit.events.v1"` + Brokers []string `env:"SAMPLE_KAFKA_BROKERS" default:"localhost:29092"` + EventsTopic string `env:"SAMPLE_KAFKA_EVENTS_TOPIC" default:"sample.events.v1"` + InvoiceBatchFlushInterval time.Duration `env:"SAMPLE_KAFKA_INVOICE_BATCH_FLUSH_INTERVAL" default:"1s"` + InvoiceBatchMaxSize int `env:"SAMPLE_KAFKA_INVOICE_BATCH_MAX_SIZE" default:"1"` + InvoiceGroup string `env:"SAMPLE_KAFKA_INVOICE_GROUP" default:"sample-api-invoice-group"` + InvoiceRebalanceDrainTimeout time.Duration `env:"SAMPLE_KAFKA_INVOICE_REBALANCE_DRAIN_TIMEOUT" default:"20s"` + InvoiceRebalanceTimeout time.Duration `env:"SAMPLE_KAFKA_INVOICE_REBALANCE_TIMEOUT" default:"30s"` + InvoiceRetryInitialDelay time.Duration `env:"SAMPLE_KAFKA_INVOICE_RETRY_INITIAL_DELAY" default:"1s"` + InvoiceRetryMaxAttempts int `env:"SAMPLE_KAFKA_INVOICE_RETRY_MAX_ATTEMPTS" default:"3"` + InvoiceRetryMaxDelay time.Duration `env:"SAMPLE_KAFKA_INVOICE_RETRY_MAX_DELAY" default:"30s"` + InvoiceRetryMaxElapsedTime time.Duration `env:"SAMPLE_KAFKA_INVOICE_RETRY_MAX_ELAPSED_TIME" default:"0s"` + InvoiceShutdownTimeout time.Duration `env:"SAMPLE_KAFKA_INVOICE_SHUTDOWN_TIMEOUT" default:"30s"` + InvoiceTopic string `env:"SAMPLE_KAFKA_INVOICE_TOPIC" default:"sample.invoice.events.v1"` +} + +type LoggingConfig struct { + Level string `env:"SAMPLE_LOG_LEVEL" default:"info"` +} + +type PprofConfig struct { + Address string `env:"SAMPLE_PPROF_ADDR" default:"127.0.0.1:6060"` +} + +type RedisConfig struct { + CacheAddress string `env:"SAMPLE_REDIS_CACHE_ADDR" default:"localhost:6379"` +} + +type S3Config struct { + Endpoint string `env:"SAMPLE_S3_ENDPOINT"` + ForcePathStyle bool `env:"SAMPLE_S3_FORCE_PATH_STYLE" default:"false"` + MediaBucket string `env:"SAMPLE_S3_MEDIA_BUCKET" default:"media-local"` + Region string `env:"SAMPLE_S3_REGION" default:"us-east-1"` +} + +type TelemetryConfig struct { + DeploymentEnvironment string `env:"SAMPLE_DEPLOYMENT_ENVIRONMENT" default:"development"` + ServiceVersion string `env:"SAMPLE_SERVICE_VERSION" default:"dev"` +} + +func (c *Config) Validate() error { + if c == nil { + return fmt.Errorf("config is nil") + } + return nil +} + +var _ time.Duration diff --git a/internal/service/scaffold/testdata/full/go.mod b/internal/service/scaffold/testdata/full/go.mod new file mode 100644 index 0000000..3fd9045 --- /dev/null +++ b/internal/service/scaffold/testdata/full/go.mod @@ -0,0 +1,45 @@ +module example.test/sample-api + +go 1.26.0 + +require ( + github.com/ClickHouse/clickhouse-go/v2 v2.48.0 + github.com/aws/aws-sdk-go-v2 v1.45.1 + github.com/aws/aws-sdk-go-v2/config v1.33.1 + github.com/aws/aws-sdk-go-v2/credentials v1.20.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 + github.com/bufbuild/buf v1.72.0 + github.com/devctllabs/go-libs/config v0.1.0 + github.com/devctllabs/go-libs/debugserver v0.1.0 + github.com/devctllabs/go-libs/di v0.1.0 + github.com/devctllabs/go-libs/health v0.1.0 + github.com/devctllabs/go-libs/healthserver v0.1.0 + github.com/devctllabs/go-libs/kafka v0.1.0 + github.com/devctllabs/go-libs/kafkaproto v0.1.0 + github.com/devctllabs/go-libs/lifecycle v0.2.0 + github.com/devctllabs/go-libs/log v0.2.0 + github.com/devctllabs/go-libs/oapivalidator v0.2.0 + github.com/devctllabs/go-libs/postgresdb v0.2.0 + github.com/devctllabs/go-libs/retry v0.1.0 + github.com/devctllabs/go-libs/sqlitedb v0.1.0 + github.com/devctllabs/go-libs/telemetry v0.1.0 + github.com/devctllabs/go-libs/txmanager v0.1.0 + github.com/labstack/echo/v5 v5.3.1 + github.com/redis/go-redis/v9 v9.22.0 + github.com/twmb/franz-go v1.21.6 + github.com/urfave/cli/v3 v3.10.1 + go.uber.org/zap v1.28.0 + google.golang.org/grpc v1.83.2 + google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 + google.golang.org/protobuf v1.36.12 +) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + +require github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 + +tool ( + github.com/bufbuild/buf/cmd/buf + google.golang.org/grpc/cmd/protoc-gen-go-grpc + google.golang.org/protobuf/cmd/protoc-gen-go +) diff --git a/internal/service/scaffold/testdata/full/internal/deps/application.go b/internal/service/scaffold/testdata/full/internal/deps/application.go new file mode 100644 index 0000000..e6a88bb --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/application.go @@ -0,0 +1,66 @@ +package deps + +import ( + "context" + "fmt" + + "github.com/devctllabs/go-libs/di" + "github.com/labstack/echo/v5" + "google.golang.org/grpc" +) + +// application is the user-owned composition root. Add application dependencies here. +type application struct{} + +func (*application) RegisterHTTP(*echo.Echo) {} +func (*application) RegisterGRPC(*grpc.Server) {} + +// provideApplication is created once. Add newly scaffolded provider calls manually. +func provideApplication(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.Provide[HTTPRegistrar](graph, func(di.Resolver) (HTTPRegistrar, error) { return &application{}, nil }); err != nil { + return fmt.Errorf("di.Provide HTTPRegistrar: %w", err) + } + if err := di.Provide[GRPCRegistrar](graph, func(di.Resolver) (GRPCRegistrar, error) { return &application{}, nil }); err != nil { + return fmt.Errorf("di.Provide GRPCRegistrar: %w", err) + } + if err := provideLogging(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideLogging: %w", err) + } + if err := provideTelemetry(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideTelemetry: %w", err) + } + if err := provideStoragePrimary(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideStoragePrimary: %w", err) + } + if err := provideStorageAnalytics(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideStorageAnalytics: %w", err) + } + if err := provideAuditConsumer(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideAuditConsumer: %w", err) + } + if err := provideInvoiceConsumer(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideInvoiceConsumer: %w", err) + } + if err := provideEventsKafkaProducer(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideEventsKafkaProducer: %w", err) + } + if err := provideCatalogHTTPClient(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideCatalogHTTPClient: %w", err) + } + if err := provideBillingGRPCClient(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideBillingGRPCClient: %w", err) + } + if err := provideRedisCache(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideRedisCache: %w", err) + } + if err := provideS3Default(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideS3Default: %w", err) + } + if err := provideS3MediaBucket(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideS3MediaBucket: %w", err) + } + if err := provideRuntime(ctx, graph, cfg); err != nil { + return fmt.Errorf("provideRuntime: %w", err) + } + return nil +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/config.gen.go b/internal/service/scaffold/testdata/full/internal/deps/config.gen.go new file mode 100644 index 0000000..99f5dc3 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/config.gen.go @@ -0,0 +1,23 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + generatedconfig "example.test/sample-api/gen/config" + configlib "github.com/devctllabs/go-libs/config" +) + +// Config is the canonical generated runtime configuration. +type Config = generatedconfig.Config + +func loadConfig(ctx context.Context) (*Config, error) { + var cfg Config + loader := configlib.Chain(configlib.Defaults(), configlib.OSEnv()) + if err := loader.Load(ctx, &cfg); err != nil { + return nil, fmt.Errorf("loader.Load: %w", err) + } + return &cfg, nil +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/consumer_audit.go b/internal/service/scaffold/testdata/full/internal/deps/consumer_audit.go new file mode 100644 index 0000000..99135ca --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/consumer_audit.go @@ -0,0 +1,35 @@ +package deps + +import ( + "context" + "fmt" + + auditconsumer "example.test/sample-api/internal/transport/consumerkafka/audit" + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +// provideAuditConsumer is user-owned: change []byte and the decoder for generated schema types. +func provideAuditConsumer(ctx context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey("audit") + if err := provideAuditConsumerConfig(ctx, graph, cfg); err != nil { + return fmt.Errorf("provide consumer config: %w", err) + } + if err := di.ProvideNamedValue[kafka.Decoder[[]byte]](graph, key, rawKafkaDecoder()); err != nil { + return fmt.Errorf("provide consumer decoder: %w", err) + } + if err := di.ProvideNamedValue[kafka.BatchHandler[[]byte]](graph, key, auditconsumer.NewHandler()); err != nil { + return fmt.Errorf("provide consumer handler: %w", err) + } + if err := di.ProvideNamed[retry.Policy](graph, key, func(di.Resolver) (retry.Policy, error) { + return retry.NewExponential(retry.ExponentialConfig{ + InitialDelay: cfg.Kafka.AuditRetryInitialDelay, + MaxDelay: cfg.Kafka.AuditRetryMaxDelay, + Multiplier: 2, + }) + }); err != nil { + return fmt.Errorf("provide consumer retry policy: %w", err) + } + return provideConsumer[[]byte](graph, key) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/consumer_invoice.go b/internal/service/scaffold/testdata/full/internal/deps/consumer_invoice.go new file mode 100644 index 0000000..202e6d7 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/consumer_invoice.go @@ -0,0 +1,35 @@ +package deps + +import ( + "context" + "fmt" + + invoiceconsumer "example.test/sample-api/internal/transport/consumerkafka/invoice" + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +// provideInvoiceConsumer is user-owned: change []byte and the decoder for generated schema types. +func provideInvoiceConsumer(ctx context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey("invoice") + if err := provideInvoiceConsumerConfig(ctx, graph, cfg); err != nil { + return fmt.Errorf("provide consumer config: %w", err) + } + if err := di.ProvideNamedValue[kafka.Decoder[[]byte]](graph, key, rawKafkaDecoder()); err != nil { + return fmt.Errorf("provide consumer decoder: %w", err) + } + if err := di.ProvideNamedValue[kafka.BatchHandler[[]byte]](graph, key, invoiceconsumer.NewHandler()); err != nil { + return fmt.Errorf("provide consumer handler: %w", err) + } + if err := di.ProvideNamed[retry.Policy](graph, key, func(di.Resolver) (retry.Policy, error) { + return retry.NewExponential(retry.ExponentialConfig{ + InitialDelay: cfg.Kafka.InvoiceRetryInitialDelay, + MaxDelay: cfg.Kafka.InvoiceRetryMaxDelay, + Multiplier: 2, + }) + }); err != nil { + return fmt.Errorf("provide consumer retry policy: %w", err) + } + return provideConsumer[[]byte](graph, key) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/container.gen.go b/internal/service/scaffold/testdata/full/internal/deps/container.gen.go new file mode 100644 index 0000000..e6bd630 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/container.gen.go @@ -0,0 +1,141 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + "github.com/devctllabs/go-libs/di" + "github.com/devctllabs/go-libs/lifecycle" + loglib "github.com/devctllabs/go-libs/log" + telemetrylib "github.com/devctllabs/go-libs/telemetry" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "time" +) + +type dbChecker interface{ Check(context.Context) error } +type scenarioRunner interface{ Run(context.Context) error } + +// Scenario owns one lazily resolved runnable branch and its dependency graph. +type Scenario struct { + graph *di.Container + tasks []lifecycle.Task +} + +// Run coordinates the selected branch until cancellation or failure. +func (s *Scenario) Run(ctx context.Context) error { + return lifecycle.Run(ctx, lifecycle.Config{ + ShutdownTimeout: 30 * time.Second, + Shutdown: s.Shutdown, + Tasks: s.tasks, + }) +} + +// Shutdown closes only resources constructed by this Scenario. +func (s *Scenario) Shutdown(ctx context.Context) error { + if s == nil || s.graph == nil { + return nil + } + if err := s.graph.Shutdown(ctx); err != nil { + return fmt.Errorf("graph.Shutdown: %w", err) + } + return nil +} + +func newScenarioGraph(ctx context.Context) (*di.Container, *Config, error) { + cfg, err := loadConfig(ctx) + if err != nil { + return nil, nil, fmt.Errorf("loadConfig: %w", err) + } + graph := di.New() + if err := di.ProvideValue(graph, cfg); err != nil { + return nil, nil, fmt.Errorf("di.ProvideValue: %w", err) + } + if err := provideApplication(ctx, graph, cfg); err != nil { + shutdownErr := graph.Shutdown(context.WithoutCancel(ctx)) + return nil, nil, errors.Join(fmt.Errorf("provideApplication: %w", err), shutdownErr) + } + return graph, cfg, nil +} +func provideLogging(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*zap.Logger, error) { + level := zapcore.InfoLevel + if err := level.Set(cfg.Logging.Level); err != nil { + return nil, fmt.Errorf("logging level: %w", err) + } + return loglib.New(level, false).Named("sample-api"), nil + }, func(_ context.Context, value *zap.Logger) error { _ = value.Sync(); return nil }) +} +func provideTelemetry(ctx context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(di.Resolver) (*telemetrylib.Runtime, error) { + value, err := telemetrylib.Open(ctx, telemetrylib.Config{ + Enabled: true, + ServiceName: "sample-api", + ServiceVersion: cfg.Telemetry.ServiceVersion, + DeploymentEnvironment: cfg.Telemetry.DeploymentEnvironment, + }) + if err != nil { + return nil, fmt.Errorf("telemetrylib.Open: %w", err) + } + return value, nil + }, func(ctx context.Context, value *telemetrylib.Runtime) error { return value.Shutdown(ctx) }) +} + +// NewAPI resolves only the API Runtime branch. +func NewAPI(ctx context.Context) (*Scenario, error) { + graph, _, err := newScenarioGraph(ctx) + if err != nil { + return nil, err + } + runtime, err := di.Resolve[*Runtime](graph) + if err != nil { + return nil, errors.Join(fmt.Errorf("di.Resolve Runtime: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + return &Scenario{graph: graph, tasks: runtime.Tasks()}, nil +} + +// ConsumerSelectionError reports an unknown or disabled selected consumer. +type ConsumerSelectionError struct { + Name string + Reason string +} + +func (e *ConsumerSelectionError) Error() string { + return fmt.Sprintf("Kafka consumer %q is %s", e.Name, e.Reason) +} + +// NewConsumer validates selection before resolving the selected consumer branch. +func NewConsumer(ctx context.Context, name string) (*Scenario, error) { + cfg, err := loadConfig(ctx) + if err != nil { + return nil, fmt.Errorf("loadConfig: %w", err) + } + switch name { + case "audit": + if !(true) { + return nil, &ConsumerSelectionError{Name: name, Reason: "disabled"} + } + case "invoice": + if !(true) { + return nil, &ConsumerSelectionError{Name: name, Reason: "disabled"} + } + default: + return nil, &ConsumerSelectionError{Name: name, Reason: "unknown"} + } + graph := di.New() + if err := di.ProvideValue(graph, cfg); err != nil { + return nil, fmt.Errorf("di.ProvideValue: %w", err) + } + if err := provideApplication(ctx, graph, cfg); err != nil { + return nil, errors.Join(fmt.Errorf("provideApplication: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + runner, err := di.ResolveNamed[scenarioRunner](graph, kafkaConsumerKey(name)) + if err != nil { + return nil, errors.Join(fmt.Errorf("di.ResolveNamed consumer: %w", err), graph.Shutdown(context.WithoutCancel(ctx))) + } + return &Scenario{graph: graph, tasks: []lifecycle.Task{ + {Name: "kafka-consumer:" + name, Run: runner.Run}, + }}, nil +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/grpc.gen.go b/internal/service/scaffold/testdata/full/internal/deps/grpc.gen.go new file mode 100644 index 0000000..385bcdb --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/grpc.gen.go @@ -0,0 +1,8 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import "google.golang.org/grpc" + +// newGRPCServer constructs the handwritten gRPC runtime boundary. +func newGRPCServer() *grpc.Server { return grpc.NewServer() } diff --git a/internal/service/scaffold/testdata/full/internal/deps/grpc_clients.gen.go b/internal/service/scaffold/testdata/full/internal/deps/grpc_clients.gen.go new file mode 100644 index 0000000..23d2621 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/grpc_clients.gen.go @@ -0,0 +1,23 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + + "github.com/devctllabs/go-libs/di" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +const grpcClientBillingKey = "grpc-client:billing" + +func provideBillingGRPCClient(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, grpcClientBillingKey, func(di.Resolver) (*grpc.ClientConn, error) { + return grpc.NewClient(cfg.GRPCClients.BillingAddress, grpc.WithTransportCredentials(insecure.NewCredentials())) + }, func(_ context.Context, connection *grpc.ClientConn) error { return connection.Close() }) +} + +func BillingGRPCConn(resolver di.Resolver) (*grpc.ClientConn, error) { + return di.ResolveNamed[*grpc.ClientConn](resolver, grpcClientBillingKey) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/http_clients.gen.go b/internal/service/scaffold/testdata/full/internal/deps/http_clients.gen.go new file mode 100644 index 0000000..952cc4a --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/http_clients.gen.go @@ -0,0 +1,30 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "net/http" + + "github.com/devctllabs/go-libs/di" +) + +type httpBaseURL string + +const httpClientCatalogKey = "http-client:catalog" + +func provideCatalogHTTPClient(_ context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedValue(graph, httpClientCatalogKey, &http.Client{}); err != nil { + return err + } + return di.ProvideNamedValue(graph, httpClientCatalogKey, httpBaseURL(cfg.HTTPClients.CatalogBaseURL)) +} + +func CatalogHTTPTransport(resolver di.Resolver) (*http.Client, error) { + return di.ResolveNamed[*http.Client](resolver, httpClientCatalogKey) +} + +func CatalogHTTPBaseURL(resolver di.Resolver) (string, error) { + value, err := di.ResolveNamed[httpBaseURL](resolver, httpClientCatalogKey) + return string(value), err +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/kafka_broker.gen.go b/internal/service/scaffold/testdata/full/internal/deps/kafka_broker.gen.go new file mode 100644 index 0000000..acbe825 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/kafka_broker.gen.go @@ -0,0 +1,7 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +func kafkaConsumerKey(name string) string { return "kafka-consumer:" + name } + +func kafkaProducerKey(name string) string { return "kafka-producer:" + name } diff --git a/internal/service/scaffold/testdata/full/internal/deps/kafka_consumers.gen.go b/internal/service/scaffold/testdata/full/internal/deps/kafka_consumers.gen.go new file mode 100644 index 0000000..1b2c754 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/kafka_consumers.gen.go @@ -0,0 +1,79 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" + kafkaproto "github.com/devctllabs/go-libs/kafkaproto" + retry "github.com/devctllabs/go-libs/retry" +) + +func rawKafkaDecoder() kafka.Decoder[[]byte] { + // The consumer owns the record bytes until Handle returns; handlers must not retain a batch. + return kafka.DecoderFunc[[]byte](func(_ context.Context, value []byte) ([]byte, error) { return value, nil }) +} + +func jsonKafkaDecoder[T any]() kafka.Decoder[T] { return kafka.NewJSONDecoder[T]() } +func protoKafkaDecoder[T any, PT kafkaproto.ProtoPtr[T]]() kafka.Decoder[PT] { + return kafkaproto.NewDecoder[T, PT]() +} + +func provideConsumer[T any](graph *di.Container, key string) error { + return di.ProvideNamed[scenarioRunner](graph, key, func(resolver di.Resolver) (scenarioRunner, error) { + config, err := di.ResolveNamed[kafka.ConsumerConfig](resolver, key) + if err != nil { + return nil, fmt.Errorf("resolve consumer config: %w", err) + } + decoder, err := di.ResolveNamed[kafka.Decoder[T]](resolver, key) + if err != nil { + return nil, fmt.Errorf("resolve consumer decoder: %w", err) + } + handler, err := di.ResolveNamed[kafka.BatchHandler[T]](resolver, key) + if err != nil { + return nil, fmt.Errorf("resolve consumer handler: %w", err) + } + policy, err := di.ResolveNamed[retry.Policy](resolver, key) + if err != nil { + return nil, fmt.Errorf("resolve consumer retry policy: %w", err) + } + config.Retry.Policy = policy + config.CommitRetry = &config.Retry + consumer, err := kafka.NewConsumer(config, decoder, handler) + if err != nil { + return nil, fmt.Errorf("kafka.NewConsumer: %w", err) + } + return consumer, nil + }) +} +func provideAuditConsumerConfig(_ context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey("audit") + return di.ProvideNamedValue(graph, key, kafka.ConsumerConfig{ + Brokers: cfg.Kafka.Brokers, + Group: cfg.Kafka.AuditGroup, + Topics: []string{cfg.Kafka.AuditTopic}, + Batch: kafka.BatchConfig{MaxSize: cfg.Kafka.AuditBatchMaxSize, FlushInterval: cfg.Kafka.AuditBatchFlushInterval}, + Retry: kafka.RetryConfig{MaxAttempts: uint(cfg.Kafka.AuditRetryMaxAttempts), MaxElapsedTime: cfg.Kafka.AuditRetryMaxElapsedTime}, + OnReject: kafka.RejectStop, + RebalanceTimeout: cfg.Kafka.AuditRebalanceTimeout, + RebalanceDrainTimeout: cfg.Kafka.AuditRebalanceDrainTimeout, + ShutdownTimeout: cfg.Kafka.AuditShutdownTimeout, + }) +} +func provideInvoiceConsumerConfig(_ context.Context, graph *di.Container, cfg *Config) error { + key := kafkaConsumerKey("invoice") + return di.ProvideNamedValue(graph, key, kafka.ConsumerConfig{ + Brokers: cfg.Kafka.Brokers, + Group: cfg.Kafka.InvoiceGroup, + Topics: []string{cfg.Kafka.InvoiceTopic}, + Batch: kafka.BatchConfig{MaxSize: cfg.Kafka.InvoiceBatchMaxSize, FlushInterval: cfg.Kafka.InvoiceBatchFlushInterval}, + Retry: kafka.RetryConfig{MaxAttempts: uint(cfg.Kafka.InvoiceRetryMaxAttempts), MaxElapsedTime: cfg.Kafka.InvoiceRetryMaxElapsedTime}, + OnReject: kafka.RejectStop, + RebalanceTimeout: cfg.Kafka.InvoiceRebalanceTimeout, + RebalanceDrainTimeout: cfg.Kafka.InvoiceRebalanceDrainTimeout, + ShutdownTimeout: cfg.Kafka.InvoiceShutdownTimeout, + }) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/kafka_producers.gen.go b/internal/service/scaffold/testdata/full/internal/deps/kafka_producers.gen.go new file mode 100644 index 0000000..01febc8 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/kafka_producers.gen.go @@ -0,0 +1,21 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + "github.com/devctllabs/go-libs/di" + kafka "github.com/devctllabs/go-libs/kafka" +) + +func provideEventsKafkaProducer(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, kafkaProducerKey("events"), func(di.Resolver) (*kafka.Producer[[]byte], error) { + producer, err := kafka.NewProducer(kafka.ProducerConfig{Brokers: cfg.Kafka.Brokers}, kafka.NewBytesEncoder()) + if err != nil { + return nil, fmt.Errorf("kafka.NewProducer: %w", err) + } + return producer, nil + }, func(ctx context.Context, producer *kafka.Producer[[]byte]) error { return producer.Close(ctx) }) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/redis.gen.go b/internal/service/scaffold/testdata/full/internal/deps/redis.gen.go new file mode 100644 index 0000000..9edb194 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/redis.gen.go @@ -0,0 +1,18 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + + "github.com/devctllabs/go-libs/di" + redis "github.com/redis/go-redis/v9" +) + +const redisCacheKey = "redis-connection:cache" + +func provideRedisCache(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, redisCacheKey, func(di.Resolver) (*redis.Client, error) { + return redis.NewClient(&redis.Options{Addr: cfg.Redis.CacheAddress}), nil + }, func(ctx context.Context, client *redis.Client) error { return client.Close() }) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/runtime.gen.go b/internal/service/scaffold/testdata/full/internal/deps/runtime.gen.go new file mode 100644 index 0000000..bd752cc --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/runtime.gen.go @@ -0,0 +1,161 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + + debugserverlib "github.com/devctllabs/go-libs/debugserver" + "github.com/devctllabs/go-libs/di" + healthlib "github.com/devctllabs/go-libs/health" + healthserverlib "github.com/devctllabs/go-libs/healthserver" + "github.com/devctllabs/go-libs/lifecycle" + "github.com/labstack/echo/v5" + "google.golang.org/grpc" + "net" + "net/http" + "time" +) + +// HTTPRegistrar is implemented by user-owned application composition. +type HTTPRegistrar interface{ RegisterHTTP(*echo.Echo) } + +// GRPCRegistrar is implemented by user-owned application composition. +type GRPCRegistrar interface{ RegisterGRPC(*grpc.Server) } + +// Runtime owns the optional long-lived components selected by configuration. +type Runtime struct { + http *http.Server + grpc *grpc.Server + grpcAddress string + health *healthserverlib.Server + pprof *debugserverlib.Server +} + +func newRuntime(resolver di.Resolver, cfg *Config) (*Runtime, error) { + httpRegistrar, err := di.Resolve[HTTPRegistrar](resolver) + if err != nil { + return nil, fmt.Errorf("di.Resolve HTTPRegistrar: %w", err) + } + grpcRegistrar, err := di.Resolve[GRPCRegistrar](resolver) + if err != nil { + return nil, fmt.Errorf("di.Resolve GRPCRegistrar: %w", err) + } + options := make([]healthlib.Option, 0, 2) + { + checker, err := di.ResolveNamed[dbChecker](resolver, "db-connection:primary") + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + options = append(options, healthlib.NonCritical("db.primary", checker)) + } + { + checker, err := di.ResolveNamed[dbChecker](resolver, "db-connection:analytics") + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + options = append(options, healthlib.NonCritical("db.analytics", checker)) + } + return NewRuntime(cfg, httpRegistrar, grpcRegistrar, options...) +} + +func NewRuntime(cfg *Config, httpRegistrar HTTPRegistrar, grpcRegistrar GRPCRegistrar, options ...healthlib.Option) (*Runtime, error) { + if cfg == nil { + return nil, errors.New("config is nil") + } + runtime := &Runtime{} + httpRouter := echo.New() + httpRegistrar.RegisterHTTP(httpRouter) + runtime.http = &http.Server{Addr: cfg.HTTP.Address, Handler: httpRouter, ReadHeaderTimeout: 2 * time.Second, IdleTimeout: 30 * time.Second} + runtime.grpc = newGRPCServer() + grpcRegistrar.RegisterGRPC(runtime.grpc) + runtime.grpcAddress = cfg.GRPC.Address + probes, err := healthlib.New(options...) + if err != nil { + return nil, fmt.Errorf("healthlib.New: %w", err) + } + runtime.health, err = healthserverlib.NewServer(probes, healthserverlib.WithAddress(cfg.Health.Address)) + if err != nil { + return nil, fmt.Errorf("healthserverlib.NewServer: %w", err) + } + pprofServer, err := debugserverlib.NewServer(debugserverlib.WithAddress(cfg.Pprof.Address)) + if err != nil { + return nil, fmt.Errorf("debugserverlib.NewServer: %w", err) + } + runtime.pprof = pprofServer + return runtime, nil +} + +func provideRuntime(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideResource(graph, func(resolver di.Resolver) (*Runtime, error) { + return newRuntime(resolver, cfg) + }, func(ctx context.Context, value *Runtime) error { return value.Shutdown(ctx) }) +} + +// Tasks returns enabled runtime roots in deterministic startup order. +func (r *Runtime) Tasks() []lifecycle.Task { + tasks := make([]lifecycle.Task, 0, 3) + tasks = append(tasks, lifecycle.Task{Name: "http", Run: func(context.Context) error { + if err := r.http.ListenAndServe(); err != nil { + return fmt.Errorf("r.http.ListenAndServe: %w", err) + } + return nil + }}) + tasks = append(tasks, lifecycle.Task{Name: "grpc", Run: func(context.Context) error { + listener, err := net.Listen("tcp", r.grpcAddress) + if err != nil { + return fmt.Errorf("net.Listen: %w", err) + } + if err := r.grpc.Serve(listener); err != nil { + return fmt.Errorf("r.grpc.Serve: %w", err) + } + return nil + }}) + tasks = append(tasks, lifecycle.Task{Name: "health", Run: func(context.Context) error { + if err := r.health.ListenAndServe(); err != nil { + return fmt.Errorf("r.health.ListenAndServe: %w", err) + } + return nil + }}) + tasks = append(tasks, lifecycle.Task{Name: "pprof", Run: func(context.Context) error { + if err := r.pprof.ListenAndServe(); err != nil { + return fmt.Errorf("r.pprof.ListenAndServe: %w", err) + } + return nil + }}) + return tasks +} + +// Shutdown stops every constructed runtime component and joins cleanup failures. +func (r *Runtime) Shutdown(ctx context.Context) error { + var shutdownErrors []error + if r.http != nil { + if err := r.http.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.http.Shutdown: %w", err)) + } + } + if r.grpc != nil { + stopped := make(chan struct{}) + go func() { r.grpc.GracefulStop(); close(stopped) }() + select { + case <-stopped: + case <-ctx.Done(): + r.grpc.Stop() + <-stopped + shutdownErrors = append(shutdownErrors, fmt.Errorf("grpc shutdown: %w", ctx.Err())) + } + } + if r.health != nil { + if err := r.health.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.health.Shutdown: %w", err)) + } + } + if r.pprof != nil { + if err := r.pprof.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("r.pprof.Shutdown: %w", err)) + } + } + return errors.Join(shutdownErrors...) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/s3.gen.go b/internal/service/scaffold/testdata/full/internal/deps/s3.gen.go new file mode 100644 index 0000000..7481224 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/s3.gen.go @@ -0,0 +1,51 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/devctllabs/go-libs/di" +) + +// S3Bucket binds one configured bucket name to its connection. +type S3Bucket struct { + Client *s3.Client + Name string +} + +const s3DefaultKey = "s3-connection:default" + +func provideS3Default(ctx context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamedResource(graph, s3DefaultKey, func(di.Resolver) (*s3.Client, error) { + loadOptions := []func(*awsconfig.LoadOptions) error{ + awsconfig.WithRegion(cfg.S3.Region), + } + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, loadOptions...) + if err != nil { + return nil, err + } + return s3.NewFromConfig(awsCfg, func(options *s3.Options) { + if endpoint := cfg.S3.Endpoint; endpoint != "" { + options.BaseEndpoint = aws.String(endpoint) + } + options.UsePathStyle = cfg.S3.ForcePathStyle + }), nil + }, func(context.Context, *s3.Client) error { return nil }) +} + +const s3MediaBucketKey = "s3-bucket:media" + +func provideS3MediaBucket(_ context.Context, graph *di.Container, cfg *Config) error { + return di.ProvideNamed[S3Bucket](graph, s3MediaBucketKey, func(resolver di.Resolver) (S3Bucket, error) { + client, err := di.ResolveNamed[*s3.Client](resolver, s3DefaultKey) + if err != nil { + return S3Bucket{}, fmt.Errorf("resolve S3 connection default: %w", err) + } + return S3Bucket{Client: client, Name: cfg.S3.MediaBucket}, nil + }) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/storage_analytics.gen.go b/internal/service/scaffold/testdata/full/internal/deps/storage_analytics.gen.go new file mode 100644 index 0000000..ba97eef --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/storage_analytics.gen.go @@ -0,0 +1,45 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + + "github.com/ClickHouse/clickhouse-go/v2" + clickhousedriver "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "github.com/devctllabs/go-libs/di" +) + +const storageAnalyticsConnectionName = "db-connection:analytics" + +type clickHouseAnalyticsChecker struct{ connection clickhousedriver.Conn } + +func (c clickHouseAnalyticsChecker) Check(ctx context.Context) error { return c.connection.Ping(ctx) } + +func provideStorageAnalytics(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedResource[clickhousedriver.Conn](graph, storageAnalyticsConnectionName, func(di.Resolver) (clickhousedriver.Conn, error) { + options, err := clickhouse.ParseDSN(cfg.DBAnalytics.ClickhouseDSN) + if err != nil { + return nil, fmt.Errorf("clickhouse.ParseDSN: %w", err) + } + connection, err := clickhouse.Open(options) + if err != nil { + return nil, fmt.Errorf("clickhouse.Open: %w", err) + } + if err := connection.Ping(ctx); err != nil { + return nil, errors.Join(fmt.Errorf("connection.Ping: %w", err), connection.Close()) + } + return connection, nil + }, func(_ context.Context, connection clickhousedriver.Conn) error { return connection.Close() }); err != nil { + return fmt.Errorf("di.ProvideNamedResource: %w", err) + } + return di.ProvideNamed[dbChecker](graph, storageAnalyticsConnectionName, func(resolver di.Resolver) (dbChecker, error) { + connection, err := di.ResolveNamed[clickhousedriver.Conn](resolver, storageAnalyticsConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + return clickHouseAnalyticsChecker{connection: connection}, nil + }) +} diff --git a/internal/service/scaffold/testdata/full/internal/deps/storage_primary.gen.go b/internal/service/scaffold/testdata/full/internal/deps/storage_primary.gen.go new file mode 100644 index 0000000..0d23bf8 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/deps/storage_primary.gen.go @@ -0,0 +1,152 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "errors" + "fmt" + + "github.com/devctllabs/go-libs/di" + postgresdb "github.com/devctllabs/go-libs/postgresdb" + sqlitedb "github.com/devctllabs/go-libs/sqlitedb" + telemetrylib "github.com/devctllabs/go-libs/telemetry" + "github.com/devctllabs/go-libs/txmanager" +) + +const storagePrimaryConnectionName = "db-connection:primary" + +type storagePrimary struct { + sqlite *sqlitedb.DB + postgres *postgresdb.DB +} + +func provideStoragePrimary(ctx context.Context, graph *di.Container, cfg *Config) error { + if err := di.ProvideNamedResource[*storagePrimary](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (*storagePrimary, error) { + return openStoragePrimary(ctx, resolver, cfg) + }, func(_ context.Context, value *storagePrimary) error { return value.close() }); err != nil { + return fmt.Errorf("di.ProvideNamedResource: %w", err) + } + if err := di.ProvideNamed[*sqlitedb.Endpoint](graph, storagePrimaryConnectionName+".reader", func(resolver di.Resolver) (*sqlitedb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.sqlite == nil { + return nil, fmt.Errorf("storage primary does not use sqlite") + } + return storage.sqlite.Reader(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed reader: %w", err) + } + if err := di.ProvideNamed[*sqlitedb.Endpoint](graph, storagePrimaryConnectionName+".writer", func(resolver di.Resolver) (*sqlitedb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.sqlite == nil { + return nil, fmt.Errorf("storage primary does not use sqlite") + } + return storage.sqlite.Writer(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed writer: %w", err) + } + if err := di.ProvideNamed[*postgresdb.Endpoint](graph, storagePrimaryConnectionName+".reader", func(resolver di.Resolver) (*postgresdb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.postgres == nil { + return nil, fmt.Errorf("storage primary does not use postgres") + } + return storage.postgres.Reader(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed reader: %w", err) + } + if err := di.ProvideNamed[*postgresdb.Endpoint](graph, storagePrimaryConnectionName+".writer", func(resolver di.Resolver) (*postgresdb.Endpoint, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + if storage.postgres == nil { + return nil, fmt.Errorf("storage primary does not use postgres") + } + return storage.postgres.Writer(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed writer: %w", err) + } + if err := di.ProvideNamed[txmanager.Managers](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (txmanager.Managers, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + return storage.managers(), nil + }); err != nil { + return fmt.Errorf("di.ProvideNamed tx managers: %w", err) + } + return di.ProvideNamed[dbChecker](graph, storagePrimaryConnectionName, func(resolver di.Resolver) (dbChecker, error) { + storage, err := di.ResolveNamed[*storagePrimary](resolver, storagePrimaryConnectionName) + if err != nil { + return nil, fmt.Errorf("di.ResolveNamed: %w", err) + } + return storage.checker(), nil + }) +} + +func openStoragePrimary(ctx context.Context, resolver di.Resolver, cfg *Config) (*storagePrimary, error) { + telemetryRuntime, err := di.Resolve[*telemetrylib.Runtime](resolver) + if err != nil { + return nil, fmt.Errorf("di.Resolve: %w", err) + } + switch cfg.DBPrimary.Kind { + case "sqlite": + db, err := sqlitedb.Open(ctx, sqlitedb.Config{DSN: cfg.DBPrimary.SqliteDSN, Telemetry: sqlitedb.Telemetry{TracerProvider: telemetryRuntime.TracerProvider(), MeterProvider: telemetryRuntime.MeterProvider()}}) + if err != nil { + return nil, fmt.Errorf("sqlitedb.Open: %w", err) + } + return &storagePrimary{sqlite: db}, nil + case "postgres": + db, err := postgresdb.Open(ctx, postgresdb.Config{Writer: postgresdb.EndpointConfig{DSN: cfg.DBPrimary.PostgresDSN}, Telemetry: postgresdb.Telemetry{TracerProvider: telemetryRuntime.TracerProvider(), MeterProvider: telemetryRuntime.MeterProvider()}}) + if err != nil { + return nil, fmt.Errorf("postgresdb.Open: %w", err) + } + return &storagePrimary{postgres: db}, nil + default: + return nil, fmt.Errorf("unsupported primary database kind %q", cfg.DBPrimary.Kind) + } +} + +func (s *storagePrimary) managers() txmanager.Managers { + if s.sqlite != nil { + return s.sqlite.TxManagers() + } + if s.postgres != nil { + return s.postgres.TxManagers() + } + return nil +} + +func (s *storagePrimary) checker() dbChecker { + if s.sqlite != nil { + return s.sqlite.Writer() + } + if s.postgres != nil { + return s.postgres.Writer() + } + return nil +} + +func (s *storagePrimary) close() error { + var closeErrors []error + if s.sqlite != nil { + if err := s.sqlite.Close(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close sqlite: %w", err)) + } + } + if s.postgres != nil { + if err := s.postgres.Close(); err != nil { + closeErrors = append(closeErrors, fmt.Errorf("close postgres: %w", err)) + } + } + return errors.Join(closeErrors...) +} diff --git a/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/audit/handler.go b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/audit/handler.go new file mode 100644 index 0000000..e994717 --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/audit/handler.go @@ -0,0 +1,20 @@ +package audit + +import ( + "context" + "errors" + + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +var ErrNotImplemented = errors.New("Kafka consumer handler is not implemented") + +type Handler struct{} + +func NewHandler() *Handler { return &Handler{} } + +// Handle processes the batch synchronously. It must not retain batch data after returning. +func (h *Handler) Handle(context.Context, *kafka.Batch[[]byte]) error { + return retry.Permanent(ErrNotImplemented) +} diff --git a/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/invoice/handler.go b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/invoice/handler.go new file mode 100644 index 0000000..5465c9b --- /dev/null +++ b/internal/service/scaffold/testdata/full/internal/transport/consumerkafka/invoice/handler.go @@ -0,0 +1,20 @@ +package invoice + +import ( + "context" + "errors" + + kafka "github.com/devctllabs/go-libs/kafka" + retry "github.com/devctllabs/go-libs/retry" +) + +var ErrNotImplemented = errors.New("Kafka consumer handler is not implemented") + +type Handler struct{} + +func NewHandler() *Handler { return &Handler{} } + +// Handle processes the batch synchronously. It must not retain batch data after returning. +func (h *Handler) Handle(context.Context, *kafka.Batch[[]byte]) error { + return retry.Permanent(ErrNotImplemented) +} diff --git a/internal/service/scaffold/testdata/full/migrations/analytics/clickhouse/.gitkeep b/internal/service/scaffold/testdata/full/migrations/analytics/clickhouse/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/service/scaffold/testdata/full/tools/buf/grpc.gen.yaml b/internal/service/scaffold/testdata/full/tools/buf/grpc.gen.yaml new file mode 100644 index 0000000..9483aef --- /dev/null +++ b/internal/service/scaffold/testdata/full/tools/buf/grpc.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +plugins: + - local: [go, tool, protoc-gen-go] + out: . + opt: + - paths=source_relative + - local: [go, tool, protoc-gen-go-grpc] + out: . + opt: + - paths=source_relative diff --git a/internal/service/scaffold/testdata/full/tools/buf/kafka.gen.yaml b/internal/service/scaffold/testdata/full/tools/buf/kafka.gen.yaml new file mode 100644 index 0000000..9483aef --- /dev/null +++ b/internal/service/scaffold/testdata/full/tools/buf/kafka.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +plugins: + - local: [go, tool, protoc-gen-go] + out: . + opt: + - paths=source_relative + - local: [go, tool, protoc-gen-go-grpc] + out: . + opt: + - paths=source_relative diff --git a/internal/service/scaffold/testdata/full/tools/oapi/clients.catalog.yaml b/internal/service/scaffold/testdata/full/tools/oapi/clients.catalog.yaml new file mode 100644 index 0000000..3334bfb --- /dev/null +++ b/internal/service/scaffold/testdata/full/tools/oapi/clients.catalog.yaml @@ -0,0 +1,4 @@ +package: clienthttp +generate: + models: true + client: true diff --git a/internal/service/scaffold/testdata/full/tools/oapi/server.yaml b/internal/service/scaffold/testdata/full/tools/oapi/server.yaml new file mode 100644 index 0000000..45c9a22 --- /dev/null +++ b/internal/service/scaffold/testdata/full/tools/oapi/server.yaml @@ -0,0 +1,6 @@ +package: serverhttp +generate: + models: true + echo5-server: true + strict-server: true + embedded-spec: true diff --git a/internal/service/scaffold/testdata/minimal/.env.example b/internal/service/scaffold/testdata/minimal/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/internal/service/scaffold/testdata/minimal/.golangci.yml b/internal/service/scaffold/testdata/minimal/.golangci.yml new file mode 100644 index 0000000..a01c843 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/.golangci.yml @@ -0,0 +1,76 @@ +version: "2" +run: + relative-path-mode: gomod + tests: true + modules-download-mode: readonly +linters: + default: none + enable: + - asasalint + - bidichk + - bodyclose + - containedctx + - contextcheck + - durationcheck + - errcheck + - errchkjson + - errname + - errorlint + - exhaustive + - fatcontext + - forcetypeassert + - gocheckcompilerdirectives + - gochecknoinits + - gocognit + - govet + - inamedparam + - ineffassign + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - nolintlint + - nosprintfhostport + - paralleltest + - predeclared + - reassign + - recvcheck + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - testifylint + - thelper + - tparallel + - unconvert + - unused + - usetesting + - wastedassign + - wrapcheck + settings: + gocognit: + min-complexity: 20 + nolintlint: + allow-unused: false + require-explanation: true + require-specific: true + paralleltest: + ignore-missing: false + ignore-missing-subtests: false + check-cleanup: true + revive: + rules: + - name: argument-limit + arguments: [4] + - name: function-result-limit + arguments: [3] + exclusions: + generated: strict + paths: ["^gen/"] +formatters: + enable: [gofmt] + exclusions: + generated: strict + paths: ["^gen/"] diff --git a/internal/service/scaffold/testdata/minimal/.mise.toml b/internal/service/scaffold/testdata/minimal/.mise.toml new file mode 100644 index 0000000..1d2631c --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/.mise.toml @@ -0,0 +1,26 @@ +[tools] +go = "1.26.0" +golangci-lint = "2.12.2" + +[tasks.fmt] +run = "golangci-lint fmt" +[tasks."fmt:check"] +run = "golangci-lint fmt --diff" +[tasks."lint:contracts"] +run = "devctl lint" +[tasks."lint:go"] +run = "golangci-lint run" +[tasks.lint] +depends = ["lint:contracts", "lint:go"] +[tasks.test] +run = "go test ./..." +[tasks.gen] +run = "devctl gen" +[tasks."gen:http"] +run = "devctl gen http" +[tasks."gen:grpc"] +run = "devctl gen grpc" +[tasks."gen:kafka"] +run = "devctl gen kafka" +[tasks.check] +depends = ["fmt:check", "lint", "test"] diff --git a/internal/service/scaffold/testdata/minimal/README.md b/internal/service/scaffold/testdata/minimal/README.md new file mode 100644 index 0000000..bdd81bf --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/README.md @@ -0,0 +1,30 @@ +# sample + +This project foundation is scaffolded by Devctl. + +## Bootstrap + +```sh +mise install +go mod download all +go mod tidy +devctl lint +devctl gen +go mod tidy +mise run check +``` + +Inspect the application commands with `go run ./cmd/sample --help`. + +## Updating the foundation + +- Run `devctl sync` after changing remote sources. +- Run `devctl init scaffold` after changing components in `devctl.yaml`. +- Run `devctl gen` after changing API or schema contracts. + +Devctl replaces files ending in `*.gen.go`. Ordinary `.go` files and this +README are created once, so application code and local notes are preserved. + +When a component adds a provider seed, review it and call the provider from +`internal/deps/application.go`. That file is the user-owned composition root; +Devctl does not rewrite its provider list. diff --git a/internal/service/scaffold/testdata/minimal/cmd/sample/main.go b/internal/service/scaffold/testdata/minimal/cmd/sample/main.go new file mode 100644 index 0000000..bbeec23 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/cmd/sample/main.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/urfave/cli/v3" +) + +func main() { + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signals) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + select { + case <-signals: + cancel() + case <-ctx.Done(): + } + }() + root := &cli.Command{ + Name: "sample", + Usage: "Run sample", + } + if err := root.Run(ctx, os.Args); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/internal/service/scaffold/testdata/minimal/gen/config/config.gen.go b/internal/service/scaffold/testdata/minimal/gen/config/config.gen.go new file mode 100644 index 0000000..d35b347 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/gen/config/config.gen.go @@ -0,0 +1,20 @@ +// Code generated by devctl. DO NOT EDIT. + +package config + +import ( + "fmt" + "time" +) + +type Config struct { +} + +func (c *Config) Validate() error { + if c == nil { + return fmt.Errorf("config is nil") + } + return nil +} + +var _ time.Duration diff --git a/internal/service/scaffold/testdata/minimal/go.mod b/internal/service/scaffold/testdata/minimal/go.mod new file mode 100644 index 0000000..06feaf3 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/go.mod @@ -0,0 +1,9 @@ +module example.test/sample + +go 1.26.0 + +require ( + github.com/devctllabs/go-libs/config v0.1.0 + github.com/devctllabs/go-libs/di v0.1.0 + github.com/urfave/cli/v3 v3.10.1 +) diff --git a/internal/service/scaffold/testdata/minimal/internal/deps/application.go b/internal/service/scaffold/testdata/minimal/internal/deps/application.go new file mode 100644 index 0000000..f16db85 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/internal/deps/application.go @@ -0,0 +1,15 @@ +package deps + +import ( + "context" + + "github.com/devctllabs/go-libs/di" +) + +// application is the user-owned composition root. Add application dependencies here. +type application struct{} + +// provideApplication is created once. Add newly scaffolded provider calls manually. +func provideApplication(ctx context.Context, graph *di.Container, cfg *Config) error { + return nil +} diff --git a/internal/service/scaffold/testdata/minimal/internal/deps/config.gen.go b/internal/service/scaffold/testdata/minimal/internal/deps/config.gen.go new file mode 100644 index 0000000..c4b921f --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/internal/deps/config.gen.go @@ -0,0 +1,23 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps + +import ( + "context" + "fmt" + + generatedconfig "example.test/sample/gen/config" + configlib "github.com/devctllabs/go-libs/config" +) + +// Config is the canonical generated runtime configuration. +type Config = generatedconfig.Config + +func loadConfig(ctx context.Context) (*Config, error) { + var cfg Config + loader := configlib.Chain(configlib.Defaults(), configlib.OSEnv()) + if err := loader.Load(ctx, &cfg); err != nil { + return nil, fmt.Errorf("loader.Load: %w", err) + } + return &cfg, nil +} diff --git a/internal/service/scaffold/testdata/minimal/internal/deps/container.gen.go b/internal/service/scaffold/testdata/minimal/internal/deps/container.gen.go new file mode 100644 index 0000000..a3d09f6 --- /dev/null +++ b/internal/service/scaffold/testdata/minimal/internal/deps/container.gen.go @@ -0,0 +1,3 @@ +// Code generated by devctl. DO NOT EDIT. + +package deps diff --git a/internal/service/sync/mocks/sync_service.go b/internal/service/sync/mocks/sync_service.go new file mode 100644 index 0000000..0cd13b5 --- /dev/null +++ b/internal/service/sync/mocks/sync_service.go @@ -0,0 +1,326 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/sync (interfaces: ProjectRepository,Materializer,WorkspaceRepository) +// +// Generated by this command: +// +// mockgen -destination mocks/sync_service.go -package mocks -typed . ProjectRepository,Materializer,WorkspaceRepository +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + artifact "github.com/devctllabs/devctl/internal/domain/artifact" + contract "github.com/devctllabs/devctl/internal/domain/contract" + project "github.com/devctllabs/devctl/internal/domain/project" + gomock "go.uber.org/mock/gomock" +) + +// MockProjectRepository is a mock of ProjectRepository interface. +type MockProjectRepository struct { + ctrl *gomock.Controller + recorder *MockProjectRepositoryMockRecorder + isgomock struct{} +} + +// MockProjectRepositoryMockRecorder is the mock recorder for MockProjectRepository. +type MockProjectRepositoryMockRecorder struct { + mock *MockProjectRepository +} + +// NewMockProjectRepository creates a new mock instance. +func NewMockProjectRepository(ctrl *gomock.Controller) *MockProjectRepository { + mock := &MockProjectRepository{ctrl: ctrl} + mock.recorder = &MockProjectRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProjectRepository) EXPECT() *MockProjectRepositoryMockRecorder { + return m.recorder +} + +// LoadProject mocks base method. +func (m *MockProjectRepository) LoadProject(ctx context.Context, manifestPath string) (project.Project, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LoadProject", ctx, manifestPath) + ret0, _ := ret[0].(project.Project) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LoadProject indicates an expected call of LoadProject. +func (mr *MockProjectRepositoryMockRecorder) LoadProject(ctx, manifestPath any) *MockProjectRepositoryLoadProjectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadProject", reflect.TypeOf((*MockProjectRepository)(nil).LoadProject), ctx, manifestPath) + return &MockProjectRepositoryLoadProjectCall{Call: call} +} + +// MockProjectRepositoryLoadProjectCall wrap *gomock.Call +type MockProjectRepositoryLoadProjectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockProjectRepositoryLoadProjectCall) Return(arg0 project.Project, arg1 error) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockProjectRepositoryLoadProjectCall) Do(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockProjectRepositoryLoadProjectCall) DoAndReturn(f func(context.Context, string) (project.Project, error)) *MockProjectRepositoryLoadProjectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockMaterializer is a mock of Materializer interface. +type MockMaterializer struct { + ctrl *gomock.Controller + recorder *MockMaterializerMockRecorder + isgomock struct{} +} + +// MockMaterializerMockRecorder is the mock recorder for MockMaterializer. +type MockMaterializerMockRecorder struct { + mock *MockMaterializer +} + +// NewMockMaterializer creates a new mock instance. +func NewMockMaterializer(ctrl *gomock.Controller) *MockMaterializer { + mock := &MockMaterializer{ctrl: ctrl} + mock.recorder = &MockMaterializerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMaterializer) EXPECT() *MockMaterializerMockRecorder { + return m.recorder +} + +// Materialize mocks base method. +func (m *MockMaterializer) Materialize(ctx context.Context, root string, source project.Source, reference contract.Reference) (contract.Snapshot, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Materialize", ctx, root, source, reference) + ret0, _ := ret[0].(contract.Snapshot) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Materialize indicates an expected call of Materialize. +func (mr *MockMaterializerMockRecorder) Materialize(ctx, root, source, reference any) *MockMaterializerMaterializeCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Materialize", reflect.TypeOf((*MockMaterializer)(nil).Materialize), ctx, root, source, reference) + return &MockMaterializerMaterializeCall{Call: call} +} + +// MockMaterializerMaterializeCall wrap *gomock.Call +type MockMaterializerMaterializeCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockMaterializerMaterializeCall) Return(arg0 contract.Snapshot, arg1 error) *MockMaterializerMaterializeCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockMaterializerMaterializeCall) Do(f func(context.Context, string, project.Source, contract.Reference) (contract.Snapshot, error)) *MockMaterializerMaterializeCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockMaterializerMaterializeCall) DoAndReturn(f func(context.Context, string, project.Source, contract.Reference) (contract.Snapshot, error)) *MockMaterializerMaterializeCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockWorkspaceRepository is a mock of WorkspaceRepository interface. +type MockWorkspaceRepository struct { + ctrl *gomock.Controller + recorder *MockWorkspaceRepositoryMockRecorder + isgomock struct{} +} + +// MockWorkspaceRepositoryMockRecorder is the mock recorder for MockWorkspaceRepository. +type MockWorkspaceRepositoryMockRecorder struct { + mock *MockWorkspaceRepository +} + +// NewMockWorkspaceRepository creates a new mock instance. +func NewMockWorkspaceRepository(ctrl *gomock.Controller) *MockWorkspaceRepository { + mock := &MockWorkspaceRepository{ctrl: ctrl} + mock.recorder = &MockWorkspaceRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWorkspaceRepository) EXPECT() *MockWorkspaceRepositoryMockRecorder { + return m.recorder +} + +// PreviewPruneDirectories mocks base method. +func (m *MockWorkspaceRepository) PreviewPruneDirectories(ctx context.Context, root, parent string, keep []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PreviewPruneDirectories", ctx, root, parent, keep) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PreviewPruneDirectories indicates an expected call of PreviewPruneDirectories. +func (mr *MockWorkspaceRepositoryMockRecorder) PreviewPruneDirectories(ctx, root, parent, keep any) *MockWorkspaceRepositoryPreviewPruneDirectoriesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PreviewPruneDirectories", reflect.TypeOf((*MockWorkspaceRepository)(nil).PreviewPruneDirectories), ctx, root, parent, keep) + return &MockWorkspaceRepositoryPreviewPruneDirectoriesCall{Call: call} +} + +// MockWorkspaceRepositoryPreviewPruneDirectoriesCall wrap *gomock.Call +type MockWorkspaceRepositoryPreviewPruneDirectoriesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPreviewPruneDirectoriesCall) Return(arg0 []string, arg1 error) *MockWorkspaceRepositoryPreviewPruneDirectoriesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPreviewPruneDirectoriesCall) Do(f func(context.Context, string, string, []string) ([]string, error)) *MockWorkspaceRepositoryPreviewPruneDirectoriesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPreviewPruneDirectoriesCall) DoAndReturn(f func(context.Context, string, string, []string) ([]string, error)) *MockWorkspaceRepositoryPreviewPruneDirectoriesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PruneDirectories mocks base method. +func (m *MockWorkspaceRepository) PruneDirectories(ctx context.Context, root, parent string, keep []string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PruneDirectories", ctx, root, parent, keep) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PruneDirectories indicates an expected call of PruneDirectories. +func (mr *MockWorkspaceRepositoryMockRecorder) PruneDirectories(ctx, root, parent, keep any) *MockWorkspaceRepositoryPruneDirectoriesCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PruneDirectories", reflect.TypeOf((*MockWorkspaceRepository)(nil).PruneDirectories), ctx, root, parent, keep) + return &MockWorkspaceRepositoryPruneDirectoriesCall{Call: call} +} + +// MockWorkspaceRepositoryPruneDirectoriesCall wrap *gomock.Call +type MockWorkspaceRepositoryPruneDirectoriesCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPruneDirectoriesCall) Return(arg0 []string, arg1 error) *MockWorkspaceRepositoryPruneDirectoriesCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPruneDirectoriesCall) Do(f func(context.Context, string, string, []string) ([]string, error)) *MockWorkspaceRepositoryPruneDirectoriesCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPruneDirectoriesCall) DoAndReturn(f func(context.Context, string, string, []string) ([]string, error)) *MockWorkspaceRepositoryPruneDirectoriesCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PublishDirectory mocks base method. +func (m *MockWorkspaceRepository) PublishDirectory(ctx context.Context, root, target string, tree artifact.Tree) (artifact.PublishResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishDirectory", ctx, root, target, tree) + ret0, _ := ret[0].(artifact.PublishResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishDirectory indicates an expected call of PublishDirectory. +func (mr *MockWorkspaceRepositoryMockRecorder) PublishDirectory(ctx, root, target, tree any) *MockWorkspaceRepositoryPublishDirectoryCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishDirectory", reflect.TypeOf((*MockWorkspaceRepository)(nil).PublishDirectory), ctx, root, target, tree) + return &MockWorkspaceRepositoryPublishDirectoryCall{Call: call} +} + +// MockWorkspaceRepositoryPublishDirectoryCall wrap *gomock.Call +type MockWorkspaceRepositoryPublishDirectoryCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPublishDirectoryCall) Return(arg0 artifact.PublishResult, arg1 error) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPublishDirectoryCall) Do(f func(context.Context, string, string, artifact.Tree) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPublishDirectoryCall) DoAndReturn(f func(context.Context, string, string, artifact.Tree) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishDirectoryCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// PublishFile mocks base method. +func (m *MockWorkspaceRepository) PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PublishFile", ctx, root, target, content) + ret0, _ := ret[0].(artifact.PublishResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PublishFile indicates an expected call of PublishFile. +func (mr *MockWorkspaceRepositoryMockRecorder) PublishFile(ctx, root, target, content any) *MockWorkspaceRepositoryPublishFileCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PublishFile", reflect.TypeOf((*MockWorkspaceRepository)(nil).PublishFile), ctx, root, target, content) + return &MockWorkspaceRepositoryPublishFileCall{Call: call} +} + +// MockWorkspaceRepositoryPublishFileCall wrap *gomock.Call +type MockWorkspaceRepositoryPublishFileCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockWorkspaceRepositoryPublishFileCall) Return(arg0 artifact.PublishResult, arg1 error) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockWorkspaceRepositoryPublishFileCall) Do(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockWorkspaceRepositoryPublishFileCall) DoAndReturn(f func(context.Context, string, string, []byte) (artifact.PublishResult, error)) *MockWorkspaceRepositoryPublishFileCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/sync/prune.go b/internal/service/sync/prune.go new file mode 100644 index 0000000..6281c2a --- /dev/null +++ b/internal/service/sync/prune.go @@ -0,0 +1,79 @@ +package sync + +import ( + "context" + "fmt" + "path" + "sort" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" +) + +func (s *Service) pruneStaleTargets( + ctx context.Context, + project projectdomain.Project, + family string, + preview bool, +) ([]syncdomain.Change, error) { + type subtree struct{ family, role, targetPrefix string } + subtrees := []subtree{ + {family: "http", role: "client", targetPrefix: "http-client:"}, + {family: "grpc", role: "client", targetPrefix: "grpc-client:"}, + {family: "kafka", role: "consumer", targetPrefix: "kafka-consumer:"}, + {family: "kafka", role: "producer", targetPrefix: "kafka-producer:"}, + } + catalog := projectdomain.NewTargetCatalog(project.Manifest).Select(projectdomain.TargetOperationSync, "", "") + changes := make([]syncdomain.Change, 0) + for _, subtree := range subtrees { + if family != "" && subtree.family != family { + continue + } + keep := make([]string, 0) + for _, target := range catalog { + if target.Family == subtree.family && target.Role == subtree.role && target.Source.Type != projectdomain.SourceLocal { + keep = append(keep, target.Name) + } + } + sort.Strings(keep) + parent := path.Join(externalContractsRoot(project.Manifest), subtree.family, subtree.role) + removed, err := s.staleDirectories(ctx, staleDirectoryRequest{ + root: project.Root, parent: parent, keep: keep, preview: preview, + }) + if err != nil { + operationErr := &syncdomain.OperationError{Operation: syncdomain.OperationPrune, Path: parent, Kind: syncdomain.FailureUnavailable, Cause: err} + return changes, fmt.Errorf("stale target selection: %w", operationErr) + } + sort.Strings(removed) + action := syncdomain.ChangeRemoved + if preview { + action = syncdomain.ChangePlannedRemove + } + for _, name := range removed { + changes = append(changes, syncdomain.Change{Target: subtree.targetPrefix + name, Path: path.Join(parent, name), Action: action}) + } + } + return changes, nil +} + +type staleDirectoryRequest struct { + root string + parent string + keep []string + preview bool +} + +func (s *Service) staleDirectories(ctx context.Context, request staleDirectoryRequest) ([]string, error) { + if request.preview { + removed, err := s.workspace.PreviewPruneDirectories(ctx, request.root, request.parent, request.keep) + if err != nil { + return removed, fmt.Errorf("workspace.PreviewPruneDirectories: %w", err) + } + return removed, nil + } + removed, err := s.workspace.PruneDirectories(ctx, request.root, request.parent, request.keep) + if err != nil { + return removed, fmt.Errorf("workspace.PruneDirectories: %w", err) + } + return removed, nil +} diff --git a/internal/service/sync/service.go b/internal/service/sync/service.go new file mode 100644 index 0000000..e0dd734 --- /dev/null +++ b/internal/service/sync/service.go @@ -0,0 +1,168 @@ +package sync + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/devctllabs/devctl/internal/domain/contract" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" + "go.uber.org/zap" +) + +//go:generate go tool mockgen -destination mocks/sync_service.go -package mocks -typed . ProjectRepository,Materializer,WorkspaceRepository + +// ProjectRepository resolves the valid project selected for synchronization. +type ProjectRepository interface { + // LoadProject returns a structurally and semantically valid project or an execution error. + LoadProject(ctx context.Context, manifestPath string) (projectdomain.Project, error) +} + +// Materializer obtains exact contract closures from configured sources. +type Materializer interface { + // Materialize resolves reference from source without publishing managed output. + Materialize(ctx context.Context, root string, source projectdomain.Source, reference contract.Reference) (contract.Snapshot, error) +} + +// WorkspaceRepository atomically publishes target snapshots and removes stale target directories. +type WorkspaceRepository interface { + // PublishFile atomically publishes one contained file and reports whether bytes changed. + PublishFile(ctx context.Context, root, target string, content []byte) (artifact.PublishResult, error) + // PublishDirectory atomically replaces one contained target with the complete tree and reports whether content changed. + PublishDirectory(ctx context.Context, root, target string, tree artifact.Tree) (artifact.PublishResult, error) + // PruneDirectories removes child directories below parent except names in keep and returns removed child names. + PruneDirectories(ctx context.Context, root, parent string, keep []string) ([]string, error) + // PreviewPruneDirectories returns the same validated stale child set without mutating the workspace. + PreviewPruneDirectories(ctx context.Context, root, parent string, keep []string) ([]string, error) +} + +type Service struct { + logger *zap.Logger + projects ProjectRepository + sources Materializer + workspace WorkspaceRepository +} + +func New(logger *zap.Logger, projects ProjectRepository, sources Materializer, workspace WorkspaceRepository) *Service { + return &Service{logger: logger, projects: projects, sources: sources, workspace: workspace} +} + +type materializationKey struct { + source projectdomain.Source + reference contract.Reference +} + +type syncExecution struct { + project projectdomain.Project + dryRun bool + resolved map[materializationKey]artifact.Tree +} + +// Sync materializes and publishes selected targets sequentially in deterministic order. +// Dry-run performs no source materialization or filesystem mutation; a failure returns completed changes without rollback. +func (s *Service) Sync(ctx context.Context, command syncdomain.Command) (syncdomain.Result, error) { + result := syncdomain.Result{Targets: []string{}, Changes: []syncdomain.Change{}, DryRun: command.DryRun} + project, err := s.projects.LoadProject(ctx, command.ManifestPath) + if err != nil { + return result, fmt.Errorf("projects.LoadProject: %w", err) + } + targets, err := syncTargets(project.Manifest, command.Family, command.Target) + if err != nil { + return result, fmt.Errorf("syncTargets: %w", err) + } + execution := syncExecution{ + project: project, + dryRun: command.DryRun, + resolved: make(map[materializationKey]artifact.Tree), + } + for _, target := range targets { + id, change, err := s.syncTarget(ctx, execution, target) + if err != nil { + return result, err + } + result.Targets = append(result.Targets, id) + if change != nil { + result.Changes = append(result.Changes, *change) + } + } + if command.Target == "" { + changes, err := s.pruneStaleTargets(ctx, project, command.Family, command.DryRun) + if err != nil { + return result, err + } + result.Changes = append(result.Changes, changes...) + } + s.logger.Debug("source synchronization completed", zap.Int("targets", len(result.Targets))) + return result, nil +} + +func (s *Service) syncTarget( + ctx context.Context, + execution syncExecution, + target projectdomain.Target, +) (string, *syncdomain.Change, error) { + id := target.ID + if err := ctx.Err(); err != nil { + return "", nil, fmt.Errorf("ctx.Err: %w", err) + } + if target.Source.Type == projectdomain.SourceLocal { + return id, nil, nil + } + destination := target.Location.RelativePath + if execution.dryRun { + return id, &syncdomain.Change{Target: id, Path: destination, Action: syncdomain.ChangePlannedPublish}, nil + } + files, err := s.materializedFiles(ctx, execution.project.Root, target, execution.resolved) + if err != nil { + return "", nil, err + } + published, err := s.workspace.PublishDirectory(ctx, execution.project.Root, destination, files) + if err != nil { + operationErr := &syncdomain.OperationError{Operation: syncdomain.OperationPublish, Target: id, Path: destination, Kind: syncdomain.FailureUnavailable, Cause: err} + return "", nil, fmt.Errorf("workspace.PublishDirectory: %w", operationErr) + } + action := syncdomain.ChangeAction(published.Action) + return id, &syncdomain.Change{Target: id, Path: destination, Action: action}, nil +} + +func (s *Service) materializedFiles( + ctx context.Context, + projectRoot string, + target projectdomain.Target, + resolved map[materializationKey]artifact.Tree, +) (artifact.Tree, error) { + reference := target.Reference + key := materializationKey{source: target.Source, reference: reference} + if files, cached := resolved[key]; cached { + return files, nil + } + snapshot, err := s.sources.Materialize(ctx, projectRoot, target.Source, reference) + if err != nil { + operationErr := &syncdomain.OperationError{ + Operation: syncdomain.OperationMaterialize, + Target: target.ID, Source: target.SourceName, Path: target.Reference.Entrypoint, + Kind: syncdomain.FailureUnavailable, Cause: err, + } + return artifact.Tree{}, fmt.Errorf("sources.Materialize: %w", operationErr) + } + files := managedTree(snapshot) + resolved[key] = files + return files, nil +} + +func managedTree(snapshot contract.Snapshot) artifact.Tree { + files := make([]artifact.File, 0, len(snapshot.Files)+1) + for _, file := range snapshot.Files { + files = append(files, artifact.File{Path: file.Path, Content: append([]byte(nil), file.Content...), Mode: file.Mode}) + } + if snapshot.Metadata != nil { + content, err := json.Marshal(snapshot.Metadata) + if err == nil { + content = append(content, '\n') + files = append(files, artifact.File{Path: ".devctl-contract.json", Content: content, Mode: 0o644}) + } + } + return artifact.Tree{Files: files} +} diff --git a/internal/service/sync/service_test.go b/internal/service/sync/service_test.go new file mode 100644 index 0000000..7760fbe --- /dev/null +++ b/internal/service/sync/service_test.go @@ -0,0 +1,425 @@ +package sync_test + +import ( + "context" + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/artifact" + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/failure" + materializedomain "github.com/devctllabs/devctl/internal/domain/materialize" + projectdomain "github.com/devctllabs/devctl/internal/domain/project" + syncdomain "github.com/devctllabs/devctl/internal/domain/sync" + syncservice "github.com/devctllabs/devctl/internal/service/sync" + "github.com/devctllabs/devctl/internal/service/sync/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" +) + +func TestServiceSyncOwnsTargetSelectionAndOutcome(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{ + Root: "/project", + ManifestPath: "/project/devctl.yaml", + Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{ + "alpha": {Type: "url", URL: "https://example.test/alpha.yaml"}, + "bravo": {Type: "git", Repo: "example/repo", Ref: "main"}, + "charlie": {Type: "url", URL: "https://example.test/charlie.yaml"}, + "local": {Type: "local", Path: "api/local.yaml"}, + }, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{ + {Name: "local", Source: "local", Path: "openapi.yaml"}, + {Name: "bravo", Source: "bravo", Path: "openapi.yaml"}, + {Name: "alpha", Source: "alpha", Path: "openapi.yaml"}, + {Name: "charlie", Source: "charlie", Path: "openapi.yaml"}, + }}}, + }, + } + projects.EXPECT().LoadProject(gomock.Any(), "custom.yaml").Return(project, nil) + gomock.InOrder( + sources.EXPECT().Materialize(gomock.Any(), project.Root, projectdomain.Source{Type: projectdomain.SourceURL, URL: "https://example.test/alpha.yaml"}, contract.Reference{Entrypoint: "openapi.yaml"}).Return(snapshot(t, "alpha"), nil), + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/http/client/alpha", artifact.Tree{Files: []artifact.File{{Path: "openapi.yaml", Content: []byte("alpha")}}}).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil), + sources.EXPECT().Materialize(gomock.Any(), project.Root, projectdomain.Source{Type: projectdomain.SourceGit, Repo: "example/repo", Ref: "main"}, contract.Reference{Entrypoint: "openapi.yaml"}).Return(snapshot(t, "bravo"), nil), + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/http/client/bravo", artifact.Tree{Files: []artifact.File{{Path: "openapi.yaml", Content: []byte("bravo")}}}).Return(artifact.PublishResult{Action: artifact.PublishUnchanged}, nil), + sources.EXPECT().Materialize(gomock.Any(), project.Root, projectdomain.Source{Type: projectdomain.SourceURL, URL: "https://example.test/charlie.yaml"}, contract.Reference{Entrypoint: "openapi.yaml"}).Return(snapshot(t, "charlie"), nil), + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/http/client/charlie", artifact.Tree{Files: []artifact.File{{Path: "openapi.yaml", Content: []byte("charlie")}}}).Return(artifact.PublishResult{Action: artifact.PublishUpdated}, nil), + ) + gomock.InOrder( + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/http/client", []string{"alpha", "bravo", "charlie"}).Return([]string{"stale"}, nil), + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/grpc/client", []string{}).Return(nil, nil), + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/kafka/consumer", []string{}).Return(nil, nil), + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/kafka/producer", []string{}).Return(nil, nil), + ) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + result, err := service.Sync(context.Background(), syncdomain.Command{ManifestPath: "custom.yaml"}) + + require.NoError(t, err) + require.Equal(t, []string{"http-client:alpha", "http-client:bravo", "http-client:charlie", "http-client:local"}, result.Targets) + require.Equal(t, []syncdomain.Change{ + {Target: "http-client:alpha", Path: "api/external/http/client/alpha", Action: syncdomain.ChangeCreated}, + {Target: "http-client:bravo", Path: "api/external/http/client/bravo", Action: syncdomain.ChangeUnchanged}, + {Target: "http-client:charlie", Path: "api/external/http/client/charlie", Action: syncdomain.ChangeUpdated}, + {Target: "http-client:stale", Path: "api/external/http/client/stale", Action: syncdomain.ChangeRemoved}, + }, result.Changes) +} + +func TestServiceSyncReturnsAppliedChangesWithLateFailure(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{ + "alpha": {Type: "url", URL: "https://example.test/alpha.yaml"}, + "bravo": {Type: "url", URL: "https://example.test/bravo.yaml"}, + }, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{ + {Name: "bravo", Source: "bravo", Path: "openapi.yaml"}, + {Name: "alpha", Source: "alpha", Path: "openapi.yaml"}, + }}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + gomock.InOrder( + sources.EXPECT().Materialize(gomock.Any(), project.Root, projectdomain.Source{Type: projectdomain.SourceURL, URL: "https://example.test/alpha.yaml"}, contract.Reference{Entrypoint: "openapi.yaml"}).Return(snapshot(t, "alpha"), nil), + workspace.EXPECT().PublishDirectory(gomock.Any(), "/project", "api/external/http/client/alpha", gomock.Any()).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil), + sources.EXPECT().Materialize(gomock.Any(), project.Root, projectdomain.Source{Type: projectdomain.SourceURL, URL: "https://example.test/bravo.yaml"}, contract.Reference{Entrypoint: "openapi.yaml"}).Return(contract.Snapshot{}, errors.New("upstream failed")), + ) + + result, err := syncservice.New(zap.NewNop(), projects, sources, workspace).Sync(context.Background(), syncdomain.Command{ManifestPath: "devctl.yaml"}) + + require.Equal(t, failure.Unavailable, failure.CategoryOf(err)) + require.Equal(t, []string{"http-client:alpha"}, result.Targets) + require.Equal(t, []syncdomain.Change{{ + Target: "http-client:alpha", Path: "api/external/http/client/alpha", Action: syncdomain.ChangeCreated, + }}, result.Changes) +} + +func TestServiceSyncPreservesStaleSnapshotDiagnosticDuringReexport(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + source := projectdomain.Source{Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"} + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"upstream": source}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "events", Topic: "sample.events.created.v1", Contract: projectdomain.KafkaContract{ + Source: "upstream", Export: "events", Format: "json", + }, + }}}}, + }} + reference := contract.Reference{Export: "events", Topic: "sample.events.created.v1", Format: "json"} + metadataErr := &contract.SnapshotMetadataError{ + Field: "entrypoint", Reason: contract.MetadataRequired, Hint: "devctl sync", + } + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + sources.EXPECT().Materialize(gomock.Any(), project.Root, source, reference).Return(contract.Snapshot{}, metadataErr) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + _, err := service.Sync(context.Background(), syncdomain.Command{ + ManifestPath: "devctl.yaml", Target: "kafka-consumer:events", + }) + + require.ErrorIs(t, err, metadataErr) + require.Equal(t, failure.InvalidInput, failure.CategoryOf(err)) +} + +func TestServiceSyncPreservesMaterializationFailureCategories(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cause error + category failure.Category + }{ + {name: "invalid input", cause: &materializedomain.OperationError{Kind: materializedomain.FailureInvalid}, category: failure.InvalidInput}, + {name: "not found", cause: &materializedomain.OperationError{Kind: materializedomain.FailureNotFound}, category: failure.NotFound}, + {name: "unsupported", cause: &materializedomain.OperationError{Kind: materializedomain.FailureUnsupported}, category: failure.Unsupported}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + source := projectdomain.Source{Type: projectdomain.SourceURL, URL: "https://example.test/openapi.yaml"} + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"upstream": source}, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "upstream", Source: "upstream", Path: "openapi.yaml", + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + sources.EXPECT().Materialize( + gomock.Any(), project.Root, source, contract.Reference{Entrypoint: "openapi.yaml"}, + ).Return(contract.Snapshot{}, test.cause) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + _, err := service.Sync(context.Background(), syncdomain.Command{ + ManifestPath: "devctl.yaml", Target: "http-client:upstream", + }) + + require.Equal(t, test.category, failure.CategoryOf(err)) + var operationErr *syncdomain.OperationError + require.ErrorAs(t, err, &operationErr) + require.Equal(t, "http-client:upstream", operationErr.Target) + require.Equal(t, "upstream", operationErr.Source) + require.Equal(t, "openapi.yaml", operationErr.Path) + }) + } +} + +func TestServiceSyncCarriesProtoRootIntoMaterialization(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + source := projectdomain.Source{Type: projectdomain.SourceGit, Repo: "example/contracts", Ref: "v1", Proto: projectdomain.SourceProto{BufConfig: "buf.yaml"}} + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{"contracts": source}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Path: "proto/acme/billing/v1/service.proto", ProtoRoot: "proto", + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + sources.EXPECT().Materialize(gomock.Any(), project.Root, source, contract.Reference{ + Entrypoint: "proto/acme/billing/v1/service.proto", Format: "proto", ProtoRoot: "proto", + }).Return(contract.Snapshot{Entrypoint: "proto/acme/billing/v1/service.proto", Files: []contract.File{{ + Path: "proto/acme/billing/v1/service.proto", Content: []byte("syntax = \"proto3\";\n"), + }}}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/grpc/client/billing", gomock.Any()).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil) + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/grpc/client", []string{"billing"}).Return(nil, nil) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + result, err := service.Sync(context.Background(), syncdomain.Command{ManifestPath: "devctl.yaml", Family: "grpc"}) + + require.NoError(t, err) + require.Equal(t, []string{"grpc-client:billing"}, result.Targets) +} + +func TestServiceSyncPublishesDevctlGRPCMetadataAtTargetRoot(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + source := projectdomain.Source{Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"} + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"contracts": source}, + Components: projectdomain.Components{GRPC: &projectdomain.GRPC{Clients: []projectdomain.GRPCClient{{ + Name: "billing", Source: "contracts", Export: "billing", + }}}}, + }} + reference := contract.Reference{Export: "billing", Format: "proto"} + snapshot := contract.Snapshot{ + ModuleRoot: "api/proto/grpc", + Files: []contract.File{ + {Path: "api/proto/grpc/service.proto", Content: []byte("syntax = \"proto3\";\n")}, + {Path: "buf.yaml", Content: []byte("version: v2\n")}, + }, + Metadata: &contract.Metadata{ + Kind: "grpc", Format: "proto", ModuleRoot: "api/proto/grpc", BufConfig: "buf.yaml", + }, + } + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + sources.EXPECT().Materialize(gomock.Any(), project.Root, source, reference).Return(snapshot, nil) + workspace.EXPECT().PublishDirectory( + gomock.Any(), project.Root, "api/external/grpc/client/billing", artifact.Tree{Files: []artifact.File{ + {Path: "api/proto/grpc/service.proto", Content: []byte("syntax = \"proto3\";\n")}, + {Path: "buf.yaml", Content: []byte("version: v2\n")}, + {Path: ".devctl-contract.json", Content: []byte("{\"kind\":\"grpc\",\"format\":\"proto\",\"module_root\":\"api/proto/grpc\",\"buf_config\":\"buf.yaml\"}\n"), Mode: 0o644}, + }}, + ).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + _, err := service.Sync(context.Background(), syncdomain.Command{ + ManifestPath: "devctl.yaml", Target: "grpc-client:billing", + }) + + require.NoError(t, err) +} + +func TestServiceSyncPublishesKafkaSidecarAtConsumerAndProducerRoots(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + source := projectdomain.Source{Type: projectdomain.SourceDevctl, Repo: "example/contracts", Ref: "v1"} + selected := projectdomain.KafkaContract{Source: "contracts", Export: "events", Format: "json"} + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Sources: map[string]projectdomain.Source{"contracts": source}, + Components: projectdomain.Components{Kafka: &projectdomain.Kafka{ + Consumers: []projectdomain.KafkaConsumer{{Name: "audit", Topic: "sample.audit.events.v1", Contract: selected}}, + Producers: []projectdomain.KafkaProducer{{Name: "audit", Topic: "sample.audit.events.v1", Contract: selected}}, + }}, + }} + reference := contract.Reference{Export: "events", Format: "json", Topic: "sample.audit.events.v1"} + snapshot := contract.Snapshot{ + Entrypoint: "schemas/event.json", + Files: []contract.File{{Path: "schemas/event.json", Content: []byte(`{"title":"Event"}`)}}, + Metadata: &contract.Metadata{ + Kind: "kafka", Topic: "sample.audit.events.v1", Format: "json", Entrypoint: "schemas/event.json", + }, + } + expectedTree := artifact.Tree{Files: []artifact.File{ + {Path: "schemas/event.json", Content: []byte(`{"title":"Event"}`)}, + {Path: ".devctl-contract.json", Content: []byte("{\"kind\":\"kafka\",\"topic\":\"sample.audit.events.v1\",\"format\":\"json\",\"entrypoint\":\"schemas/event.json\"}\n"), Mode: 0o644}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + sources.EXPECT().Materialize(gomock.Any(), project.Root, source, reference).Return(snapshot, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/kafka/consumer/audit", expectedTree).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil) + workspace.EXPECT().PublishDirectory(gomock.Any(), project.Root, "api/external/kafka/producer/audit", expectedTree).Return(artifact.PublishResult{Action: artifact.PublishCreated}, nil) + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/kafka/consumer", []string{"audit"}).Return(nil, nil) + workspace.EXPECT().PruneDirectories(gomock.Any(), project.Root, "api/external/kafka/producer", []string{"audit"}).Return(nil, nil) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + _, err := service.Sync(context.Background(), syncdomain.Command{ManifestPath: "devctl.yaml", Family: "kafka"}) + + require.NoError(t, err) +} + +func TestServiceSyncDryRunDoesNotMaterializeURLSource(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{ + Paths: projectdomain.ManifestPaths{ExternalContracts: "api/external"}, + Sources: map[string]projectdomain.Source{ + "billing": {Type: projectdomain.SourceURL, URL: "https://example.test/billing/openapi.yaml"}, + }, + Components: projectdomain.Components{HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "billing", Source: "billing", Path: "spec/openapi.yaml", + }}}}, + }} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PreviewPruneDirectories( + gomock.Any(), project.Root, "api/external/http/client", []string{"billing"}, + ).Return(nil, nil) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + result, err := service.Sync(context.Background(), syncdomain.Command{ + ManifestPath: "devctl.yaml", Family: "http", DryRun: true, + }) + + require.NoError(t, err) + require.Equal(t, []string{"http-client:billing"}, result.Targets) + require.Equal(t, []syncdomain.Change{{ + Target: "http-client:billing", Path: "api/external/http/client/billing", Action: syncdomain.ChangePlannedPublish, + }}, result.Changes) +} + +func TestServiceSyncAppliesCatalogSelectionContract(t *testing.T) { + t.Parallel() + + manifest := projectdomain.Manifest{ + Project: projectdomain.Identity{Language: "go"}, + Sources: map[string]projectdomain.Source{ + "local": {Type: projectdomain.SourceLocal, Path: "api/contracts"}, + }, + Components: projectdomain.Components{ + HTTP: &projectdomain.HTTP{Clients: []projectdomain.HTTPClient{{ + Name: "local", Source: "local", Path: "openapi.yaml", + }}}, + Kafka: &projectdomain.Kafka{Consumers: []projectdomain.KafkaConsumer{{ + Name: "raw", Topic: "sample.events.raw.v1", Contract: projectdomain.KafkaContract{Format: "raw"}, + }}}, + }, + } + tests := []struct { + name string + command syncdomain.Command + targets []string + category failure.Category + }{ + {name: "known empty family", command: syncdomain.Command{Family: "grpc", DryRun: true}, targets: []string{}}, + {name: "local sync no-op", command: syncdomain.Command{Target: "http-client:local", DryRun: true}, targets: []string{"http-client:local"}}, + {name: "unknown family", command: syncdomain.Command{Family: "other", DryRun: true}, category: failure.InvalidInput}, + {name: "unknown target", command: syncdomain.Command{Target: "grpc-client:missing", DryRun: true}, category: failure.NotFound}, + {name: "config does not sync", command: syncdomain.Command{Target: "config", DryRun: true}, category: failure.Unsupported}, + {name: "raw Kafka does not sync", command: syncdomain.Command{Target: "kafka-consumer:raw", DryRun: true}, category: failure.Unsupported}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(projectdomain.Project{Root: "/project", Manifest: manifest}, nil) + if test.name == "known empty family" { + workspace.EXPECT().PreviewPruneDirectories( + gomock.Any(), "/project", "api/external/grpc/client", []string{}, + ).Return(nil, nil) + } + service := syncservice.New( + zap.NewNop(), projects, mocks.NewMockMaterializer(ctrl), workspace, + ) + command := test.command + command.ManifestPath = "devctl.yaml" + + result, err := service.Sync(context.Background(), command) + + if test.category != "" { + require.Equal(t, test.category, failure.CategoryOf(err)) + return + } + require.NoError(t, err) + require.Equal(t, test.targets, result.Targets) + require.Empty(t, result.Changes) + }) + } +} + +func TestServiceSyncDryRunReportsPlannedStaleRemovalWithoutMutation(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + projects := mocks.NewMockProjectRepository(ctrl) + sources := mocks.NewMockMaterializer(ctrl) + workspace := mocks.NewMockWorkspaceRepository(ctrl) + project := projectdomain.Project{Root: "/project", Manifest: projectdomain.Manifest{}} + projects.EXPECT().LoadProject(gomock.Any(), "devctl.yaml").Return(project, nil) + workspace.EXPECT().PreviewPruneDirectories( + gomock.Any(), project.Root, "api/external/http/client", []string{}, + ).Return([]string{"stale"}, nil) + service := syncservice.New(zap.NewNop(), projects, sources, workspace) + + result, err := service.Sync(context.Background(), syncdomain.Command{ + ManifestPath: "devctl.yaml", Family: "http", DryRun: true, + }) + + require.NoError(t, err) + require.Empty(t, result.Targets) + require.Equal(t, []syncdomain.Change{{ + Target: "http-client:stale", Path: "api/external/http/client/stale", Action: syncdomain.ChangePlannedRemove, + }}, result.Changes) +} + +func snapshot(t *testing.T, content string) contract.Snapshot { + t.Helper() + return contract.Snapshot{Entrypoint: "openapi.yaml", Files: []contract.File{{Path: "openapi.yaml", Content: []byte(content)}}} +} diff --git a/internal/service/sync/targets.go b/internal/service/sync/targets.go new file mode 100644 index 0000000..75d6762 --- /dev/null +++ b/internal/service/sync/targets.go @@ -0,0 +1,22 @@ +package sync + +import ( + "fmt" + + projectdomain "github.com/devctllabs/devctl/internal/domain/project" +) + +func syncTargets(spec projectdomain.Manifest, family, selected string) ([]projectdomain.Target, error) { + targets, err := projectdomain.NewTargetCatalog(spec).Resolve(projectdomain.TargetOperationSync, family, selected) + if err != nil { + return nil, fmt.Errorf("catalog.Resolve: %w", err) + } + return targets, nil +} + +func externalContractsRoot(spec projectdomain.Manifest) string { + if spec.Paths.ExternalContracts != "" { + return spec.Paths.ExternalContracts + } + return "api/external" +} diff --git a/internal/service/targetinput/mocks/resolver.go b/internal/service/targetinput/mocks/resolver.go new file mode 100644 index 0000000..f8b1328 --- /dev/null +++ b/internal/service/targetinput/mocks/resolver.go @@ -0,0 +1,144 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/devctllabs/devctl/internal/service/targetinput (interfaces: EntrypointResolver,SnapshotLoader) +// +// Generated by this command: +// +// mockgen -destination mocks/resolver.go -package mocks -typed . EntrypointResolver,SnapshotLoader +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + contract "github.com/devctllabs/devctl/internal/domain/contract" + gomock "go.uber.org/mock/gomock" +) + +// MockEntrypointResolver is a mock of EntrypointResolver interface. +type MockEntrypointResolver struct { + ctrl *gomock.Controller + recorder *MockEntrypointResolverMockRecorder + isgomock struct{} +} + +// MockEntrypointResolverMockRecorder is the mock recorder for MockEntrypointResolver. +type MockEntrypointResolverMockRecorder struct { + mock *MockEntrypointResolver +} + +// NewMockEntrypointResolver creates a new mock instance. +func NewMockEntrypointResolver(ctrl *gomock.Controller) *MockEntrypointResolver { + mock := &MockEntrypointResolver{ctrl: ctrl} + mock.recorder = &MockEntrypointResolverMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEntrypointResolver) EXPECT() *MockEntrypointResolverMockRecorder { + return m.recorder +} + +// ResolveContract mocks base method. +func (m *MockEntrypointResolver) ResolveContract(ctx context.Context, location contract.Location) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveContract", ctx, location) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveContract indicates an expected call of ResolveContract. +func (mr *MockEntrypointResolverMockRecorder) ResolveContract(ctx, location any) *MockEntrypointResolverResolveContractCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveContract", reflect.TypeOf((*MockEntrypointResolver)(nil).ResolveContract), ctx, location) + return &MockEntrypointResolverResolveContractCall{Call: call} +} + +// MockEntrypointResolverResolveContractCall wrap *gomock.Call +type MockEntrypointResolverResolveContractCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockEntrypointResolverResolveContractCall) Return(arg0 string, arg1 error) *MockEntrypointResolverResolveContractCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockEntrypointResolverResolveContractCall) Do(f func(context.Context, contract.Location) (string, error)) *MockEntrypointResolverResolveContractCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockEntrypointResolverResolveContractCall) DoAndReturn(f func(context.Context, contract.Location) (string, error)) *MockEntrypointResolverResolveContractCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// MockSnapshotLoader is a mock of SnapshotLoader interface. +type MockSnapshotLoader struct { + ctrl *gomock.Controller + recorder *MockSnapshotLoaderMockRecorder + isgomock struct{} +} + +// MockSnapshotLoaderMockRecorder is the mock recorder for MockSnapshotLoader. +type MockSnapshotLoaderMockRecorder struct { + mock *MockSnapshotLoader +} + +// NewMockSnapshotLoader creates a new mock instance. +func NewMockSnapshotLoader(ctrl *gomock.Controller) *MockSnapshotLoader { + mock := &MockSnapshotLoader{ctrl: ctrl} + mock.recorder = &MockSnapshotLoaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSnapshotLoader) EXPECT() *MockSnapshotLoaderMockRecorder { + return m.recorder +} + +// Load mocks base method. +func (m *MockSnapshotLoader) Load(ctx context.Context, root, treeRoot string, expected contract.MetadataExpectation) (contract.Snapshot, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Load", ctx, root, treeRoot, expected) + ret0, _ := ret[0].(contract.Snapshot) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Load indicates an expected call of Load. +func (mr *MockSnapshotLoaderMockRecorder) Load(ctx, root, treeRoot, expected any) *MockSnapshotLoaderLoadCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Load", reflect.TypeOf((*MockSnapshotLoader)(nil).Load), ctx, root, treeRoot, expected) + return &MockSnapshotLoaderLoadCall{Call: call} +} + +// MockSnapshotLoaderLoadCall wrap *gomock.Call +type MockSnapshotLoaderLoadCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockSnapshotLoaderLoadCall) Return(arg0 contract.Snapshot, arg1 error) *MockSnapshotLoaderLoadCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockSnapshotLoaderLoadCall) Do(f func(context.Context, string, string, contract.MetadataExpectation) (contract.Snapshot, error)) *MockSnapshotLoaderLoadCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockSnapshotLoaderLoadCall) DoAndReturn(f func(context.Context, string, string, contract.MetadataExpectation) (contract.Snapshot, error)) *MockSnapshotLoaderLoadCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/internal/service/targetinput/resolver.go b/internal/service/targetinput/resolver.go new file mode 100644 index 0000000..86639a4 --- /dev/null +++ b/internal/service/targetinput/resolver.go @@ -0,0 +1,61 @@ +package targetinput + +import ( + "context" + "fmt" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/project" +) + +//go:generate go tool mockgen -destination mocks/resolver.go -package mocks -typed . EntrypointResolver,SnapshotLoader + +// EntrypointResolver resolves a concrete contract entrypoint within a contained location. +type EntrypointResolver interface { + // ResolveContract returns the contained entrypoint selected by location. + ResolveContract(ctx context.Context, location contract.Location) (string, error) +} + +// SnapshotLoader reconstructs one committed Contract Snapshot without contacting its supplier. +type SnapshotLoader interface { + // Load reconstructs the Snapshot rooted at treeRoot and validates it against expected. + Load(ctx context.Context, root, treeRoot string, expected contract.MetadataExpectation) (contract.Snapshot, error) +} + +// Resolver attaches concrete local input to a logical Target. +type Resolver struct { + entrypoints EntrypointResolver + snapshots SnapshotLoader +} + +func New(entrypoints EntrypointResolver, snapshots SnapshotLoader) *Resolver { + return &Resolver{entrypoints: entrypoints, snapshots: snapshots} +} + +// Resolve attaches the concrete input required to execute target in selected Project. +func (r *Resolver) Resolve( + ctx context.Context, + selected project.Project, + target project.Target, +) (project.Target, error) { + if target.Source.Type == project.SourceDevctl && (target.Family == "grpc" || target.Family == "kafka") { + snapshot, err := r.snapshots.Load( + ctx, selected.Root, target.Location.RelativePath, target.SnapshotExpectation(), + ) + if err != nil { + return target, fmt.Errorf("snapshots.Load: %w", err) + } + return target.WithSnapshot(snapshot), nil + } + if target.Family != "http" { + return target, nil + } + location := target.Location + location.Root = selected.Root + input, err := r.entrypoints.ResolveContract(ctx, location) + if err != nil { + return target, fmt.Errorf("entrypoints.ResolveContract: %w", err) + } + target.Input = input + return target, nil +} diff --git a/internal/service/targetinput/resolver_test.go b/internal/service/targetinput/resolver_test.go new file mode 100644 index 0000000..6350816 --- /dev/null +++ b/internal/service/targetinput/resolver_test.go @@ -0,0 +1,225 @@ +package targetinput_test + +import ( + "context" + "errors" + "testing" + + "github.com/devctllabs/devctl/internal/domain/contract" + "github.com/devctllabs/devctl/internal/domain/project" + "github.com/devctllabs/devctl/internal/service/targetinput" + "github.com/devctllabs/devctl/internal/service/targetinput/mocks" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestResolverResolvesHTTPEntrypointInsideProject(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + entrypoints := mocks.NewMockEntrypointResolver(ctrl) + snapshots := mocks.NewMockSnapshotLoader(ctrl) + resolver := targetinput.New(entrypoints, snapshots) + selected := project.Project{Root: "/project"} + target := project.Target{ + ID: "http-server", Family: "http", + Location: contract.Location{ + RelativePath: "api/openapi/swagger.yaml", + Entrypoint: "api/openapi/swagger.yaml", + Local: true, + }, + } + entrypoints.EXPECT().ResolveContract(gomock.Any(), contract.Location{ + Root: selected.Root, + RelativePath: target.Location.RelativePath, + Entrypoint: target.Location.Entrypoint, + Local: true, + }).Return("/project/api/openapi/swagger.yaml", nil) + + resolved, err := resolver.Resolve(context.Background(), selected, target) + + require.NoError(t, err) + require.Equal(t, "/project/api/openapi/swagger.yaml", resolved.Input) + require.Equal(t, target.Location, resolved.Location) +} + +func TestResolverResolvesExternalHTTPEntrypointInsideMaterializedTree(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + entrypoints := mocks.NewMockEntrypointResolver(ctrl) + snapshots := mocks.NewMockSnapshotLoader(ctrl) + target := project.Target{ + ID: "http-client:billing", Family: "http", + Source: project.Source{Type: project.SourceURL}, + Location: contract.Location{ + RelativePath: "api/external/http/client/billing", + Entrypoint: "openapi.yaml", + }, + } + entrypoints.EXPECT().ResolveContract(gomock.Any(), contract.Location{ + Root: "/project", + RelativePath: target.Location.RelativePath, + Entrypoint: target.Location.Entrypoint, + }).Return("/project/api/external/http/client/billing/openapi.yaml", nil) + + resolved, err := targetinput.New(entrypoints, snapshots).Resolve( + context.Background(), project.Project{Root: "/project"}, target, + ) + + require.NoError(t, err) + require.Equal(t, "/project/api/external/http/client/billing/openapi.yaml", resolved.Input) + require.Equal(t, target.Location, resolved.Location) +} + +func TestResolverResolvesCommittedSnapshotTargets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target project.Target + snapshot contract.Snapshot + input string + paths []string + }{ + { + name: "grpc", + target: project.Target{ + ID: "grpc-client:billing", Family: "grpc", Format: "proto", + Source: project.Source{Type: project.SourceDevctl}, + Location: contract.Location{RelativePath: "api/external/grpc/client/billing"}, + }, + snapshot: contract.Snapshot{ModuleRoot: "api/proto/grpc"}, + input: "api/external/grpc/client/billing/api/proto/grpc", + }, + { + name: "kafka proto", + target: project.Target{ + ID: "kafka-consumer:events", Family: "kafka", Format: "proto", + Source: project.Source{Type: project.SourceDevctl}, + Reference: contract.Reference{Topic: "sample.events.created.v1"}, + Location: contract.Location{RelativePath: "api/external/kafka/consumer/events"}, + }, + snapshot: contract.Snapshot{ModuleRoot: "proto", Entrypoint: "proto/events.proto"}, + input: "api/external/kafka/consumer/events/proto", + paths: []string{"events.proto"}, + }, + { + name: "kafka json", + target: project.Target{ + ID: "kafka-consumer:events", Family: "kafka", Format: "json", + Source: project.Source{Type: project.SourceDevctl}, + Reference: contract.Reference{Topic: "sample.events.created.v1"}, + Location: contract.Location{RelativePath: "api/external/kafka/consumer/events"}, + }, + snapshot: contract.Snapshot{Entrypoint: "schemas/events.json"}, + input: "api/external/kafka/consumer/events/schemas/events.json", + }, + { + name: "kafka raw", + target: project.Target{ + ID: "kafka-consumer:events", Family: "kafka", Format: "raw", + Source: project.Source{Type: project.SourceDevctl}, + Reference: contract.Reference{Topic: "sample.events.created.v1"}, + Location: contract.Location{RelativePath: "api/external/kafka/consumer/events"}, + }, + snapshot: contract.Snapshot{}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + entrypoints := mocks.NewMockEntrypointResolver(ctrl) + snapshots := mocks.NewMockSnapshotLoader(ctrl) + selected := project.Project{Root: "/project"} + snapshots.EXPECT().Load( + gomock.Any(), selected.Root, test.target.Location.RelativePath, test.target.SnapshotExpectation(), + ).Return(test.snapshot, nil) + + resolved, err := targetinput.New(entrypoints, snapshots).Resolve( + context.Background(), selected, test.target, + ) + + require.NoError(t, err) + require.Equal(t, test.input, resolved.Input) + require.Equal(t, test.paths, resolved.Paths) + if test.snapshot.Entrypoint != "" { + require.Equal(t, test.snapshot.Entrypoint, resolved.Location.Entrypoint) + } + }) + } +} + +func TestResolverPassesThroughTargetsWithoutResolvableInput(t *testing.T) { + t.Parallel() + + tests := []project.Target{ + {ID: "config", Family: "config", Format: "go"}, + {ID: "grpc-server", Family: "grpc", Format: "proto", Source: project.Source{Type: project.SourceLocal}}, + {ID: "kafka-producer:events", Family: "kafka", Format: "json", Source: project.Source{Type: project.SourceLocal}}, + } + for _, target := range tests { + target := target + t.Run(target.ID, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + resolved, err := targetinput.New( + mocks.NewMockEntrypointResolver(ctrl), mocks.NewMockSnapshotLoader(ctrl), + ).Resolve(context.Background(), project.Project{Root: "/project"}, target) + + require.NoError(t, err) + require.Equal(t, target, resolved) + }) + } +} + +func TestResolverPreservesDependencyErrors(t *testing.T) { + t.Parallel() + + t.Run("entrypoint", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + entrypoints := mocks.NewMockEntrypointResolver(ctrl) + snapshots := mocks.NewMockSnapshotLoader(ctrl) + cause := errors.New("entrypoint unavailable") + target := project.Target{Family: "http", Location: contract.Location{RelativePath: "api", Entrypoint: "openapi.yaml"}} + entrypoints.EXPECT().ResolveContract(gomock.Any(), contract.Location{ + Root: "/project", RelativePath: "api", Entrypoint: "openapi.yaml", + }).Return("", cause) + + resolved, err := targetinput.New(entrypoints, snapshots).Resolve( + context.Background(), project.Project{Root: "/project"}, target, + ) + + require.Equal(t, target, resolved) + require.ErrorIs(t, err, cause) + }) + + t.Run("snapshot", func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + entrypoints := mocks.NewMockEntrypointResolver(ctrl) + snapshots := mocks.NewMockSnapshotLoader(ctrl) + cause := errors.New("snapshot unavailable") + target := project.Target{ + Family: "grpc", Format: "proto", Source: project.Source{Type: project.SourceDevctl}, + Location: contract.Location{RelativePath: "contracts"}, + } + snapshots.EXPECT().Load( + gomock.Any(), "/project", "contracts", target.SnapshotExpectation(), + ).Return(contract.Snapshot{}, cause) + + resolved, err := targetinput.New(entrypoints, snapshots).Resolve( + context.Background(), project.Project{Root: "/project"}, target, + ) + + require.Equal(t, target, resolved) + require.ErrorIs(t, err, cause) + }) +} diff --git a/internal/testutil/testexec/testexec.go b/internal/testutil/testexec/testexec.go new file mode 100644 index 0000000..be3e66d --- /dev/null +++ b/internal/testutil/testexec/testexec.go @@ -0,0 +1,18 @@ +// Package testexec provides fixtures for testing subprocess clients. +package testexec + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// StubPathCommand installs script as name and returns a PATH that resolves it first. +func StubPathCommand(t *testing.T, name, script string) string { + t.Helper() + bin := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(bin, name), []byte(script), 0o755)) + return bin + string(os.PathListSeparator) + os.Getenv("PATH") +}