diff --git a/.env.example b/.env.example index 839f4c9d2..2e508af1e 100644 --- a/.env.example +++ b/.env.example @@ -52,12 +52,12 @@ RENEW_DEADLINE=40 # Seconds; must be < LEASE_DURATION RETRY_PERIOD=10 # Seconds; retry interval for leader election # ------------------------------------------------------------------------------ -# Catalog / Stack Configuration +# Blueprint / Stack Configuration # ------------------------------------------------------------------------------ -# Paths to Catalog or Stack files (comma-separated or single path) +# Paths to Blueprint or Stack files (comma-separated or single path) # Can be overridden with --file flag -CATALOG_PATH=./catalog.yaml +BLUEPRINT_PATH=./blueprint.yaml # Default queue depth and degrade threshold for CRDs MAX_QUEUE_DEPTH=100 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1387382f6..37f3bb152 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -31,4 +31,4 @@ What actually happened. ## Additional Context -Logs, Catalog snippets, CRDs, etc. +Logs, Blueprint snippets, CRDs, etc. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index c743403b8..d0325f70f 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -14,7 +14,7 @@ Why is this useful? What problem does it solve? ## Proposal -How you imagine it working (CLI, Catalog, Stack, Registry, etc.). +How you imagine it working (CLI, Blueprint, Stack, Registry, etc.). ## Alternatives diff --git a/.github/workflows/build-matrix.yml b/.github/workflows/build-matrix.yml index af6cb643e..8f5e33e5f 100644 --- a/.github/workflows/build-matrix.yml +++ b/.github/workflows/build-matrix.yml @@ -47,8 +47,8 @@ jobs: go-version-file: go.mod cache: true - - name: Run note generator - run: make generate-notes + - name: Run function generator + run: make generate-functions - name: Cache compiled binaries for ${{ matrix.os }}_${{ matrix.arch }} id: cache-binaries diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24faba366..6a33d05b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,48 +1,66 @@ +# .github/workflows/ci.yml name: CI on: - # push: - # branches: [main] + workflow_dispatch: + push: + branches: [main] + paths: + - 'cmd/**' + - 'pkg/**' + - 'domain/**' + - 'examples/**' + - 'go.mod' + - 'go.sum' pull_request: - branches: [disabled] + types: [opened, synchronize, reopened] + paths: + - 'cmd/**' + - 'pkg/**' + - 'domain/**' + - 'examples/**' + - 'go.mod' + - 'go.sum' jobs: - # ── Lint ────────────────────────────────────────────────────────────────── - lint: - name: Lint + validate-functions: + name: Validate generated functions runs-on: ubuntu-latest - steps: - - name: Checkout + - name: Checkout code uses: actions/checkout@v6 + with: + fetch-depth: 0 - # - name: Set up Go - # uses: actions/setup-go@v5 - # with: - # go-version-file: go.mod - # cache: true + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true - # - name: golangci-lint - # uses: golangci/golangci-lint-action@v6 - # with: - # version: latest + - name: Run function generator + run: make generate-functions - - name: Write summary - if: always() + - name: Check for uncommitted changes (generated function registries) run: | - echo "## Lint" >> "$GITHUB_STEP_SUMMARY" - echo "| Check | Status |" >> "$GITHUB_STEP_SUMMARY" - echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY" - echo "| golangci-lint | ${{ job.status == 'success' && '✅ passed' || '❌ failed' }} |" >> "$GITHUB_STEP_SUMMARY" - - # ── Build ────────────────────────────────────────────────────────────────── - build: - name: Build - runs-on: ubuntu-latest + FUNCS="pkg/template/funcs/funcs_generated.go docs/reference/inrun-functions" + if ! git diff --quiet -- $FUNCS; then + echo "❌ Generated function files are out of date:" + git diff --stat -- $FUNCS + echo "Run 'make generate-functions' locally and commit the changes." + exit 1 + else + echo "✅ Function list is up-to-date." + fi + validate-resource-docs: + name: Validate generated resource docs + runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout code uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v5 @@ -50,92 +68,44 @@ jobs: go-version-file: go.mod cache: true - - name: Build - run: go build ./... + - name: Run resource docs generator + run: make generate-resource-docs - - name: Build CLI binary + - name: Check for uncommitted changes (docs/reference/schema/resources) run: | - go build -o ./bin/inrun ./cmd/inrun/ - echo "✅ Binary built:" - ls -lh ./bin/inrun + if ! git diff --quiet docs/reference/schema/resources/; then + echo "❌ docs/reference/schema/resources/ is out of date." + echo "Run 'make generate-resource-docs' locally and commit the changes." + exit 1 + else + echo "✅ Resource docs are up-to-date." + fi - - name: Write summary - if: always() - run: | - echo "## Build" >> "$GITHUB_STEP_SUMMARY" - echo "| Step | Status |" >> "$GITHUB_STEP_SUMMARY" - echo "|------|--------|" >> "$GITHUB_STEP_SUMMARY" - echo "| go build ./... | ${{ job.status == 'success' && '✅ passed' || '❌ failed' }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| CLI binary | ${{ job.status == 'success' && '✅ built' || '❌ failed' }} |" >> "$GITHUB_STEP_SUMMARY" - - # ── Test ─────────────────────────────────────────────────────────────────── - test: - name: Test + validate-code: + name: Run static checks runs-on: ubuntu-latest - steps: - - name: Checkout + - name: Checkout code uses: actions/checkout@v6 - # - name: Set up Go - # uses: actions/setup-go@v5 - # with: - # go-version-file: go.mod - # cache: true - - - name: Run tests - run: | - go test ./... \ - -v \ - -race \ - -coverprofile=coverage.out \ - -covermode=atomic \ - -timeout 120s \ - 2>&1 | tee test_output.txt - - - name: Vulnerability scan - run: | - go install golang.org/x/vuln/cmd/govulncheck@latest - govulncheck ./... - - - name: Coverage report - run: | - go tool cover -func=coverage.out | tail -1 - COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}') - echo "COVERAGE=${COVERAGE}" >> "$GITHUB_ENV" + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true - - name: Write summary - if: always() - run: | - echo "## Tests" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - - # Count pass/fail - PASSED=$(grep -c "^--- PASS" test_output.txt 2>/dev/null || echo 0) - FAILED=$(grep -c "^--- FAIL" test_output.txt 2>/dev/null || echo 0) - - echo "| Result | Count |" >> "$GITHUB_STEP_SUMMARY" - echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" - echo "| ✅ Passed | ${PASSED} |" >> "$GITHUB_STEP_SUMMARY" - echo "| ❌ Failed | ${FAILED} |" >> "$GITHUB_STEP_SUMMARY" - echo "| 📊 Coverage | ${COVERAGE:-n/a} |" >> "$GITHUB_STEP_SUMMARY" - - if [ "${FAILED}" -gt 0 ]; then - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "### Failed tests" >> "$GITHUB_STEP_SUMMARY" - echo '```' >> "$GITHUB_STEP_SUMMARY" - grep "^--- FAIL" test_output.txt >> "$GITHUB_STEP_SUMMARY" || true - echo '```' >> "$GITHUB_STEP_SUMMARY" - fi + - name: Run tests (no cluster required) + run: make test - # ── Validate examples ────────────────────────────────────────────────────── - validate-examples: - name: Validate examples + # Runs every example's simulate.yaml against the CLI built from this + # checkout, the way each example's README tells users to. No cluster. + # Typed examples are skipped: each builds its own binary from a nested + # module, which needs a published Inrun version. + simulate-examples: + name: Simulate examples runs-on: ubuntu-latest - needs: build - steps: - - name: Checkout + - name: Checkout code uses: actions/checkout@v6 - name: Set up Go @@ -144,38 +114,47 @@ jobs: go-version-file: go.mod cache: true - - name: Build CLI - run: go build -o ./bin/inrun ./cmd/inrun/ + - name: Build inrun + run: go build -o "$RUNNER_TEMP/inrun" ./cmd/inrun - - name: Validate examples + - name: Start the remote example's reconciler + working-directory: examples/reconcilers/remote/endpoint + env: + RECONCILER_TOKEN: inrun-demo-token run: | - for k in $(find examples -name catalog.yaml | sort); do - ./bin/inrun validate --file "$k" + python3 reconciler/main.py > "$RUNNER_TEMP/reconciler.log" 2>&1 & + for i in $(seq 30); do + curl -s -o /dev/null localhost:8025 && exit 0 + sleep 1 done + echo "reconciler did not start"; cat "$RUNNER_TEMP/reconciler.log"; exit 1 - # ── Summary ──────────────────────────────────────────────────────────────── - summary: - name: CI Summary - runs-on: ubuntu-latest - needs: [lint, build, test, validate-examples] - if: always() + - name: Simulate declarative and remote examples + working-directory: examples + run: | + "$RUNNER_TEMP/inrun" simulate ./... --skip typed,intent --dev-server - steps: - - name: Write final summary + - name: Simulate the intent example + working-directory: examples/intent run: | - echo "## CI Summary" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY" - echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY" - echo "| Lint | ${{ needs.lint.result == 'success' && '✅' || '❌' }} ${{ needs.lint.result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| Build | ${{ needs.build.result == 'success' && '✅' || '❌' }} ${{ needs.build.result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| Test | ${{ needs.test.result == 'success' && '✅' || '❌' }} ${{ needs.test.result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| Validate examples | ${{ needs.validate-examples.result == 'success' && '✅' || '❌' }} ${{ needs.validate-examples.result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - - ALL_PASSED="${{ needs.lint.result == 'success' && needs.build.result == 'success' && needs.test.result == 'success' && needs.validate-examples.result == 'success' }}" - if [ "$ALL_PASSED" = "true" ]; then - echo "### ✅ All checks passed — ready to merge" >> "$GITHUB_STEP_SUMMARY" - else - echo "### ❌ Some checks failed — review above" >> "$GITHUB_STEP_SUMMARY" - fi \ No newline at end of file + "$RUNNER_TEMP/inrun" serve play -i intent.json --token dev --simulate=test/simulate.yaml + + vuln-check: + name: Vulnerability check (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [vuln-console, vuln-runtime, vuln-gateway] + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run vulnerability check + run: make ${{ matrix.target }} diff --git a/.github/workflows/package-examples.yml b/.github/workflows/package-examples.yml index d25bbbeee..2e42584eb 100644 --- a/.github/workflows/package-examples.yml +++ b/.github/workflows/package-examples.yml @@ -27,7 +27,7 @@ jobs: mkdir -p dist/examples TAG="${{ github.ref_name }}" - for pack in reconcilers/declarative reconcilers/remote reconcilers/typed intent; do + for pack in reconcilers/declarative reconcilers/remote reconcilers/typed intent migration registry; do tar -czf "dist/examples_$(basename $pack)_${TAG}.tar.gz" -C "examples/$pack" . done @@ -49,10 +49,12 @@ jobs: echo "" >> "$GITHUB_STEP_SUMMARY" echo "| Pack | Description | Archive |" >> "$GITHUB_STEP_SUMMARY" echo "|------|-------------|---------|" >> "$GITHUB_STEP_SUMMARY" - echo "| Declarative | Operators declared in a Catalog | \`examples_declarative_${TAG}.tar.gz\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Declarative | Operators declared in a Blueprint | \`examples_declarative_${TAG}.tar.gz\` |" >> "$GITHUB_STEP_SUMMARY" echo "| Remote | A reconciler in any language over HTTP | \`examples_remote_${TAG}.tar.gz\` |" >> "$GITHUB_STEP_SUMMARY" echo "| Typed | Go hooks, constructor, controller-runtime drop-in | \`examples_typed_${TAG}.tar.gz\` |" >> "$GITHUB_STEP_SUMMARY" echo "| Intent | Intent in through the gateway, view out | \`examples_intent_${TAG}.tar.gz\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Migration | Change the CRD under live CRs | \`examples_migration_${TAG}.tar.gz\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Registry | Consume, publish and deprecate patterns | \`examples_registry_${TAG}.tar.gz\` |" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "**Download from the release assets or run:**" >> "$GITHUB_STEP_SUMMARY" echo '```bash' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-summary.yml b/.github/workflows/release-summary.yml index dcd650ce6..3005fb2dd 100644 --- a/.github/workflows/release-summary.yml +++ b/.github/workflows/release-summary.yml @@ -86,7 +86,7 @@ jobs: echo "| Console binaries | $(icon ${{ inputs.build_result }}) ${{ inputs.build_result }} |" >> "$GITHUB_STEP_SUMMARY" echo "| Example packs | $(icon ${{ inputs.examples_result }}) ${{ inputs.examples_result }} |" >> "$GITHUB_STEP_SUMMARY" echo "| Container images | $(icon ${{ inputs.image_result }}) ${{ inputs.image_result }} |" >> "$GITHUB_STEP_SUMMARY" - echo "| GPG signing + GitHub Release | $(icon ${{ inputs.release_result }}) ${{ inputs.release_result }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Keyless signing + GitHub Release | $(icon ${{ inputs.release_result }}) ${{ inputs.release_result }} |" >> "$GITHUB_STEP_SUMMARY" echo "| Helm chart | $(icon ${{ inputs.helm_result }}) ${{ inputs.helm_result }} |" >> "$GITHUB_STEP_SUMMARY" echo "| Homebrew formulas | $(icon ${{ inputs.homebrew_result }}) ${{ inputs.homebrew_result }} |" >> "$GITHUB_STEP_SUMMARY" echo "| Windows packages | 📦 Download from [GitHub Releases](https://github.com/${REPO}/releases/tag/${TAG}) |" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d57ab40e..1e888586e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,9 @@ jobs: release: name: Sign and Release uses: ./.github/workflows/sign-and-release.yml + permissions: + contents: write + id-token: write needs: [prepare, build, image, examples] secrets: inherit with: diff --git a/.github/workflows/sign-and-release.yml b/.github/workflows/sign-and-release.yml index 9cec1187a..01a97ab79 100644 --- a/.github/workflows/sign-and-release.yml +++ b/.github/workflows/sign-and-release.yml @@ -57,6 +57,9 @@ jobs: release: name: Sign and Release runs-on: ubuntu-latest + permissions: + contents: write + id-token: write steps: - name: Checkout uses: actions/checkout@v6 @@ -69,55 +72,32 @@ jobs: path: dist/ merge-multiple: true - - name: Import GPG signing key - env: - GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} - GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - run: | - echo "${GPG_PRIVATE_KEY}" | gpg --batch --import - FINGERPRINT=$(gpg --list-secret-keys --with-colons releases@${{ inputs.inrun }}.io \ - | grep '^fpr' \ - | head -1 \ - | cut -d: -f10) - echo "${FINGERPRINT}:6:" | gpg --import-ownertrust - echo "✅ GPG key imported: ${FINGERPRINT}" - - - name: Sign tarballs (and ZIPs if present) - env: - GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - run: | - cd dist - for archive in inrun_*.tar.gz inrun-console_*.tar.gz; do - gpg --batch --yes --passphrase "${GPG_PASSPHRASE}" --pinentry-mode loopback --armor --detach-sign "${archive}" - echo "✅ Signed: ${archive}" - done - for zip in inrun_*.zip inrun-console_*.zip; do - [ -f "$zip" ] || continue - gpg --batch --yes --passphrase "${GPG_PASSPHRASE}" --pinentry-mode loopback --armor --detach-sign "${zip}" - echo "✅ Signed: ${zip}" - done - - - name: Export public key - run: | - gpg --armor --export releases@${{ inputs.inrun }}.io > dist/${{ inputs.inrun }}-public-key.asc - echo "✅ Public key exported" + - name: Install cosign + uses: sigstore/cosign-installer@v4 - name: Generate checksums run: | cd dist - sha256sum inrun_*.tar.gz inrun-console_*.tar.gz inrun_*.zip inrun-console_*.zip 2>/dev/null | grep -v "No such file" > checksums.txt + sha256sum inrun_*.tar.gz inrun-console_*.tar.gz inrun_*.zip inrun-console_*.zip examples_*.tar.gz 2>/dev/null | grep -v "No such file" > checksums.txt echo "✅ Checksums generated" - - name: Verify signatures + # Keyless: cosign signs with this workflow's GitHub OIDC identity, so + # there is no key to keep. The bundle carries the signature and the + # certificate; every archive is covered through checksums.txt. + - name: Sign checksums + run: | + cd dist + cosign sign-blob --yes --bundle checksums.txt.sigstore.json checksums.txt + echo "✅ Signed: checksums.txt" + + - name: Verify signature and checksums run: | cd dist - for archive in inrun_*.tar.gz inrun-console_*.tar.gz; do - gpg --verify "${archive}.asc" "${archive}" && echo "✅ Verified: ${archive}" || { echo "❌ Failed: ${archive}"; exit 1; } - done - for zip in inrun_*.zip inrun-console_*.zip; do - [ -f "$zip" ] || continue - gpg --verify "${zip}.asc" "${zip}" && echo "✅ Verified: ${zip}" || { echo "❌ Failed: ${zip}"; exit 1; } - done + cosign verify-blob checksums.txt \ + --bundle checksums.txt.sigstore.json \ + --certificate-identity "https://github.com/${{ github.repository }}/.github/workflows/sign-and-release.yml@${{ github.ref }}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + sha256sum -c checksums.txt - name: Extract changelog run: | @@ -185,35 +165,25 @@ jobs: prerelease: ${{ contains(github.ref_name, '-') }} files: | dist/inrun_linux_amd64.tar.gz - dist/inrun_linux_amd64.tar.gz.asc dist/inrun_linux_arm64.tar.gz - dist/inrun_linux_arm64.tar.gz.asc dist/inrun_darwin_amd64.tar.gz - dist/inrun_darwin_amd64.tar.gz.asc dist/inrun_darwin_arm64.tar.gz - dist/inrun_darwin_arm64.tar.gz.asc dist/inrun_windows_amd64.tar.gz - dist/inrun_windows_amd64.tar.gz.asc dist/inrun_windows_amd64.zip - dist/inrun_windows_amd64.zip.asc dist/inrun-console_linux_amd64.tar.gz - dist/inrun-console_linux_amd64.tar.gz.asc dist/inrun-console_linux_arm64.tar.gz - dist/inrun-console_linux_arm64.tar.gz.asc dist/inrun-console_darwin_amd64.tar.gz - dist/inrun-console_darwin_amd64.tar.gz.asc dist/inrun-console_darwin_arm64.tar.gz - dist/inrun-console_darwin_arm64.tar.gz.asc dist/inrun-console_windows_amd64.tar.gz - dist/inrun-console_windows_amd64.tar.gz.asc dist/inrun-console_windows_amd64.zip - dist/inrun-console_windows_amd64.zip.asc dist/checksums.txt - dist/${{ inputs.inrun }}-public-key.asc + dist/checksums.txt.sigstore.json dist/examples_declarative_${{ github.ref_name }}.tar.gz dist/examples_remote_${{ github.ref_name }}.tar.gz dist/examples_typed_${{ github.ref_name }}.tar.gz dist/examples_intent_${{ github.ref_name }}.tar.gz + dist/examples_migration_${{ github.ref_name }}.tar.gz + dist/examples_registry_${{ github.ref_name }}.tar.gz fail_on_unmatched_files: true - name: Upload checksums as artifact diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml deleted file mode 100644 index 9d9feff04..000000000 --- a/.github/workflows/validate-pr.yml +++ /dev/null @@ -1,292 +0,0 @@ -# .github/workflows/validate-pr.yml -name: Validate Pull Request - -on: - workflow_dispatch: - pull_request: - types: [opened, synchronize, reopened] - paths: - - 'cmd/**' - - 'pkg/**' - - 'domain/**' - - 'examples/**' - - 'go.mod' - - 'go.sum' - -jobs: - validate-notes: - name: Validate generated notes - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Run note generator - run: make generate-notes - - - name: Check for uncommitted changes (list_generated.go) - run: | - if ! git diff --quiet pkg/note/list_generated.go; then - echo "❌ pkg/note/list_generated.go is out of date." - echo "Run 'make generate-notes' locally and commit the changes." - exit 1 - else - echo "✅ Note list is up-to-date." - fi - - validate-resource-docs: - name: Validate generated resource docs - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Run resource docs generator - run: make generate-resource-docs - - - name: Check for uncommitted changes (docs/reference/schema/06-resources) - run: | - if ! git diff --quiet docs/reference/schema/06-resources/; then - echo "❌ docs/reference/schema/06-resources/ is out of date." - echo "Run 'make generate-resource-docs' locally and commit the changes." - exit 1 - else - echo "✅ Resource docs are up-to-date." - fi - - validate-code: - name: Run static checks - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Run tests (no cluster required) - run: make test - - # Runs every example's simulate.yaml against the CLI built from this - # checkout, the way each example's README tells users to. No cluster. - # Typed examples are skipped: each builds its own binary from a nested - # module, which needs a published Inrun version. - simulate-examples: - name: Simulate examples - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Build inrun - run: go build -o "$RUNNER_TEMP/inrun" ./cmd/inrun - - - name: Start the remote example's reconciler - working-directory: examples/reconcilers/remote - env: - RECONCILER_TOKEN: inrun-demo-token - run: | - python3 reconciler/main.py > "$RUNNER_TEMP/reconciler.log" 2>&1 & - for i in $(seq 30); do - curl -s -o /dev/null localhost:8025 && exit 0 - sleep 1 - done - echo "reconciler did not start"; cat "$RUNNER_TEMP/reconciler.log"; exit 1 - - - name: Simulate declarative and remote examples - working-directory: examples - run: | - "$RUNNER_TEMP/inrun" simulate ./... --skip typed,intent - - - name: Simulate the intent example - working-directory: examples/intent - run: | - "$RUNNER_TEMP/inrun" serve play -i intent.json --token dev --simulate=test/simulate.yaml - - # Runs as its own job, in parallel with validate-code, so a slow vuln DB - # fetch doesn't add to the critical path of the tests everyone's actually - # waiting on. Matrix so all three targets also run in parallel with each - # other, and so a failure names exactly which one. - # - # Scoped to what actually ships: runtime, gateway, and Console. - # vuln-inrun scans the dev-CLI build config (no tags — never deployed) - # and is manually triggered instead, from vuln-inrun.yml — that's also - # where the known, accepted findings live (see the comment above `vuln` in - # the Makefile); none of them reach these three, so this is a hard gate — - # no continue-on-error. - vuln-check: - name: Vulnerability check (${{ matrix.target }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - target: [vuln-console, vuln-runtime, vuln-gateway] - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Run vulnerability check - run: make ${{ matrix.target }} - - # ── Fixture: note family ──────────────────────────────────────────────────── - # Runs only when the note package or its fixture changes. - # Exercises every kubernetes-family note against a real API server. - fixture-note: - name: Fixture — note (kind cluster) - runs-on: ubuntu-latest - if: > - github.event_name == 'workflow_dispatch' || - contains(github.event.pull_request.changed_files, 'pkg/note/') - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Build inrun - run: make inrun - - - name: Create kind cluster - uses: helm/kind-action@v1 - with: - cluster_name: inrun-note-fixture - - - name: Apply CRD - run: kubectl apply -f pkg/note/fixture/crd.yaml - - - name: Generate and apply bundle - run: | - $HOME/.inrun/bin/inrun bundle \ - --file pkg/note/fixture/catalog.yaml | kubectl apply -f - - - - name: Install Inrun via Helm - run: | - helm install inrun ./charts/inrun \ - --namespace default \ - --wait --timeout 120s - - - name: Apply CR - run: kubectl apply -f pkg/note/fixture/cr.yaml - - - name: Wait for status - run: | - kubectl wait noteprobe/my-probe \ - --for=jsonpath='{.status.phase}'=Ready \ - --timeout=120s - - - name: Assert note fields populated - run: | - kubectl get noteprobe my-probe -o yaml | tee /tmp/probe.yaml - # Fail if phase is empty — means status is not being written - PHASE=$(kubectl get noteprobe my-probe -o jsonpath='{.status.phase}') - [ -n "$PHASE" ] || (echo "❌ status.phase is empty"; exit 1) - echo "✅ Note fixture passed — phase: $PHASE" - - - name: Debug on failure - if: failure() - run: | - echo "=== ReconcilerProbe ===" && kubectl get noteprobe my-probe -o yaml || true - echo "=== Operator logs ===" && kubectl logs -l app.kubernetes.io/name=inrun --tail=100 || true - - # ── Fixture: reconciler run_* ─────────────────────────────────────────────── - # Runs only when the reconciler package or its fixture changes. - # Exercises every run_* resource type in a single reconcile cycle. - fixture-reconciler: - name: Fixture — reconciler run_* (kind cluster) - runs-on: ubuntu-latest - if: > - github.event_name == 'workflow_dispatch' || - contains(github.event.pull_request.changed_files, 'pkg/runtime/reconciler/') - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - cache: true - - - name: Build inrun - run: make inrun - - - name: Create kind cluster - uses: helm/kind-action@v1 - with: - cluster_name: inrun-reconciler-fixture - - - name: Apply CRD - run: kubectl apply -f pkg/runtime/reconciler/fixture/crd.yaml - - - name: Generate and apply bundle - run: | - $HOME/.inrun/bin/inrun bundle \ - --file pkg/runtime/reconciler/fixture/catalog.yaml | kubectl apply -f - - - - name: Install Inrun via Helm - run: | - helm install inrun ./charts/inrun \ - --namespace default \ - --wait --timeout 120s - - - name: Apply CR - run: kubectl apply -f pkg/runtime/reconciler/fixture/cr.yaml - - - name: Wait for status - run: | - kubectl wait reconcilerprobe/probe \ - --for=jsonpath='{.status.tier}'=premium \ - --timeout=120s - - - name: Assert all run_* resources created - run: | - kubectl get deployment probe-app || (echo "❌ probe-app deployment missing"; exit 1) - kubectl get deployment probe-premium || (echo "❌ probe-premium deployment missing"; exit 1) - kubectl get service probe-svc || (echo "❌ probe-svc service missing"; exit 1) - kubectl get configmap probe-config || (echo "❌ probe-config configmap missing"; exit 1) - kubectl get secret probe-creds || (echo "❌ probe-creds secret missing"; exit 1) - kubectl get serviceaccount probe-sa || (echo "❌ probe-sa serviceaccount missing"; exit 1) - kubectl get cronjob probe-cron || (echo "❌ probe-cron cronjob missing"; exit 1) - echo "✅ Reconciler fixture passed — all run_* resources present" - - - name: Debug on failure - if: failure() - run: | - echo "=== ReconcilerProbe ===" && kubectl get reconcilerprobe probe -o yaml || true - echo "=== All resources in default ===" && kubectl get all || true - echo "=== Operator logs ===" && kubectl logs -l app.kubernetes.io/name=inrun --tail=100 || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d16eba1..d1c92a484 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,94 +2,10 @@ Inrun follows [Semantic Versioning](https://semver.org/). Until `v1.0`, minor versions may contain breaking changes. -## Unreleased +## v0.1.0 -Inrun is the intent runner that began as Orkestra. This first release narrows it to one loop: an intent arrives through the gateway, becomes a custom resource, is reconciled, and returns to the caller as a view. +Orkestra becomes Inrun, and versions start again at `v0.1.0`. -### Highlights +Everything Orkestra named is renamed: the CLI is `inrun`, the API group, labels, annotations and finalizers are `inrun.dev`, and Katalog, Komposer and Motif are Blueprint, Stack and Module. Resources created by Orkestra are not adopted. -- **One reconciler contract.** Hooks, constructors and remote reconcilers now receive the same request and return the same result: status, resources, labels and annotations, a requeue, or a cleanup. Inrun performs every write and checks each resource against the Catalog's `managedResources` first. Declarative reconcilers keep creating resources from their templates. -- **Args in the reconciler's own vocabulary.** `reconcile.args` maps fields of the custom resource onto the names your reconciler uses, rendered on every reconcile. `reconcile.external` calls run beforehand, so args can carry their results. -- **Cleanup from code.** A reconciler can ask for its resource to be deleted after a grace period. Requeues and cleanup are independent; whichever is due first acts, and the reconciler keeps running in between. -- **Typed Catalogs through the stock gateway.** The gateway no longer needs compiled types, so one gateway image serves any Catalog. - -### Upgrading from Orkestra - -Resources created by Orkestra are not adopted. Rename as follows, then -redeploy. - -| Orkestra | Inrun | -| --- | --- | -| Go module, CLI, console | `github.com/intentd/inrun`, `inrun`, `inrun-console` | -| Images | `ghcr.io/intentd/inrun`, `inrun-gateway`, `inrun-console` | -| Helm chart | `inrun`, starting at `0.1.0` | -| API group, labels, annotations, finalizers | `inrun.dev` | -| Environment variables | `INRUN_*` | -| `Katalog`, `Komposer`, `Motif` | `Catalog`, `Stack`, `Module` | -| `inrun run` | `inrun` with no subcommand | -| Control Center | `inrun console` | -| `hooks.args`, `constructor.args`, `remote.args` | `reconcile.args` | -| `managedResources` under `hooks`, `constructor` or `remote` | `reconcile.managedResources` | -| `hooks.external` | `reconcile.external` | -| `reconcile.remote.payload` | `reconcile.remote.request` | -| `runtime.cleanup.deleteAfter` | `runtime.cleanup.after` | - -**Typed reconcilers (Go):** - -- Hooks have a new signature: `OnReconcile(ctx, req, obj) (domain.Result, error)`, and the same for - `OnDelete`. `OnNotFound` is removed. -- Read args from `req.Args`. `kube.Args()`, `WithArgs` and `ScopedFor` are removed; `kubeclient.Args` is now `domain.Args`, and `BindArgs` is `Bind`. -- `domain.Result.StatusPatch` is now `Status`. - -**Remote reconcilers:** - -- `result` is `ok` or `error`. Ask for a requeue with the `requeue` field (`{"after": "60s", "reason": "…"}`) or the `requeueAfter` shorthand. -- Return resources instead of applying them. Inrun applies them under the field manager `inrun-reconciler`, which was `inrun-remote`. -- The request body is `key`, `apiVersion`, `kind`, `target`, `object`, `args` and `children`. `gvk` is replaced by `apiVersion` and `kind`, `children` moves up from `prepared`, and `prepared` is gone: `object` is the only copy of the CR, so `request.object.exclude` hides what it names. - -**Helm:** point `catalog.existingConfigMap` at the ConfigMap from `inrun generate bundle`. The inline Catalog values are removed. - -### Added - -- `inrun` also starts the console on :8081, and the gateway on :8082 when the Catalog serves a CRD, so one command runs the whole loop locally; the console can create resources through that gateway. Set `INRUN_CONSOLE_PORT` or `INRUN_GATEWAY_PORT` to move them. Either one failing to start is logged; the runtime runs regardless. -- The console honours `--log-level`; it printed every request and fetch. -- The gateway's `/api/v1/apply` takes YAML as well as JSON, like an intent file for `inrun serve play`. Both decode to the same values, so an intent builds the same CR in either format. -- A Simulate spec can name an intent instead of a CR (`intent`, with the `token` it is sent with): `inrun simulate` builds the CR the way the gateway does, then reconciles it. `inrun serve play` takes `--envtest`, and `--envtest` installs the Catalog's `crdFile`s when a spec lists no CRDs. -- Reconcilers can return labels and annotations for their resource. Inrun applies them with server-side apply: a key the reconciler stops returning is removed, and keys set by anyone else are left alone. The `inrun.dev` domain is reserved. -- Requeues carry a reason, shown in the logs. -- Typed reconcilers can return resources, in full or intent form. -- The CR lifecycle moves from the generic reconciler into the runtime, so every reconciler gets it: the cleanup finalizer and a finalizing reconcile (`req.Finalizing()`; remote: `"finalizing": true`), deletion of children owner references cannot reach, the serve-target sweep, and `Reconciled`/`ReconcileError` events. -- `runtime.finalizers.timeout` releases a CR still finalizing after that long, with a `FinalizeTimeout` warning. -- `reconcile.prune` deletes children a reconciler applied before and no longer returns. It is off unless `prune.enabled` is true, and a serve target can set its own. Only children labelled with the CR's UID (`inrun.dev/owner-uid`) and applied by `inrun-reconciler` are candidates; `prune.keep` spares kinds such as PersistentVolumeClaims, an empty Result prunes nothing unless `prune.allowEmpty` is set, and nothing is pruned when the reconcile or an apply fails. Each deletion emits a `Pruned` event. -- Intent form covers `namespace` and `pdb`, besides `deployment`, `statefulset`, `service`, `ingress`, `configmap`, `secret`, `serviceaccount`, `job`, `cronjob` and `custom`. -- `inrun simulate` runs remote reconcilers against their endpoint. -- `inrun e2e --chart` installs a local chart, to test unreleased changes end to end. -- `inrun generate bundle`, `configmap` and `rbac` accept Catalogs that use `crdFile`. -- One project layout: `catalog.yaml` at the root, manifests in `manifests/`, and tests in `test/`. `inrun init`, `create pattern` and `migrate` write it, and `inrun simulate` and `inrun e2e` read it by default. -- Examples in four packs: declarative, remote, typed and intent. - -### Changed - -- `reconcile.args`, `reconcile.managedResources` and `reconcile.external` are rejected on declarative-only CRDs. -- Constructor args are rendered as templates, like hook args. -- A serve target's `args` merge over the CRD's; its `managedResources` and `external` replace them. - -### Removed - -- `inrun validate --play`: a Simulate spec that gets its CR from an intent says so with `spec.intent`. -- Features that don't serve the intent loop. They remain on the `archive` branch. - -### Fixed - -- A reconciler that returns resources was called again after every reconcile: each apply stamped the resources' `inrun.dev/managed-since` with the current time, updating them. They now carry their owner's value, so an unchanged resource is left untouched. -- Validation and mutation rules can target label and annotation keys with dots: escape them, as in `metadata.labels.app\.kubernetes\.io/name`. Such keys used to be split into nested fields. -- Admission mutations patch keys containing `/` (such as `example.com/owner`) and objects without a `labels` or `annotations` map. -- A serve target's own `onCreate`, `onReconcile` and `onDelete` templates were ignored when the CRD also had a `reconcile` block. -- `inrun simulate` ignored the Simulate's `target`, so per-target hooks never ran. -- `inrun simulate` dropped the status a reconciler returned. -- `inrun e2e` repeated mutations such as `patch` and `scale` on every retry, and its teardown could hang on a resource whose controller was gone. - -## Earlier versions - -Inrun's history before the rename is Orkestra's: see the `archive` branch and -the `orkestra-v0.7.18` tag. +Orkestra's history is on the `archive` branch and the `orkestra-v0.7.18` tag. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 56ee5b5f6..c55685d98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,78 +1,7 @@ # Contributing to Inrun -Thank you for your interest in contributing to Inrun. +Any help is welcome: a bug report, a docs fix, an example, code. The guide is +[docs/contributing](docs/contributing/index.md): ways to help, setting up, +and how pull requests work. -Inrun is the intent runner for Kubernetes. A caller sends an intent through the gateway; Inrun turns it into a custom resource, reconciles it, and returns a view. - ---- - -## Start here - -Full contribution guides live in [`docs/contributing/`](docs/contributing/): - -| Guide | What it covers | -|-------|---------------| -| [index](docs/contributing/index.md) | Overview and where to go | -| [Codebase map](docs/contributing/codebase-map.md) | Which packages belong to which binary; how to navigate the code | -| [Resources](docs/contributing/contributing-resources.md) | Add or improve resource types in `pkg/resources` | -| [Console](docs/contributing/contributing-console.md) | Improve the web UI — metrics, CR status, multi-instance views | -| [Examples](docs/contributing/contributing-examples.md) | Add example operator packs | -| [Publishing a new pack](docs/contributing/publishing-a-new-pack.md) | Exact checklist for adding a new examples pack without breaking CI | - ---- - -## Quick start - -```bash -# Clone -git clone https://github.com/intentd/inrun.git -cd inrun - -# Build (runs codegen + gofmt + go build) -make inrun - -# Test -make test -``` - ---- - -## Pull requests - -1. Fork and create a branch from `main`. -2. Read the relevant guide above. -3. Run `make test-race` and confirm it passes (vet + unit tests + race detector). -4. Open a PR with a short description of what and why. Link any related issues. - ---- - -## Commit messages - -Follow [Conventional Commits](https://www.conventionalcommits.org/): - -``` -feat(registry): add DaemonSet handler -fix(reconciler): correct window-based rollback trigger -docs(contributing): add resources contribution guide -``` - ---- - -## Code style - -- `gofmt` is enforced by CI. -- Exported functions must have a doc comment. -- Wrap errors with context: `fmt.Errorf("creating deployment: %w", err)`. -- No comments that describe *what* the code does — only *why* when the reason is non-obvious. - ---- - -## Code of Conduct - -This project follows the [Inrun Code of Conduct](CODE_OF_CONDUCT.md). Report unacceptable behaviour to conduct@inrun.dev. - ---- - -## Questions? - -Open a [GitHub Discussion](https://github.com/intentd/inrun/discussions). +Questions go to [GitHub Discussions](https://github.com/intentd/inrun/discussions). diff --git a/Makefile b/Makefile index 1dc527969..f06b1408d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build inrun-console clean test test-unit test-console test-race test-integration test-all test-coverage test-coverage-text vet vuln vuln-inrun vuln-console vuln-runtime vuln-gateway certs docs docs-sync docs-build docs-serve hugo-install generate-notes generate-e2e-example generate-resource-docs test-fixture-note test-fixture-reconciler inrun-gateway-linux docker-gateway gateway-reload runtime-reload console-reload reload docker-devserver release-devserver devserver-reload +.PHONY: build inrun-console clean test test-unit test-console test-race test-integration test-all test-coverage test-coverage-text vet vuln vuln-inrun vuln-console vuln-runtime vuln-gateway certs docs docs-sync docs-build docs-serve hugo-install generate-functions generate-e2e-example generate-resource-docs test-fixture-reconciler inrun-gateway-linux docker-gateway gateway-reload runtime-reload console-reload reload docker-devserver release-devserver devserver-reload # ── Configuration ──────────────────────────────────────────────────────────── INRUN_DIR := . @@ -20,22 +20,22 @@ CONSOLE_LDFLAGS := -X github.com/intentd/inrun/console/version.Version=$(GIT_VE -X github.com/intentd/inrun/console/version.Date=$(GIT_DATE) # ── Local build ─────────────────────────────────────────────────────────── -generate-notes: - @echo "Generating note list..." - go run ./hack/generate-notes - @echo "✅ Note list generated at pkg/note/list_generated.go" +generate-functions: + @echo "Generating Inrun function registry and reference pages..." + go run ./hack/generate-functions + @echo "✅ Generated pkg/template/funcs/funcs_generated.go and docs/reference/inrun-functions" generate-e2e-example: @echo "Generating e2e complete example doc..." @bash scripts/generate-e2e-example.sh - @echo "✅ docs/reference/schema/04-e2e/08-complete-example.md updated" + @echo "✅ docs/reference/schema/e2e/complete-example.md updated" generate-resource-docs: @echo "Generating resource schema docs..." go run ./hack/generate-resource-docs - @echo "✅ docs/reference/schema/06-resources/*.md updated" + @echo "✅ docs/reference/schema/resources/*.md updated" -inrun: generate-notes generate-e2e-example generate-resource-docs +inrun: generate-functions generate-e2e-example generate-resource-docs @echo "Building Inrun..." @mkdir -p $(OUTPUT_DIR) cd $(INRUN_DIR) && gofmt -w . @@ -108,7 +108,7 @@ BIN := $(OUTPUT_DIR)/inrun # ── Linux Build Targets (for Docker) ────────────────────────────────────────── -inrun-linux: generate-notes +inrun-linux: generate-functions @echo "Building Inrun (Linux amd64)..." @mkdir -p $(OUTPUT_DIR) gofmt -w . && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build \ @@ -123,7 +123,7 @@ inrun-console-linux: cd $(CONSOLE_DIR) && gofmt -w . && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o $(OUTPUT_DIR)/inrun-console . @echo "✅ Linux Console binary built: $(OUTPUT_DIR)/inrun-console" -inrun-gateway-linux: generate-notes +inrun-gateway-linux: generate-functions @echo "Building Inrun Gateway (Linux amd64)..." @mkdir -p $(OUTPUT_DIR) gofmt -w . && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build \ @@ -136,7 +136,7 @@ inrun-gateway-linux: generate-notes # These targets build Linux binaries to DOCKER_TMP (not OUTPUT_DIR) so they # never overwrite the developer's local inrun CLI binary in ~/.inrun/bin. -docker: generate-notes +docker: generate-functions @mkdir -p $(DOCKER_TMP) @echo "Building Inrun runtime (Linux amd64) → $(DOCKER_TMP)/inrun..." gofmt -w . && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build \ @@ -161,7 +161,7 @@ docker-console: @rm -f $(DOCKER_TMP)/inrun-console @echo "✔ Docker image built: $(INRUN_CONSOLE_IMAGE)" -docker-gateway: generate-notes +docker-gateway: generate-functions @mkdir -p $(DOCKER_TMP) @echo "Building Inrun gateway (Linux amd64) → $(DOCKER_TMP)/inrun-gateway..." gofmt -w . && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build \ @@ -365,34 +365,11 @@ test-coverage-text: go test ./pkg/... -coverprofile=coverage.out -covermode=atomic -count=1 @go tool cover -func=coverage.out | tail -5 -# ── Fixture tests (kind cluster required) ──────────────────────────────────── -# Each target spins up a dedicated kind cluster, installs Inrun via Helm, -# applies the fixture catalog + CR, asserts resources exist, then tears down. -# Requires: kind, helm, kubectl, inrun — all on $PATH. - -FIXTURE_NOTE_CLUSTER := inrun-note-fixture -FIXTURE_RECONCILER_CLUSTER := inrun-reconciler-fixture -HELM_CHART := ./charts/inrun - -test-fixture-note: - @echo "── Note fixture test ──────────────────────────────────────────" - @bash scripts/setup-kind.sh $(FIXTURE_NOTE_CLUSTER) - @kubectl apply -f pkg/note/fixture/crd.yaml - @inrun generate bundle --file pkg/note/fixture/catalog.yaml | kubectl apply -f - - @helm install inrun $(HELM_CHART) --namespace inrun-system --wait --timeout 120s - @kubectl apply -f pkg/note/fixture/cr.yaml - @kubectl wait reconcilerprobe/my-probe --for=jsonpath='{.status.phase}'=Ready \ - --timeout=120s 2>/dev/null || kubectl wait noteprobe/my-probe \ - --for=condition=Ready=true --timeout=120s || \ - kubectl get noteprobe my-probe -o yaml - @echo "✅ Note fixture passed" - @bash scripts/setup-kind.sh delete $(FIXTURE_NOTE_CLUSTER) - test-fixture-reconciler: @echo "── Reconciler fixture test ────────────────────────────────────" @bash scripts/setup-kind.sh $(FIXTURE_RECONCILER_CLUSTER) @kubectl apply -f pkg/runtime/reconciler/fixture/crd.yaml - @inrun generate bundle --file pkg/runtime/reconciler/fixture/catalog.yaml | kubectl apply -f - + @inrun generate bundle --file pkg/runtime/reconciler/fixture/blueprint.yaml | kubectl apply -f - @helm install inrun $(HELM_CHART) --namespace inrun-system --wait --timeout 120s @kubectl apply -f pkg/runtime/reconciler/fixture/cr.yaml @kubectl wait reconcilerprobe/probe --for=jsonpath='{.status.tier}'=premium \ @@ -469,7 +446,7 @@ vet: # (pkg/registry/client.go, pkg/registry/module/pull.go) are both build-tag # excluded from the runtime and gateway binaries — see the comments atop # those files. Both are authoring-time-only (inrun push/pull/generate bundle); -# the runtime and gateway only ever read an already-merged catalog.yaml key +# the runtime and gateway only ever read an already-merged blueprint.yaml key # from a ConfigMap. validate-pr.yml's vuln-check (console/runtime/ # gateway) has no known findings left to accept — it runs as a hard gate. # @@ -480,7 +457,7 @@ vet: # NewPullWithOpts starts from a zero-value Pull{} (Verify defaults to # false), and Inrun's own call site never sets pull.Verify = true — # so the verification code path that would invoke openpgp is never -# exercised, regardless of what a Catalog's HelmSource points at. +# exercised, regardless of what a Blueprint's HelmSource points at. # - GO-2026-5622 / GO-2026-5338 / GO-2026-5064 (github.com/containerd/containerd, # no fix yet): all three are specifically about CRI checkpoint/restore, a # container-runtime feature nothing in this codebase calls. containerd is diff --git a/README.md b/README.md index 30ec74652..ded8ada1d 100644 --- a/README.md +++ b/README.md @@ -4,63 +4,113 @@

Run intents on Kubernetes.

+ CI Release Artifact Hub - Go - Kubernetes + Go 1.27+ + Kubernetes 1.37 License

-A caller sends an **intent**: a small JSON payload in their own words, with no -`apiVersion`, `kind` or `spec`. Inrun turns it into a custom resource, -reconciles it, and gives the caller back a **view** of the result. +Kubernetes reconciliation is battle-tested and free. Using it is expensive: a purpose-built operator, Kubernetes expertise, and maintenance for as long as it runs. So developers leave it to platform engineers. + +Inrun opens it to anyone who has written a service that takes a request and returns a response. + +You bring the decision. Inrun brings the machinery. + +## How it works + +A caller sends an **intent**: a small payload in their own words, with no `apiVersion`, `kind` or `spec`. Inrun turns it into a custom resource, reconciles it, and gives the caller back a **view** of the result. ```console intent ──▶ gateway ──▶ custom resource ──▶ runtime ──▶ reconcile ──▶ status ──▶ view ``` -How a resource is reconciled is up to you: +**The reconciler decides. Inrun writes.** Inrun runs the informers, queues, retries, leader election, server-side apply, owner references, finalizers, status, events, cleanup and metrics. How a resource is reconciled is up to you: -- **Declarative:** describe the resources in a Catalog; no code. -- **Remote:** any HTTP endpoint, in any language, returns what should exist. +- **Declarative:** describe the resources in a Blueprint. No code. +- **Remote:** any HTTP service, in any language, returns what should exist. +- **Existing service:** an API that knows nothing about Inrun reconciles as it is. Inrun calls it once per reconcile and maps its answer into status. - **Typed:** Go hooks, a full reconciler, or an unchanged controller-runtime `Reconcile`. -Inrun runs the informers, queues, workers, retries, leader election, status, -cleanup and metrics. +Declarative, remote and typed reconcilers take the same request and return the same result: status, resources, labels and annotations, a requeue, or a cleanup. -> **Status:** pre-release. The first release, `v0.1.0`, is in preparation, and -> the API may still change before `v1.0`. +## What comes with it + +- **Operators as data.** A Blueprint is the operator. It's versioned and published to an OCI registry, carrying its own simulate and e2e results and the tests that produced them. `inrun inspect` shows them before you install anything; `inrun e2e` runs them again on a kind cluster it brings and tears down, so checking the claim needs nothing but Docker. See [examples/registry](examples/registry/). +- **Callers apart from the schema.** Callers send intents; the CRD stays an internal detail. When a field moves, `migrate.from` brings stored resources to the new shape with the same mapping the gateway applies; when the CRD itself changes (a new kind, group or scope), `migrate.to` moves them to it. Either way, callers never notice. See [examples/migration](examples/migration/). +- **Kubernetes as a backend.** A reconciler doesn't have to create anything: the custom resource can be a durable state machine, cleaned up when it's done. See [examples/reconcilers/remote/existing-service-ids](examples/reconcilers/remote/existing-service-ids/). + +## Install + +```bash +brew install intentd/tap/inrun +# or +curl -sSL https://get.inrun.dev | bash +``` ## Try it -Build the CLI from source (Go 1.26+): +Two commands to your first operator: ```bash -go build -o inrun ./cmd/inrun +inrun init # a hello-website operator in the current directory +inrun # run it on your current cluster; the console opens on :8081 ``` -Then run the first example against a cluster in your current kubeconfig: +No cluster? Add `--dev` to create a temporary kind cluster (needs Docker). Or check it without one, in memory: ```bash -cd examples/reconcilers/declarative/01-hello-website +inrun simulate ``` +Or start from a published pattern: + ```bash -inrun +inrun inspect postgres:v0.1.0 # version, description, test results +inrun postgres:v0.1.0 --apply-cr --dev # run it - all batteries included ``` +`postgres:v0.1.0` is short for `ghcr.io/intentd/registry/patterns/blueprints/postgres:v0.1.0`. Patterns can live in any OCI registry: set `INRUN_REGISTRY` to use your own. The published ones are built from [inrun-registry](https://github.com/intentd/inrun-registry). + ## Examples -| Pack | Shows | -| --- | --- | -| [declarative](examples/reconcilers/declarative/) | Operators declared in a Catalog. Start here. | -| [remote](examples/reconcilers/remote/) | A reconciler in any language, called over HTTP | -| [typed](examples/reconcilers/typed/) | Go hooks, a constructor, and a controller-runtime drop-in | -| [intent](examples/intent/) | Intent in through the gateway, view out | +Examples come in packs. Copy one into a new project with `inrun init --pack`: + +```bash +inrun init --pack declarative # Start here: operators declared in a Blueprint, no code +inrun init --pack remote # A reconciler in any language, called over HTTP, including an existing service +inrun init --pack typed # Go: hooks, a constructor, and a controller-runtime drop-in +inrun init --pack intent # Intent in through the gateway, view out +inrun init --pack migration # Change the CRD under live CRs: move fields, or move CRs to another CRD +inrun init --pack registry # Share operators as patterns: consume one, publish your own, deprecate it +``` + +`inrun init --list` shows them all. Each pack is a folder of examples; for any of them: + +```bash +cd declarative/01-hello-website +inrun # run it +inrun simulate # check it without a cluster +inrun simulate --envtest # the same against a real API server and etcd, still no cluster +inrun e2e # test it end to end on a kind cluster it creates and removes +``` + +Each example's README is its guide. + +## Built on -Each example can be checked without a cluster (`inrun simulate`) and end to -end on kind (`inrun e2e`). +Two libraries that came out of Inrun and stand on their own: + +- [KEL](https://github.com/intentd/kel): functions that know Kubernetes, for reading state in templates and Go. +- [kubewright](https://github.com/intentd/kubewright): building and writing an owner's child resources. + +## Status + +`v0.1.0` is the first release. The API may still change before `v1.0`; breaking changes are listed in [CHANGELOG.md](CHANGELOG.md). + +Inrun began as Orkestra. Its history is on the `archive` branch and the `orkestra-v0.7.18` tag. ## Development @@ -68,13 +118,7 @@ end on kind (`inrun e2e`). make test ``` -See [tests/README.md](tests/README.md) for the test tiers and -[docs/contributing](docs/contributing/) for how the code is laid out. - -## History - -Inrun began as Orkestra. Its history is on the `archive` branch and the -`orkestra-v0.7.18` tag. +See [tests/README.md](tests/README.md) for the test tiers and [docs/contributing](docs/contributing/) for how the code is laid out. ## License diff --git a/SECURITY.md b/SECURITY.md index 56c099d98..0758040dd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -43,6 +43,6 @@ We will acknowledge your report within **72 hours**. ## What Is *Not* Considered a Security Issue? - Misconfigured RBAC in user clusters -- Incorrect Catalog definitions +- Incorrect Blueprint definitions - Expected Kubernetes behavior - Resource exhaustion caused by user‑defined CRDs diff --git a/charts/examples/platform-catalog/Chart.yaml b/charts/examples/platform-blueprint/Chart.yaml similarity index 77% rename from charts/examples/platform-catalog/Chart.yaml rename to charts/examples/platform-blueprint/Chart.yaml index 6a68c78c4..bdaec9a73 100644 --- a/charts/examples/platform-catalog/Chart.yaml +++ b/charts/examples/platform-blueprint/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v1 -name: platform-catalog +name: platform-blueprint description: >- - Example Helm chart that renders an Inrun Catalog. + Example Helm chart that renders an Inrun Blueprint. Demonstrates how to ship CRD definitions as a Helm chart so teams can consume them via sources.helm in their Stack. type: application @@ -10,7 +10,7 @@ appVersion: "1.0.0" keywords: - inrun - stack - - catalog + - blueprint - operator home: https://github.com/intentd/inrun maintainers: diff --git a/charts/examples/platform-blueprint/README.md b/charts/examples/platform-blueprint/README.md new file mode 100644 index 000000000..0742ca808 --- /dev/null +++ b/charts/examples/platform-blueprint/README.md @@ -0,0 +1,23 @@ +# platform-blueprint + +A Helm chart that renders an Inrun Blueprint and nothing else: no CRDs, no +operator. A Stack pulls it in through `sources.helm`. + +## Values + +| Field | Default | Description | +| --- | --- | --- | +| `apiGroup` | `demo.inrun.dev` | API group for every CRD | +| `apiVersion` | `v1alpha1` | API version for every CRD | +| `crds` | `database`, `cache` | Map of CRD name to `kind`, `plural`, `namespaced`, `enabled`, `reconcile.workers`, `reconcile.resync`, `description` | + +## Use it from a Stack + +```yaml +sources: + helm: + - repo: https://github.com/intentd/inrun.git + path: charts/examples/platform-blueprint + chart: platform-blueprint + version: main +``` diff --git a/charts/examples/platform-catalog/cr.yaml b/charts/examples/platform-blueprint/cr.yaml similarity index 100% rename from charts/examples/platform-catalog/cr.yaml rename to charts/examples/platform-blueprint/cr.yaml diff --git a/charts/examples/platform-catalog/crd.yaml b/charts/examples/platform-blueprint/crd.yaml similarity index 100% rename from charts/examples/platform-catalog/crd.yaml rename to charts/examples/platform-blueprint/crd.yaml diff --git a/charts/examples/platform-blueprint/templates/blueprint.yaml b/charts/examples/platform-blueprint/templates/blueprint.yaml new file mode 100644 index 000000000..2eb0b4742 --- /dev/null +++ b/charts/examples/platform-blueprint/templates/blueprint.yaml @@ -0,0 +1,33 @@ +apiVersion: inrun.dev/v1 +kind: Blueprint +metadata: + name: {{ .Release.Name }}-blueprint + version: 0.1.0 + author: intentd + description: > + blueprint.yaml Helm template — renders an Inrun Blueprint from Helm values. + Inrun's merger reads this template's output and extracts CRD definitions. + The chart must render at least one template with kind Blueprint. + tags: + - helm + - composition + - data + +spec: + crds: + {{- range $name, $crd := .Values.crds }} + {{ $name }}: + enabled: {{ if hasKey $crd "enabled" }}{{ $crd.enabled }}{{ else }}true{{ end }} + namespaced: {{ if hasKey $crd "namespaced" }}{{ $crd.namespaced }}{{ else }}true{{ end }} + operatorBox: + reconcile: + workers: {{ $crd.reconcile.workers | default 2 }} + resync: {{ $crd.reconcile.resync | default "30s" }} + + apiTypes: + group: {{ $.Values.apiGroup }} + version: {{ $.Values.apiVersion | default "v1alpha1" }} + kind: {{ $crd.kind }} + plural: {{ $crd.plural }} + description: {{ $crd.description | default "" | quote }} + {{- end }} \ No newline at end of file diff --git a/charts/examples/platform-blueprint/values.yaml b/charts/examples/platform-blueprint/values.yaml new file mode 100644 index 000000000..313d949c2 --- /dev/null +++ b/charts/examples/platform-blueprint/values.yaml @@ -0,0 +1,33 @@ +# Default values for platform-blueprint. +# Override these in your own values file or via --set. + +# apiGroup — the Kubernetes API group for all CRDs in this chart. +apiGroup: demo.inrun.dev + +# apiVersion — the API version for all CRDs in this chart. +apiVersion: v1alpha1 + +# crds — map of CRD definitions to include in the rendered Blueprint. +# Each key is the CRD name and produces one CRDEntry in the output. +crds: + database: + enabled: true + kind: Database + plural: databases + namespaced: false + reconcile: + workers: 3 + resync: 1m + description: >- + Manages a database instance. Provisioned by the platform team Helm chart. + + cache: + enabled: true + kind: Cache + plural: caches + namespaced: true + reconcile: + workers: 3 + resync: 2m + description: >- + Manages a cache instance. Provisioned by the platform team Helm chart. \ No newline at end of file diff --git a/charts/examples/platform-catalog/README.md b/charts/examples/platform-catalog/README.md deleted file mode 100644 index d785cacc0..000000000 --- a/charts/examples/platform-catalog/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# platform-catalog - -A Helm chart that renders an Inrun Catalog and nothing else: no CRDs, no -operator. A Stack pulls it in through `sources.helm`. - -## Values - -| Field | Default | Description | -| --- | --- | --- | -| `apiGroup` | `demo.inrun.dev` | API group for every CRD | -| `apiVersion` | `v1alpha1` | API version for every CRD | -| `crds` | `database`, `cache` | Map of CRD name to `kind`, `plural`, `namespaced`, `enabled`, `reconcile.workers`, `reconcile.resync`, `description` | - -## Use it from a Stack - -```yaml -sources: - helm: - - repo: https://github.com/intentd/inrun.git - path: charts/examples/platform-catalog - chart: platform-catalog - version: main -``` diff --git a/charts/examples/platform-catalog/templates/catalog.yaml b/charts/examples/platform-catalog/templates/catalog.yaml deleted file mode 100644 index f7cb31c2a..000000000 --- a/charts/examples/platform-catalog/templates/catalog.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: inrun.dev/v1 -kind: Catalog -metadata: - name: {{ .Release.Name }}-catalog - version: 0.1.0 - author: intentd - description: > - catalog.yaml Helm template — renders an Inrun Catalog from Helm values. - Inrun's merger reads this template's output and extracts CRD definitions. - The chart must render at least one template with kind Catalog. - tags: - - helm - - composition - - data - -spec: - crds: - {{- range $name, $crd := .Values.crds }} - {{ $name }}: - enabled: {{ if hasKey $crd "enabled" }}{{ $crd.enabled }}{{ else }}true{{ end }} - namespaced: {{ if hasKey $crd "namespaced" }}{{ $crd.namespaced }}{{ else }}true{{ end }} - operatorBox: - reconcile: - workers: {{ $crd.reconcile.workers | default 2 }} - resync: {{ $crd.reconcile.resync | default "30s" }} - - apiTypes: - group: {{ $.Values.apiGroup }} - version: {{ $.Values.apiVersion | default "v1alpha1" }} - kind: {{ $crd.kind }} - plural: {{ $crd.plural }} - description: {{ $crd.description | default "" | quote }} - {{- end }} \ No newline at end of file diff --git a/charts/examples/platform-catalog/values.yaml b/charts/examples/platform-catalog/values.yaml deleted file mode 100644 index ca91974bf..000000000 --- a/charts/examples/platform-catalog/values.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# Default values for platform-catalog. -# Override these in your own values file or via --set. - -# apiGroup — the Kubernetes API group for all CRDs in this chart. -apiGroup: demo.inrun.dev - -# apiVersion — the API version for all CRDs in this chart. -apiVersion: v1alpha1 - -# crds — map of CRD definitions to include in the rendered Catalog. -# Each key is the CRD name and produces one CRDEntry in the output. -crds: - database: - enabled: true - kind: Database - plural: databases - namespaced: false - reconcile: - workers: 3 - resync: 1m - description: >- - Manages a database instance. Provisioned by the platform team Helm chart. - - cache: - enabled: true - kind: Cache - plural: caches - namespaced: true - reconcile: - workers: 3 - resync: 2m - description: >- - Manages a cache instance. Provisioned by the platform team Helm chart. \ No newline at end of file diff --git a/charts/inrun/README.md b/charts/inrun/README.md index 981b31940..ac0c85c5e 100644 --- a/charts/inrun/README.md +++ b/charts/inrun/README.md @@ -6,7 +6,7 @@ view. This chart deploys: -- **Runtime:** reconciles the custom resources declared in your Catalog. +- **Runtime:** reconciles the custom resources declared in your Blueprint. - **Gateway:** takes intents in, serves admission webhooks, and enforces deletion and namespace protection. - **Console:** a web UI over one or more runtimes. @@ -23,8 +23,8 @@ curl -sSL https://get.inrun.dev | bash ## Install -Inrun does not create RBAC or its Catalog ConfigMap itself. You generate them -from your Catalog, review them, and apply them, so every permission is visible +Inrun does not create RBAC or its Blueprint ConfigMap itself. You generate them +from your Blueprint, review them, and apply them, so every permission is visible before it reaches the cluster. ### 1. Install the CRD @@ -35,13 +35,13 @@ This uses the Website CRD from the hello-website example: kubectl apply -f https://raw.githubusercontent.com/intentd/inrun/main/examples/reconcilers/declarative/01-hello-website/manifests/crd.yaml ``` -### 2. Write a Catalog +### 2. Write a Blueprint -Save as `catalog.yaml`: +Save as `blueprint.yaml`: ```yaml apiVersion: inrun.dev/v1 -kind: Catalog +kind: Blueprint metadata: name: hello-website spec: @@ -71,16 +71,16 @@ spec: ### 3. Review the permissions ```bash -inrun validate --full -f catalog.yaml +inrun validate --full -f blueprint.yaml ``` -This prints every RBAC rule the Catalog needs, per CRD and per component, with +This prints every RBAC rule the Blueprint needs, per CRD and per component, with a note on why each one exists. No cluster required. ### 4. Generate and apply the bundle ```bash -inrun generate bundle -f catalog.yaml -o bundle.yaml +inrun generate bundle -f blueprint.yaml -o bundle.yaml ``` ```bash @@ -88,8 +88,8 @@ kubectl apply -f bundle.yaml ``` The bundle holds the `inrun-system` Namespace, the ServiceAccounts, a -ClusterRole with only the permissions the Catalog needs, its binding, and the -`inrun-catalog` ConfigMap. Commit it to your GitOps repo if you use one. +ClusterRole with only the permissions the Blueprint needs, its binding, and the +`inrun-blueprint` ConfigMap. Commit it to your GitOps repo if you use one. ### 5. Install the chart @@ -121,7 +121,7 @@ The runtime and gateway deploy independently. | Gateway only | `false` | `true` | Deletion and namespace protection only | | Full | `true` | `true` | Intents in, reconcile, views out | -For gateway only, set `gateway.standalone: true` in the Catalog and generate +For gateway only, set `gateway.standalone: true` in the Blueprint and generate with `inrun generate bundle --for gateway`. ## Values @@ -142,8 +142,8 @@ with `inrun generate bundle --for gateway`. | `runtime.config.maxDepth` | Max queue depth per CRD | `500` | | `runtime.config.failureThreshold` | Failures before degraded | `10` | | `runtime.leaderElection.enabled` | Leader election | `true` | -| `runtime.catalog.existingConfigMap` | Catalog ConfigMap | `-catalog` | -| `runtime.catalog.configMapKey` | Key in the ConfigMap | `catalog.yaml` | +| `runtime.blueprint.existingConfigMap` | Blueprint ConfigMap | `-blueprint` | +| `runtime.blueprint.configMapKey` | Key in the ConfigMap | `blueprint.yaml` | | `runtime.gatewayEndpoint` | Gateway URL shown to the console | `""` | | `runtime.registry.url` | Pattern registry | `""` | @@ -159,7 +159,7 @@ with `inrun generate bundle --for gateway`. | `gateway.server.httpsPort` | Webhook port | `8443` | | `gateway.webhooks.enabled` | Serve webhooks with your own TLS secret | `false` | | `gateway.webhooks.existingSecret` | TLS secret for webhooks | `""` | -| `gateway.catalog.existingConfigMap` | Catalog ConfigMap | the runtime's | +| `gateway.blueprint.existingConfigMap` | Blueprint ConfigMap | the runtime's | | `gateway.ingress.enabled` | Expose the Gateway API | `false` | ### Console diff --git a/charts/inrun/e2e.yaml b/charts/inrun/e2e.yaml index edce6c5a1..b33bcb8ba 100644 --- a/charts/inrun/e2e.yaml +++ b/charts/inrun/e2e.yaml @@ -29,7 +29,7 @@ spec: - path: fixtures/inrun-bundle.yaml wait: - kind: ConfigMap - name: inrun-catalog + name: inrun-blueprint namespace: inrun-system timeout: 30s - path: fixtures/cr.yaml @@ -120,7 +120,7 @@ spec: leaderElection: lease: inrun-leader port: 8080 - path: /catalog/website/health + path: /blueprint/website/health jq: resourceCount equals: "1" @@ -128,7 +128,7 @@ spec: leaderElection: lease: inrun-leader port: 8080 - path: /catalog/website/health + path: /blueprint/website/health jq: healthy equals: "true" @@ -136,8 +136,8 @@ spec: leaderElection: lease: inrun-leader port: 8080 - path: /catalog/website - outputContains: "Website CRD, GVK: demo.inrun.dev/v1alpha1, Kind=Website" + path: /blueprint/website + contains: "Website CRD, GVK: demo.inrun.dev/v1alpha1, Kind=Website" - include: ./e2e/cleanup.yaml diff --git a/charts/inrun/fixtures/blueprint.yaml b/charts/inrun/fixtures/blueprint.yaml new file mode 100644 index 000000000..4c1ae0007 --- /dev/null +++ b/charts/inrun/fixtures/blueprint.yaml @@ -0,0 +1,45 @@ +apiVersion: inrun.dev/v1 +kind: Blueprint +metadata: + name: hello-website + version: 0.1.0 + description: Fixture blueprint for chart e2e — one CRD, one Deployment, one Service. + +security: + namespaceProtection: + enabled: true + cleanupOnShutdown: true + +gateway: + enabled: true + +spec: + crds: + website: + apiTypes: + group: demo.inrun.dev + version: v1alpha1 + kind: Website + plural: websites + operatorBox: + reconcile: + onCreate: + deployments: + - name: "{{ .metadata.name }}" + image: "{{ .spec.image }}" + replicas: "{{ .spec.replicas }}" + port: "{{ .spec.port }}" + reconcile: true + services: + - name: "{{ .metadata.name }}-svc" + port: "80" + targetPort: "{{ .spec.port }}" + reconcile: true + emit: + status: + fields: + - path: phase + value: "Running" + runtime: + allowedNamespaces: + - default diff --git a/charts/inrun/fixtures/catalog.yaml b/charts/inrun/fixtures/catalog.yaml deleted file mode 100644 index 8e95ef607..000000000 --- a/charts/inrun/fixtures/catalog.yaml +++ /dev/null @@ -1,45 +0,0 @@ -apiVersion: inrun.dev/v1 -kind: Catalog -metadata: - name: hello-website - version: 0.1.0 - description: Fixture catalog for chart e2e — one CRD, one Deployment, one Service. - -security: - namespaceProtection: - enabled: true - cleanupOnShutdown: true - -gateway: - enabled: true - -spec: - crds: - website: - apiTypes: - group: demo.inrun.dev - version: v1alpha1 - kind: Website - plural: websites - operatorBox: - reconcile: - onCreate: - deployments: - - name: "{{ .metadata.name }}" - image: "{{ .spec.image }}" - replicas: "{{ .spec.replicas }}" - port: "{{ .spec.port }}" - reconcile: true - services: - - name: "{{ .metadata.name }}-svc" - port: "80" - targetPort: "{{ .spec.port }}" - reconcile: true - emit: - status: - fields: - - path: phase - value: "Running" - runtime: - allowedNamespaces: - - default diff --git a/charts/inrun/fixtures/inrun-bundle.yaml b/charts/inrun/fixtures/inrun-bundle.yaml index 512427604..6b4176a58 100644 --- a/charts/inrun/fixtures/inrun-bundle.yaml +++ b/charts/inrun/fixtures/inrun-bundle.yaml @@ -189,20 +189,20 @@ subjects: --- apiVersion: v1 data: - catalog.yaml: | + blueprint.yaml: | apiVersion: inrun.dev/v1 - kind: Catalog + kind: Blueprint metadata: name: hello-website - description: Fixture catalog for chart e2e — one CRD, one Deployment, one Service. + description: Fixture blueprint for chart e2e — one CRD, one Deployment, one Service. version: 0.1.0 spec: crds: website: - catalogName: hello-website - catalogNamespace: default - catalogDescription: Fixture catalog for chart e2e — one CRD, one Deployment, one Service. - catalogVersion: 0.1.0 + blueprintName: hello-website + blueprintNamespace: default + blueprintDescription: Fixture blueprint for chart e2e — one CRD, one Deployment, one Service. + blueprintVersion: 0.1.0 description: 'Website CRD, GVK: demo.inrun.dev/v1alpha1, Kind=Website' mode: dynamic apiTypes: @@ -251,6 +251,6 @@ metadata: app.kubernetes.io/name: inrun app.kubernetes.io/tag: inrun-internal inrun.dev/deletion-protection: "true" - name: inrun-catalog + name: inrun-blueprint namespace: inrun-system diff --git a/charts/inrun/templates/_helpers.tpl b/charts/inrun/templates/_helpers.tpl index 771674404..4afbb7ced 100644 --- a/charts/inrun/templates/_helpers.tpl +++ b/charts/inrun/templates/_helpers.tpl @@ -65,13 +65,13 @@ Runtime image — respects tag override, falls back to appVersion. {{- end }} {{/* -Catalog ConfigMap the runtime mounts. +Blueprint ConfigMap the runtime mounts. */}} -{{- define "inrun.catalogConfigMapName" -}} -{{- if .Values.runtime.catalog.existingConfigMap }} -{{- .Values.runtime.catalog.existingConfigMap }} +{{- define "inrun.blueprintConfigMapName" -}} +{{- if .Values.runtime.blueprint.existingConfigMap }} +{{- .Values.runtime.blueprint.existingConfigMap }} {{- else }} -{{- printf "%s-catalog" (include "inrun.fullname" .) }} +{{- printf "%s-blueprint" (include "inrun.fullname" .) }} {{- end }} {{- end }} @@ -87,10 +87,10 @@ Gateway image — respects tag override, falls back to appVersion. {{- end }} {{/* -Catalog ConfigMap the gateway mounts — its own, or the runtime's. +Blueprint ConfigMap the gateway mounts — its own, or the runtime's. */}} -{{- define "inrun.gatewayCatalogConfigMapName" -}} -{{- default (include "inrun.catalogConfigMapName" .) .Values.gateway.catalog.existingConfigMap }} +{{- define "inrun.gatewayBlueprintConfigMapName" -}} +{{- default (include "inrun.blueprintConfigMapName" .) .Values.gateway.blueprint.existingConfigMap }} {{- end }} {{/* diff --git a/charts/inrun/templates/gateway-deployment.yaml b/charts/inrun/templates/gateway-deployment.yaml index ebfb46074..1cce7ded6 100644 --- a/charts/inrun/templates/gateway-deployment.yaml +++ b/charts/inrun/templates/gateway-deployment.yaml @@ -52,7 +52,7 @@ spec: args: - --file - - {{ .Values.gateway.catalog.mountPath }}/{{ .Values.gateway.catalog.configMapKey }} + - {{ .Values.gateway.blueprint.mountPath }}/{{ .Values.gateway.blueprint.configMapKey }} env: - name: LOG_LEVEL @@ -138,8 +138,8 @@ spec: mountPath: /tls readOnly: true {{- end }} - - name: catalog - mountPath: {{ .Values.gateway.catalog.mountPath }} + - name: blueprint + mountPath: {{ .Values.gateway.blueprint.mountPath }} readOnly: true - name: tmp mountPath: /tmp @@ -153,9 +153,9 @@ spec: secret: secretName: {{ $webhooks.existingSecret }} {{- end }} - - name: catalog + - name: blueprint configMap: - name: {{ include "inrun.gatewayCatalogConfigMapName" . }} + name: {{ include "inrun.gatewayBlueprintConfigMapName" . }} - name: tmp emptyDir: {} {{- with .Values.gateway.extraVolumes }} diff --git a/charts/inrun/templates/networkpolicy.yaml b/charts/inrun/templates/networkpolicy.yaml index c931eb152..58fde74a0 100644 --- a/charts/inrun/templates/networkpolicy.yaml +++ b/charts/inrun/templates/networkpolicy.yaml @@ -26,7 +26,7 @@ spec: {{- end }} egress: # Allow all egress — Inrun must reach the Kubernetes API server - # and any remote Catalog sources (URLs, Helm repos) + # and any remote Blueprint sources (URLs, Helm repos) - {} {{- end }} --- diff --git a/charts/inrun/templates/runtime-deployment.yaml b/charts/inrun/templates/runtime-deployment.yaml index c53dff4b8..42c11ed98 100644 --- a/charts/inrun/templates/runtime-deployment.yaml +++ b/charts/inrun/templates/runtime-deployment.yaml @@ -45,7 +45,7 @@ spec: args: - --file - - {{ .Values.runtime.catalog.mountPath }}/{{ .Values.runtime.catalog.configMapKey }} + - {{ .Values.runtime.blueprint.mountPath }}/{{ .Values.runtime.blueprint.configMapKey }} env: - name: LOG_LEVEL @@ -131,8 +131,8 @@ spec: {{- toYaml .Values.runtime.resources | nindent 12 }} volumeMounts: - - name: catalog - mountPath: {{ .Values.runtime.catalog.mountPath }} + - name: blueprint + mountPath: {{ .Values.runtime.blueprint.mountPath }} readOnly: true - name: tmp mountPath: /tmp @@ -141,9 +141,9 @@ spec: {{- end }} volumes: - - name: catalog + - name: blueprint configMap: - name: {{ include "inrun.catalogConfigMapName" . }} + name: {{ include "inrun.blueprintConfigMapName" . }} - name: tmp emptyDir: {} {{- with .Values.runtime.extraVolumes }} diff --git a/charts/inrun/values.schema.json b/charts/inrun/values.schema.json index eeb5ffe06..9a5a7dc52 100644 --- a/charts/inrun/values.schema.json +++ b/charts/inrun/values.schema.json @@ -65,7 +65,7 @@ "retryPeriod": { "type": "integer" } } }, - "catalog": { + "blueprint": { "type": "object", "properties": { "existingConfigMap": { "type": "string" }, @@ -158,7 +158,7 @@ "keyKey": { "type": "string" } } }, - "catalog": { + "blueprint": { "type": "object", "properties": { "existingConfigMap": { "type": "string" }, diff --git a/charts/inrun/values.yaml b/charts/inrun/values.yaml index c43729321..6cb1ac50d 100644 --- a/charts/inrun/values.yaml +++ b/charts/inrun/values.yaml @@ -89,13 +89,13 @@ runtime: renewDeadline: 10 retryPeriod: 5 - # Catalog (runtime only) - catalog: - existingConfigMap: "" # ConfigMap from 'inrun generate bundle'; default -catalog - configMapKey: catalog.yaml - mountPath: /etc/inrun/catalog + # Blueprint (runtime only) + blueprint: + existingConfigMap: "" # ConfigMap from 'inrun generate bundle'; default -blueprint + configMapKey: blueprint.yaml + mountPath: /etc/inrun/blueprint - # Gateway endpoint advertised in the runtime /catalog response. + # Gateway endpoint advertised in the runtime /blueprint response. # Set to the HTTP base URL of the companion gateway deployment so the # console can discover and merge gateway stats. # Overrides INRUN_GATEWAY_ENDPOINT env. Empty = no gateway configured. @@ -259,11 +259,11 @@ gateway: certKey: tls.crt # key in secret for certificate keyKey: tls.key # key in secret for private key - # Catalog (needed to know which CRDs have webhooks) - catalog: - existingConfigMap: "" # default: runtime.catalog's ConfigMap - configMapKey: catalog.yaml - mountPath: /etc/inrun/catalog + # Blueprint (needed to know which CRDs have webhooks) + blueprint: + existingConfigMap: "" # default: runtime.blueprint's ConfigMap + configMapKey: blueprint.yaml + mountPath: /etc/inrun/blueprint # Registry registry: diff --git a/cmd/cli/cluster/clusters.go b/cmd/cli/cluster/clusters.go index 38f6f53b5..22311d9e5 100644 --- a/cmd/cli/cluster/clusters.go +++ b/cmd/cli/cluster/clusters.go @@ -9,8 +9,8 @@ import ( "github.com/intentd/inrun/cmd/cli/cmdutil" - "github.com/intentd/inrun/pkg/catalog" - "github.com/intentd/inrun/pkg/catalog/validate" + "github.com/intentd/inrun/pkg/blueprint" + "github.com/intentd/inrun/pkg/blueprint/validate" apigateway "github.com/intentd/inrun/pkg/gateway/api" "github.com/intentd/inrun/pkg/tools/cluster" "github.com/intentd/inrun/pkg/types" @@ -25,26 +25,26 @@ var clustersCmd = &cobra.Command{ Long: `List all clusters registered in gateway.clusters. Shows the name, endpoint, and credential form for each cluster. -Reads the catalog from the current directory or --file.`, +Reads the blueprint from the current directory or --file.`, RunE: func(cmd *cobra.Command, args []string) error { - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } - return runClustersList(cat) + return runClustersList(bp) }, } -func runClustersList(cat *catalog.Catalog) error { +func runClustersList(bp *blueprint.Blueprint) error { fmt.Println() - if !cat.IsGatewayEnabled() { + if !bp.IsGatewayEnabled() { fmt.Printf(" %s gateway is not enabled\n", cmdutil.Gray("○")) fmt.Println() return nil } - clusters := cat.GatewayClusters() + clusters := bp.GatewayClusters() if len(clusters) == 0 { fmt.Printf(" %s gateway.clusters is empty — no remote clusters registered\n", cmdutil.Gray("○")) fmt.Println() @@ -69,9 +69,9 @@ func runClustersList(cat *catalog.Catalog) error { var clustersValidateCmd = &cobra.Command{ Use: "validate", Short: "Validate gateway.clusters configuration offline", - Long: `Validate the gateway.clusters block in the catalog. + Long: `Validate the gateway.clusters block in the blueprint. -Reads the catalog and checks each cluster entry for structural validity: +Reads the blueprint and checks each cluster entry for structural validity: endpoint required, exactly one credential form, required secret refs present. Also verifies that every static serve.cluster and target.cluster reference @@ -82,34 +82,34 @@ With --full, also reports which CRDs route to each cluster.`, RunE: func(cmd *cobra.Command, args []string) error { full, _ := cmd.Flags().GetBool("full") - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } - return runClustersValidate(cat, full) + return runClustersValidate(bp, full) }, } -func runClustersValidate(cat *catalog.Catalog, full bool) error { +func runClustersValidate(bp *blueprint.Blueprint, full bool) error { fmt.Println() fmt.Printf("%s inrun clusters validate\n", cmdutil.Bold("⎈")) fmt.Println() - if !cat.IsGatewayEnabled() { + if !bp.IsGatewayEnabled() { fmt.Printf(" %s gateway is not enabled — no clusters to validate\n", cmdutil.Gray("○")) fmt.Println() return nil } - clusters := cat.GatewayClusters() + clusters := bp.GatewayClusters() if len(clusters) == 0 { fmt.Printf(" %s gateway.clusters is empty — no remote clusters registered\n", cmdutil.Gray("○")) fmt.Println() return nil } - clusterRefs := buildClusterRefIndex(cat) + clusterRefs := buildClusterRefIndex(bp) fmt.Printf(" gateway.clusters (%d registered)\n", len(clusters)) fmt.Println() @@ -122,7 +122,7 @@ func runClustersValidate(cat *catalog.Catalog, full bool) error { } } - if err := validate.ValidateGatewayClusters(cat); err != nil { + if err := validate.ValidateGatewayClusters(bp); err != nil { fmt.Printf(" %s %s\n", cmdutil.FailureMark(), cmdutil.Red(err.Error())) allOK = false } @@ -145,13 +145,13 @@ var clustersCheckCmd = &cobra.Command{ Use: "check", Short: "Connect to each registered cluster and verify CRD presence", Long: `Go online: read each cluster's credential secret from the management -cluster, connect to the remote cluster, verify the catalog's CRDs are installed, +cluster, connect to the remote cluster, verify the blueprint's CRDs are installed, and report pass / unreachable / missing-CRD per cluster. Uses the current kubectl context to reach the management cluster. Pass --context to use a specific context instead. -With --config , skips the catalog entirely and checks connectivity only +With --config , skips the blueprint entirely and checks connectivity only for each cluster listed in the bootstrap config file. Use this to verify contexts are reachable before bootstrapping them.`, RunE: func(cmd *cobra.Command, args []string) error { @@ -167,12 +167,12 @@ contexts are reachable before bootstrapping them.`, return runClustersCheck(cmd.Context(), nil, clusters, clusterCtx) } - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } - all := cat.GatewayClusters() + all := bp.GatewayClusters() if len(all) == 0 { fmt.Printf(" %s gateway.clusters is empty — nothing to check\n", cmdutil.Gray("○")) return nil @@ -193,11 +193,11 @@ contexts are reachable before bootstrapping them.`, clusters = selected } - return runClustersCheck(cmd.Context(), cat, clusters, clusterCtx) + return runClustersCheck(cmd.Context(), bp, clusters, clusterCtx) }, } -func runClustersCheck(ctx context.Context, cat *catalog.Catalog, clusters map[string]types.GatewayClusterConfig, clusterCtx string) error { +func runClustersCheck(ctx context.Context, bp *blueprint.Blueprint, clusters map[string]types.GatewayClusterConfig, clusterCtx string) error { fmt.Println() fmt.Printf("%s inrun clusters check\n", cmdutil.Bold("⎈")) fmt.Println() @@ -207,7 +207,7 @@ func runClustersCheck(ctx context.Context, cat *catalog.Catalog, clusters map[st return fmt.Errorf("%s management cluster: %w", cmdutil.FailureMark(), err) } - results := apigateway.CheckClusters(ctx, cat, clusters, localKube, "default") + results := apigateway.CheckClusters(ctx, bp, clusters, localKube, "default") anyFailed := false for _, r := range results { @@ -308,9 +308,9 @@ func credentialSummary(cfg types.GatewayClusterConfig) string { } } -func buildClusterRefIndex(cat *catalog.Catalog) map[string][]string { +func buildClusterRefIndex(bp *blueprint.Blueprint) map[string][]string { idx := map[string][]string{} - for crdName, entry := range cat.EnabledCRDs() { + for crdName, entry := range bp.EnabledCRDs() { if entry.Serve == nil { continue } @@ -334,7 +334,7 @@ func init() { clustersValidateCmd.Flags().Bool("full", false, "Show CRD routing references per cluster") clustersCheckCmd.Flags().String("context", "", "kubectl context for reading credential secrets (defaults to current context)") clustersCheckCmd.Flags().String("clusters", "", "comma-separated list of cluster names to check (default: all registered clusters)") - clustersCheckCmd.Flags().String("config", "", "path to a cluster-config.yaml to check connectivity without a catalog") + clustersCheckCmd.Flags().String("config", "", "path to a cluster-config.yaml to check connectivity without a blueprint") clustersCmd.AddCommand(clustersValidateCmd) clustersCmd.AddCommand(clustersCheckCmd) diff --git a/cmd/cli/cluster/clusters_bootstrap.go b/cmd/cli/cluster/clusters_bootstrap.go index 225626773..88ecef803 100644 --- a/cmd/cli/cluster/clusters_bootstrap.go +++ b/cmd/cli/cluster/clusters_bootstrap.go @@ -14,7 +14,7 @@ import ( "github.com/spf13/cobra" - "github.com/intentd/inrun/pkg/catalog" + "github.com/intentd/inrun/pkg/blueprint" "github.com/intentd/inrun/pkg/tools/cluster/bootstrap" ) @@ -26,7 +26,7 @@ cluster, then store the credentials in the gateway cluster so the gateway can route applies to it. The ClusterRole is scoped to exactly the serve-enabled CRDs declared in the -catalog — no wildcard resources, no cluster-admin. +blueprint — no wildcard resources, no cluster-admin. Examples: inrun clusters bootstrap --context kind-prod --name prod @@ -68,12 +68,12 @@ Examples: SANamespace: saNamespace, } - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } - return runBootstrapSingle(cmd.Context(), cat, entry, opts) + return runBootstrapSingle(cmd.Context(), bp, entry, opts) }, } @@ -94,7 +94,7 @@ func runBootstrapValidate(path string) error { } // runBootstrapConfig bootstraps all clusters listed in the config file. -// No catalog is required — rules come from each entry's rules: field (if any). +// No blueprint is required — rules come from each entry's rules: field (if any). // When outPath is non-empty, the bootstrap results are written to a // gateway.clusters-shaped YAML file suitable for `inrun clusters check --config`. func runBootstrapConfig(cmd *cobra.Command, path, outPath string, opts bootstrap.RunOptions) error { @@ -128,7 +128,7 @@ func runBootstrapConfig(cmd *cobra.Command, path, outPath string, opts bootstrap } // runBootstrapSingleResult is the result-returning variant used by runBootstrapConfig. -func runBootstrapSingleResult(ctx context.Context, cat *catalog.Catalog, entry bootstrap.ClusterEntry, opts bootstrap.RunOptions) (*bootstrap.Result, error) { +func runBootstrapSingleResult(ctx context.Context, bp *blueprint.Blueprint, entry bootstrap.ClusterEntry, opts bootstrap.RunOptions) (*bootstrap.Result, error) { fmt.Println() fmt.Printf("%s inrun clusters bootstrap\n", cmdutil.Bold("⎈")) fmt.Printf(" %s cluster name: %s\n", cmdutil.Gray("→"), cmdutil.Bold(entry.Name)) @@ -140,7 +140,7 @@ func runBootstrapSingleResult(ctx context.Context, cat *catalog.Catalog, entry b } fmt.Println() - result, err := bootstrap.Cluster(ctx, cat, entry, opts, func(msg string) { + result, err := bootstrap.Cluster(ctx, bp, entry, opts, func(msg string) { fmt.Printf(" %s %s\n", cmdutil.SuccessMark(), msg) }) if err != nil { @@ -158,12 +158,12 @@ func runBootstrapSingleResult(ctx context.Context, cat *catalog.Catalog, entry b } fmt.Println() - bootstrapPrintCatalogSnippet(entry.Name, result.Endpoint, result.SecretName, result.SecretNamespace, !result.HasCA) + bootstrapPrintBlueprintSnippet(entry.Name, result.Endpoint, result.SecretName, result.SecretNamespace, !result.HasCA) return result, nil } // runBootstrapSingle bootstraps one cluster entry and prints the appropriate output. -func runBootstrapSingle(ctx context.Context, cat *catalog.Catalog, entry bootstrap.ClusterEntry, opts bootstrap.RunOptions) error { +func runBootstrapSingle(ctx context.Context, bp *blueprint.Blueprint, entry bootstrap.ClusterEntry, opts bootstrap.RunOptions) error { fmt.Println() fmt.Printf("%s inrun clusters bootstrap\n", cmdutil.Bold("⎈")) fmt.Printf(" %s cluster name: %s\n", cmdutil.Gray("→"), cmdutil.Bold(entry.Name)) @@ -175,7 +175,7 @@ func runBootstrapSingle(ctx context.Context, cat *catalog.Catalog, entry bootstr } fmt.Println() - result, err := bootstrap.Cluster(ctx, cat, entry, opts, func(msg string) { + result, err := bootstrap.Cluster(ctx, bp, entry, opts, func(msg string) { fmt.Printf(" %s %s\n", cmdutil.SuccessMark(), msg) }) if err != nil { @@ -193,14 +193,14 @@ func runBootstrapSingle(ctx context.Context, cat *catalog.Catalog, entry bootstr } fmt.Println() - bootstrapPrintCatalogSnippet(entry.Name, result.Endpoint, result.SecretName, result.SecretNamespace, !result.HasCA) + bootstrapPrintBlueprintSnippet(entry.Name, result.Endpoint, result.SecretName, result.SecretNamespace, !result.HasCA) return nil } // ── output helpers ──────────────────────────────────────────────────────────── -func bootstrapPrintCatalogSnippet(name, endpoint, secretName, namespace string, insecure bool) { - fmt.Printf("%s Add to your catalog:\n\n", cmdutil.Bold("⎈")) +func bootstrapPrintBlueprintSnippet(name, endpoint, secretName, namespace string, insecure bool) { + fmt.Printf("%s Add to your blueprint:\n\n", cmdutil.Bold("⎈")) if insecure { fmt.Printf(`gateway: clusters: diff --git a/cmd/cli/cmdutil/blueprint.go b/cmd/cli/cmdutil/blueprint.go new file mode 100644 index 000000000..4c6c8736c --- /dev/null +++ b/cmd/cli/cmdutil/blueprint.go @@ -0,0 +1,135 @@ +//go:build !runtime && !gateway + +package cmdutil + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/intentd/inrun/pkg/blueprint" + "github.com/intentd/inrun/pkg/blueprint/pipeline" + "github.com/intentd/inrun/pkg/merger" + "github.com/intentd/inrun/pkg/types" + "github.com/spf13/cobra" +) + +// DefaultNamespace returns the namespace to use when --namespace is not supplied. +// Reads INRUN_NAMESPACE from the environment so that CLI invocations inside +// an already-configured cluster automatically target the right namespace. +func DefaultNamespace() string { + if ns := os.Getenv("INRUN_NAMESPACE"); ns != "" { + return ns + } + return "inrun-system" +} + +// ParseFilePaths handles comma-separated values and returns a slice of paths +func ParseFilePaths(paths []string) []string { + var expanded []string + for _, p := range paths { + // Split by comma and trim spaces + parts := strings.Split(p, ",") + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + expanded = append(expanded, trimmed) + } + } + } + return expanded +} + +type MergerOut struct { + Merger *merger.Merger + CRDs []types.CRDEntry + Blueprint *blueprint.Blueprint + Paths []string + Enabled map[string]types.CRDEntry // m.Enabled() +} + +func GenerateBlueprint(cmd *cobra.Command) (*MergerOut, error) { + blueprintPaths, _ := cmd.Flags().GetStringSlice("file") + + expanded := ParseFilePaths(blueprintPaths) + if len(expanded) == 0 { + expanded = DefaultFilePaths() + } + if len(expanded) == 0 { + return nil, fmt.Errorf(ErrNoBlueprint) + } + + // Absolutize so merger.FirstEntryDir() is always absolute. Without this, + // relative paths cause a double-join when PopulateAPITypesFromCRDFile + // prepends blueprintDir to a crdFile that was already joined once during load. + for i, p := range expanded { + if abs, err := filepath.Abs(p); err == nil { + expanded[i] = abs + } + } + + m := merger.New(expanded...) + if err := m.Merge(); err != nil { + return nil, err + } + + var bp blueprint.Blueprint + bp.Spec = m.ToSpec() + + // Fill apiTypes from crdFile, as the pipeline does, so generators see a + // fully specified CRD. The merger has already made crdFile absolute. + crdMap := bp.Spec.CRDs + if err := resolveCRDFiles(crdMap); err != nil { + return nil, err + } + enabled := m.Enabled() + if err := resolveCRDFiles(enabled); err != nil { + return nil, err + } + + // Convert map to slice for generate functions + crds := make([]types.CRDEntry, 0, len(crdMap)) + for _, c := range crdMap { + crds = append(crds, c) + } + + return &MergerOut{ + Merger: m, + CRDs: crds, + Blueprint: &bp, + Paths: blueprintPaths, + Enabled: enabled, + }, nil +} + +// resolveCRDFiles fills each entry's apiTypes from its crdFile, and migrate.to +// from migrate.to.crdFile, then clears both so nothing downstream depends on +// a local path. +func resolveCRDFiles(crds map[string]types.CRDEntry) error { + for name, crd := range crds { + if crd.CRDFile != "" { + if err := blueprint.PopulateAPITypesFromCRDFile(&crd, ""); err != nil { + return fmt.Errorf("CRD %q: %w", name, err) + } + crd.CRDFile = "" + } + if crd.Migrate != nil && crd.Migrate.To.CRDFile != "" { + if err := blueprint.PopulateMigrateTargetFromCRDFile(&crd, ""); err != nil { + return fmt.Errorf("CRD %q: %w", name, err) + } + crd.Migrate.To.CRDFile = "" + } + crds[name] = crd + } + return nil +} + +// BuildBlueprint builds the expanded Blueprint using the --file flag from cmd. +func BuildBlueprint(cmd *cobra.Command) (*blueprint.Blueprint, error) { + m, err := GenerateBlueprint(cmd) + if err != nil { + return nil, fmt.Errorf("generating Blueprint: %w", err) + } + return pipeline.BuildExpanded(Cfg, m.Merger) +} diff --git a/cmd/cli/cmdutil/catalog.go b/cmd/cli/cmdutil/catalog.go deleted file mode 100644 index 092c4ecc6..000000000 --- a/cmd/cli/cmdutil/catalog.go +++ /dev/null @@ -1,129 +0,0 @@ -//go:build !runtime && !gateway - -package cmdutil - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/intentd/inrun/pkg/catalog" - "github.com/intentd/inrun/pkg/catalog/pipeline" - "github.com/intentd/inrun/pkg/merger" - "github.com/intentd/inrun/pkg/types" - "github.com/spf13/cobra" -) - -// DefaultNamespace returns the namespace to use when --namespace is not supplied. -// Reads INRUN_NAMESPACE from the environment so that CLI invocations inside -// an already-configured cluster automatically target the right namespace. -func DefaultNamespace() string { - if ns := os.Getenv("INRUN_NAMESPACE"); ns != "" { - return ns - } - return "inrun-system" -} - -// ParseFilePaths handles comma-separated values and returns a slice of paths -func ParseFilePaths(paths []string) []string { - var expanded []string - for _, p := range paths { - // Split by comma and trim spaces - parts := strings.Split(p, ",") - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed != "" { - expanded = append(expanded, trimmed) - } - } - } - return expanded -} - -type MergerOut struct { - Merger *merger.Merger - CRDs []types.CRDEntry - Catalog *catalog.Catalog - Paths []string - Enabled map[string]types.CRDEntry // m.Enabled() -} - -func GenerateCatalog(cmd *cobra.Command) (*MergerOut, error) { - catalogPaths, _ := cmd.Flags().GetStringSlice("file") - - expanded := ParseFilePaths(catalogPaths) - if len(expanded) == 0 { - expanded = DefaultFilePaths() - } - if len(expanded) == 0 { - return nil, fmt.Errorf(ErrNoCatalog) - } - - // Absolutize so merger.FirstEntryDir() is always absolute. Without this, - // relative paths cause a double-join when PopulateAPITypesFromCRDFile - // prepends catalogDir to a crdFile that was already joined once during load. - for i, p := range expanded { - if abs, err := filepath.Abs(p); err == nil { - expanded[i] = abs - } - } - - m := merger.New(expanded...) - if err := m.Merge(); err != nil { - return nil, err - } - - var cat catalog.Catalog - cat.Spec = m.ToSpec() - - // Fill apiTypes from crdFile, as the pipeline does, so generators see a - // fully specified CRD. The merger has already made crdFile absolute. - crdMap := cat.Spec.CRDs - if err := resolveCRDFiles(crdMap); err != nil { - return nil, err - } - enabled := m.Enabled() - if err := resolveCRDFiles(enabled); err != nil { - return nil, err - } - - // Convert map to slice for generate functions - crds := make([]types.CRDEntry, 0, len(crdMap)) - for _, c := range crdMap { - crds = append(crds, c) - } - - return &MergerOut{ - Merger: m, - CRDs: crds, - Catalog: &cat, - Paths: catalogPaths, - Enabled: enabled, - }, nil -} - -// resolveCRDFiles fills each entry's apiTypes from its crdFile and clears -// crdFile, so nothing downstream depends on a local path. -func resolveCRDFiles(crds map[string]types.CRDEntry) error { - for name, crd := range crds { - if crd.CRDFile == "" { - continue - } - if err := catalog.PopulateAPITypesFromCRDFile(&crd, ""); err != nil { - return fmt.Errorf("CRD %q: %w", name, err) - } - crd.CRDFile = "" - crds[name] = crd - } - return nil -} - -// BuildCatalog builds the expanded Catalog using the --file flag from cmd. -func BuildCatalog(cmd *cobra.Command) (*catalog.Catalog, error) { - m, err := GenerateCatalog(cmd) - if err != nil { - return nil, fmt.Errorf("generating Catalog: %w", err) - } - return pipeline.BuildExpanded(Cfg, m.Merger) -} diff --git a/cmd/cli/cmdutil/constants.go b/cmd/cli/cmdutil/constants.go index 6bf3feb1c..b727a421a 100644 --- a/cmd/cli/cmdutil/constants.go +++ b/cmd/cli/cmdutil/constants.go @@ -3,7 +3,7 @@ package cmdutil const ( - SchemaRefCatalog = "https://inrun.dev/docs/reference/schema/catalog/" + SchemaRefBlueprint = "https://inrun.dev/docs/reference/schema/blueprint/" SchemaRefStack = "https://inrun.dev/docs/reference/schema/stack/" SchemaRefModule = "https://inrun.dev/docs/reference/schema/module/" SchemaRefE2E = "https://inrun.dev/docs/reference/schema/e2e/" diff --git a/cmd/cli/cmdutil/crs.go b/cmd/cli/cmdutil/crs.go index 481977322..936cf01b6 100644 --- a/cmd/cli/cmdutil/crs.go +++ b/cmd/cli/cmdutil/crs.go @@ -64,7 +64,7 @@ type CRSimInputs struct { // The FIRST document of the target kind is the CR under test — unchanged // behavior from a single-CR file. Any FURTHER documents of that same kind // are pre-existing instances: seeded into the fake dynamic client so -// reconcile-time checks that list other instances (operator: unique) can +// reconcile-time checks that list other instances (unique: true) can // see them, but never reconciled themselves — same convention already used // for cross: peers, extended from "one doc per other kind" to "one or more // docs of the CRD's own kind". Documents of OTHER kinds become cross: peers, diff --git a/cmd/cli/cmdutil/doc.go b/cmd/cli/cmdutil/doc.go index f0ad1be06..2ec593db7 100644 --- a/cmd/cli/cmdutil/doc.go +++ b/cmd/cli/cmdutil/doc.go @@ -1,4 +1,4 @@ // Package cmdutil holds what every command shares: the root command and -// Execute, loading a Catalog from --file, reading CR files, output helpers -// and aliases for the general-purpose helpers in pkg/utils. +// Execute, loading a Blueprint from --file, reading CR files, output helpers +// and aliases for the helpers in pkg/util/term and pkg/util/fileio. package cmdutil diff --git a/cmd/cli/cmdutil/files.go b/cmd/cli/cmdutil/files.go index 2b59a06f2..00f786391 100644 --- a/cmd/cli/cmdutil/files.go +++ b/cmd/cli/cmdutil/files.go @@ -5,51 +5,44 @@ import ( "os" "path/filepath" - "github.com/intentd/inrun/pkg/utils" + "github.com/intentd/inrun/pkg/registry" + "github.com/intentd/inrun/pkg/util/fileio" ) const ( - FileCatalog = "catalog.yaml" - fileStack = "stack.yaml" - FileE2e = "e2e.yaml" - FileSimulate = "simulate.yaml" - FileCrd = "crd.yaml" - FileCr = "cr.yaml" + FileBlueprint = registry.FileBlueprint + fileStack = registry.FileStack + FileE2e = registry.FileE2E + FileSimulate = registry.FileSimulate + FileCrd = registry.FileCRD + FileCr = registry.FileCR FileReadMe = "README.md" FileMakeFile = "Makefile" FileDockerfile = "Dockerfile" FileValues = "values.yaml" - // A project keeps catalog.yaml at its root, what it applies to the cluster + // A project keeps blueprint.yaml at its root, what it applies to the cluster // (crd.yaml, cr.yaml, setup files) in manifests/, and its simulate.yaml, // e2e.yaml and test values in test/. - DirManifests = "manifests" - DirTest = "test" + DirManifests = registry.DirManifests + DirTest = registry.DirTest ) -// FindFile returns name if it exists in the current directory, else the -// first dirs/name that exists, else "". -func FindFile(name string, dirs ...string) string { - if FileExists(name) { - return name - } - for _, d := range dirs { - if p := filepath.Join(d, name); FileExists(p) { - return p - } - } - return "" +// FindFile returns where name is in the project in the current directory: +// its root, else its place in the standard layout (manifests/ or test/), else "". +func FindFile(name string) string { + return registry.FindPatternFile(".", name) } -// ResolveCatalogPaths resolves the catalog file paths in the following order: +// ResolveBlueprintPaths resolves the blueprint file paths in the following order: // // 1. Explicit CLI paths (highest priority) -// 2. Default file paths in the working directory (catalog.yaml, stack.yaml, etc.) -// 3. Paths defined in the Config (cfg.Catalog().Paths()) +// 2. Default file paths in the working directory (blueprint.yaml, stack.yaml, etc.) +// 3. Paths defined in the Config (cfg.Blueprint().Paths()) // -// Returns an error if no catalog file can be resolved. -func ResolveCatalogPaths(cliPaths []string) ([]string, error) { - cfgPaths := Cfg.Catalog().Paths() +// Returns an error if no blueprint file can be resolved. +func ResolveBlueprintPaths(cliPaths []string) ([]string, error) { + cfgPaths := Cfg.Blueprint().Paths() // 1. CLI-provided paths — convert to absolute so all downstream relative // path resolutions (crdFile, crFiles, setup, Stack imports) use the @@ -76,14 +69,14 @@ func ResolveCatalogPaths(cliPaths []string) ([]string, error) { return cfgPaths, nil } - return nil, fmt.Errorf(ErrNoCatalog) + return nil, fmt.Errorf(ErrNoBlueprint) } -// DefaultFilePaths returns the default catalog file if one exists in the -// current directory and no -f flag was provided. Tries catalog.yaml first, +// DefaultFilePaths returns the default blueprint file if one exists in the +// current directory and no -f flag was provided. Tries blueprint.yaml first, // then stack.yaml — the same precedence as Docker's Dockerfile / compose.yaml. func DefaultFilePaths() []string { - for _, name := range []string{FileCatalog, fileStack} { + for _, name := range []string{FileBlueprint, fileStack} { if _, err := os.Stat(name); err == nil { return []string{name} } @@ -91,20 +84,20 @@ func DefaultFilePaths() []string { return nil } -const ErrNoCatalog = "no catalog.yaml or stack.yaml found in current directory\n" + +const ErrNoBlueprint = "no blueprint.yaml or stack.yaml found in current directory\n" + "pass -f or create one with inrun init" -// ResolveCatalogFile resolves a single catalog file path from a CLI flag value. +// ResolveBlueprintFile resolves a single blueprint file path from a CLI flag value. // If flagValue is empty it falls back to defaultFilePaths(). The resolved path // is always returned as an absolute path. -func ResolveCatalogFile(flagValue string) (string, error) { +func ResolveBlueprintFile(flagValue string) (string, error) { if flagValue == "" { if d := DefaultFilePaths(); len(d) > 0 { flagValue = d[0] } } if flagValue == "" { - return "", fmt.Errorf(ErrNoCatalog) + return "", fmt.Errorf(ErrNoBlueprint) } if abs, err := filepath.Abs(flagValue); err == nil { return abs, nil @@ -112,8 +105,33 @@ func ResolveCatalogFile(flagValue string) (string, error) { return flagValue, nil } -// File helpers live in pkg/utils; these names keep existing callers unchanged. var ( - FileExists = utils.FileExists - IsDir = utils.IsDir + FileExists = fileio.FileExists + IsDir = fileio.IsDir ) + +// ChartDir returns a --chart flag value as an absolute path, or "" when it is +// empty. The path must be a Helm chart directory: e2e may change directory +// before installing. +func ChartDir(chart string) (string, error) { + if chart == "" { + return "", nil + } + abs, err := filepath.Abs(chart) + if err != nil { + return "", fmt.Errorf("--chart %q: %w", chart, err) + } + if _, err := os.Stat(filepath.Join(abs, "Chart.yaml")); err != nil { + return "", fmt.Errorf("--chart %q: not a Helm chart directory (no Chart.yaml)", chart) + } + return abs, nil +} + +// HelmSetArgs turns --set flag values into helm arguments: "--set", value, … +func HelmSetArgs(sets []string) []string { + var args []string + for _, s := range sets { + args = append(args, "--set", s) + } + return args +} diff --git a/cmd/cli/cmdutil/files_dev.go b/cmd/cli/cmdutil/files_dev.go index e5524c155..614181b68 100644 --- a/cmd/cli/cmdutil/files_dev.go +++ b/cmd/cli/cmdutil/files_dev.go @@ -13,10 +13,10 @@ import ( "github.com/intentd/inrun/pkg/registry" ) -// ResolveOCIRunPath resolves an OCI pattern reference to a local catalog or +// ResolveOCIRunPath resolves an OCI pattern reference to a local blueprint or // stack path, pulling to the cache if necessary. func ResolveOCIRunPath(ctx context.Context, ref string, useStack, refresh bool) (string, error) { - r, err := registry.ResolveForKind(ref, registry.CatalogKind) + r, err := registry.ResolveForKind(ref, registry.BlueprintKind) if err != nil { return "", fmt.Errorf("invalid reference: %w", err) } @@ -41,7 +41,7 @@ func ResolveOCIRunPath(ctx context.Context, ref string, useStack, refresh bool) return "", err } - target := FileCatalog + target := FileBlueprint if useStack { target = fileStack } diff --git a/cmd/cli/cmdutil/helper.go b/cmd/cli/cmdutil/helper.go index d305d1ec8..4e52785f0 100644 --- a/cmd/cli/cmdutil/helper.go +++ b/cmd/cli/cmdutil/helper.go @@ -14,10 +14,14 @@ import ( "strings" "time" - "github.com/intentd/inrun/pkg/catalog" + "github.com/intentd/inrun/pkg/util/fileio" + "github.com/intentd/inrun/pkg/util/kube" + "github.com/intentd/inrun/pkg/util/structured" + "github.com/intentd/inrun/pkg/util/term" + + "github.com/intentd/inrun/pkg/blueprint" "github.com/intentd/inrun/pkg/registry" "github.com/intentd/inrun/pkg/types" - "github.com/intentd/inrun/pkg/utils" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) @@ -28,61 +32,61 @@ import ( var ( // colors / styles - Gray = utils.Gray - Bold = utils.Bold - Dim = utils.Dim - Cyan = utils.Cyan - Green = utils.Green - Blue = utils.Blue - White = utils.White - Yellow = utils.Yellow - Red = utils.Red - magenta = utils.Magenta + Gray = term.Gray + Bold = term.Bold + Dim = term.Dim + Cyan = term.Cyan + Green = term.Green + Blue = term.Blue + White = term.White + Yellow = term.Yellow + Red = term.Red + magenta = term.Magenta // marks and icons - SuccessMark = utils.SuccessMark - FailureMark = utils.FailureMark - WarningMark = utils.WarningMark - InfoMark = utils.InfoMark - SecureMark = utils.SecureMark - SomeSecureMark = utils.SomeSecureMark - NoSecurityMark = utils.NoSecurityMark - HealthIcon = utils.HealthIcon - HealthIconReady = utils.HealthIconReady - HealthIconWarn = utils.HealthIconWarning - HealthIconInfo = utils.HealthIconInfo + SuccessMark = term.SuccessMark + FailureMark = term.FailureMark + WarningMark = term.WarningMark + InfoMark = term.InfoMark + SecureMark = term.SecureMark + SomeSecureMark = term.SomeSecureMark + NoSecurityMark = term.NoSecurityMark + HealthIcon = term.HealthIcon + HealthIconReady = term.HealthIconReady + HealthIconWarn = term.HealthIconWarning + HealthIconInfo = term.HealthIconInfo // other cli utilities - IsRunningInPod = utils.IsRunningInPod - WriteFileAndFormat = utils.WriteFileAndFormat - SplitCommaSeparated = utils.SplitCommaSeparated - ReadLocal = utils.ReadLocal - StrictUnmarshal = utils.StrictUnmarshal - PruneEmptyYAML = utils.PruneEmptyYAML - CopyFile = utils.CopyFile - CopyDir = utils.CopyDir - JoinPath = utils.JoinRelative - FormatSize = utils.FormatSize - WordWrap = utils.WordWrap - visibleLen = utils.VisibleLen - PadRight = utils.PadRight - OrDefault = utils.OrDefault - ContainsTag = utils.ContainsFold - IsTerminal = utils.IsTerminal - HumanDuration = utils.FormatDuration - DumpCRsJSON = utils.DumpCRsJSON - DumpCRsYAML = utils.DumpCRsYAML + IsRunningInPod = kube.IsRunningInPod + WriteFileAndFormat = structured.WriteFileAndFormat + SplitCommaSeparated = structured.SplitCommaSeparated + ReadLocal = fileio.ReadLocal + StrictUnmarshal = structured.StrictUnmarshal + PruneEmptyYAML = structured.PruneEmptyYAML + CopyFile = fileio.CopyFile + CopyDir = fileio.CopyDir + JoinPath = fileio.JoinRelative + FormatSize = term.FormatSize + WordWrap = term.WordWrap + visibleLen = term.VisibleLen + PadRight = term.PadRight + OrDefault = term.OrDefault + ContainsTag = term.ContainsFold + IsTerminal = term.IsTerminal + HumanDuration = term.FormatDuration + DumpCRsJSON = kube.DumpCRsJSON + DumpCRsYAML = kube.DumpCRsYAML ) // SortedKeys returns a sorted slice of all keys from the given map. func SortedKeys[V any](m map[string]V) []string { - return utils.SortedKeys(m) + return structured.SortedKeys(m) } // StartSpinner starts a terminal progress spinner with the given message. // Call Success, Failure, or Stop on the returned value when done. -func StartSpinner(msg string) *utils.Spinner { - return utils.StartSpinner(msg) +func StartSpinner(msg string) *term.Spinner { + return term.StartSpinner(msg) } // Shadow global command flags @@ -128,18 +132,18 @@ func ShadowGlobalCommandFlags(cmd *cobra.Command, flags ...string) { // PrintTemplateSummary prints the human-readable default output of `inrun template`. // CRDs are listed in startup order so the user sees the dependency sequence. -func PrintTemplateSummary(cat *catalog.Catalog, crds map[string]types.CRDEntry, startupOrder []string) { - meta := cat.Metadata() +func PrintTemplateSummary(bp *blueprint.Blueprint, crds map[string]types.CRDEntry, startupOrder []string) { + meta := bp.Metadata() - fmt.Printf("\n%s", Cyan(Bold("Catalog"))) + fmt.Printf("\n%s", Cyan(Bold("Blueprint"))) if meta.Name != "" { fmt.Printf(": %s", Bold(meta.Name)) } if meta.Version != "" { fmt.Printf(" %s", Dim("("+meta.Version+")")) } - if cat.APIVersion != "" { - fmt.Printf(" %s", Dim(cat.APIVersion)) + if bp.APIVersion != "" { + fmt.Printf(" %s", Dim(bp.APIVersion)) } fmt.Println() @@ -238,8 +242,8 @@ func PrintTemplateSummary(cat *catalog.Catalog, crds map[string]types.CRDEntry, // Autoscale if crd.AutoscaleEnabled() { a := crd.Box().EffectiveAutoscale() - if a.Profile != "" { - fmt.Printf("%s%s profile=%s\n", indent, magenta("autoscale:"), a.Profile) + if a.Preset != "" { + fmt.Printf("%s%s preset=%s\n", indent, magenta("autoscale:"), a.Preset) } else { parts := []string{} if len(a.Conditions.When) > 0 { @@ -266,7 +270,7 @@ func PrintTemplateSummary(cat *catalog.Catalog, crds map[string]types.CRDEntry, fmt.Println() } - fmt.Printf(" %s\n\n", Green("✓ Catalog is valid")) + fmt.Printf(" %s\n\n", Green("✓ Blueprint is valid")) } // ── printDependencyGraph ────────────────────────────────────────────────────── @@ -274,7 +278,7 @@ func PrintTemplateSummary(cat *catalog.Catalog, crds map[string]types.CRDEntry, // PrintDependencyGraph prints a two-part dependency view: // 1. Ordered startup list (flat) // 2. Tree view showing the dependency hierarchy -func PrintDependencyGraph(crds map[string]types.CRDEntry, g *catalog.DependencyGraph, startupOrder []string) { +func PrintDependencyGraph(crds map[string]types.CRDEntry, g *blueprint.DependencyGraph, startupOrder []string) { fmt.Printf("\n%s\n\n", Cyan(Bold("Dependency Graph"))) // ── Part 1: startup order list ──────────────────────────────────────────── @@ -333,7 +337,7 @@ func PrintDependencyGraph(crds map[string]types.CRDEntry, g *catalog.DependencyG } // printGraphNode recursively prints one CRD node and its dependents in tree form. -func printGraphNode(crds map[string]types.CRDEntry, g *catalog.DependencyGraph, name, indent, connector string, printed map[string]bool) { +func printGraphNode(crds map[string]types.CRDEntry, g *blueprint.DependencyGraph, name, indent, connector string, printed map[string]bool) { crd, ok := crds[name] if !ok { return @@ -378,7 +382,7 @@ func printGraphNode(crds map[string]types.CRDEntry, g *catalog.DependencyGraph, // PrintCRDDetail prints the full expanded state of a single CRD as a // human-readable document — what the runtime will use for this CRD. -func PrintCRDDetail(crd types.CRDEntry, g *catalog.DependencyGraph) { +func PrintCRDDetail(crd types.CRDEntry, g *blueprint.DependencyGraph) { fmt.Printf("\n%s\n", Bold(crd.Name)) fmt.Printf(" %s %s/%s\n", Cyan("APIVersion:"), crd.APITypes.Group, crd.APITypes.Version) fmt.Printf(" %s %s (plural: %s)\n", Cyan("Kind: "), crd.APITypes.Kind, crd.APITypes.Plural) @@ -440,8 +444,8 @@ func PrintCRDDetail(crd types.CRDEntry, g *catalog.DependencyGraph) { if crd.AutoscaleEnabled() { a := crd.Box().EffectiveAutoscale() fmt.Printf(" %s\n", magenta(Bold("Autoscale"))) - if a.Profile != "" { - fmt.Printf(" Profile: %s\n", magenta(a.Profile)) + if a.Preset != "" { + fmt.Printf(" Preset: %s\n", magenta(a.Preset)) } else { fmt.Printf(" Interval: %s\n", a.EffectiveInterval()) fmt.Printf(" Cooldown: %s\n", a.EffectiveCooldown()) @@ -721,7 +725,7 @@ func PrintTypedBuildSteps(hasMakefile bool) { // PrintTypedOperatorHint is called when a registry-sourced typed operator fails // validation or simulate. Tells the user to pull the pattern, build the custom // runtime, then re-run the same command. -func PrintTypedOperatorHint(err *catalog.TypedOperatorError, command string) { +func PrintTypedOperatorHint(err *blueprint.TypedOperatorError, command string) { fmt.Printf("\n%s This operator is typed — requires a custom runtime.\n\n", Yellow("⚠")) fmt.Printf(" Pull and build, then re-run:\n") fmt.Printf(" inrun pull %s -o .\n", err.Ref) @@ -731,14 +735,14 @@ func PrintTypedOperatorHint(err *catalog.TypedOperatorError, command string) { // ── crdModeLabel ────────────────────────────────────────────────────────────── -// PrintCatalogDeprecation prints the deprecation notice for a locally validated -// catalog. Reads timeline state from the CatalogDeprecation methods. +// PrintBlueprintDeprecation prints the deprecation notice for a locally validated +// blueprint. Reads timeline state from the BlueprintDeprecation methods. // Prints nothing if the block is nil or today is before timeline.from. -func PrintCatalogDeprecation(d *types.CatalogDeprecation) { - PrintCatalogDeprecationWithHint(d, "") +func PrintBlueprintDeprecation(d *types.BlueprintDeprecation) { + PrintBlueprintDeprecationWithHint(d, "") } -func PrintCatalogDeprecationWithHint(d *types.CatalogDeprecation, hint string) { +func PrintBlueprintDeprecationWithHint(d *types.BlueprintDeprecation, hint string) { if d == nil { return } @@ -756,7 +760,7 @@ func PrintPatternDeprecation(dep *registry.PatternDeprecated) { if dep == nil { return } - d := &types.CatalogDeprecation{ + d := &types.BlueprintDeprecation{ MigratedTo: dep.MigratedTo, Message: dep.Message, } @@ -766,7 +770,7 @@ func PrintPatternDeprecation(dep *registry.PatternDeprecated) { To: dep.TimelineTo, } } - PrintCatalogDeprecation(d) + PrintBlueprintDeprecation(d) } // printDeprecationBlock renders the deprecation block for a given state. diff --git a/cmd/cli/cmdutil/root.go b/cmd/cli/cmdutil/root.go index 9b8dc174e..3da5a32e2 100644 --- a/cmd/cli/cmdutil/root.go +++ b/cmd/cli/cmdutil/root.go @@ -5,11 +5,12 @@ import ( "os" "path/filepath" + "github.com/intentd/inrun/pkg/util/term" + "github.com/spf13/cobra" "github.com/intentd/inrun/pkg/config" "github.com/intentd/inrun/pkg/logger" - "github.com/intentd/inrun/pkg/utils" ) var ( @@ -27,8 +28,8 @@ func Execute(k *config.Config, c context.Context) { Cfg = k Ctx = c - if err := RootCmd.Execute(); err != nil { - utils.Exit(err) + if err := RootCmd.ExecuteContext(c); err != nil { + term.Exit(err) } } @@ -43,7 +44,7 @@ func init() { // Global flags — always present in both runtime and dev builds RootCmd.PersistentFlags().Bool("debug", false, "Enable debug logging") - RootCmd.PersistentFlags().StringSliceP("file", "f", nil, "Path(s) or URL(s) to catalog.yaml (repeatable)") + RootCmd.PersistentFlags().StringSliceP("file", "f", nil, "Path(s) or URL(s) to blueprint.yaml (repeatable)") // Dev-only flags (--kubeconfig, --verbose) and required-flag marking for // dev commands are registered in root_dev.go (//go:build !runtime). } diff --git a/cmd/cli/commands_dev.go b/cmd/cli/commands_dev.go index 137620877..d49c15f87 100644 --- a/cmd/cli/commands_dev.go +++ b/cmd/cli/commands_dev.go @@ -5,11 +5,12 @@ package cli import ( _ "github.com/intentd/inrun/cmd/cli/cluster" _ "github.com/intentd/inrun/cmd/cli/console" + _ "github.com/intentd/inrun/cmd/cli/functions" _ "github.com/intentd/inrun/cmd/cli/gate" _ "github.com/intentd/inrun/cmd/cli/generate" _ "github.com/intentd/inrun/cmd/cli/migrate" - _ "github.com/intentd/inrun/cmd/cli/notes" _ "github.com/intentd/inrun/cmd/cli/registry" + _ "github.com/intentd/inrun/cmd/cli/rewrite" _ "github.com/intentd/inrun/cmd/cli/run" _ "github.com/intentd/inrun/cmd/cli/scaffold" _ "github.com/intentd/inrun/cmd/cli/self" diff --git a/cmd/cli/console/console.go b/cmd/cli/console/console.go index e41decc88..73c1485c6 100644 --- a/cmd/cli/console/console.go +++ b/cmd/cli/console/console.go @@ -32,15 +32,15 @@ func init() { controlCmd.Flags().StringVarP(&consolePort, "port", "p", "8081", "Port to run the Console on") controlCmd.Flags().BoolVarP(&consoleIgnoreDefault, "ignore-default", "i", false, "Do not add the default localhost:8080 URL; start with no instances") controlCmd.Flags().StringVarP(&consoleURLs, "urls", "u", "http://localhost:8080", "Comma-separated list of Inrun runtime URLs") - controlCmd.Flags().StringVar(&consoleRefresh, "refresh", "10s", "Refresh interval for fetching Catalogs") + controlCmd.Flags().StringVar(&consoleRefresh, "refresh", "10s", "Refresh interval for fetching Blueprints") controlCmd.Flags().StringVar(&consoleLogLevel, "log-level", "info", "Log level (debug, info, warn, error)") controlCmd.AddCommand(controlVersionCmd) cmdutil.RootCmd.AddCommand(controlCmd) // Shadow global flags so they don't appear under `inrun console` - controlCmd.Flags().StringSlice("catalog", nil, "") - controlCmd.Flags().MarkHidden("catalog") + controlCmd.Flags().StringSlice("blueprint", nil, "") + controlCmd.Flags().MarkHidden("blueprint") cmdutil.ShadowGlobalCommandFlags(controlCmd) } diff --git a/cmd/cli/functions/functions.go b/cmd/cli/functions/functions.go new file mode 100644 index 000000000..d397baf17 --- /dev/null +++ b/cmd/cli/functions/functions.go @@ -0,0 +1,204 @@ +//go:build !runtime && !gateway + +// package function implements the functions command for browsing and searching the +// template functions available in Blueprint expressions. +package function + +import ( + "fmt" + "os" + "os/exec" + "strings" + "text/tabwriter" + + "github.com/intentd/inrun/cmd/cli/cmdutil" + + "github.com/intentd/inrun/pkg/merger" + "github.com/intentd/inrun/pkg/template/funcs" + "github.com/intentd/inrun/pkg/types" + "github.com/intentd/kel" + "github.com/spf13/cobra" +) + +var functionsCmd = &cobra.Command{ + Use: "functions", + Short: "Browse and search Inrun functions", + Long: `List, search, and inspect Inrun functions — the template functions available +in every Blueprint expression. + + inrun functions list all functions + inrun functions --domain strings filter by domain + inrun functions search full-text search + inrun functions show show full detail for one function`, + RunE: func(cmd *cobra.Command, args []string) error { + domain, _ := cmd.Flags().GetString("domain") + noPager, _ := cmd.Flags().GetBool("no-pager") + files, _ := cmd.Flags().GetStringSlice("file") + expanded := cmdutil.ParseFilePaths(files) + + // When -f is provided, show user-defined functions from the Blueprint. + if len(expanded) > 0 { + m := merger.New(expanded...) + if err := m.Merge(); err != nil { + return fmt.Errorf("loading blueprint: %w", err) + } + reg := m.ToFunctions() + if reg.Empty() { + fmt.Println("No user-defined functions declared in this Blueprint.") + return nil + } + return printUserFunctionTable(reg, noPager) + } + + var fns []kel.FunctionInfo + if domain != "" { + fns = funcs.Registry().ByDomain(domain) + if len(fns) == 0 { + return fmt.Errorf("no functions found for domain %q — run `inrun functions` to see available domains", domain) + } + } else { + fns = funcs.Registry().Functions + } + return printFunctionTable(fns, noPager) + }, +} + +var functionsSearchCmd = &cobra.Command{ + Use: "search ", + Short: "Search functions by name, description, or example", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + noPager, _ := cmd.Flags().GetBool("no-pager") + results := funcs.Registry().Search(args[0]) + if len(results) == 0 { + fmt.Printf("no functions match %q\n", args[0]) + return nil + } + fmt.Printf("Found %d function(s) matching %q:\n\n", len(results), args[0]) + return printFunctionTable(results, noPager) + }, +} + +var functionsShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show full documentation for a single function", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + noPager, _ := cmd.Flags().GetBool("no-pager") + n, ok := funcs.Registry().Get(args[0]) + if !ok { + return fmt.Errorf("function %q not found — run `inrun functions search %s` to find similar functions", args[0], args[0]) + } + return printFunctionDetail(n, noPager) + }, +} + +var functionsDomainCmd = &cobra.Command{ + Use: "domains", + Short: "List available function domains", + RunE: func(cmd *cobra.Command, args []string) error { + reg := funcs.Registry() + fmt.Println("Available domains:") + fmt.Println() + for _, d := range reg.Domains { + fmt.Printf(" %-16s %-22s %d functions\n", d.Name, d.Title, len(reg.ByDomain(d.Name))) + } + return nil + }, +} + +func init() { + functionsCmd.Flags().StringP("domain", "d", "", "Filter by domain (e.g. strings, cron, kubernetes)") + functionsCmd.Flags().Bool("no-pager", false, "Print directly without paging") + functionsSearchCmd.Flags().Bool("no-pager", false, "Print directly without paging") + functionsShowCmd.Flags().Bool("no-pager", false, "Print directly without paging") + + functionsCmd.AddCommand(functionsSearchCmd) + functionsCmd.AddCommand(functionsShowCmd) + functionsCmd.AddCommand(functionsDomainCmd) + cmdutil.RootCmd.AddCommand(functionsCmd) + + // Shadow global flags + cmdutil.ShadowGlobalCommandFlags(functionsCmd) +} + +// ── Rendering ───────────────────────────────────────────────────────────────── + +func printFunctionTable(functions []kel.FunctionInfo, noPager bool) error { + var sb strings.Builder + w := tabwriter.NewWriter(&sb, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "DOMAIN\tNAME\tDESCRIPTION") + fmt.Fprintln(w, "──────\t────\t───────────") + for _, n := range functions { + desc := n.Description + if len(desc) > 72 { + desc = desc[:69] + "..." + } + fmt.Fprintf(w, "%s\t%s\t%s\n", n.Domain, n.Name, desc) + } + w.Flush() + return page(sb.String(), noPager) +} + +func printFunctionDetail(n kel.FunctionInfo, noPager bool) error { + sep := strings.Repeat("─", 64) + var sb strings.Builder + fmt.Fprintln(&sb, sep) + fmt.Fprintf(&sb, " %s\n", n.Name) + fmt.Fprintln(&sb, sep) + fmt.Fprintf(&sb, " Domain: %s\n", n.Domain) + if n.Description != "" { + fmt.Fprintf(&sb, " Description: %s\n", n.Description) + } + if len(n.Keywords) > 0 { + fmt.Fprintf(&sb, " Keywords: %s\n", strings.Join(n.Keywords, ", ")) + } + if len(n.SeeAlso) > 0 { + fmt.Fprintf(&sb, " See also: %s\n", strings.Join(n.SeeAlso, ", ")) + } + if n.Example != "" { + fmt.Fprintln(&sb) + fmt.Fprintln(&sb, " Example:") + for _, line := range strings.Split(n.Example, "\n") { + fmt.Fprintf(&sb, " %s\n", line) + } + } + fmt.Fprintln(&sb, sep) + return page(sb.String(), noPager) +} + +// page writes output through `less` when stdout is a terminal, otherwise prints directly. +func page(content string, noPager bool) error { + if noPager || !cmdutil.IsTerminal() { + fmt.Print(content) + return nil + } + pager := exec.Command("less", "-RFX") + pager.Stdin = strings.NewReader(content) + pager.Stdout = os.Stdout + pager.Stderr = os.Stderr + if err := pager.Run(); err != nil { + fmt.Print(content) + } + return nil +} + +func printUserFunctionTable(reg types.KELFunctionRegistry, noPager bool) error { + var sb strings.Builder + w := tabwriter.NewWriter(&sb, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tEXPRESSION\tDESCRIPTION") + fmt.Fprintln(w, "────\t──────────\t───────────") + for _, n := range reg.Definitions { + expr := n.Expression + if len(expr) > 60 { + expr = expr[:57] + "..." + } + desc := n.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + fmt.Fprintf(w, "%s\t%s\t%s\n", n.Name, expr, desc) + } + w.Flush() + return page(sb.String(), noPager) +} diff --git a/cmd/cli/gate/admission.go b/cmd/cli/gate/admission.go index 0528ad9ff..bcef101a0 100644 --- a/cmd/cli/gate/admission.go +++ b/cmd/cli/gate/admission.go @@ -5,10 +5,11 @@ package gate import ( "fmt" + "github.com/intentd/inrun/pkg/util/structured" + "github.com/intentd/inrun/pkg/runtime/coordinator/prepare" "github.com/intentd/inrun/pkg/template" "github.com/intentd/inrun/pkg/types" - "github.com/intentd/inrun/pkg/utils" ) // AdmissionViolation is a single fired validation rule — deny or warn. @@ -64,9 +65,9 @@ type AdmissionMutationResult struct { // applyMutationPreviews returns a deep copy of obj with all mutation previews // applied. Used to simulate mutateFirst: true behaviour locally. func applyMutationPreviews(obj map[string]interface{}, previews []AdmissionMutationPreview) map[string]interface{} { - clone := utils.DeepCopyMap(obj) + clone := structured.DeepCopyMap(obj) for _, p := range previews { - _ = utils.SetNestedPath(clone, p.Field, p.To) + _ = structured.SetNestedPath(clone, p.Field, p.To) } return clone } diff --git a/cmd/cli/gate/gate.go b/cmd/cli/gate/gate.go index 7ed82d322..eb88b1a24 100644 --- a/cmd/cli/gate/gate.go +++ b/cmd/cli/gate/gate.go @@ -9,6 +9,7 @@ import ( "github.com/intentd/inrun/cmd/cli/cmdutil" "github.com/intentd/inrun/cmd/internal" + "github.com/intentd/inrun/pkg/gateway/app" "github.com/intentd/inrun/pkg/logger" "github.com/intentd/inrun/pkg/merger" "github.com/spf13/cobra" @@ -22,28 +23,31 @@ func runGateway(cmd *cobra.Command, args []string) error { paths = cmdutil.DefaultFilePaths() } if len(paths) == 0 { - paths = cmdutil.Cfg.Catalog().Paths() + paths = cmdutil.Cfg.Blueprint().Paths() } if len(paths) == 0 { - return fmt.Errorf(cmdutil.ErrNoCatalog) + return fmt.Errorf(cmdutil.ErrNoBlueprint) } + doMigrate, _ := cmd.Flags().GetBool("migrate") + m := merger.New(paths...) if err := m.Merge(); err != nil { - return fmt.Errorf("merging catalogs: %w", err) + return fmt.Errorf("merging blueprints: %w", err) } logger.Debug(). - Str("catalogs", strings.Join(paths, ", ")). + Str("blueprints", strings.Join(paths, ", ")). Int("total", m.Count()). Int("enabled", m.EnabledCount()). - Msg("catalogs merged") + Msg("blueprints merged") - internal.RunGateway(cmdutil.Cfg, m, cmdutil.Ctx) + internal.RunGateway(cmdutil.Ctx, cmdutil.Cfg, m, app.GatewayOptions{Migrate: doMigrate}) return nil } func init() { cmdutil.RootCmd.Args = cobra.NoArgs cmdutil.RootCmd.RunE = runGateway + cmdutil.RootCmd.Flags().Bool("migrate", false, "Run schema migration before starting the gateway") } diff --git a/cmd/cli/gate/gate_dev.go b/cmd/cli/gate/gate_dev.go index aa9a939d8..1c43f4a26 100644 --- a/cmd/cli/gate/gate_dev.go +++ b/cmd/cli/gate/gate_dev.go @@ -9,7 +9,7 @@ package gate // process to start, but operators still need to validate admission rules before // deploying. This command fills that gap: // -// inrun gate -f catalog.yaml --cr cr.yaml +// inrun gate -f blueprint.yaml --cr cr.yaml // // It evaluates validation.rules and previews mutation.rules in-process, using // the same EvaluateConditions + EvaluateValidationRule logic as the webhook and @@ -25,93 +25,135 @@ import ( "strings" "github.com/intentd/inrun/cmd/cli/cmdutil" - "github.com/intentd/inrun/cmd/internal" - "github.com/intentd/inrun/pkg/catalog" - "github.com/intentd/inrun/pkg/catalog/pipeline" + "github.com/intentd/inrun/pkg/blueprint" + "github.com/intentd/inrun/pkg/blueprint/pipeline" + "github.com/intentd/inrun/pkg/gateway/app" "github.com/intentd/inrun/pkg/merger" "github.com/intentd/inrun/pkg/template" "github.com/intentd/inrun/pkg/types" "github.com/spf13/cobra" ) -var gatewayCmd = &cobra.Command{ - Use: "gate", - Short: "Evaluate admission rules locally against a CR (no cluster required)", - Long: `Evaluate admission rules locally against a CR. +var gateRunCmd = &cobra.Command{ + Use: "run", + Short: "Start the gateway locally (HTTP only, no TLS, no admission webhooks)", + Long: `Start the Inrun Gateway in local HTTP mode. -Reads the validation.rules declared in the Catalog and runs them against the -provided CR using the same evaluation logic as the admission webhook and the -reconciler. No cluster, no TLS, no webhook server required. +The Gateway API (POST /api/v1/apply, GET /api/v1/resources/, intake webhooks) +runs on the health port (default :8080). Admission and conversion webhooks are +disabled — TLS and a live cluster are required for those. -Limitations: - operator: unique — skipped (no live informer cache) - external: calls — skipped (no real endpoint to call) -Both are noted in the output. +Use this to test serve routing, apply flows, and intake payloads without a +Helm deployment. Example: - inrun gate -f catalog.yaml --cr cr.yaml`, + inrun gate run -f blueprint.yaml`, RunE: func(cmd *cobra.Command, args []string) error { - crFile, _ := cmd.Flags().GetString("cr") - if crFile == "" { - crFile = cmdutil.FindFile(cmdutil.FileCr, cmdutil.DirManifests) - } - paths, _ := cmd.Flags().GetStringSlice("file") if len(paths) == 0 { paths = cmdutil.DefaultFilePaths() } if len(paths) == 0 { - return fmt.Errorf(cmdutil.ErrNoCatalog) + return fmt.Errorf(cmdutil.ErrNoBlueprint) } + migrate, _ := cmd.Flags().GetBool("migrate") + m := merger.New(paths...) if err := m.Merge(); err != nil { - return fmt.Errorf("merging Catalog: %w", err) - } - cat, err := pipeline.BuildExpanded(cmdutil.Cfg, m) - if err != nil { - return fmt.Errorf("parsing Catalog: %w", err) + return fmt.Errorf("merging blueprints: %w", err) } - crData, err := cmdutil.ReadLocal(crFile) - if err != nil { - return fmt.Errorf("reading CR %q: %w", crFile, err) - } - crs := cmdutil.ParseMultiDocCRs(crData) - if len(crs) == 0 { - return fmt.Errorf("no valid CR documents found in %s", crFile) - } + internal.RunGateway(cmdutil.Ctx, cmdutil.Cfg, m, app.GatewayOptions{Migrate: migrate}) + return nil + }, +} - fmt.Println() - fmt.Printf("%s inrun gate\n", cmdutil.Bold("▶")) - fmt.Printf(" %s %s\n", cmdutil.Gray("cr: "), cmdutil.Cyan(crFile)) - fmt.Printf(" %s %s\n", cmdutil.Gray("catalog:"), cmdutil.Cyan(strings.Join(paths, ", "))) - fmt.Println() - - var anyDenied bool - for _, name := range cat.CRDNames() { - crdEntry, ok := cat.CRDEntry(name) - if !ok { - continue - } - in, ok := cmdutil.ResolveCRInputs(crs, crdEntry.APITypes.Kind) - if !ok { - continue - } - anyDenied = gateEvalCRD(cat, &crdEntry, in.CR.Object) || anyDenied - } +func init() { + // Wire admission evaluator as the default action for bare `inrun gate`. + gatewayCmd.RunE = gateEvalRunE + gatewayCmd.Long = `Evaluate admission rules locally against a CR. + +Reads the validation.rules declared in the Blueprint and runs them against the +provided CR using the same evaluation logic as the admission webhook and the +reconciler. No cluster, no TLS, no webhook server required. + +Limitations: + unique: true — skipped (no live informer cache) + external: calls — skipped (no real endpoint to call) +Both are noted in the output. + +Example: + inrun gate -f blueprint.yaml --cr cr.yaml` + + gatewayCmd.AddCommand(gateRunCmd) + gatewayCmd.Flags().String("cr", "", "CR file to evaluate (default: cr.yaml)") + gateRunCmd.Flags().StringSliceP("file", "f", nil, "Path(s) to blueprint.yaml (repeatable)") + gateRunCmd.Flags().Bool("migrate", false, "Run schema migration before starting the gateway") + cmdutil.ShadowGlobalCommandFlags(gateRunCmd) +} + +func gateEvalRunE(cmd *cobra.Command, args []string) error { + crFile, _ := cmd.Flags().GetString("cr") + if crFile == "" { + crFile = cmdutil.FindFile(cmdutil.FileCr) + } + + paths, _ := cmd.Flags().GetStringSlice("file") + if len(paths) == 0 { + paths = cmdutil.DefaultFilePaths() + } + if len(paths) == 0 { + return fmt.Errorf(cmdutil.ErrNoBlueprint) + } + + m := merger.New(paths...) + if err := m.Merge(); err != nil { + return fmt.Errorf("merging Blueprint: %w", err) + } + bp, err := pipeline.BuildExpanded(cmdutil.Cfg, m) + if err != nil { + return fmt.Errorf("parsing Blueprint: %w", err) + } + + crData, err := cmdutil.ReadLocal(crFile) + if err != nil { + return fmt.Errorf("reading CR %q: %w", crFile, err) + } + crs := cmdutil.ParseMultiDocCRs(crData) + if len(crs) == 0 { + return fmt.Errorf("no valid CR documents found in %s", crFile) + } - if anyDenied { - return fmt.Errorf("admission denied") + fmt.Println() + fmt.Printf("%s inrun gate\n", cmdutil.Bold("▶")) + fmt.Printf(" %s %s\n", cmdutil.Gray("cr: "), cmdutil.Cyan(crFile)) + fmt.Printf(" %s %s\n", cmdutil.Gray("blueprint:"), cmdutil.Cyan(strings.Join(paths, ", "))) + fmt.Println() + + var anyDenied bool + for _, name := range bp.CRDNames() { + crdEntry, ok := bp.CRDEntry(name) + if !ok { + continue } - return nil - }, + in, ok := cmdutil.ResolveCRInputs(crs, crdEntry.APITypes.Kind) + if !ok { + continue + } + anyDenied = gateEvalCRD(bp, &crdEntry, in.CR.Object) || anyDenied + } + + if anyDenied { + return fmt.Errorf("admission denied") + } + return nil } // gateEvalCRD evaluates validation rules and previews mutation rules for one // CRD+CR pair. Returns true if any deny-action validation rule fired. -func gateEvalCRD(cat *catalog.Catalog, crd *types.CRDEntry, obj map[string]interface{}) bool { +func gateEvalCRD(bp *blueprint.Blueprint, crd *types.CRDEntry, obj map[string]interface{}) bool { fmt.Printf("%s %s %s\n", cmdutil.Cyan("◆"), cmdutil.Bold(crd.Kind()), cmdutil.Gray(fmt.Sprintf("(%s)", crd.ServeTarget()))) if !crd.HasValidationRules() && !crd.HasMutationRules() { @@ -120,17 +162,17 @@ func gateEvalCRD(cat *catalog.Catalog, crd *types.CRDEntry, obj map[string]inter } // Limitation notes — printed once for the CRD. - hasUnique := hasUniqueRule(crd) + hasUnique := crd.EffectiveValidation().HasUniqueRule() hasValidationExternal := crd.EffectiveValidation() != nil && len(crd.EffectiveValidation().AdmissionExternal()) > 0 hasMutationExternal := crd.EffectiveMutation() != nil && len(crd.EffectiveMutation().AdmissionExternal()) > 0 if hasUnique { - fmt.Printf(" %s operator: unique — skipped (no live cluster)\n", cmdutil.Dim("note:")) + fmt.Printf(" %s unique: true — skipped (no live cluster)\n", cmdutil.Dim("note:")) } if hasValidationExternal || hasMutationExternal { fmt.Printf(" %s external: calls — skipped (no endpoint)\n", cmdutil.Dim("note:")) } - resolver := template.NewResolverFromMap(obj).WithUserNotes(cat.Notes) + resolver := template.NewResolverFromMap(obj).WithUserFunctions(bp.Functions) eval := resolver.TemplateEvaluator() // When mutateFirst is set the real webhook applies mutations before @@ -141,7 +183,7 @@ func gateEvalCRD(cat *catalog.Catalog, crd *types.CRDEntry, obj map[string]inter mutResult = EvalAdmissionMutation(obj, crd, resolver, eval) if len(mutResult.Previews) > 0 { validationObj = applyMutationPreviews(obj, mutResult.Previews) - validationResolver := template.NewResolverFromMap(validationObj).WithUserNotes(cat.Notes) + validationResolver := template.NewResolverFromMap(validationObj).WithUserFunctions(bp.Functions) resolver = validationResolver eval = resolver.TemplateEvaluator() } @@ -223,59 +265,3 @@ func printGateMutateResult(r AdmissionMutationResult) { fmt.Printf(" %s no mutations apply\n", cmdutil.Dim("note:")) } } - -func hasUniqueRule(crd *types.CRDEntry) bool { - v := crd.EffectiveValidation() - if v == nil { - return false - } - for _, r := range v.Rules { - if r.Operator == types.ConditionUnique { - return true - } - } - return false -} - -var gateRunCmd = &cobra.Command{ - Use: "run", - Short: "Start the gateway locally (HTTP only, no TLS, no admission webhooks)", - Long: `Start the Inrun Gateway in local HTTP mode. - -The Gateway API (POST /api/v1/apply, GET /api/v1/resources/, intake webhooks) -runs on the health port (default :8080). Admission and conversion webhooks are -disabled — TLS and a live cluster are required for those. - -Use this to test serve routing, apply flows, and intake payloads without a -Helm deployment. - -Example: - inrun gate run -f catalog.yaml`, - RunE: func(cmd *cobra.Command, args []string) error { - paths, _ := cmd.Flags().GetStringSlice("file") - if len(paths) == 0 { - paths = cmdutil.DefaultFilePaths() - } - if len(paths) == 0 { - return fmt.Errorf(cmdutil.ErrNoCatalog) - } - - m := merger.New(paths...) - if err := m.Merge(); err != nil { - return fmt.Errorf("merging catalogs: %w", err) - } - - internal.RunGatewayDev(cmdutil.Cfg, m, cmdutil.Ctx) - return nil - }, -} - -func init() { - cmdutil.RootCmd.AddCommand(gatewayCmd) - gatewayCmd.AddCommand(gateRunCmd) - gatewayCmd.Flags().StringSliceP("file", "f", nil, "Path(s) to catalog.yaml (repeatable)") - gatewayCmd.Flags().String("cr", "", "CR file to evaluate (default: cr.yaml)") - gateRunCmd.Flags().StringSliceP("file", "f", nil, "Path(s) to catalog.yaml (repeatable)") - cmdutil.ShadowGlobalCommandFlags(gatewayCmd) - cmdutil.ShadowGlobalCommandFlags(gateRunCmd) -} diff --git a/cmd/cli/gate/gate_parent.go b/cmd/cli/gate/gate_parent.go new file mode 100644 index 000000000..44b6f654b --- /dev/null +++ b/cmd/cli/gate/gate_parent.go @@ -0,0 +1,21 @@ +//go:build !runtime + +package gate + +import ( + "github.com/intentd/inrun/cmd/cli/cmdutil" + "github.com/spf13/cobra" +) + +// gatewayCmd is the "gate" parent command. It is available in both dev and +// gateway builds (//go:build !runtime) so that subcommands like "migrate" +// work in both environments. +var gatewayCmd = &cobra.Command{ + Use: "gate", + Short: "Gateway commands — admission evaluation, local dev server, schema migration", +} + +func init() { + cmdutil.RootCmd.AddCommand(gatewayCmd) + gatewayCmd.Flags().StringSliceP("file", "f", nil, "Path(s) to blueprint.yaml (repeatable)") +} diff --git a/cmd/cli/generate/generate.go b/cmd/cli/generate/generate.go index a0b050228..0dcc39573 100644 --- a/cmd/cli/generate/generate.go +++ b/cmd/cli/generate/generate.go @@ -12,8 +12,8 @@ import ( "github.com/intentd/inrun/cmd/cli/cmdutil" - "github.com/intentd/inrun/pkg/catalog" - "github.com/intentd/inrun/pkg/catalog/pipeline" + "github.com/intentd/inrun/pkg/blueprint" + "github.com/intentd/inrun/pkg/blueprint/pipeline" "github.com/intentd/inrun/pkg/merger" "github.com/intentd/inrun/pkg/tools/generate" "github.com/spf13/cobra" @@ -71,12 +71,12 @@ var generateCmd = &cobra.Command{ Short: "Generate Inrun components", } -// buildCatalogFromPath builds an expanded Catalog from a file path without +// buildBlueprintFromPath builds an expanded Blueprint from a file path without // requiring a cobra command context. Useful when the path is already known. -func buildCatalogFromPath(path string) (*catalog.Catalog, error) { +func buildBlueprintFromPath(path string) (*blueprint.Blueprint, error) { m := merger.New(path) if err := m.Merge(); err != nil { - return nil, fmt.Errorf("merging catalog: %w", err) + return nil, fmt.Errorf("merging blueprint: %w", err) } return pipeline.BuildExpanded(cmdutil.Cfg, m) } @@ -84,7 +84,7 @@ func buildCatalogFromPath(path string) (*catalog.Catalog, error) { var generateRbacCmd = &cobra.Command{ Use: "rbac", Short: "Generate RBAC ClusterRoles and ServiceAccounts for Inrun components", - Long: `Reads one or more catalog.yaml files, merges them, and generates minimal + Long: `Reads one or more blueprint.yaml files, merges them, and generates minimal ClusterRoles for the runtime and gateway processes, plus ServiceAccounts for all three components (runtime, gateway, console). @@ -92,21 +92,21 @@ Use --for to limit the output to specific components. By default all three are included. Multiple values are comma-separated. Examples: - inrun generate rbac -f catalog.yaml - inrun generate rbac -f catalog.yaml --for gateway - inrun generate rbac -f catalog.yaml --for runtime,console + inrun generate rbac -f blueprint.yaml + inrun generate rbac -f blueprint.yaml --for gateway + inrun generate rbac -f blueprint.yaml --for runtime,console inrun generate rbac -f a.yaml,b.yaml`, RunE: func(cmd *cobra.Command, args []string) error { - out, err := cmdutil.GenerateCatalog(cmd) + out, err := cmdutil.GenerateBlueprint(cmd) if err != nil { return err } namespace, _ := cmd.Flags().GetString("namespace") outputFile, _ := cmd.Flags().GetString("output") - cat, err := pipeline.BuildExpanded(cmdutil.Cfg, out.Merger) + bp, err := pipeline.BuildExpanded(cmdutil.Cfg, out.Merger) if err != nil { - return fmt.Errorf("build catalog: %w", err) + return fmt.Errorf("build blueprint: %w", err) } log.Println("generating rbac...") @@ -116,11 +116,11 @@ Examples: return err } - if !cat.IsGatewayEnabled() { + if !bp.IsGatewayEnabled() { opts.IncludeGateway = false } - runtimeRules := cat.GenerateRuntimeRBACRules() - gatewayRules := cat.GenerateGatewayRBACRules() + runtimeRules := bp.GenerateRuntimeRBACRules() + gatewayRules := bp.GenerateGatewayRBACRules() output, err := generate.RBACWithOptions(runtimeRules, gatewayRules, opts, namespace, outputFile) if err != nil { @@ -131,22 +131,22 @@ Examples: return err } - return writeClusterRBACFiles(cat, outputFile) + return writeClusterRBACFiles(bp, outputFile) }, } var generateConfigMapCmd = &cobra.Command{ Use: "configmap", - Short: "Generate a ConfigMap embedding a Catalog or Stack", - Long: `Reads a catalog.yaml or stack.yaml file and produces a ConfigMap -that embeds the file under data:. Useful for injecting Catalogs + Short: "Generate a ConfigMap embedding a Blueprint or Stack", + Long: `Reads a blueprint.yaml or stack.yaml file and produces a ConfigMap +that embeds the file under data:. Useful for injecting Blueprints into the in-cluster Inrun runtime. Example: - inrun generate configmap -f catalog.yaml + inrun generate configmap -f blueprint.yaml inrun generate configmap -f stack.yaml -n inrun-system -o out.yaml`, RunE: func(cmd *cobra.Command, args []string) error { - out, err := cmdutil.GenerateCatalog(cmd) + out, err := cmdutil.GenerateBlueprint(cmd) if err != nil { return err } @@ -154,16 +154,16 @@ Example: namespace, _ := cmd.Flags().GetString("namespace") outputFile, _ := cmd.Flags().GetString("output") - cat, err := pipeline.BuildExpanded(cmdutil.Cfg, out.Merger) + bp, err := pipeline.BuildExpanded(cmdutil.Cfg, out.Merger) if err != nil { - return fmt.Errorf("build catalog: %w", err) + return fmt.Errorf("build blueprint: %w", err) } log.Println("generating configmap...") - expanded, err := cat.SerializeExpanded() + expanded, err := bp.SerializeExpanded() if err != nil { - return fmt.Errorf("serialize catalog: %w", err) + return fmt.Errorf("serialize blueprint: %w", err) } cm, err := generate.ConfigMap(expanded, namespace) @@ -182,18 +182,18 @@ var generateBundleCmd = &cobra.Command{ • Namespace (default: 'inrun-system') • ServiceAccounts for runtime, gateway, and console • ClusterRoles and ClusterRoleBindings (one per process, minimal permissions) - • ConfigMap embedding your Catalog + • ConfigMap embedding your Blueprint Use --for to limit the output to specific components. By default all three are included. Multiple values are comma-separated. Examples: - inrun generate bundle -f catalog.yaml - inrun generate bundle -f catalog.yaml --for gateway - inrun generate bundle -f catalog.yaml --for runtime,console - inrun generate bundle -f catalog.yaml -o bundle.yaml -n inrun-system`, + inrun generate bundle -f blueprint.yaml + inrun generate bundle -f blueprint.yaml --for gateway + inrun generate bundle -f blueprint.yaml --for runtime,console + inrun generate bundle -f blueprint.yaml -o bundle.yaml -n inrun-system`, RunE: func(cmd *cobra.Command, args []string) error { - out, err := cmdutil.GenerateCatalog(cmd) + out, err := cmdutil.GenerateBlueprint(cmd) if err != nil { return err } @@ -202,9 +202,9 @@ Examples: workloadNamespace, _ := cmd.Flags().GetString("workload-namespace") outputFile, _ := cmd.Flags().GetString("output") - cat, err := pipeline.BuildExpanded(cmdutil.Cfg, out.Merger) + bp, err := pipeline.BuildExpanded(cmdutil.Cfg, out.Merger) if err != nil { - return fmt.Errorf("build catalog: %w", err) + return fmt.Errorf("build blueprint: %w", err) } opts, err := bundleOptsFromFor(cmd) @@ -214,15 +214,15 @@ Examples: log.Println("generating bundle...") - if !cat.IsGatewayEnabled() { + if !bp.IsGatewayEnabled() { opts.IncludeGateway = false } - runtimeRules := cat.GenerateRuntimeRBACRules() - gatewayRules := cat.GenerateGatewayRBACRules() + runtimeRules := bp.GenerateRuntimeRBACRules() + gatewayRules := bp.GenerateGatewayRBACRules() - expanded, err := cat.SerializeExpanded() + expanded, err := bp.SerializeExpanded() if err != nil { - return fmt.Errorf("serialize catalog: %w", err) + return fmt.Errorf("serialize blueprint: %w", err) } bundle, err := generate.RenderBundle(runtimeRules, gatewayRules, expanded, namespace, workloadNamespace, opts) @@ -234,7 +234,7 @@ Examples: return err } - return writeClusterRBACFiles(cat, outputFile) + return writeClusterRBACFiles(bp, outputFile) }, } @@ -242,8 +242,8 @@ Examples: // that has serve-enabled CRDs routed to it. Files land in the same directory as // outputFile (or the current directory when outputFile is empty or "-"). // Template-routed CRDs appear in every cluster file; a warning is printed for those. -func writeClusterRBACFiles(cat *catalog.Catalog, outputFile string) error { - clusterRules, templateKinds := cat.GenerateGatewayClusterRBACRules() +func writeClusterRBACFiles(bp *blueprint.Blueprint, outputFile string) error { + clusterRules, templateKinds := bp.GenerateGatewayClusterRBACRules() if len(clusterRules) == 0 { return nil } @@ -252,7 +252,7 @@ func writeClusterRBACFiles(cat *catalog.Catalog, outputFile string) error { } dir := clusterOutputDir(outputFile) - clusters := cat.GatewayClusters() + clusters := bp.GatewayClusters() if len(templateKinds) > 0 { crdTxt := "CRDs" @@ -307,9 +307,9 @@ func init() { generateCmd.AddCommand(generateConfigMapCmd) generateCmd.AddCommand(generateBundleCmd) - // All three commands use StringSliceP so generateCatalog can read them uniformly. + // All three commands use StringSliceP so generateBlueprint can read them uniformly. for _, cmd := range []*cobra.Command{generateConfigMapCmd, generateBundleCmd, generateRbacCmd} { - cmd.Flags().StringSliceP("file", "f", []string{}, "Path to catalog.yaml or stack.yaml (repeatable or comma-separated)") + cmd.Flags().StringSliceP("file", "f", []string{}, "Path to blueprint.yaml or stack.yaml (repeatable or comma-separated)") } generateRegistryCmd.Flags().StringP("dirs", "d", "", "Comma-separated list of project directories to generate registries for") @@ -342,5 +342,5 @@ func init() { // Shadow global flags so they don't appear under `inrun generate` cmdutil.ShadowGlobalCommandFlags(generateCmd) - cobra.MarkFlagRequired(generateCmd.Flags(), "catalog") + cobra.MarkFlagRequired(generateCmd.Flags(), "blueprint") } diff --git a/cmd/cli/generate/helper.go b/cmd/cli/generate/helper.go index 58432ee41..32f584932 100644 --- a/cmd/cli/generate/helper.go +++ b/cmd/cli/generate/helper.go @@ -98,7 +98,7 @@ func goGetModules(mods []string, timeoutPerModule time.Duration, dryRun bool) er return nil } - fmt.Printf("best-effort fetching %d module(s) referenced by catalog", len(mods)) + fmt.Printf("best-effort fetching %d module(s) referenced by blueprint", len(mods)) // ensure 'go' exists if _, err := exec.LookPath("go"); err != nil { diff --git a/cmd/cli/generate/registry.go b/cmd/cli/generate/registry.go index 853a46978..3d5a3ce74 100644 --- a/cmd/cli/generate/registry.go +++ b/cmd/cli/generate/registry.go @@ -19,7 +19,7 @@ import ( // Generate 'pkg/typeregistry/zz_generated_typeregistry.go' for one or more operator projects. // -// This command scans a Catalog, resolves all CRDs, typed extensions, and +// This command scans a Blueprint, resolves all CRDs, typed extensions, and // managed‑resource contracts, and produces a complete runtime registry: // // - CRD registrations @@ -43,30 +43,30 @@ import ( // Examples: // // # Generate registry for the current project -// inrun generate registry --file catalog.yaml +// inrun generate registry --file blueprint.yaml // -// # Generate registry for a specific catalog file -// inrun generate registry --file path/to/catalog.yaml +// # Generate registry for a specific blueprint file +// inrun generate registry --file path/to/blueprint.yaml // // # Generate registry for multiple operator projects // inrun generate registry --dirs ./website,./database,./pipeline // // # Dry‑run (print generated files without writing them) -// inrun generate registry --file catalog.yaml --dry-run +// inrun generate registry --file blueprint.yaml --dry-run // // Each project directory must contain: // - go.mod -// - a catalog.yaml (or --file pointing to one) +// - a blueprint.yaml (or --file pointing to one) // - pkg/typeregistry/ (created automatically if missing) // // The generated registry is deterministic and reflects the exact typed‑mode -// contracts declared in the Catalog (hooks, constructors, operatorBox). +// contracts declared in the Blueprint (hooks, constructors, operatorBox). var generateRegistryCmd = &cobra.Command{ Use: "registry", - Short: "Generate 'pkg/typeregistry/zz_generated_typeregistry.go' from a Catalog", + Short: "Generate 'pkg/typeregistry/zz_generated_typeregistry.go' from a Blueprint", Long: `Generate the Inrun runtime registry for one or multiple operator projects. -This command scans a Catalog, resolves all CRDs, typed extensions, and +This command scans a Blueprint, resolves all CRDs, typed extensions, and managed‑resource contracts, and produces the runtime registry used by the operator process. The registry includes: @@ -86,20 +86,20 @@ projects in one invocation using --dirs. Examples: # Generate registry for the current project - inrun generate registry --file catalog.yaml + inrun generate registry --file blueprint.yaml - # Generate registry for a specific catalog file - inrun generate registry --file path/to/catalog.yaml + # Generate registry for a specific blueprint file + inrun generate registry --file path/to/blueprint.yaml # Generate registries for multiple operator projects inrun generate registry --dirs ./database,./pipeline --file stack.yaml # Dry-run (print generated files without writing them) - inrun generate registry --file catalog.yaml --dry-run + inrun generate registry --file blueprint.yaml --dry-run Each project directory must contain: • go.mod - • a catalog.yaml + • a blueprint.yaml • pkg/typeregistry/ (created automatically if missing)`, RunE: func(cmd *cobra.Command, args []string) error { dryRun, _ := cmd.Flags().GetBool("dry-run") @@ -135,10 +135,10 @@ func generateRegistryForDir(dir string, cmd *cobra.Command, perModuleTimeout tim return fmt.Errorf("chdir %s: %w", dir, err) } - // Load catalog (from --file or default) - out, err := cmdutil.GenerateCatalog(cmd) + // Load blueprint (from --file or default) + out, err := cmdutil.GenerateBlueprint(cmd) if err != nil { - return fmt.Errorf("loading catalog: %w", err) + return fmt.Errorf("loading blueprint: %w", err) } // Validate Go project @@ -219,26 +219,25 @@ func ensureMainGo(root, moduleName string, dryRun bool) error { package main import ( - "context" + "context" - "github.com/intentd/inrun/cmd/cli" - "github.com/intentd/inrun/pkg/config" - "github.com/intentd/inrun/pkg/logger" - "github.com/intentd/inrun/pkg/utils" + "github.com/intentd/inrun/cmd/cli" + "github.com/intentd/inrun/pkg/config" + "github.com/intentd/inrun/pkg/util/term" - %s + %s ) func main() { - cfg, err := config.Init() - if err != nil { - logger.Fatal().AnErr("failed to load configurations", err) - utils.Exit(err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - cli.Execute(cfg, ctx) -}`, timestamp, importLine) + cfg, err := config.Init() + if err != nil { + term.FatalExit("failed to load configurations", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cli.Execute(cfg, ctx) +} +`, timestamp, importLine) if dryRun { fmt.Println(content) diff --git a/cmd/cli/migrate/clean.go b/cmd/cli/migrate/clean.go new file mode 100644 index 000000000..1ef58d25d --- /dev/null +++ b/cmd/cli/migrate/clean.go @@ -0,0 +1,35 @@ +//go:build !runtime + +package migrate + +import ( + "github.com/intentd/inrun/pkg/migrate" + "github.com/spf13/cobra" +) + +var cleanCmd = &cobra.Command{ + Use: "clean", + Short: "Remove migration annotations without touching the spec", + Long: `Remove the migration snapshot (inrun.dev/migrate-field-snapshot or +inrun.dev/migrate-kind-snapshot) and inrun.dev/migrate-timestamp annotations +from CRs that carry them. The spec is left untouched. + +Run this after a migration is confirmed stable to discard the rollback +safety net and release the annotation space. It is a one-way operation: +once the snapshot is removed, rollback is no longer possible for that CR. + +CRs without migration annotations are skipped. Clean is idempotent. +Requires a live cluster connection. + +Example: + inrun migrate clean -f blueprint.yaml + inrun migrate clean -f blueprint.yaml --dry-run + inrun migrate clean -f blueprint.yaml --namespace platform-01 --name my-app`, + RunE: func(cmd *cobra.Command, args []string) error { + return runMigrateCmd(cmd, migrate.ModeClean) + }, +} + +func init() { + migrateCmd.AddCommand(cleanCmd) +} diff --git a/cmd/cli/migrate/helper.go b/cmd/cli/migrate/helper.go new file mode 100644 index 000000000..a457966d6 --- /dev/null +++ b/cmd/cli/migrate/helper.go @@ -0,0 +1,177 @@ +//go:build !runtime + +package migrate + +import ( + "context" + "fmt" + "os" + + "github.com/intentd/inrun/cmd/cli/cmdutil" + "github.com/intentd/inrun/cmd/cli/migrate/output" + "github.com/intentd/inrun/pkg/blueprint" + "github.com/intentd/inrun/pkg/blueprint/pipeline" + "github.com/intentd/inrun/pkg/kubeclient" + "github.com/intentd/inrun/pkg/labels" + "github.com/intentd/inrun/pkg/logger" + "github.com/intentd/inrun/pkg/migrate" + "github.com/intentd/inrun/pkg/types" + "github.com/spf13/cobra" +) + +// runMigrateCmd runs a migrate-family operation end to end: load the blueprint, +// build a migrator for the given mode, build the output options, and render +// the result. +func runMigrateCmd(cmd *cobra.Command, mode migrate.MigrateMode) error { + paths, err := resolvePaths(cmd) + if err != nil { + return err + } + + bp, kube, err := pipeline.Bootstrap(cmd.Context(), cmdutil.Cfg, paths) + if err != nil { + return err + } + + migrator, migrateOpts, err := newMigrator(cmd, kube, bp.UserFunctions(), mode) + if err != nil { + return err + } + prog := newProgress(cmd, mode) + defer prog.stop() + migrator.Progress = prog.callback() + migrateOpts.Progress = migrator.Progress + + if mode == migrate.ModeForward && !migrateOpts.DryRun { + if yes, _ := cmd.Flags().GetBool("yes"); !yes { + ok, err := confirmPrune(cmd.Context(), bp, kube, bp.UserFunctions(), migrateOpts, prog) + if err != nil { + return err + } + if !ok { + fmt.Fprintln(os.Stderr, "Aborted.") + return nil + } + } + } + + opts := outputOptsFrom(cmd, paths, migrateOpts) + opts.Clean = mode == migrate.ModeClean + + printFn, err := resolveMigrateOutputFormat(cmd, mode.String()) + if err != nil { + return err + } + + prog.begin() + return runMigratorForKinds(cmd.Context(), bp, migrator, opts, printFn, prog) +} + +// newMigrator builds a CLI migrator for the given mode. The CLI renders its +// own output, so the migrator logs nothing. +func newMigrator(cmd *cobra.Command, kube kubeclient.Interface, funcs types.KELFunctionRegistry, mode migrate.MigrateMode) (*migrate.Migrator, migrate.MigratorOpts, error) { + dryRun, _ := cmd.Flags().GetBool("dry-run") + strict, _ := cmd.Flags().GetBool("strict") + pageSize, _ := cmd.Flags().GetInt64("page-size") + kind, _ := cmd.Flags().GetString("kind") + name, _ := cmd.Flags().GetString("name") + selector, _ := cmd.Flags().GetString("selector") + namespaces, _ := cmd.Flags().GetStringSlice("namespace") + force, _ := cmd.Flags().GetBool("force") + if _, err := labels.ValidateLabelSelector(selector); err != nil { + return nil, migrate.MigratorOpts{}, err + } + + opts := migrate.MigratorOpts{ + DryRun: dryRun, + Strict: strict, + PageSize: pageSize, + Kind: kind, + Name: name, + Selector: selector, + Namespaces: namespaces, + Force: force, + Log: logger.Nop(), + } + return migrate.New(cmd.Context(), kube, funcs, mode, opts), opts, nil +} + +// outputOptsFrom builds the rendering options for a migrate-family command +// from its flags and the already-resolved blueprint paths. It reads the same +// flags the migrator does — dry-run, verbose, limit, kind, name, selector, +// namespace — plus the paths resolved from --file. +func outputOptsFrom(cmd *cobra.Command, paths []string, migrator migrate.MigratorOpts) output.Opts { + kind, _ := cmd.Flags().GetString("kind") + verbose, _ := cmd.Flags().GetBool("verbose") + limit, _ := cmd.Flags().GetInt("limit") + + return output.Opts{ + Kind: kind, + DryRun: migrator.DryRun, + Namespaces: migrator.Namespaces, + Name: migrator.Name, + Selector: migrator.Selector, + Verbose: verbose, + Limit: limit, + Paths: paths, + } +} + +// resolvePaths reads blueprint file paths from the --file flag, falling back to +// defaults. Returns an error if no paths are found. +func resolvePaths(cmd *cobra.Command) ([]string, error) { + paths, _ := cmd.Flags().GetStringSlice("file") + if len(paths) == 0 { + paths = cmdutil.DefaultFilePaths() + } + if len(paths) == 0 { + return nil, fmt.Errorf(cmdutil.ErrNoBlueprint) + } + return paths, nil +} + +// runMigratorForKinds runs the migrator over bp and prints the results, also +// when the run fails part way. +func runMigratorForKinds(ctx context.Context, bp *blueprint.Blueprint, migrator *migrate.Migrator, opts output.Opts, printFn func(output.Opts), prog *progress) error { + results, err := migrator.Run(ctx, bp) + prog.stop() + opts.Results = results + printFn(opts) + return err +} + +// resolveMigrateOutputFormat maps the --output flag to the printFn for a +// migrate or rollback run: "text" (default) for the human printer, "json" +// for the machine-readable one. mode selects which pair of printers to use. +func resolveMigrateOutputFormat(cmd *cobra.Command, mode string) (func(output.Opts), error) { + outputFormat, _ := cmd.Flags().GetString("output") + switch outputFormat { + case "text", "": + return textPrinterFor(mode), nil + case "json": + return jsonPrinterFor(mode), nil + default: + return nil, fmt.Errorf("unknown output format %q (want text or json)", outputFormat) + } +} + +// textPrinterFor returns the human printer for the given mode. +// mode is "migrate" or "rollback" — the same string the CLI's subcommand uses. +func textPrinterFor(mode string) func(output.Opts) { + switch mode { + case "rollback": + return output.PrintRollback + default: + return output.PrintMigrate + } +} + +// jsonPrinterFor returns the JSON printer for the given mode. +func jsonPrinterFor(mode string) func(output.Opts) { + switch mode { + case "rollback": + return output.PrintRollbackJSON + default: + return output.PrintMigrateJSON + } +} diff --git a/cmd/cli/migrate/migrate.go b/cmd/cli/migrate/migrate.go index 5182d7c5d..6842ebfac 100644 --- a/cmd/cli/migrate/migrate.go +++ b/cmd/cli/migrate/migrate.go @@ -1,193 +1,61 @@ -//go:build !runtime && !gateway +//go:build !runtime -// Package migrate implements the migrate command, which converts a -// controller-runtime reconciler to run on this runtime. package migrate import ( - "bufio" - "fmt" - "os" - "path/filepath" - "strings" - "github.com/intentd/inrun/cmd/cli/cmdutil" - - "github.com/intentd/inrun/pkg/tools/migrate" - "github.com/intentd/inrun/pkg/version" + "github.com/intentd/inrun/pkg/migrate" "github.com/spf13/cobra" ) var migrateCmd = &cobra.Command{ - Use: "migrate ", - Short: "Migrate a controller-runtime operator to Inrun", - Long: `Migrates a controller-runtime reconciler to Inrun. Your Reconcile logic -is untouched — Inrun takes over the infrastructure. - -Default mode (--mode toclient): zero changes to your Reconcile signature or -call sites. SetupWithManager is removed; a two-line constructor using -adapter.ToClient and domain.ReconcilerFrom is injected. Your reconciler -compiles and runs inside Inrun with no other edits. - - inrun migrate ./controller/webapp_controller.go -o ./my-operator - -The output directory receives the rewritten file plus scaffolding: -catalog.yaml, simulate.yaml, e2e.yaml, go.mod, Makefile, Dockerfile. - -For a full rewrite to idiomatic Inrun style (new Reconcile signature, -struct fields, call sites), use --mode native. - -Examples: - inrun migrate ./controller/webapp_controller.go -o ./my-operator - inrun migrate ./controller/webapp_controller.go --mode native -o ./out - inrun migrate ./controller/webapp_controller.go # prompts before replacing`, - Args: cobra.ExactArgs(1), + Use: "migrate", + Short: "Migrate CR spec fields declared with migrate.from in the Blueprint", + Long: `Apply schema migrations to live CRs using migrate.from declarations in the Blueprint. + +For each serve field with migrate.from set, the migrator: + 1. reads the old value from the migrate.from spec path + 2. applies the field's value/values transform (if any) + 3. writes the result to the field's current spec path + 4. removes the migrate.from path + +Migration is idempotent — CRs where migrate.from is absent are skipped. +Requires a live cluster connection. + +Subcommands: + rollback Restore CRs from their migration snapshots + clean Remove migration annotations without touching the spec + +Example: + inrun migrate -f blueprint.yaml + inrun migrate -f blueprint.yaml --dry-run + inrun migrate -f blueprint.yaml --kind WebApp`, RunE: func(cmd *cobra.Command, args []string) error { - inputPath := args[0] - outputDir, _ := cmd.Flags().GetString("output") - modulePath, _ := cmd.Flags().GetString("module") - operatorName, _ := cmd.Flags().GetString("name") - modeFlag, _ := cmd.Flags().GetString("mode") - - var mode migrate.Mode - switch modeFlag { - case "native": - mode = migrate.ModeNative - case "toclient", "": - mode = migrate.ModeToClient - default: - return fmt.Errorf("unknown --mode %q: valid values are toclient, native", modeFlag) - } - - src, err := cmdutil.ReadLocal(inputPath) - if err != nil { - return fmt.Errorf("read %s: %w", inputPath, err) - } - - res, err := migrate.Rewrite(src, mode) - if err != nil { - return fmt.Errorf("migrate: %w", err) - } - - if len(res.Warnings) > 0 { - fmt.Fprintf(os.Stderr, "\n%s\n", cmdutil.Yellow("Warnings — review these before running:")) - for _, w := range res.Warnings { - fmt.Fprintf(os.Stderr, " %s %s\n", cmdutil.Yellow("⚠"), w) - } - fmt.Fprintln(os.Stderr) - } - - opts := migrate.Options{ - ModulePath: modulePath, - OperatorName: operatorName, - InrunVersion: version.Short(), - } - files := migrate.Generate(res, opts) - - if outputDir != "" { - return writeOutputDir(outputDir, inputPath, res, files) - } - - return replaceInPlace(inputPath, res, files) + return runMigrateCmd(cmd, migrate.ModeForward) }, } -func writeOutputDir(dir, inputPath string, res *migrate.Result, files migrate.Files) error { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("create output dir: %w", err) - } - - reconcilerDest := filepath.Join(dir, filepath.Base(inputPath)) - if err := os.WriteFile(reconcilerDest, res.Source, 0o644); err != nil { - return fmt.Errorf("write reconciler: %w", err) - } - fmt.Printf(" %s %s\n", cmdutil.Green("→"), reconcilerDest) - - type namedFile struct { - name string - content string - } - generated := []namedFile{ - {"catalog.yaml", files.Catalog}, - {"test/simulate.yaml", files.Simulate}, - {"test/e2e.yaml", files.E2E}, - {"go.mod", files.GoMod}, - {"Makefile", files.Makefile}, - {"Dockerfile", files.Dockerfile}, - {"README.md", files.README}, - } - for _, f := range generated { - dest := filepath.Join(dir, f.name) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return fmt.Errorf("create %s: %w", filepath.Dir(dest), err) - } - if err := os.WriteFile(dest, []byte(f.content), 0o644); err != nil { - return fmt.Errorf("write %s: %w", f.name, err) - } - fmt.Printf(" %s %s\n", cmdutil.Green("→"), dest) - } - - fmt.Printf("\n%s Search for %s in %s to see what needs attention.\n", - cmdutil.Green("✓ Done."), cmdutil.Bold("TODO(inrun migrate)"), dir) - return nil -} - -func replaceInPlace(inputPath string, res *migrate.Result, files migrate.Files) error { - dir := filepath.Dir(inputPath) - - fmt.Printf("This will replace %s and write %s alongside it.\n", - cmdutil.Bold(inputPath), cmdutil.Bold("catalog.yaml, test/simulate.yaml, test/e2e.yaml, go.mod, Makefile, Dockerfile")) - fmt.Printf("%s [y/N] ", cmdutil.Yellow("Continue?")) - - scanner := bufio.NewScanner(os.Stdin) - scanner.Scan() - answer := strings.TrimSpace(strings.ToLower(scanner.Text())) - if answer != "y" && answer != "yes" { - fmt.Println("Aborted.") - return nil - } - - if err := os.WriteFile(inputPath, res.Source, 0o644); err != nil { - return fmt.Errorf("write %s: %w", inputPath, err) - } - fmt.Printf(" %s %s\n", cmdutil.Green("→"), inputPath) - - type namedFile struct { - name string - content string - } - generated := []namedFile{ - {"catalog.yaml", files.Catalog}, - {"test/simulate.yaml", files.Simulate}, - {"test/e2e.yaml", files.E2E}, - {"go.mod", files.GoMod}, - {"Makefile", files.Makefile}, - {"Dockerfile", files.Dockerfile}, - } - for _, f := range generated { - dest := filepath.Join(dir, f.name) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return fmt.Errorf("create %s: %w", filepath.Dir(dest), err) - } - if err := os.WriteFile(dest, []byte(f.content), 0o644); err != nil { - return fmt.Errorf("write %s: %w", f.name, err) - } - fmt.Printf(" %s %s\n", cmdutil.Green("→"), dest) - } - - fmt.Printf("\n%s Search for %s to see what needs attention.\n", - cmdutil.Green("✓ Done."), cmdutil.Bold("TODO(inrun migrate)")) - return nil -} - func init() { cmdutil.RootCmd.AddCommand(migrateCmd) - migrateCmd.Flags().StringP("output", "o", "", "Write output to this directory (non-destructive; skips confirmation)") - migrateCmd.Flags().String("module", "", "Go module path for the migrated operator (e.g. github.com/myorg/my-operator)") - migrateCmd.Flags().String("name", "", "Operator name in kebab-case (e.g. my-operator); derived from receiver type if omitted") - migrateCmd.Flags().String("mode", "toclient", "Migration mode: toclient (default, zero Reconcile changes) or native (full rewrite)") - - // Shadow global flags so they don't appear under `inrun migrate` - cmdutil.ShadowGlobalCommandFlags(migrateCmd, "file") + // Persistent flags are inherited by every subcommand (rollback, clean). + // Declare shared options here so a new subcommand picks them up without + // re-registering, and so `inrun migrate --help` lists them once. + migrateCmd.PersistentFlags().StringSliceP("file", "f", nil, "Path(s) to blueprint.yaml (repeatable)") + migrateCmd.PersistentFlags().Bool("dry-run", false, "Preview changes without writing to the cluster") + migrateCmd.PersistentFlags().Bool("strict", false, "Fail if any individual CR cannot be migrated (default: warn and skip)") + migrateCmd.PersistentFlags().Bool("verbose", false, "Print the resulting spec for each migrated CR") + migrateCmd.PersistentFlags().Int("limit", 0, "Max CRs to show in verbose mode (0 = all)") + migrateCmd.PersistentFlags().Int64("page-size", 0, "CRs fetched per List call (0 = API server default, useful for testing pagination)") + migrateCmd.PersistentFlags().StringP("kind", "k", "", "Migrate only this kind (default: all kinds with migrate.from)") + migrateCmd.PersistentFlags().String("name", "", "Migrate only the CR with this name") + migrateCmd.PersistentFlags().String("selector", "", "Select CRs by label (Kubernetes label selector syntax)") + migrateCmd.PersistentFlags().StringSliceP("namespace", "n", nil, "Restrict migration to these namespaces (comma-separated; default: all)") + migrateCmd.PersistentFlags().StringP("output", "o", "text", "Output format: text, json") + migrateCmd.PersistentFlags().Bool("force", false, "Take ownership of fields owned by another manager when applying the crd.migrate.to destination (SSA force). Default: fail on conflict.") + + migrateCmd.Flags().BoolP("yes", "y", false, "Do not ask before migrate.prune deletes source CRs") + + // Shadow global flags so they don't appear under `inrun version` + cmdutil.ShadowGlobalCommandFlags(migrateCmd) } diff --git a/cmd/cli/migrate/output/json.go b/cmd/cli/migrate/output/json.go new file mode 100644 index 000000000..9691ae1e6 --- /dev/null +++ b/cmd/cli/migrate/output/json.go @@ -0,0 +1,171 @@ +//go:build !runtime && !gateway + +package output + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/intentd/inrun/pkg/migrate" +) + +// MigrateReport is the machine-readable form of a migrate, rollback, or clean +// run. The field names are a stable interface: consumers rely on them for CI +// checks, diffing, and audit logs. Change them only with a version bump. +// +// The report mirrors PrintMigrate/PrintRollback's vocabulary — "migrated", +// "drifted", "skipped", "failed" — so a script and a human reading the same +// run see the same buckets. +type MigrateReport struct { + Mode string `json:"mode"` // "forward" | "rollback" | "clean" + DryRun bool `json:"dryRun"` + Blueprint []string `json:"blueprint"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Selector string `json:"selector,omitempty"` + Namespaces []string `json:"namespaces,omitempty"` + Kinds []KindReport `json:"kinds"` + Totals ReportTotals `json:"totals"` +} + +// KindReport is the per-kind section of the report. +type KindReport struct { + Kind string `json:"kind"` + To string `json:"to,omitempty"` + Scope *Scope `json:"scope,omitempty"` + CRs []CRReport `json:"crs"` +} + +// Scope is the selection a kind ran with: the blueprint's migrate.selection +// merged with the CLI flags. +type Scope struct { + Namespaces []string `json:"namespaces,omitempty"` + Name string `json:"name,omitempty"` + Selector string `json:"selector,omitempty"` +} + +// CRReport is one CR's outcome. Ref is "namespace/name" — the same form the +// human output uses. Fields, DriftedFields, and Error are omitted when empty +// so a clean run reads as small JSON. +type CRReport struct { + Ref string `json:"ref"` + Fields []string `json:"fields,omitempty"` + DriftedFields []string `json:"driftedFields,omitempty"` + AllDrifted bool `json:"allDrifted,omitempty"` + Drifted bool `json:"drifted,omitempty"` + Pruned bool `json:"pruned,omitempty"` + AfterSpec map[string]interface{} `json:"afterSpec,omitempty"` + Skipped bool `json:"skipped,omitempty"` + Excluded bool `json:"excluded,omitempty"` + Error string `json:"error,omitempty"` +} + +// ReportTotals aggregates the per-CR buckets across all kinds. A consumer can +// fail a pipeline on Totals.Failed > 0 or Totals.Drifted > 0 without walking +// the per-CR list. +type ReportTotals struct { + CRs int `json:"crs"` + Migrated int `json:"migrated"` + Drifted int `json:"drifted"` + AllDrifted int `json:"allDrifted"` + Skipped int `json:"skipped"` + Excluded int `json:"excluded"` + Pruned int `json:"pruned"` + Failed int `json:"failed"` +} + +// writeJSON writes the run's results as indented JSON to w. mode is one of +// "forward", "rollback", "clean" — the same strings that appear in the report. +func writeJSON(w io.Writer, mode string, opts Opts) error { + report := buildReport(mode, opts) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(report) +} + +// PrintMigrateJSON is the printFn-shaped entry point for JSON migrate output. +// Errors go to stderr so stdout stays a single valid JSON document. +func PrintMigrateJSON(opts Opts) { + if err := writeJSON(os.Stdout, migrate.ModeForward.String(), opts); err != nil { + fmt.Fprintf(os.Stderr, "marshal json: %v\n", err) + } +} + +// PrintRollbackJSON is the printFn-shaped entry point for JSON rollback output. +func PrintRollbackJSON(opts Opts) { + if err := writeJSON(os.Stdout, migrate.ModeRollback.String(), opts); err != nil { + fmt.Fprintf(os.Stderr, "marshal json: %v\n", err) + } +} + +// buildReport converts Opts into a MigrateReport, computing the same totals +// the human summary line shows. +func buildReport(mode string, opts Opts) MigrateReport { + report := MigrateReport{ + Mode: mode, + DryRun: opts.DryRun, + Blueprint: opts.Paths, + Kind: opts.Kind, + Name: opts.Name, + Selector: opts.Selector, + Namespaces: opts.Namespaces, + } + for _, r := range opts.Results { + if r == nil { + continue + } + kind := KindReport{Kind: r.Kind, To: r.To} + if !r.Selection.IsEmpty() { + kind.Scope = &Scope{Namespaces: r.Selection.Namespaces, Name: r.Selection.Name, Selector: r.Selection.Selector} + } + for _, cr := range r.CRs { + kind.CRs = append(kind.CRs, crReport(cr)) + report.Totals.CRs++ + switch { + case cr.Err != nil: + report.Totals.Failed++ + case cr.Excluded: + report.Totals.Excluded++ + case cr.Skipped: + report.Totals.Skipped++ + case cr.AllDrifted: + report.Totals.AllDrifted++ + case cr.Drifted: + report.Totals.Drifted++ + default: + if len(cr.Fields) > 0 { + report.Totals.Migrated++ + } + if len(cr.DriftedFields) > 0 { + report.Totals.Drifted++ + } + } + if cr.Err == nil && cr.Pruned { + report.Totals.Pruned++ + } + } + report.Kinds = append(report.Kinds, kind) + } + return report +} + +// crReport converts one migrate.CRMigrateResult to its JSON form. Error is stringified. +func crReport(cr migrate.CRMigrateResult) CRReport { + out := CRReport{ + Ref: cr.Ref(), + Fields: cr.Fields, + DriftedFields: cr.DriftedFields, + AllDrifted: cr.AllDrifted, + Drifted: cr.Drifted, + Pruned: cr.Pruned, + AfterSpec: cr.AfterSpec, + Skipped: cr.Skipped, + Excluded: cr.Excluded, + } + if cr.Err != nil { + out.Error = cr.Err.Error() + } + return out +} diff --git a/cmd/cli/migrate/output/migrate.go b/cmd/cli/migrate/output/migrate.go new file mode 100644 index 000000000..491757f1b --- /dev/null +++ b/cmd/cli/migrate/output/migrate.go @@ -0,0 +1,7 @@ +//go:build gateway + +package output + +// PrintMigrate is a no-op in gateway builds — migration output comes from the +// structured logger, not a terminal-formatted printer. +func PrintMigrate(_ Opts) {} diff --git a/cmd/cli/migrate/output/migrate_dev.go b/cmd/cli/migrate/output/migrate_dev.go new file mode 100644 index 000000000..6561e4a1e --- /dev/null +++ b/cmd/cli/migrate/output/migrate_dev.go @@ -0,0 +1,221 @@ +//go:build !runtime && !gateway + +package output + +import ( + "fmt" + "strings" + + "github.com/intentd/inrun/cmd/cli/cmdutil" + "github.com/intentd/inrun/pkg/migrate" + sigsyaml "sigs.k8s.io/yaml" +) + +func PrintMigrate(opts Opts) { + fmt.Println() + op := "migrate" + if opts.Clean { + op = "migrate clean" + } + label := cmdutil.Bold("⟳") + " inrun " + op + if opts.DryRun { + label += " " + cmdutil.Yellow("[dry-run]") + } + fmt.Println(label) + fmt.Printf(" %s %s\n", cmdutil.Gray("blueprint:"), cmdutil.Cyan(strings.Join(opts.Paths, ", "))) + if len(opts.Namespaces) > 0 { + nsStr := "namespace" + if len(opts.Namespaces) > 1 { + nsStr += "s" + } + fmt.Printf(" %s %s\n", cmdutil.Gray(nsStr+":"), cmdutil.Cyan(strings.Join(opts.Namespaces, ", "))) + } + if opts.Name != "" { + fmt.Printf(" %s %s\n", cmdutil.Gray("name:"), cmdutil.Cyan(opts.Name)) + } + if opts.Selector != "" { + fmt.Printf(" %s %s\n", cmdutil.Gray("selector:"), cmdutil.Cyan(opts.Selector)) + } + if opts.Kind != "" { + fmt.Printf(" %s %s\n", cmdutil.Gray("kind:"), cmdutil.Cyan(opts.Kind)) + } + fmt.Println() + + for _, r := range opts.Results { + if r == nil { + continue + } + + var c migrateCounts + for _, cr := range r.CRs { + c.add(cr) + } + + total := len(r.CRs) + printKindHeader(r, total, opts) + + verboseShown := 0 + for _, cr := range r.CRs { + ref := cr.Ref() + switch { + case cr.Err != nil: + fmt.Printf(" %s %s %s\n", cmdutil.FailureMark(), cmdutil.Red(ref), cmdutil.Red(cr.Err.Error())) + case cr.Drifted: + fmt.Printf(" %s %s %s\n", cmdutil.Dim("~"), cmdutil.Gray(ref), cmdutil.Yellow("destination exists and differs — skipped")) + case cr.Excluded: + fmt.Printf(" %s %s\n", cmdutil.Dim("–"), cmdutil.Gray(ref+" (not selected: migrate.selection when/or)")) + case cr.Skipped: + skippedMsg := "(already migrated)" + if opts.Clean { + skippedMsg = "(no annotations)" + } + fmt.Printf(" %s %s\n", cmdutil.Dim("–"), cmdutil.Gray(ref+" "+skippedMsg)) + case len(cr.Fields) == 0 && cr.Pruned: + fmt.Printf(" %s %s %s\n", cmdutil.SuccessMark(), cmdutil.Cyan(ref), cmdutil.Gray("already migrated; "+pruneVerb(opts.DryRun))) + default: + var verb string + switch { + case opts.Clean && opts.DryRun: + verb = "would clean" + case opts.Clean: + verb = "cleaned" + case opts.DryRun: + verb = "would migrate" + default: + verb = "migrated" + } + line := verb + ": " + strings.Join(cr.Fields, ", ") + if r.To != "" && !opts.Clean { + line = verb + " to " + r.To + } + if cr.Pruned { + line += "; " + pruneVerb(opts.DryRun) + } + fmt.Printf(" %s %s %s\n", cmdutil.SuccessMark(), cmdutil.Cyan(ref), cmdutil.Gray(line)) + if opts.Verbose && cr.AfterSpec != nil { + if opts.Limit == 0 || verboseShown < opts.Limit { + printSpec(cr.AfterSpec) + verboseShown++ + } + } + } + } + + if opts.Verbose && opts.Limit > 0 && verboseShown >= opts.Limit && c.migrated > opts.Limit { + fmt.Printf(" %s\n", cmdutil.Gray(fmt.Sprintf("… %d more (use --limit 0 to show all)", c.migrated-opts.Limit))) + } + + fmt.Printf("\n %s\n\n", buildMigrateSummary(c, total, opts.DryRun, opts.Clean)) + } +} + +// migrateCounts buckets the CRs of one kind for the summary line. +type migrateCounts struct { + migrated, skipped, excluded, drifted, pruned, failed int +} + +func (c *migrateCounts) add(cr migrate.CRMigrateResult) { + switch { + case cr.Err != nil: + c.failed++ + return + case cr.Drifted: + c.drifted++ + case cr.Excluded: + c.excluded++ + case cr.Skipped: + c.skipped++ + case len(cr.Fields) > 0: + c.migrated++ + } + if cr.Pruned { + c.pruned++ + } +} + +func pruneVerb(dryRun bool) string { + if dryRun { + return "would prune source" + } + return "source pruned" +} + +// printKindHeader prints the kind line, and the scope the run used when it +// differs from the CLI flags (the blueprint's migrate.selection applied). +func printKindHeader(r *migrate.KindMigrateResult, total int, opts Opts) { + countStr := fmt.Sprintf("%d CR", total) + if total != 1 { + countStr += "s" + } + name := cmdutil.Bold(r.Kind) + if r.To != "" { + name += cmdutil.Gray(" → ") + cmdutil.Bold(r.To) + } + fmt.Printf("%s %s %s\n", cmdutil.Cyan("◆"), name, cmdutil.Gray(countStr)) + + flags := migrate.Selection{Namespaces: opts.Namespaces, Name: opts.Name, Selector: opts.Selector} + if r.Selection.IsEmpty() || r.Selection.Equal(flags) { + return + } + var parts []string + if len(r.Selection.Namespaces) > 0 { + parts = append(parts, "namespaces "+strings.Join(r.Selection.Namespaces, ",")) + } + if r.Selection.Name != "" { + parts = append(parts, "name "+r.Selection.Name) + } + if r.Selection.Selector != "" { + parts = append(parts, "selector "+r.Selection.Selector) + } + fmt.Printf(" %s %s\n", cmdutil.Gray("scope:"), cmdutil.Cyan(strings.Join(parts, " · "))) +} + +func buildMigrateSummary(c migrateCounts, total int, dryRun, clean bool) string { + var verb string + switch { + case clean && dryRun: + verb = "would be cleaned" + case clean: + verb = "cleaned" + case dryRun: + verb = "would be migrated" + default: + verb = "migrated" + } + parts := []string{fmt.Sprintf("%d/%d %s", c.migrated, total, verb)} + if c.pruned > 0 { + pruned := "sources pruned" + if dryRun { + pruned = "sources would be pruned" + } + parts = append(parts, cmdutil.Yellow(fmt.Sprintf("%d %s", c.pruned, pruned))) + } + if c.drifted > 0 { + parts = append(parts, cmdutil.Yellow(fmt.Sprintf("%d drifted", c.drifted))) + } + if c.skipped > 0 { + parts = append(parts, cmdutil.Gray(fmt.Sprintf("%d already done", c.skipped))) + } + if c.excluded > 0 { + parts = append(parts, cmdutil.Gray(fmt.Sprintf("%d not selected", c.excluded))) + } + if c.failed > 0 { + parts = append(parts, cmdutil.Red(fmt.Sprintf("%d failed", c.failed))) + } + mark := cmdutil.SuccessMark() + if c.failed > 0 { + mark = cmdutil.FailureMark() + } + return mark + " " + strings.Join(parts, " · ") +} + +func printSpec(spec map[string]interface{}) { + b, err := sigsyaml.Marshal(spec) + if err != nil { + return + } + fmt.Printf(" %s\n", cmdutil.Gray("spec:")) + for _, line := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") { + fmt.Printf(" %s\n", cmdutil.Dim(line)) + } +} diff --git a/cmd/cli/migrate/output/rollback.go b/cmd/cli/migrate/output/rollback.go new file mode 100644 index 000000000..658af2560 --- /dev/null +++ b/cmd/cli/migrate/output/rollback.go @@ -0,0 +1,133 @@ +//go:build !runtime && !gateway + +package output + +import ( + "fmt" + "strings" + + "github.com/intentd/inrun/cmd/cli/cmdutil" +) + +func PrintRollback(opts Opts) { + fmt.Println() + label := cmdutil.Bold("⟳") + " inrun migrate rollback" + if opts.DryRun { + label += " " + cmdutil.Yellow("[dry-run]") + } + fmt.Println(label) + fmt.Printf(" %s %s\n", cmdutil.Gray("blueprint:"), cmdutil.Cyan(strings.Join(opts.Paths, ", "))) + if len(opts.Namespaces) > 0 { + nsStr := "namespace" + if len(opts.Namespaces) != 1 { + nsStr += "s" + } + fmt.Printf(" %s %s\n", cmdutil.Gray(nsStr+":"), cmdutil.Cyan(strings.Join(opts.Namespaces, ", "))) + } + if opts.Name != "" { + fmt.Printf(" %s %s\n", cmdutil.Gray("name:"), cmdutil.Cyan(opts.Name)) + } + if opts.Selector != "" { + fmt.Printf(" %s %s\n", cmdutil.Gray("selector:"), cmdutil.Cyan(opts.Selector)) + } + if opts.Kind != "" { + fmt.Printf(" %s %s\n", cmdutil.Gray("kind:"), cmdutil.Cyan(opts.Kind)) + } + fmt.Println() + + for _, r := range opts.Results { + if r == nil { + continue + } + + rolledBack, skipped, drifted, allDrifted, failed := 0, 0, 0, 0, 0 + for _, cr := range r.CRs { + switch { + case cr.Err != nil: + failed++ + case cr.Skipped: + skipped++ + case cr.AllDrifted: + allDrifted++ + default: + if len(cr.Fields) > 0 { + rolledBack++ + } + if len(cr.DriftedFields) > 0 { + drifted++ + } + } + } + + total := len(r.CRs) + printKindHeader(r, total, opts) + + verboseShown := 0 + for _, cr := range r.CRs { + ref := cr.Ref() + switch { + case cr.Err != nil: + fmt.Printf(" %s %s %s\n", cmdutil.FailureMark(), cmdutil.Red(ref), cmdutil.Red(cr.Err.Error())) + case cr.Skipped: + fmt.Printf(" %s %s\n", cmdutil.Dim("–"), cmdutil.Gray(ref+" (no snapshot)")) + case cr.AllDrifted && r.To != "": + fmt.Printf(" %s %s %s\n", cmdutil.Dim("~"), cmdutil.Gray(ref), cmdutil.Yellow("destination modified after migration — left in place")) + default: + if len(cr.Fields) > 0 { + verb := "rolled back" + if opts.DryRun { + verb = "would roll back" + } + line := verb + ": " + strings.Join(cr.Fields, ", ") + if r.To != "" { + line = verb + " to " + r.Kind + "; " + r.To + " deleted" + if opts.DryRun { + line = verb + " to " + r.Kind + "; would delete " + r.To + } + } + fmt.Printf(" %s %s %s\n", cmdutil.SuccessMark(), cmdutil.Cyan(ref), cmdutil.Gray(line)) + if opts.Verbose && cr.AfterSpec != nil { + if opts.Limit == 0 || verboseShown < opts.Limit { + printSpec(cr.AfterSpec) + verboseShown++ + } + } + } + for _, f := range cr.DriftedFields { + fmt.Printf(" %s %s %s\n", cmdutil.Dim("~"), cmdutil.Gray(ref), cmdutil.Dim(f+": drifted — modified after migration, skipped")) + } + } + } + + if opts.Verbose && opts.Limit > 0 && verboseShown >= opts.Limit && rolledBack > opts.Limit { + fmt.Printf(" %s\n", cmdutil.Gray(fmt.Sprintf("… %d more (use --limit 0 to show all)", rolledBack-opts.Limit))) + } + + fmt.Printf("\n %s\n\n", buildRollbackSummary(rolledBack, skipped, drifted, allDrifted, failed, total, opts.DryRun)) + } +} + +func buildRollbackSummary(rolledBack, skipped, drifted, allDrifted, failed, total int, dryRun bool) string { + verb := "rolled back" + if dryRun { + verb = "would be rolled back" + } + parts := []string{fmt.Sprintf("%d/%d %s", rolledBack, total, verb)} + if drifted > 0 { + parts = append(parts, cmdutil.Yellow(fmt.Sprintf("%d drifted", drifted))) + } + if allDrifted > 0 { + parts = append(parts, cmdutil.Yellow(fmt.Sprintf("%d all fields drifted", allDrifted))) + } + if skipped > 0 { + parts = append(parts, cmdutil.Gray(fmt.Sprintf("%d no snapshot", skipped))) + } + if failed > 0 { + parts = append(parts, cmdutil.Red(fmt.Sprintf("%d failed", failed))) + } + mark := cmdutil.SuccessMark() + if failed > 0 { + mark = cmdutil.FailureMark() + } + return mark + " " + strings.Join(parts, " · ") +} diff --git a/cmd/cli/migrate/output/type.go b/cmd/cli/migrate/output/type.go new file mode 100644 index 000000000..5b51f503c --- /dev/null +++ b/cmd/cli/migrate/output/type.go @@ -0,0 +1,19 @@ +//go:build !runtime + +package output + +import "github.com/intentd/inrun/pkg/migrate" + +// Opts carries the data needed by every gate output printer. +type Opts struct { + Results []*migrate.KindMigrateResult + DryRun bool + Clean bool + Verbose bool + Limit int + Paths []string + Namespaces []string + Name string + Kind string + Selector string +} diff --git a/cmd/cli/migrate/progress.go b/cmd/cli/migrate/progress.go new file mode 100644 index 000000000..89b082f56 --- /dev/null +++ b/cmd/cli/migrate/progress.go @@ -0,0 +1,71 @@ +//go:build !runtime + +package migrate + +import ( + "fmt" + + "github.com/intentd/inrun/cmd/cli/cmdutil" + "github.com/intentd/inrun/pkg/migrate" + "github.com/intentd/inrun/pkg/util/term" + "github.com/spf13/cobra" +) + +// progress shows a spinner while a run works through CRs. It is nil, and +// every method a no-op, unless output is text to a terminal. +type progress struct { + verb string + spin *term.Spinner +} + +func newProgress(cmd *cobra.Command, mode migrate.MigrateMode) *progress { + if out, _ := cmd.Flags().GetString("output"); out != "text" || !term.IsTerminal() { + return nil + } + verb := "Migrating" + switch mode { + case migrate.ModeRollback: + verb = "Rolling back" + case migrate.ModeClean: + verb = "Cleaning" + } + return &progress{verb: verb} +} + +func (p *progress) start(msg string) { + if p == nil { + return + } + p.stop() + p.spin = cmdutil.StartSpinner(msg) +} + +// begin starts the spinner for the run itself. +func (p *progress) begin() { + if p != nil { + p.start(p.verb + "…") + } +} + +func (p *progress) update(label string, done, total int) { + if p == nil || p.spin == nil { + return + } + p.spin.Update(fmt.Sprintf("%s %s %d/%d", p.verb, label, done, total)) +} + +func (p *progress) stop() { + if p == nil || p.spin == nil { + return + } + p.spin.Stop() + p.spin = nil +} + +// callback returns the migrator Progress hook, or nil when there is no spinner. +func (p *progress) callback() func(string, int, int) { + if p == nil { + return nil + } + return p.update +} diff --git a/cmd/cli/migrate/prune.go b/cmd/cli/migrate/prune.go new file mode 100644 index 000000000..ffd7d2494 --- /dev/null +++ b/cmd/cli/migrate/prune.go @@ -0,0 +1,110 @@ +//go:build !runtime + +package migrate + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/intentd/inrun/cmd/cli/cmdutil" + "github.com/intentd/inrun/pkg/blueprint" + "github.com/intentd/inrun/pkg/kubeclient" + "github.com/intentd/inrun/pkg/migrate" + "github.com/intentd/inrun/pkg/types" + xterm "golang.org/x/term" +) + +// confirmPrune asks before a forward run deletes source CRs. It dry-runs the +// migration to count the sources each kind would prune, shows the counts and +// asks [y/N] on stderr. It returns false when the user declines. Without a +// terminal it fails and asks for --yes instead of waiting for input. +func confirmPrune(ctx context.Context, bp *blueprint.Blueprint, kube kubeclient.Interface, funcs types.KELFunctionRegistry, opts migrate.MigratorOpts, prog *progress) (bool, error) { + if !declaresPrune(bp, opts.Kind) { + return true, nil + } + + preview := opts + preview.DryRun = true + prog.start("Checking what prune would delete…") + results, err := migrate.New(ctx, kube, funcs, migrate.ModeForward, preview).Run(ctx, bp) + prog.stop() + if err != nil { + return false, err + } + + var lines []string + total := 0 + for _, r := range results { + n := 0 + for _, cr := range r.CRs { + if cr.Err == nil && cr.Pruned { + n++ + } + } + if n == 0 { + continue + } + total += n + lines = append(lines, fmt.Sprintf(" %s → %s %s", cmdutil.Bold(r.Kind), cmdutil.Bold(r.To), cmdutil.Yellow(plural(n, "source")))) + } + if total == 0 { + return true, nil + } + + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, cmdutil.Yellow("⚠ migrate.prune is on.")+" These source CRs are deleted after their destination is written:") + for _, l := range lines { + fmt.Fprintln(os.Stderr, l) + } + fmt.Fprintln(os.Stderr) + + if !stdinIsTerminal() { + return false, fmt.Errorf("prune needs confirmation: rerun with --yes, or --dry-run to preview") + } + return askYes(os.Stdin, fmt.Sprintf("Delete %s after migrating? [y/N]: ", plural(total, "source CR"))) +} + +// declaresPrune reports whether any CRD in scope declares migrate.prune. +func declaresPrune(bp *blueprint.Blueprint, kind string) bool { + for _, name := range bp.CRDNames() { + entry, ok := bp.CRDEntry(name) + if !ok || !entry.HasMigrate() || !entry.Migrate.Prune { + continue + } + if kind == "" || entry.GVK().Kind == kind { + return true + } + } + return false +} + +// askYes prints prompt to stderr and reports whether the answer is y or yes. +func askYes(in io.Reader, prompt string) (bool, error) { + fmt.Fprint(os.Stderr, prompt) + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && err != io.EOF { + return false, fmt.Errorf("read answer: %w", err) + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true, nil + } + return false, nil +} + +// stdinIsTerminal reports whether stdin is a TTY. A char-device check is +// not enough: /dev/null is a char device too. +func stdinIsTerminal() bool { + return xterm.IsTerminal(int(os.Stdin.Fd())) +} + +func plural(n int, noun string) string { + if n == 1 { + return fmt.Sprintf("1 %s", noun) + } + return fmt.Sprintf("%d %ss", n, noun) +} diff --git a/cmd/cli/migrate/prune_test.go b/cmd/cli/migrate/prune_test.go new file mode 100644 index 000000000..4cdc6d2d0 --- /dev/null +++ b/cmd/cli/migrate/prune_test.go @@ -0,0 +1,23 @@ +//go:build !runtime + +package migrate + +import ( + "strings" + "testing" +) + +func TestAskYes(t *testing.T) { + for in, want := range map[string]bool{ + "y\n": true, "Y\n": true, "yes\n": true, " YES \n": true, + "n\n": false, "\n": false, "": false, "maybe\n": false, + } { + got, err := askYes(strings.NewReader(in), "") + if err != nil { + t.Fatalf("%q: %v", in, err) + } + if got != want { + t.Errorf("askYes(%q) = %v, want %v", in, got, want) + } + } +} diff --git a/cmd/cli/migrate/rollback.go b/cmd/cli/migrate/rollback.go new file mode 100644 index 000000000..3b8f6c6b0 --- /dev/null +++ b/cmd/cli/migrate/rollback.go @@ -0,0 +1,40 @@ +//go:build !runtime + +package migrate + +import ( + "github.com/intentd/inrun/pkg/migrate" + "github.com/spf13/cobra" +) + +var rollbackCmd = &cobra.Command{ + Use: "rollback", + Short: "Roll back CRs to their pre-migration state using the stored snapshot", + Long: `Reverse a schema migration by restoring the field values captured in the +inrun.dev/migrate-field-snapshot annotation. For a migrate.to migration, the +destination's inrun.dev/migrate-kind-snapshot is used: the source is recreated +if it was pruned, then the destination is deleted. + +For each CR with a snapshot annotation, the rollback: + 1. reads the recorded {before, after} entry for each field from the snapshot + 2. compares the current spec value at the new path (path:) to the recorded + after value — if they differ the field was modified after migration and is + left untouched (reported as drifted) + 3. writes the before value back to the old path (migrate.from) + 4. nulls out the new path (path:) and removes the migration annotations + +CRs without a snapshot annotation are skipped. Rollback is idempotent. +Requires a live cluster connection. + +Example: + inrun migrate rollback -f blueprint.yaml + inrun migrate rollback -f blueprint.yaml --dry-run + inrun migrate rollback -f blueprint.yaml --namespace platform-01 --name my-app`, + RunE: func(cmd *cobra.Command, args []string) error { + return runMigrateCmd(cmd, migrate.ModeRollback) + }, +} + +func init() { + migrateCmd.AddCommand(rollbackCmd) // parent, not RootCmd +} diff --git a/cmd/cli/notes/notes.go b/cmd/cli/notes/notes.go deleted file mode 100644 index 0b2b2edbe..000000000 --- a/cmd/cli/notes/notes.go +++ /dev/null @@ -1,204 +0,0 @@ -//go:build !runtime && !gateway - -// Package notes implements the notes command for browsing and searching the -// template functions available in Catalog expressions. -package notes - -import ( - "fmt" - "os" - "os/exec" - "strings" - "text/tabwriter" - - "github.com/intentd/inrun/cmd/cli/cmdutil" - - "github.com/intentd/inrun/pkg/merger" - "github.com/intentd/inrun/pkg/note" - "github.com/intentd/inrun/pkg/types" - "github.com/spf13/cobra" -) - -var notesCmd = &cobra.Command{ - Use: "notes", - Short: "Browse and search Inrun note functions", - Long: `List, search, and inspect Inrun notes — the template functions available -in every Catalog expression. - - inrun notes list all notes - inrun notes --domain strings filter by domain - inrun notes search full-text search - inrun notes show show full detail for one note`, - RunE: func(cmd *cobra.Command, args []string) error { - domain, _ := cmd.Flags().GetString("domain") - noPager, _ := cmd.Flags().GetBool("no-pager") - files, _ := cmd.Flags().GetStringSlice("file") - expanded := cmdutil.ParseFilePaths(files) - - // When -f is provided, show user-defined notes from the Catalog. - if len(expanded) > 0 { - m := merger.New(expanded...) - if err := m.Merge(); err != nil { - return fmt.Errorf("loading catalog: %w", err) - } - reg := m.ToNotes() - if reg.Empty() { - fmt.Println("No user-defined notes declared in this Catalog.") - return nil - } - return printUserNoteTable(reg, noPager) - } - - var notes []note.NoteInfo - if domain != "" { - notes = note.ListByDomain(domain) - if len(notes) == 0 { - return fmt.Errorf("no notes found for domain %q — run `inrun notes` to see available domains", domain) - } - } else { - notes = note.ListNotes() - } - return printNoteTable(notes, noPager) - }, -} - -var notesSearchCmd = &cobra.Command{ - Use: "search ", - Short: "Search notes by name, description, or example", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - noPager, _ := cmd.Flags().GetBool("no-pager") - results := note.SearchNotes(args[0]) - if len(results) == 0 { - fmt.Printf("no notes match %q\n", args[0]) - return nil - } - fmt.Printf("Found %d note(s) matching %q:\n\n", len(results), args[0]) - return printNoteTable(results, noPager) - }, -} - -var notesShowCmd = &cobra.Command{ - Use: "show ", - Short: "Show full documentation for a single note", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - noPager, _ := cmd.Flags().GetBool("no-pager") - n, ok := note.GetNote(args[0]) - if !ok { - return fmt.Errorf("note %q not found — run `inrun notes search %s` to find similar notes", args[0], args[0]) - } - return printNoteDetail(n, noPager) - }, -} - -var notesDomainCmd = &cobra.Command{ - Use: "domains", - Short: "List available note domains", - RunE: func(cmd *cobra.Command, args []string) error { - domains := note.Domains() - fmt.Println("Available domains:") - fmt.Println() - for _, d := range domains { - notes := note.ListByDomain(d) - fmt.Printf(" %-16s %d notes\n", d, len(notes)) - } - return nil - }, -} - -func init() { - notesCmd.Flags().StringP("domain", "d", "", "Filter by domain (e.g. strings, cron, kubernetes)") - notesCmd.Flags().Bool("no-pager", false, "Print directly without paging") - notesSearchCmd.Flags().Bool("no-pager", false, "Print directly without paging") - notesShowCmd.Flags().Bool("no-pager", false, "Print directly without paging") - - notesCmd.AddCommand(notesSearchCmd) - notesCmd.AddCommand(notesShowCmd) - notesCmd.AddCommand(notesDomainCmd) - cmdutil.RootCmd.AddCommand(notesCmd) - - // Shadow global flags - cmdutil.ShadowGlobalCommandFlags(notesCmd) -} - -// ── Rendering ───────────────────────────────────────────────────────────────── - -func printNoteTable(notes []note.NoteInfo, noPager bool) error { - var sb strings.Builder - w := tabwriter.NewWriter(&sb, 0, 0, 3, ' ', 0) - fmt.Fprintln(w, "DOMAIN\tNAME\tDESCRIPTION") - fmt.Fprintln(w, "──────\t────\t───────────") - for _, n := range notes { - desc := n.Description - if len(desc) > 72 { - desc = desc[:69] + "..." - } - fmt.Fprintf(w, "%s\t%s\t%s\n", n.Domain, n.Name, desc) - } - w.Flush() - return page(sb.String(), noPager) -} - -func printNoteDetail(n note.NoteInfo, noPager bool) error { - sep := strings.Repeat("─", 64) - var sb strings.Builder - fmt.Fprintln(&sb, sep) - fmt.Fprintf(&sb, " %s\n", n.Name) - fmt.Fprintln(&sb, sep) - fmt.Fprintf(&sb, " Domain: %s\n", n.Domain) - if n.Description != "" { - fmt.Fprintf(&sb, " Description: %s\n", n.Description) - } - if len(n.Keywords) > 0 { - fmt.Fprintf(&sb, " Keywords: %s\n", strings.Join(n.Keywords, ", ")) - } - if len(n.SeeAlso) > 0 { - fmt.Fprintf(&sb, " See also: %s\n", strings.Join(n.SeeAlso, ", ")) - } - if n.Example != "" { - fmt.Fprintln(&sb) - fmt.Fprintln(&sb, " Example:") - for _, line := range strings.Split(n.Example, "\n") { - fmt.Fprintf(&sb, " %s\n", line) - } - } - fmt.Fprintln(&sb, sep) - return page(sb.String(), noPager) -} - -// page writes output through `less` when stdout is a terminal, otherwise prints directly. -func page(content string, noPager bool) error { - if noPager || !cmdutil.IsTerminal() { - fmt.Print(content) - return nil - } - pager := exec.Command("less", "-RFX") - pager.Stdin = strings.NewReader(content) - pager.Stdout = os.Stdout - pager.Stderr = os.Stderr - if err := pager.Run(); err != nil { - fmt.Print(content) - } - return nil -} - -func printUserNoteTable(reg types.NoteRegistry, noPager bool) error { - var sb strings.Builder - w := tabwriter.NewWriter(&sb, 0, 0, 3, ' ', 0) - fmt.Fprintln(w, "NAME\tEXPRESSION\tDESCRIPTION") - fmt.Fprintln(w, "────\t──────────\t───────────") - for _, n := range reg.Functions { - expr := n.Expression - if len(expr) > 60 { - expr = expr[:57] + "..." - } - desc := n.Description - if len(desc) > 50 { - desc = desc[:47] + "..." - } - fmt.Fprintf(w, "%s\t%s\t%s\n", n.Name, expr, desc) - } - w.Flush() - return page(sb.String(), noPager) -} diff --git a/cmd/cli/registry/inspect.go b/cmd/cli/registry/inspect.go index 99c7a2395..dfdce07ef 100644 --- a/cmd/cli/registry/inspect.go +++ b/cmd/cli/registry/inspect.go @@ -4,6 +4,7 @@ package registry import ( "fmt" + "os" "strings" "time" @@ -25,9 +26,9 @@ var inspectCmd = &cobra.Command{ Example: ` inrun inspect postgres:v14 inrun inspect web-service:v1.0.0 --module inrun inspect oci://ghcr.io/myorg/patterns/redis:v7 - inrun inspect redis:v1.0.0 --view catalog.yaml,simulate.yaml`, + inrun inspect redis:v1.0.0 --view blueprint.yaml,simulate.yaml`, RunE: func(cmd *cobra.Command, args []string) error { - kind := registry.CatalogKind + kind := registry.BlueprintKind if inspectModule { kind = registry.ModuleKind } @@ -77,7 +78,7 @@ var inspectCmd = &cobra.Command{ } if v.Meta.Deprecated != nil { dep := v.Meta.Deprecated - d := &types.CatalogDeprecation{ + d := &types.BlueprintDeprecation{ MigratedTo: dep.MigratedTo, Message: dep.Message, } @@ -159,20 +160,31 @@ var inspectCmd = &cobra.Command{ fileMap[f.Name] = f available = append(available, f.Name) } + // The files print as one YAML stream ("---" between them) so the output + // can be piped to kubectl apply -f -. Problems go to stderr, out of the stream. + printed := 0 for _, name := range strings.Split(viewArg, ",") { name = strings.TrimSpace(name) + // cr.yaml finds manifests/cr.yaml, e2e.yaml finds test/e2e.yaml. + if rel := registry.ResolvePatternFile(name, func(rel string) bool { _, ok := fileMap[rel]; return ok }); rel != "" { + name = rel + } f, ok := fileMap[name] if !ok { - fmt.Printf(" %s %q not in artifact (available: %s)\n", cmdutil.WarningMark(), name, strings.Join(available, ", ")) + fmt.Fprintf(os.Stderr, " %s %q not in artifact (available: %s)\n", cmdutil.WarningMark(), name, strings.Join(available, ", ")) continue } - fmt.Printf("# ── %s ──\n", name) data, err := client.ViewFile(cmd.Context(), ref, f) if err != nil { - fmt.Printf(" error: %v\n", err) + fmt.Fprintf(os.Stderr, " error reading %s: %v\n", name, err) continue } - fmt.Println(string(data)) + if printed > 0 { + fmt.Println("---") + } + fmt.Printf("# ── %s ──\n", name) + fmt.Println(strings.TrimRight(string(data), "\n")) + printed++ } return nil } @@ -181,7 +193,7 @@ var inspectCmd = &cobra.Command{ if m.Deprecated != nil { cmdutil.PrintPatternDeprecation(m.Deprecated) dep := m.Deprecated - d := &types.CatalogDeprecation{ + d := &types.BlueprintDeprecation{ MigratedTo: dep.MigratedTo, Message: dep.Message, } @@ -210,7 +222,7 @@ var inspectCmd = &cobra.Command{ fmt.Printf(" Size: %s\n", cmdutil.FormatSize(info.Size)) fmt.Printf("\n Description: %s\n", cmdutil.WordWrap(m.Description, 55, " ")) if len(m.Tags) > 0 { - fmt.Printf(" Tags: %s\n", strings.Join(m.Tags, ", ")) + fmt.Printf(" Tags: %s\n", cmdutil.WordWrap(strings.Join(m.Tags, ", "), 55, " ")) } if m.Author != "" { fmt.Printf(" Author: %s\n", m.Author) @@ -321,6 +333,10 @@ var inspectCmd = &cobra.Command{ if m.RuntimeVersion != "" { fmt.Printf(" Runtime: %s\n", m.RuntimeVersion) } + if m.Replaces != "" { + fmt.Printf(" Replaces: %s\n", m.Replaces) + fmt.Printf(" %s\n", cmdutil.Yellow("an earlier "+m.Version+", pushed over with --overwrite")) + } if len(info.Files) > 0 { fmt.Printf("\n Files:\n") for _, f := range info.Files { @@ -328,10 +344,12 @@ var inspectCmd = &cobra.Command{ } } fmt.Printf("\nTo pull:\n") + // The reference as inspected: a digest or another registry pulls exactly + // this artifact, which name:version alone would not. if m.Kind == registry.ModuleKind { - fmt.Printf(" inrun pull %s:%s --module %s\n", m.Name, m.Version, deprecated) + fmt.Printf(" inrun pull %s --module %s\n", ref.String(), deprecated) } else { - fmt.Printf(" inrun pull %s:%s %s\n", m.Name, m.Version, deprecated) + fmt.Printf(" inrun pull %s %s\n", ref.String(), deprecated) } fmt.Printf("\nTo import:\n") if m.Kind == registry.ModuleKind { @@ -349,7 +367,7 @@ var inspectCmd = &cobra.Command{ func init() { inspectCmd.Flags().BoolVarP(&inspectModule, "module", "m", false, "Resolve as a module (uses INRUN_MODULES_REGISTRY)") - inspectCmd.Flags().String("view", "", "Comma-separated list of files to print before pulling (e.g. catalog.yaml,cr.yaml)") + inspectCmd.Flags().String("view", "", "Comma-separated list of files to print before pulling (e.g. blueprint.yaml,cr.yaml)") inspectCmd.Flags().Bool("versions", false, "List up to 10 tracked versions with simulate and E2E status") cmdutil.RootCmd.AddCommand(inspectCmd) diff --git a/cmd/cli/registry/patterns.go b/cmd/cli/registry/patterns.go index ce37eafd9..08683a662 100644 --- a/cmd/cli/registry/patterns.go +++ b/cmd/cli/registry/patterns.go @@ -23,12 +23,12 @@ var patternsCmd = &cobra.Command{ Args: cobra.MaximumNArgs(1), Example: ` inrun patterns inrun patterns --modules - inrun patterns --catalogs + inrun patterns --blueprints inrun patterns oci://ghcr.io/mycompany/patterns inrun patterns --tag database`, RunE: func(cmd *cobra.Command, args []string) error { tag, _ := cmd.Flags().GetString("tag") - onlyCatalogs, _ := cmd.Flags().GetBool("catalogs") + onlyBlueprints, _ := cmd.Flags().GetBool("blueprints") onlyModules, _ := cmd.Flags().GetBool("modules") client, err := registry.NewClient() @@ -63,7 +63,7 @@ var patternsCmd = &cobra.Command{ } } } - if !onlyCatalogs { + if !onlyBlueprints { moduleURL := os.Getenv(registry.EnvModuleRegistry) if moduleURL == "" { moduleURL = registry.DefaultModuleRegistry @@ -92,8 +92,8 @@ var patternsCmd = &cobra.Command{ label := "Inrun Registry" switch { - case onlyCatalogs: - label = "Inrun Catalogs" + case onlyBlueprints: + label = "Inrun Blueprints" case onlyModules: label = "Inrun Modules" } @@ -109,7 +109,7 @@ var patternsCmd = &cobra.Command{ continue } k := e.Kind - if onlyCatalogs && k != registry.CatalogKind.ToString() { + if onlyBlueprints && k != registry.BlueprintKind.ToString() { continue } if onlyModules && k != registry.ModuleKind.ToString() { @@ -152,7 +152,7 @@ var patternsCmd = &cobra.Command{ fmt.Printf("\n%d %s%s\n", count, noun, updatedAt) fmt.Printf("\nTo pull:\n inrun pull :\n") - fmt.Printf("\nTo filter:\n inrun patterns --catalogs\n inrun patterns --modules\n") + fmt.Printf("\nTo filter:\n inrun patterns --blueprints\n inrun patterns --modules\n") fmt.Println() return nil }, @@ -169,7 +169,7 @@ func registryErrSummary(err error) string { func init() { patternsCmd.Flags().StringP("tag", "t", "", "Filter by tag (e.g. database, stateful, security)") - patternsCmd.Flags().BoolP("catalogs", "k", false, "Show only catalogs (kind: Catalog)") + patternsCmd.Flags().BoolP("blueprints", "k", false, "Show only blueprints (kind: Blueprint)") patternsCmd.Flags().BoolP("modules", "m", false, "Show only modules (kind: Module)") cmdutil.RootCmd.AddCommand(patternsCmd) diff --git a/cmd/cli/registry/pull.go b/cmd/cli/registry/pull.go index 23ff4f1fc..addb11fb7 100644 --- a/cmd/cli/registry/pull.go +++ b/cmd/cli/registry/pull.go @@ -7,12 +7,13 @@ import ( "os" "strings" + "github.com/intentd/inrun/pkg/util/fileio" + "github.com/intentd/inrun/cmd/cli/cmdutil" "github.com/intentd/inrun/pkg/merger" "github.com/intentd/inrun/pkg/registry" "github.com/intentd/inrun/pkg/registry/module" - "github.com/intentd/inrun/pkg/utils" "github.com/spf13/cobra" ) @@ -24,7 +25,7 @@ var pullCmd = &cobra.Command{ Args: cobra.RangeArgs(0, 1), Example: ` inrun pull postgres:v14 inrun pull oci://ghcr.io/myorg/patterns/redis:v7 - inrun pull -f catalog.yaml + inrun pull -f blueprint.yaml inrun pull -f stack.yaml inrun pull postgres:v14 --refresh`, RunE: func(cmd *cobra.Command, args []string) error { @@ -37,11 +38,11 @@ var pullCmd = &cobra.Command{ } if len(args) == 0 { - return fmt.Errorf("provide a reference (e.g. postgres:v14) or --file ") + return fmt.Errorf("provide a reference (e.g. postgres:v14) or --file ") } isModule, _ := cmd.Flags().GetBool("module") - kind := registry.CatalogKind + kind := registry.BlueprintKind if isModule { kind = registry.ModuleKind } @@ -106,7 +107,7 @@ var pullCmd = &cobra.Command{ func init() { pullCmd.Flags().Bool("refresh", false, "Bypass local cache and re-pull from registry") pullCmd.Flags().StringP("out", "o", "", "Extract pulled pattern to this directory") - pullCmd.Flags().StringP("file", "f", "", "Pull all OCI imports from a catalog or stack file") + pullCmd.Flags().StringP("file", "f", "", "Pull all OCI imports from a blueprint or stack file") pullCmd.Flags().BoolP("module", "m", false, "Resolve as a module (uses INRUN_MODULES_REGISTRY)") cmdutil.RootCmd.AddCommand(pullCmd) @@ -124,15 +125,15 @@ func notifyTypedPull(cacheDir string) { cmdutil.PrintTypedBuildSteps(hasMakefile == nil) } -// pullModuleDeps reads the catalog.yaml in cacheDir and pulls any OCI module +// pullModuleDeps reads the blueprint.yaml in cacheDir and pulls any OCI module // imports it declares. Warnings are printed but do not fail the main pull. -func pullModuleDeps(catalogCacheDir string) { - catalogFile := cmdutil.JoinPath(catalogCacheDir, registry.FileCatalog) - if _, err := os.Stat(catalogFile); err != nil { +func pullModuleDeps(blueprintCacheDir string) { + blueprintFile := cmdutil.JoinPath(blueprintCacheDir, registry.FileBlueprint) + if _, err := os.Stat(blueprintFile); err != nil { return } - imports, err := registry.ExtractOCIImports(catalogFile) + imports, err := registry.ExtractOCIImports(blueprintFile) if err != nil || len(imports.ModuleImports) == 0 { return } @@ -154,8 +155,8 @@ func pullModuleDeps(catalogCacheDir string) { } } -// pullFromFile extracts all OCI refs from a catalog or stack file and pulls -// each one. Module imports (Catalog) and registry imports (Stack) are both +// pullFromFile extracts all OCI refs from a blueprint or stack file and pulls +// each one. Module imports (Blueprint) and registry imports (Stack) are both // handled, including bare-name shorthands without an oci:// prefix. func pullFromFile(cmd *cobra.Command, filePath string, refresh bool) error { imports, err := registry.ExtractOCIImports(filePath) @@ -231,9 +232,9 @@ func pullFromFile(cmd *cobra.Command, filePath string, refresh bool) error { for _, url := range helmImports.RemoteFiles { spin := cmdutil.StartSpinner(url) if refresh { - utils.InvalidateFileCache(url) + fileio.InvalidateFileCache(url) } - if _, fetchErr := utils.LoadFileWithAuthRefresh(url, nil, refresh); fetchErr != nil { + if _, fetchErr := fileio.LoadFileWithAuthRefresh(url, nil, refresh); fetchErr != nil { spin.Failure() errs = append(errs, fmt.Sprintf("file %s: %v", url, fetchErr)) } else { diff --git a/cmd/cli/registry/pull_print.go b/cmd/cli/registry/pull_print.go index 2117ff17b..5ad5d1823 100644 --- a/cmd/cli/registry/pull_print.go +++ b/cmd/cli/registry/pull_print.go @@ -15,15 +15,15 @@ import ( ) // detectCacheType resolves the artifact kind by checking for sentinel files on -// disk — not by sniffing the path string. Returns (isModule, isCatalog, patternFile). +// disk — not by sniffing the path string. Returns (isModule, isBlueprint, patternFile). func detectCacheType(cacheDir string) (bool, bool, string) { moduleFile := filepath.Join(cacheDir, registry.FileModule) if _, err := os.Stat(moduleFile); err == nil { return true, false, moduleFile } - catalogFile := filepath.Join(cacheDir, registry.FileCatalog) - if _, err := os.Stat(catalogFile); err == nil { - return false, true, catalogFile + blueprintFile := filepath.Join(cacheDir, registry.FileBlueprint) + if _, err := os.Stat(blueprintFile); err == nil { + return false, true, blueprintFile } return false, false, "" } @@ -82,7 +82,7 @@ func printValidationHint(patternFile string) { // printModuleReference prints module usage and inputs sample. func printModuleReference(ref *registry.Ref, module *types.Module, sum inputsSummary) { - fmt.Printf("\nReference in a Catalog:\n") + fmt.Printf("\nReference in a Blueprint:\n") fmt.Printf(" imports:\n") fmt.Printf(" - module: %s\n", ref.String()) @@ -111,10 +111,10 @@ func printModuleReference(ref *registry.Ref, module *types.Module, sum inputsSum } } -// printCatalogReference prints catalog usage and run hint. -func printCatalogReference(ref *registry.Ref, cacheDir string) { - fmt.Printf("\nRun this catalog pattern:\n") - fmt.Printf(" inrun -f %s\n", filepath.Join(cacheDir, registry.FileCatalog)) +// printBlueprintReference prints blueprint usage and run hint. +func printBlueprintReference(ref *registry.Ref, cacheDir string) { + fmt.Printf("\nRun this blueprint pattern:\n") + fmt.Printf(" inrun -f %s\n", filepath.Join(cacheDir, registry.FileBlueprint)) fmt.Printf("\nOr reference in a Stack:\n") fmt.Printf(" imports:\n") fmt.Printf(" registry:\n") @@ -143,7 +143,7 @@ func printCachedFiles(cacheDir string) { // printPullSuggestions orchestrates the helpers and handles errors gracefully. func printPullSuggestions(ref *registry.Ref, cacheDir string) { - isModule, isCatalog, patternFile := detectCacheType(cacheDir) + isModule, isBlueprint, patternFile := detectCacheType(cacheDir) printCachedFiles(cacheDir) printValidationHint(patternFile) @@ -159,11 +159,11 @@ func printPullSuggestions(ref *registry.Ref, cacheDir string) { return } - if isCatalog { - printCatalogReference(ref, cacheDir) + if isBlueprint { + printBlueprintReference(ref, cacheDir) return } // fallback: nothing recognized - fmt.Println("No module or catalog pattern detected in cache.") + fmt.Println("No module or blueprint pattern detected in cache.") } diff --git a/cmd/cli/registry/push.go b/cmd/cli/registry/push.go index 23e12f7ca..e60fe0a91 100644 --- a/cmd/cli/registry/push.go +++ b/cmd/cli/registry/push.go @@ -4,6 +4,7 @@ package registry import ( "bufio" + "context" "fmt" "os" "path/filepath" @@ -15,8 +16,8 @@ import ( "github.com/intentd/inrun/cmd/cli/serve" "github.com/intentd/inrun/cmd/cli/testsuite" - "github.com/intentd/inrun/pkg/catalog" - "github.com/intentd/inrun/pkg/catalog/pipeline" + "github.com/intentd/inrun/pkg/blueprint" + "github.com/intentd/inrun/pkg/blueprint/pipeline" "github.com/intentd/inrun/pkg/merger" "github.com/intentd/inrun/pkg/registry" "github.com/intentd/inrun/pkg/registry/e2e" @@ -36,6 +37,11 @@ var ( pushE2ECluster string pushE2EUseCurrent bool pushE2EWorkers int + pushE2EChart string + pushE2EVersion string + pushE2EValues []string + pushE2ESet []string + pushOverwrite bool pushAddIntent string ) @@ -116,11 +122,20 @@ var pushCmd = &cobra.Command{ return fmt.Errorf("invalid reference: %w", err) } + // A published version is never replaced by accident: consumers who pulled + // it, and the proof recorded for it, refer to what was there. Checked + // before the gates, so a refused push doesn't wait for them first. With + // --overwrite, the new artifact records the digest it replaces. + replaces, err := checkPublished(cmd.Context(), ref) + if err != nil { + return err + } + cmdutil.PrintBanner() fmt.Printf("Pushing %s (%s) to %s...\n", refArg, patternKind, ref.Registry) - if patternKind == registry.CatalogKind { - localImports, err := registry.ExtractLocalModuleImports(filepath.Join(dir, registry.FileCatalog)) + if patternKind == registry.BlueprintKind { + localImports, err := registry.ExtractLocalModuleImports(filepath.Join(dir, registry.FileBlueprint)) if err == nil && len(localImports) > 0 { var lines []string for _, li := range localImports { @@ -129,29 +144,29 @@ var pushCmd = &cobra.Command{ return fmt.Errorf( "✗ Push blocked: local file imports in %s\n\n%s\n\n"+ " Local imports work for inrun simulate and inrun template, but cannot\n"+ - " be resolved by consumers after the catalog is published.\n\n"+ + " be resolved by consumers after the blueprint is published.\n\n"+ " Before publishing:\n"+ " 1. Push the module: inrun push /\n"+ " 2. Replace the local path with the OCI ref:\n"+ " module: oci:///modules/:", - registry.FileCatalog, + registry.FileBlueprint, strings.Join(lines, "\n"), ) } } - if patternKind == registry.CatalogKind { - m := merger.New(filepath.Join(dir, registry.FileCatalog)) + if patternKind == registry.BlueprintKind { + m := merger.New(filepath.Join(dir, registry.FileBlueprint)) if err := m.Merge(); err != nil { - return fmt.Errorf(" ✗ %s: %w", registry.FileCatalog, err) + return fmt.Errorf(" ✗ %s: %w", registry.FileBlueprint, err) } - cat, err := pipeline.BuildExpanded(cmdutil.Cfg, m) + bp, err := pipeline.BuildExpanded(cmdutil.Cfg, m) if err != nil { - return fmt.Errorf(" ✗ %s: %w", registry.FileCatalog, err) + return fmt.Errorf(" ✗ %s: %w", registry.FileBlueprint, err) } - fmt.Printf(" %s %-20s valid\n", cmdutil.SuccessMark(), registry.FileCatalog) - if d := cat.Deprecation(); d != nil { - cmdutil.PrintCatalogDeprecation(d) + fmt.Printf(" %s %-20s valid\n", cmdutil.SuccessMark(), registry.FileBlueprint) + if d := bp.Deprecation(); d != nil { + cmdutil.PrintBlueprintDeprecation(d) } if crd := registry.FindPatternFile(dir, registry.FileCRD); crd != "" && slices.Contains(files, crd) { @@ -163,7 +178,7 @@ var pushCmd = &cobra.Command{ } for _, f := range files { - if f == registry.FileCatalog || filepath.Base(f) == registry.FileCRD { + if f == registry.FileBlueprint || filepath.Base(f) == registry.FileCRD { continue } info, _ := os.Stat(filepath.Join(dir, f)) @@ -171,7 +186,7 @@ var pushCmd = &cobra.Command{ } var simulateMeta *registry.PatternSimulate - if patternKind == registry.CatalogKind { + if patternKind == registry.BlueprintKind { if rel := registry.FindPatternFile(dir, registry.FileSimulate); rel != "" { simFile := filepath.Join(dir, rel) if pushForce || pushNoSimulate { @@ -208,7 +223,7 @@ var pushCmd = &cobra.Command{ } var e2eMeta *registry.PatternE2E - if patternKind == registry.CatalogKind { + if patternKind == registry.BlueprintKind { e2eFile := pushE2EFile if e2eFile == "" { if rel := registry.FindPatternFile(dir, registry.FileE2E); rel != "" { @@ -227,7 +242,14 @@ var pushCmd = &cobra.Command{ } } else { fmt.Printf("\nRunning E2E gate (%s)...\n", registry.FileE2E) - runner, err := e2e.New(e2eFile, e2e.Options{ClusterCtx: pushE2ECluster, UseCurrentCtx: pushE2EUseCurrent, Workers: pushE2EWorkers}) + chart, err := cmdutil.ChartDir(pushE2EChart) + if err != nil { + return err + } + runner, err := e2e.New(e2eFile, e2e.Options{ + ClusterCtx: pushE2ECluster, UseCurrentCtx: pushE2EUseCurrent, Workers: pushE2EWorkers, + InrunVersion: pushE2EVersion, InrunChart: chart, ValueFiles: pushE2EValues, HelmArgs: cmdutil.HelmSetArgs(pushE2ESet), + }) if err != nil { return fmt.Errorf("e2e gate: %w\n\nUse --force or --no-e2e to skip", err) } @@ -258,13 +280,13 @@ var pushCmd = &cobra.Command{ } var intentMeta *registry.PatternIntent - if patternKind == registry.CatalogKind && pushAddIntent != "" { + if patternKind == registry.BlueprintKind && pushAddIntent != "" { intentFile := pushAddIntent if !filepath.IsAbs(intentFile) { intentFile = filepath.Join(dir, intentFile) } fmt.Printf("\nRunning intent play (%s)...\n", pushAddIntent) - target, perr := serve.RunIntentPlay(filepath.Join(dir, registry.FileCatalog), intentFile) + target, perr := serve.RunIntentPlay(filepath.Join(dir, registry.FileBlueprint), intentFile) status := "passed" if perr != nil { status = "failed" @@ -280,8 +302,8 @@ var pushCmd = &cobra.Command{ } var typedMeta *registry.PatternTyped - if patternKind == registry.CatalogKind { - typedMeta = detectTypedCatalog(filepath.Join(dir, registry.FileCatalog)) + if patternKind == registry.BlueprintKind { + typedMeta = detectTypedBlueprint(filepath.Join(dir, registry.FileBlueprint)) } runtimeVersion := version.Short() @@ -307,6 +329,7 @@ var pushCmd = &cobra.Command{ Intent: intentMeta, Typed: typedMeta, RuntimeVersion: runtimeVersion, + Replaces: replaces, } digest, err := client.Push(cmd.Context(), ref, dir, opts, progress) if err != nil { @@ -318,14 +341,20 @@ var pushCmd = &cobra.Command{ fmt.Printf("\n%s Pushed: %s\n", cmdutil.SuccessMark(), ref.String()) fmt.Printf(" Digest: %s\n", digest) - if patternKind == registry.CatalogKind { + if patternKind == registry.BlueprintKind { moduleYAML := filepath.Join(dir, registry.FileModule) if _, err := os.Stat(moduleYAML); err == nil { moduleRef, err := registry.ResolveForKind(fmt.Sprintf("%s:%s", meta.Name, meta.Version), registry.ModuleKind) + var moduleReplaces string + if err == nil { + if moduleReplaces, err = checkPublished(cmd.Context(), moduleRef); err != nil { + fmt.Fprintf(os.Stderr, "warning: not pushing %s: %v\n", registry.FileModule, err) + } + } if err == nil { fmt.Printf("\nAlso pushing %s to %s...\n", registry.FileModule, moduleRef.Registry) spinModule := cmdutil.StartSpinner(fmt.Sprintf("Pushing %s...", registry.FileModule)) - if mDigest, err := client.Push(cmd.Context(), moduleRef, dir, registry.PushOptions{RuntimeVersion: version.Short()}, nil); err != nil { + if mDigest, err := client.Push(cmd.Context(), moduleRef, dir, registry.PushOptions{RuntimeVersion: version.Short(), Replaces: moduleReplaces}, nil); err != nil { spinModule.Failure() fmt.Fprintf(os.Stderr, "warning: module push failed: %v\n", err) } else { @@ -337,7 +366,7 @@ var pushCmd = &cobra.Command{ } } - fmt.Printf("\nTo import in a Catalog:\n") + fmt.Printf("\nTo import in a Blueprint:\n") if patternKind == registry.ModuleKind { fmt.Printf(" imports:\n") fmt.Printf(" - module: %s\n", ref.String()) @@ -361,6 +390,11 @@ func init() { pushCmd.Flags().StringVar(&pushE2ECluster, "cluster", "", "Reuse an existing kind cluster context for the e2e gate (skips cluster creation)") pushCmd.Flags().BoolVar(&pushE2EUseCurrent, "use-current", false, "Use the current kubeconfig context for the e2e gate (skips cluster creation)") pushCmd.Flags().IntVar(&pushE2EWorkers, "workers", 0, "Number of kind worker nodes for the e2e gate cluster (0 = control-plane only)") + pushCmd.Flags().BoolVar(&pushOverwrite, "overwrite", false, "Replace a version that is already published (anyone who pulled it keeps the old content)") + pushCmd.Flags().StringVar(&pushE2EChart, "chart", "", "Local Inrun chart directory for the e2e gate to install instead of the published chart") + pushCmd.Flags().StringVar(&pushE2EVersion, "version", "", "Inrun version for the e2e gate to install (e.g. v1.2.3)") + pushCmd.Flags().StringSliceVar(&pushE2EValues, "values", nil, "Helm values files for the e2e gate's Inrun install") + pushCmd.Flags().StringSliceVar(&pushE2ESet, "set", nil, "Helm --set arguments for the e2e gate's Inrun install (e.g. key=value)") pushCmd.Flags().StringVar(&pushAddIntent, "add-intent", "", "Run inrun serve play against this intent file (YAML or JSON) and bake the result into the artifact") cmdutil.RootCmd.AddCommand(pushCmd) @@ -368,7 +402,7 @@ func init() { cmdutil.ShadowGlobalCommandFlags(pushCmd, "file") } -// detectTypedCatalog parses a catalog.yaml and returns a PatternTyped if any +// detectTypedBlueprint parses a blueprint.yaml and returns a PatternTyped if any // CRD declares customHooks or customConstructor. Returns nil on parse error. // extractRuntimeVersionFromGoMod scans go.mod for the inrun runtime dependency // and returns its version (e.g. "v0.7.6"). Returns "" if go.mod is absent or the @@ -403,14 +437,14 @@ func extractRuntimeVersionFromGoMod(dir string) string { return "" } -func detectTypedCatalog(catalogPath string) *registry.PatternTyped { - cat, err := catalog.ParseFile(catalogPath) +func detectTypedBlueprint(blueprintPath string) *registry.PatternTyped { + bp, err := blueprint.ParseFile(blueprintPath) if err != nil { return nil } var t registry.PatternTyped - for _, name := range cat.CRDNames() { - entry, ok := cat.CRDEntry(name) + for _, name := range bp.CRDNames() { + entry, ok := bp.CRDEntry(name) if !ok { continue } @@ -512,3 +546,21 @@ func detectRunner() string { return "local" } } + +// checkPublished returns the digest ref already has in the registry, for the +// new artifact to record, or "" when ref is not published. Without +// --overwrite, a published ref is an error. +func checkPublished(ctx context.Context, ref *registry.Ref) (string, error) { + client, err := registry.NewClient() + if err != nil { + return "", err + } + current, err := client.Published(ctx, ref) + if err != nil { + return "", err + } + if current != "" && !pushOverwrite { + return "", fmt.Errorf("%s is already published\n Bump metadata.version to publish a change, or pass --overwrite to replace it", ref.String()) + } + return current, nil +} diff --git a/cmd/cli/rewrite/rewrite.go b/cmd/cli/rewrite/rewrite.go new file mode 100644 index 000000000..532de888e --- /dev/null +++ b/cmd/cli/rewrite/rewrite.go @@ -0,0 +1,192 @@ +//go:build !runtime && !gateway + +// package rewrite implements the rewrite command, which converts a +// controller-runtime reconciler to run on this runtime. +package rewrite + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/intentd/inrun/cmd/cli/cmdutil" + + "github.com/intentd/inrun/pkg/tools/rewrite" + "github.com/intentd/inrun/pkg/version" + "github.com/spf13/cobra" +) + +var rewriteCmd = &cobra.Command{ + Use: "rewrite ", + Short: "Rewrite a controller-runtime operator to Inrun", + Long: `Rewrites a controller-runtime reconciler to Inrun. Your Reconcile logic +is untouched — Inrun takes over the infrastructure. + +Default mode (--mode toclient): zero changes to your Reconcile signature or +call sites. SetupWithManager is removed; a two-line constructor using +adapter.ToClient and domain.ReconcilerFrom is injected. Your reconciler +compiles and runs inside Inrun with no other edits. + + inrun rewrite ./controller/webapp_controller.go -o ./my-operator + +The output directory receives the rewritten file plus scaffolding: +blueprint.yaml, simulate.yaml, e2e.yaml, go.mod, Makefile, Dockerfile. + +For a full rewrite to Inrun style, use --mode native. + +Examples: + inrun rewrite ./controller/webapp_controller.go -o ./my-operator + inrun rewrite ./controller/webapp_controller.go --mode native -o ./out + inrun rewrite ./controller/webapp_controller.go # prompts before replacing`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + inputPath := args[0] + outputDir, _ := cmd.Flags().GetString("output") + modulePath, _ := cmd.Flags().GetString("module") + operatorName, _ := cmd.Flags().GetString("name") + modeFlag, _ := cmd.Flags().GetString("mode") + + var mode rewrite.Mode + switch modeFlag { + case "native": + mode = rewrite.ModeNative + case "toclient", "": + mode = rewrite.ModeToClient + default: + return fmt.Errorf("unknown --mode %q: valid values are toclient, native", modeFlag) + } + + src, err := cmdutil.ReadLocal(inputPath) + if err != nil { + return fmt.Errorf("read %s: %w", inputPath, err) + } + + res, err := rewrite.Apply(src, mode) + if err != nil { + return fmt.Errorf("rewrite: %w", err) + } + + if len(res.Warnings) > 0 { + fmt.Fprintf(os.Stderr, "\n%s\n", cmdutil.Yellow("Warnings — review these before running:")) + for _, w := range res.Warnings { + fmt.Fprintf(os.Stderr, " %s %s\n", cmdutil.Yellow("⚠"), w) + } + fmt.Fprintln(os.Stderr) + } + + opts := rewrite.Options{ + ModulePath: modulePath, + OperatorName: operatorName, + InrunVersion: version.Short(), + } + files := rewrite.Generate(res, opts) + + if outputDir != "" { + return writeOutputDir(outputDir, inputPath, res, files) + } + + return replaceInPlace(inputPath, res, files) + }, +} + +func writeOutputDir(dir, inputPath string, res *rewrite.Result, files rewrite.Files) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + + reconcilerDest := filepath.Join(dir, filepath.Base(inputPath)) + if err := os.WriteFile(reconcilerDest, res.Source, 0o644); err != nil { + return fmt.Errorf("write reconciler: %w", err) + } + fmt.Printf(" %s %s\n", cmdutil.Green("→"), reconcilerDest) + + type namedFile struct { + name string + content string + } + generated := []namedFile{ + {"blueprint.yaml", files.Blueprint}, + {"test/simulate.yaml", files.Simulate}, + {"test/e2e.yaml", files.E2E}, + {"go.mod", files.GoMod}, + {"Makefile", files.Makefile}, + {"Dockerfile", files.Dockerfile}, + {"README.md", files.README}, + } + for _, f := range generated { + dest := filepath.Join(dir, f.name) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(dest), err) + } + if err := os.WriteFile(dest, []byte(f.content), 0o644); err != nil { + return fmt.Errorf("write %s: %w", f.name, err) + } + fmt.Printf(" %s %s\n", cmdutil.Green("→"), dest) + } + + fmt.Printf("\n%s Search for %s in %s to see what needs attention.\n", + cmdutil.Green("✓ Done."), cmdutil.Bold("TODO(inrun rewrite)"), dir) + return nil +} + +func replaceInPlace(inputPath string, res *rewrite.Result, files rewrite.Files) error { + dir := filepath.Dir(inputPath) + + fmt.Printf("This will replace %s and write %s alongside it.\n", + cmdutil.Bold(inputPath), cmdutil.Bold("blueprint.yaml, test/simulate.yaml, test/e2e.yaml, go.mod, Makefile, Dockerfile")) + fmt.Printf("%s [y/N] ", cmdutil.Yellow("Continue?")) + + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + return nil + } + + if err := os.WriteFile(inputPath, res.Source, 0o644); err != nil { + return fmt.Errorf("write %s: %w", inputPath, err) + } + fmt.Printf(" %s %s\n", cmdutil.Green("→"), inputPath) + + type namedFile struct { + name string + content string + } + generated := []namedFile{ + {"blueprint.yaml", files.Blueprint}, + {"test/simulate.yaml", files.Simulate}, + {"test/e2e.yaml", files.E2E}, + {"go.mod", files.GoMod}, + {"Makefile", files.Makefile}, + {"Dockerfile", files.Dockerfile}, + } + for _, f := range generated { + dest := filepath.Join(dir, f.name) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("create %s: %w", filepath.Dir(dest), err) + } + if err := os.WriteFile(dest, []byte(f.content), 0o644); err != nil { + return fmt.Errorf("write %s: %w", f.name, err) + } + fmt.Printf(" %s %s\n", cmdutil.Green("→"), dest) + } + + fmt.Printf("\n%s Search for %s to see what needs attention.\n", + cmdutil.Green("✓ Done."), cmdutil.Bold("TODO(inrun rewrite)")) + return nil +} + +func init() { + cmdutil.RootCmd.AddCommand(rewriteCmd) + + rewriteCmd.Flags().StringP("output", "o", "", "Write output to this directory (non-destructive; skips confirmation)") + rewriteCmd.Flags().String("module", "", "Go module path for the rewritten operator (e.g. github.com/myorg/my-operator)") + rewriteCmd.Flags().String("name", "", "Operator name in kebab-case (e.g. my-operator); derived from receiver type if omitted") + rewriteCmd.Flags().String("mode", "toclient", "Migration mode: toclient (default, zero Reconcile changes) or native (full rewrite)") + + // Shadow global flags so they don't appear under `inrun rewrite` + cmdutil.ShadowGlobalCommandFlags(rewriteCmd, "file") +} diff --git a/cmd/cli/run/companions/companions.go b/cmd/cli/run/companions/companions.go index b1216d8b6..77bf03ab5 100644 --- a/cmd/cli/run/companions/companions.go +++ b/cmd/cli/run/companions/companions.go @@ -11,34 +11,34 @@ import ( "syscall" "github.com/intentd/inrun/cmd/cli/console" - "github.com/intentd/inrun/pkg/catalog" + "github.com/intentd/inrun/pkg/blueprint" "github.com/intentd/inrun/pkg/config" "github.com/intentd/inrun/pkg/logger" ) // Start starts the console next to the runtime, and the gateway too when the -// Catalog serves (gateway API enabled and a CRD with serve enabled). Each runs +// Blueprint serves (gateway API enabled and a CRD with serve enabled). Each runs // as a child process, so one that fails never takes the runtime down: a // companion that cannot start is logged and skipped. A signal that stops inrun // is passed on, so the children stop with it. // -// paths are the Catalogs the gateway loads; cfg is the runtime's config, which +// paths are the Blueprints the gateway loads; cfg is the runtime's config, which // advertises the gateway to the console. -func Start(ctx context.Context, cfg *config.Config, paths []string, cat *catalog.Catalog) { +func Start(ctx context.Context, cfg *config.Config, paths []string, bp *blueprint.Blueprint, migrate bool) { var children []*exec.Cmd - if cat.HasServeEnabled() { - cmd, url, err := startGateway(ctx, paths) + if bp.HasServeEnabled() { + cmd, url, err := startGateway(ctx, paths, migrate) if err != nil { logger.Warn().Err(err).Msg("gateway not started") } else { children = append(children, cmd) - // The runtime's /catalog advertises it, so the console finds it. + // The runtime's /blueprint advertises it, so the console finds it. cfg.SetGatewayEndpoint(url) } } - // The console finds the gateway through the runtime's /catalog. + // The console finds the gateway through the runtime's /blueprint. cmd, err := startConsole(ctx, "http://localhost:"+cfg.Health().Port()) if err != nil { logger.Warn().Err(err).Msg("console not started") @@ -74,9 +74,9 @@ func stopWithInrun(children []*exec.Cmd) { }() } -// startGateway runs this binary's `gate run` with the same Catalogs, on +// startGateway runs this binary's `gate run` with the same Blueprints, on // INRUN_GATEWAY_PORT (default 8082). -func startGateway(ctx context.Context, paths []string) (*exec.Cmd, string, error) { +func startGateway(ctx context.Context, paths []string, migrate bool) (*exec.Cmd, string, error) { exe, err := os.Executable() if err != nil { return nil, "", fmt.Errorf("locating inrun: %w", err) @@ -86,6 +86,9 @@ func startGateway(ctx context.Context, paths []string) (*exec.Cmd, string, error for _, p := range paths { args = append(args, "-f", p) } + if migrate { + args = append(args, "--migrate") + } cmd := exec.CommandContext(ctx, exe, args...) cmd.Env = append(os.Environ(), "INRUN_PORT="+port) if err := start(cmd, "gateway"); err != nil { diff --git a/cmd/cli/run/companions/doc.go b/cmd/cli/run/companions/doc.go index ce122fd1a..53212f648 100644 --- a/cmd/cli/run/companions/doc.go +++ b/cmd/cli/run/companions/doc.go @@ -1,6 +1,6 @@ //go:build !runtime && !gateway // Package companions starts the processes that run next to a local inrun: -// the console always, and the gateway when the Catalog serves. They run as +// the console always, and the gateway when the Blueprint serves. They run as // child processes and stop with inrun. package companions diff --git a/cmd/cli/run/doc.go b/cmd/cli/run/doc.go index c3d0b7386..2dfa061f3 100644 --- a/cmd/cli/run/doc.go +++ b/cmd/cli/run/doc.go @@ -1,4 +1,4 @@ -// Package run makes the root inrun command run a Catalog: the runtime in the +// Package run makes the root inrun command run a Blueprint: the runtime in the // runtime build, and in the developer build also a published pattern given as // :, with --dev to create a kind cluster. package run diff --git a/cmd/cli/run/inrun.go b/cmd/cli/run/inrun.go index 7146007e0..bbc27c316 100644 --- a/cmd/cli/run/inrun.go +++ b/cmd/cli/run/inrun.go @@ -15,27 +15,27 @@ import ( ) // runRuntime is the runtime build's root command: bare `inrun` runs the -// runtime. Without -f it reads catalog.yaml (or stack.yaml) from the current +// runtime. Without -f it reads blueprint.yaml (or stack.yaml) from the current // directory. func runRuntime(cmd *cobra.Command, args []string) error { - // Resolve catalog paths + // Resolve blueprint paths paths, _ := cmd.Flags().GetStringSlice("file") - paths, err := cmdutil.ResolveCatalogPaths(paths) + paths, err := cmdutil.ResolveBlueprintPaths(paths) if err != nil { return err } - // Merge catalogs + // Merge blueprints m := merger.New(paths...) if err := m.Merge(); err != nil { - return fmt.Errorf("merging catalogs: %w", err) + return fmt.Errorf("merging blueprints: %w", err) } logger.Debug(). - Str("catalogs", strings.Join(paths, ", ")). + Str("blueprints", strings.Join(paths, ", ")). Int("total", m.Count()). Int("enabled", m.EnabledCount()). - Msg("catalogs merged") + Msg("blueprints merged") internal.RunRuntime(cmdutil.Cfg, m, cmdutil.Ctx) return nil diff --git a/cmd/cli/run/inrun_dev.go b/cmd/cli/run/inrun_dev.go index 86905002b..99ec7923b 100644 --- a/cmd/cli/run/inrun_dev.go +++ b/cmd/cli/run/inrun_dev.go @@ -10,7 +10,7 @@ import ( "github.com/intentd/inrun/cmd/cli/run/companions" "github.com/intentd/inrun/cmd/internal" - "github.com/intentd/inrun/pkg/catalog" + "github.com/intentd/inrun/pkg/blueprint" "github.com/intentd/inrun/pkg/logger" "github.com/intentd/inrun/pkg/merger" "github.com/intentd/inrun/pkg/registry" @@ -18,7 +18,7 @@ import ( "github.com/spf13/cobra" ) -// runDev is the dev build's root command. Bare `inrun` runs the Catalog in the +// runDev is the dev build's root command. Bare `inrun` runs the Blueprint in the // current directory (or -f); `inrun :` pulls a published // pattern and runs it. func runDev(cmd *cobra.Command, args []string) error { @@ -51,23 +51,23 @@ func runDev(cmd *cobra.Command, args []string) error { paths = append([]string{p}, paths...) } - // Resolve catalog paths - paths, err := cmdutil.ResolveCatalogPaths(paths) + // Resolve blueprint paths + paths, err := cmdutil.ResolveBlueprintPaths(paths) if err != nil { return err } - // Merge catalogs + // Merge blueprints m := merger.New(paths...) if err := m.Merge(); err != nil { - return fmt.Errorf("merging catalogs: %w", err) + return fmt.Errorf("merging blueprints: %w", err) } logger.Debug(). - Strs("catalogs", paths). + Strs("blueprints", paths). Int("total", m.Count()). Int("enabled", m.EnabledCount()). - Msg("catalogs merged") + Msg("blueprints merged") // Apply declared crdFile, crFiles and setup paths before handing off to the runtime. if len(paths) > 0 { @@ -77,10 +77,12 @@ func runDev(cmd *cobra.Command, args []string) error { } } - // Run the runtime. Once its Catalog is built, start the console, and the - // gateway when the Catalog serves. - internal.RunRuntime(cmdutil.Cfg, m, cmdutil.Ctx, func(cat *catalog.Catalog) { - companions.Start(cmdutil.Ctx, cmdutil.Cfg, paths, cat) + migrate, _ := cmd.Flags().GetBool("migrate") + + // Run the runtime. Once its Blueprint is built, start the console, and the + // gateway when the Blueprint serves. + internal.RunRuntime(cmdutil.Cfg, m, cmdutil.Ctx, func(bp *blueprint.Blueprint) { + companions.Start(cmdutil.Ctx, cmdutil.Cfg, paths, bp, migrate) }) return nil } @@ -113,7 +115,8 @@ func init() { root.Flags().Bool("dev", false, "Create a local Kind cluster if none is reachable (development only)") root.Flags().Bool("dev-server", false, "Start the mock dev server for external: examples (no real services needed)") root.Flags().Int("dev-server-port", devserver.Port, "Port for the mock dev server") - root.Flags().Bool("use-stack", false, "Run the pattern's stack.yaml instead of its catalog.yaml") + root.Flags().Bool("use-stack", false, "Run the pattern's stack.yaml instead of its blueprint.yaml") root.Flags().Bool("refresh", false, "Pull the pattern again even if it is cached") root.Flags().Bool("apply-cr", false, "Apply the pattern's example CRD and CR before starting") + root.Flags().Bool("migrate", false, "Run schema migration before the gateway starts") } diff --git a/cmd/cli/run/inrun_dev_apply.go b/cmd/cli/run/inrun_dev_apply.go index 37c387a2a..116515847 100644 --- a/cmd/cli/run/inrun_dev_apply.go +++ b/cmd/cli/run/inrun_dev_apply.go @@ -26,17 +26,17 @@ import ( // 4. Setup files (setup) // // All resources are applied in the correct order before the operator runtime -// starts. Relative paths are resolved against the catalog directory. This +// starts. Relative paths are resolved against the blueprint directory. This // function is a no-op when running inside the cluster. -func applyPreRuntimeResources(ctx context.Context, catalogPath string, m *merger.Merger) { +func applyPreRuntimeResources(ctx context.Context, blueprintPath string, m *merger.Merger) { if cmdutil.IsRunningInPod() { return } - applyCRDFilesIfNeeded(ctx, catalogPath, m) + applyCRDFilesIfNeeded(ctx, blueprintPath, m) waitForCRDsEstablished(ctx, m) - applyCRFilesIfNeeded(ctx, catalogPath, m) - applySetupIfNeeded(ctx, catalogPath, m) + applyCRFilesIfNeeded(ctx, blueprintPath, m) + applySetupIfNeeded(ctx, blueprintPath, m) } // ensureClusterReady ensures that a Kubernetes cluster is reachable before @@ -48,6 +48,9 @@ func ensureClusterReady(dev bool) error { if err := cluster.EnsureDependencies(); err != nil { return fmt.Errorf("installing dependencies: %w", err) } + if cluster.ClusterReachable() { + return nil + } fmt.Println("\n Cannot reach a Kubernetes cluster.") fmt.Printf(" Creating local Kind cluster '%s'...\n", cluster.KindClusterName) @@ -59,7 +62,7 @@ func ensureClusterReady(dev bool) error { return nil } - // non-dev mode + // non --dev mode if cluster.ClusterReachable() { return nil } @@ -96,12 +99,12 @@ func ensureClusterReady(dev bool) error { // applyCRFilesIfNeeded applies crFiles declarations via kubectl in order before // the runtime starts. Only runs outside the cluster (dev mode). -func applyCRFilesIfNeeded(ctx context.Context, catalogPath string, m *merger.Merger) { +func applyCRFilesIfNeeded(ctx context.Context, blueprintPath string, m *merger.Merger) { if cmdutil.IsRunningInPod() { return } - catalogDir := filepath.Dir(catalogPath) + blueprintDir := filepath.Dir(blueprintPath) for crdName, entry := range m.All() { if !entry.HasCRFiles() { @@ -110,7 +113,7 @@ func applyCRFilesIfNeeded(ctx context.Context, catalogPath string, m *merger.Mer for _, crFile := range entry.CRFiles { path := crFile if !filepath.IsAbs(path) && !strings.HasPrefix(path, "http") { - path = filepath.Join(catalogDir, path) + path = filepath.Join(blueprintDir, path) } out, err := exec.CommandContext(ctx, "kubectl", "apply", "-f", path).CombinedOutput() @@ -175,12 +178,12 @@ func waitForCRDsEstablished(ctx context.Context, m *merger.Merger) { // applyCRDFilesIfNeeded applies any crdFile declarations via kubectl before the // operator starts. Only runs outside the cluster (dev mode). In production, // CRDs must be pre-applied by the platform operator. -func applyCRDFilesIfNeeded(ctx context.Context, catalogPath string, m *merger.Merger) { +func applyCRDFilesIfNeeded(ctx context.Context, blueprintPath string, m *merger.Merger) { if cmdutil.IsRunningInPod() { return } - catalogDir := filepath.Dir(catalogPath) + blueprintDir := filepath.Dir(blueprintPath) for crdName, entry := range m.All() { if !entry.HasCRDFile() { @@ -189,7 +192,7 @@ func applyCRDFilesIfNeeded(ctx context.Context, catalogPath string, m *merger.Me path := entry.CRDFile if !filepath.IsAbs(path) && !strings.HasPrefix(path, "http") { - path = filepath.Join(catalogDir, path) + path = filepath.Join(blueprintDir, path) } out, err := exec.CommandContext(ctx, "kubectl", "apply", "-f", path).CombinedOutput() @@ -211,10 +214,10 @@ func applyCRDFilesIfNeeded(ctx context.Context, catalogPath string, m *merger.Me // applyPatternExamples applies crd.yaml and cr.yaml from the pattern directory // when --apply-cr is set. These are the example files shipped with the pattern — -// distinct from crdFile/crFiles declared in the catalog itself. kubectl apply +// distinct from crdFile/crFiles declared in the blueprint itself. kubectl apply // is idempotent so overlap with applyPreRuntimeResources is safe. -func applyPatternExamples(ctx context.Context, catalogPath string, m *merger.Merger) { - dir := filepath.Dir(catalogPath) +func applyPatternExamples(ctx context.Context, blueprintPath string, m *merger.Merger) { + dir := filepath.Dir(blueprintPath) if rel := registry.FindPatternFile(dir, registry.FileCRD); rel != "" { crdPath := filepath.Join(dir, rel) @@ -240,12 +243,12 @@ func applyPatternExamples(ctx context.Context, catalogPath string, m *merger.Mer // applySetupIfNeeded applies setup YAML files via kubectl in order before // Inrun starts. Only runs outside the cluster (dev mode). -func applySetupIfNeeded(ctx context.Context, catalogPath string, m *merger.Merger) { +func applySetupIfNeeded(ctx context.Context, blueprintPath string, m *merger.Merger) { if cmdutil.IsRunningInPod() { return } - catalogDir := filepath.Dir(catalogPath) + blueprintDir := filepath.Dir(blueprintPath) for crdName, entry := range m.All() { if !entry.HasSetup() { @@ -255,7 +258,7 @@ func applySetupIfNeeded(ctx context.Context, catalogPath string, m *merger.Merge for _, setupFile := range entry.Setup.Apply { path := setupFile.Path if !filepath.IsAbs(path) && !strings.HasPrefix(path, "http") { - path = filepath.Join(catalogDir, path) + path = filepath.Join(blueprintDir, path) } out, err := exec.CommandContext(ctx, "kubectl", "apply", "-f", path).CombinedOutput() diff --git a/cmd/cli/scaffold/create_pattern.go b/cmd/cli/scaffold/create_pattern.go index bc40aaa59..6a0e67aab 100644 --- a/cmd/cli/scaffold/create_pattern.go +++ b/cmd/cli/scaffold/create_pattern.go @@ -14,11 +14,11 @@ import ( var createPatternCmd = &cobra.Command{ Use: "pattern", - Short: "Scaffold a new Inrun pattern: catalog.yaml, simulate.yaml, e2e.yaml, README.md", + Short: "Scaffold a new Inrun pattern: blueprint.yaml, simulate.yaml, e2e.yaml, README.md", Long: `Creates the files needed to build, test, and publish an Inrun pattern. Always written: - catalog.yaml — operator declaration + blueprint.yaml — operator declaration simulate.yaml — in-memory test scaffold (inrun simulate) e2e.yaml — real-cluster integration test scaffold (inrun e2e) README.md — actionable steps from edit to release @@ -28,7 +28,7 @@ Also written when --typed, --add-hook, or --add-constructor: Makefile — registry, build, build-runtime, docker, push, release Dockerfile — production container image (distroless, runtime binary only) -Typed mode flags are forwarded to catalog generation: +Typed mode flags are forwarded to blueprint generation: --add-hook Include a hooks section --add-constructor Include a constructor section --typed Include both hooks and constructor (commented) @@ -49,20 +49,20 @@ Examples: isTyped := typed || addHook || addConstructor - catalogOpts := generate.CatalogScaffoldOptions{ + blueprintOpts := generate.BlueprintScaffoldOptions{ AddHook: addHook, AddConstructor: addConstructor, Typed: typed, - OutputFile: filepath.Join(outputDir, cmdutil.FileCatalog), + OutputFile: filepath.Join(outputDir, cmdutil.FileBlueprint), } - if err := catalogOpts.Validate(); err != nil { + if err := blueprintOpts.Validate(); err != nil { return err } fmt.Printf("generating pattern scaffold → %s/\n", outputDir) - if _, err := generate.CatalogScaffold(catalogOpts); err != nil { - return fmt.Errorf("generating %s: %w", cmdutil.FileCatalog, err) + if _, err := generate.BlueprintScaffold(blueprintOpts); err != nil { + return fmt.Errorf("generating %s: %w", cmdutil.FileBlueprint, err) } if err := generate.WriteSimulateScaffold(filepath.Join(outputDir, cmdutil.DirTest, cmdutil.FileSimulate)); err != nil { @@ -90,7 +90,7 @@ Examples: } fmt.Printf("\n→ pattern scaffold written to %s\n", cmdutil.Bold(outputDir+"/")) - fmt.Printf(" %s %-16s %s\n", cmdutil.SuccessMark(), cmdutil.FileCatalog, cmdutil.Dim("declare your CRD(s) and resources")) + fmt.Printf(" %s %-16s %s\n", cmdutil.SuccessMark(), cmdutil.FileBlueprint, cmdutil.Dim("declare your CRD(s) and resources")) fmt.Printf(" %s %-16s %s\n", cmdutil.SuccessMark(), cmdutil.DirTest+"/"+cmdutil.FileSimulate, cmdutil.Dim("inrun simulate")) fmt.Printf(" %s %-16s %s\n", cmdutil.SuccessMark(), cmdutil.DirTest+"/"+cmdutil.FileE2e, cmdutil.Dim("inrun e2e")) fmt.Printf(" %s %-16s %s\n", cmdutil.SuccessMark(), cmdutil.FileReadMe, cmdutil.Dim("start here")) @@ -107,9 +107,9 @@ Examples: func init() { cmdutil.CreateCmd.AddCommand(createPatternCmd) createPatternCmd.Flags().Bool("add-hook", false, - "Typed mode: include a hooks section in catalog.yaml (also writes Makefile + Dockerfile)") + "Typed mode: include a hooks section in blueprint.yaml (also writes Makefile + Dockerfile)") createPatternCmd.Flags().Bool("add-constructor", false, - "Typed mode: include a constructor section in catalog.yaml (also writes Makefile + Dockerfile)") + "Typed mode: include a constructor section in blueprint.yaml (also writes Makefile + Dockerfile)") createPatternCmd.Flags().Bool("typed", false, "Typed mode: include both hooks and constructor commented (also writes Makefile + Dockerfile)") createPatternCmd.Flags().StringP("output", "o", "", diff --git a/cmd/cli/scaffold/init.go b/cmd/cli/scaffold/init.go index 47cf1fcae..35d055855 100644 --- a/cmd/cli/scaffold/init.go +++ b/cmd/cli/scaffold/init.go @@ -68,7 +68,7 @@ func initCanonical(name string) error { if !isCurrentDirectory(name) { steps = append(steps, initStep{"Creating project folder", func() error { return os.MkdirAll(name, 0755) }}) } - steps = append(steps, initStep{"Writing catalog.yaml", func() error { return extractCanonical(name) }}) + steps = append(steps, initStep{"Writing blueprint.yaml", func() error { return extractCanonical(name) }}) if err := runSteps(steps); err != nil { return err diff --git a/cmd/cli/scaffold/init_helper.go b/cmd/cli/scaffold/init_helper.go index 878bbabe9..ce3be92f7 100644 --- a/cmd/cli/scaffold/init_helper.go +++ b/cmd/cli/scaffold/init_helper.go @@ -95,12 +95,12 @@ func extractEmbeddedPack(root, pack string) error { } // extractCanonical writes the canonical hello-website operator into the -// project root in the standard layout: catalog.yaml, manifests/ (CRD and CR) +// project root in the standard layout: blueprint.yaml, manifests/ (CRD and CR) // and test/ (simulate and e2e specs). func extractCanonical(root string) error { srcPath := "reconcilers/declarative/01-hello-website" keep := map[string]bool{ - cmdutil.FileCatalog: true, + cmdutil.FileBlueprint: true, filepath.Join(cmdutil.DirManifests, cmdutil.FileCrd): true, filepath.Join(cmdutil.DirManifests, cmdutil.FileCr): true, filepath.Join(cmdutil.DirTest, cmdutil.FileSimulate): true, diff --git a/cmd/cli/scaffold/init_packs.go b/cmd/cli/scaffold/init_packs.go index 562263c79..eaccaa439 100644 --- a/cmd/cli/scaffold/init_packs.go +++ b/cmd/cli/scaffold/init_packs.go @@ -21,7 +21,7 @@ type Pack struct { var Packs = map[string]Pack{ "declarative": { Name: "declarative", - Description: "Start here. Operators declared in a Catalog, no code.", + Description: "Start here. Operators declared in a Blueprint, no code.", Path: "reconcilers/declarative", First: "01-hello-website", Order: 1, @@ -30,6 +30,7 @@ var Packs = map[string]Pack{ Name: "remote", Description: "A reconciler in any language, called over HTTP.", Path: "reconcilers/remote", + First: "endpoint", Order: 2, }, "typed": { @@ -45,6 +46,20 @@ var Packs = map[string]Pack{ Path: "intent", Order: 4, }, + "migration": { + Name: "migration", + Description: "Change the CRD under live CRs: move fields, or move CRs to another CRD.", + Path: "migration", + First: "field/relocation", + Order: 5, + }, + "registry": { + Name: "registry", + Description: "Share operators as patterns: consume one, publish your own, deprecate it.", + Path: "registry", + First: "consume", + Order: 6, + }, } func GetPack(name string) (Pack, bool) { diff --git a/cmd/cli/serve/chain.go b/cmd/cli/serve/chain.go index 5019e5939..af712d475 100644 --- a/cmd/cli/serve/chain.go +++ b/cmd/cli/serve/chain.go @@ -14,8 +14,8 @@ import ( "github.com/intentd/inrun/cmd/cli/gate" "github.com/intentd/inrun/cmd/cli/testsuite" - "github.com/intentd/inrun/pkg/catalog" - "github.com/intentd/inrun/pkg/catalog/pipeline" + "github.com/intentd/inrun/pkg/blueprint" + "github.com/intentd/inrun/pkg/blueprint/pipeline" "github.com/intentd/inrun/pkg/gateway/api" "github.com/intentd/inrun/pkg/intent" "github.com/intentd/inrun/pkg/merger" @@ -29,7 +29,7 @@ import ( // planChain runs stages 1-6 of the play chain against an already-resolved // intent map, reporting each stage through n: stageNarrator for serve play, // quietNarrator for simulate. The steps are the same either way. -func planChain(c *catalog.Catalog, raw map[string]interface{}, tokenName string, op types.ServeOperation, n narrator) (*unstructured.Unstructured, *types.CRDEntry, string, error) { +func planChain(c *blueprint.Blueprint, raw map[string]interface{}, tokenName string, op types.ServeOperation, n narrator) (*unstructured.Unstructured, *types.CRDEntry, string, error) { target, _ := raw["target"].(string) if strings.TrimSpace(target) == "" { return nil, nil, "", fmt.Errorf(`"target" must be set in the intent`) @@ -56,8 +56,8 @@ func planChain(c *catalog.Catalog, raw map[string]interface{}, tokenName string, // Stage 3: CR construction n.stage(3, "CR construction") - notes := c.Notes - obj, err := intent.Build(raw, crd, notes) + funcs := c.Functions + obj, err := intent.BuildCR(raw, crd, funcs) if err != nil { n.fail(err.Error()) return nil, nil, "", err @@ -91,7 +91,7 @@ func planChain(c *catalog.Catalog, raw map[string]interface{}, tokenName string, // unique: operator is skipped (no live cluster); external: calls are skipped. n.stage(5, "Admission validation") { - resolver := template.NewResolverFromMap(obj.Object).WithUserNotes(c.Notes).WithRequest(raw) + resolver := template.NewResolverFromMap(obj.Object).WithUserFunctions(c.Functions).WithIntent(raw) eval := resolver.TemplateEvaluator() r := gate.EvalAdmissionValidation(obj.Object, crd, resolver, eval) for _, v := range r.Violations { @@ -129,8 +129,8 @@ func planChain(c *catalog.Catalog, raw map[string]interface{}, tokenName string, // Stage 6: Response payload n.stage(6, "Response payload") - if payload := api.EvaluatePayload(obj.Object, crd, alias, notes); payload == nil { - n.detail(cmdutil.Gray("no serve.config.response declared — default CR response")) + if payload := api.EvaluatePayload(obj.Object, crd, alias, funcs); payload == nil { + n.detail(cmdutil.Gray("no serve.view declared — the full CR is returned")) } else { n.json(payload) } @@ -141,9 +141,9 @@ func planChain(c *catalog.Catalog, raw map[string]interface{}, tokenName string, // playRunSimulate writes the built CR to a temp file and hands it to simulate. // simulateConfig is the path to a simulate.yaml; empty means op-print mode with -// the same catalog play used. Shared by "inrun serve play --simulate" and +// the same blueprint play used. Shared by "inrun serve play --simulate" and // "inrun webhook play --simulate" via playSimulate below. -func playRunSimulate(ctx context.Context, catalogFile string, obj *unstructured.Unstructured, simulateConfig string, simOpts testsuite.CliSimulateOptions) error { +func playRunSimulate(ctx context.Context, blueprintFile string, obj *unstructured.Unstructured, simulateConfig string, simOpts testsuite.CliSimulateOptions) error { b, err := yaml.Marshal(obj.Object) if err != nil { return fmt.Errorf("marshalling CR for simulate: %w", err) @@ -167,10 +167,10 @@ func playRunSimulate(ctx context.Context, catalogFile string, obj *unstructured. } simOpts.MaxCycles = 10 simOpts.SkipExternal = true - return testsuite.RunSimulate(ctx, catalogFile, tmp.Name(), simOpts) + return testsuite.RunSimulate(ctx, blueprintFile, tmp.Name(), simOpts) } -// runSimulateWithCR runs simulate using the spec file for catalog/cycles/expect +// runSimulateWithCR runs simulate using the spec file for blueprint/cycles/expect // but substitutes crFile (the play-built CR) instead of spec.cr. // The expect: block is fully evaluated — this is assert mode, not op-print mode. func runSimulateWithCR(ctx context.Context, specPath, crFile string, simOpts testsuite.CliSimulateOptions) error { @@ -189,28 +189,28 @@ func runSimulateWithCR(ctx context.Context, specPath, crFile string, simOpts tes return fmt.Errorf("simulate config %q: missing spec", specPath) } - catalogPath := cmdutil.JoinPath(specPath, doc.Spec.Catalog) + blueprintPath := cmdutil.JoinPath(specPath, doc.Spec.Blueprint) cycles := doc.Spec.Cycles if cycles <= 0 { cycles = 10 } opts := simulate.RunOptions{SkipExternal: doc.Spec.SkipExternal} - m := merger.New(catalogPath) + m := merger.New(blueprintPath) if err := m.Merge(); err != nil { - return fmt.Errorf("merging Catalog: %w", err) + return fmt.Errorf("merging Blueprint: %w", err) } - // --envtest installs the spec's CRDs, else the ones the Catalog points at. + // --envtest installs the spec's CRDs, else the ones the Blueprint points at. var crdPaths []string for _, p := range doc.Spec.AllCRDPaths() { crdPaths = append(crdPaths, cmdutil.JoinPath(specPath, p)) } if len(crdPaths) == 0 { - crdPaths = testsuite.CatalogCRDPaths(m) + crdPaths = testsuite.BlueprintCRDPaths(m) } - cat, err := pipeline.BuildExpanded(cmdutil.Cfg, m) + bp, err := pipeline.BuildExpanded(cmdutil.Cfg, m) if err != nil { - return fmt.Errorf("parsing Catalog: %w", err) + return fmt.Errorf("parsing Blueprint: %w", err) } crData, err := cmdutil.ReadLocal(crFile) @@ -223,14 +223,14 @@ func runSimulateWithCR(ctx context.Context, specPath, crFile string, simOpts tes } var failed []string - for _, name := range cat.CRDNames() { - crdEntry, ok := cat.CRDEntry(name) + for _, name := range bp.CRDNames() { + crdEntry, ok := bp.CRDEntry(name) if !ok { continue } in, ok := cmdutil.ResolveCRInputs(crs, crdEntry.APITypes.Kind) if !ok { - if len(cat.CRDNames()) > 1 { + if len(bp.CRDNames()) > 1 { fmt.Printf(" %s no CR found for %s — skipped\n\n", cmdutil.Dim("note:"), crdEntry.APITypes.Kind) continue } @@ -240,7 +240,7 @@ func runSimulateWithCR(ctx context.Context, specPath, crFile string, simOpts tes crdOpts.Peers = in.Peers crdOpts.ExistingInstances = in.Existing expect := simulate.ExpectForCRD(doc.Spec.Expect, name) - if err := testsuite.SimulateOne(ctx, cat, name, in.CR, cycles, crdOpts, simOpts, crdPaths, expect); err != nil { + if err := testsuite.SimulateOne(ctx, bp, name, in.CR, cycles, crdOpts, simOpts, crdPaths, expect); err != nil { failed = append(failed, name) } } @@ -250,17 +250,17 @@ func runSimulateWithCR(ctx context.Context, specPath, crFile string, simOpts tes return nil } -// RunIntentPlay builds a Catalog from catalogPath, runs the play chain +// RunIntentPlay builds a Blueprint from blueprintPath, runs the play chain // against intentFile without printing stage output, and returns the resolved // target name on success. -func RunIntentPlay(catalogPath, intentFile string) (string, error) { - m := merger.New(catalogPath) +func RunIntentPlay(blueprintPath, intentFile string) (string, error) { + m := merger.New(blueprintPath) if err := m.Merge(); err != nil { - return "", fmt.Errorf("merging catalog: %w", err) + return "", fmt.Errorf("merging blueprint: %w", err) } c, err := pipeline.BuildExpanded(cmdutil.Cfg, m) if err != nil { - return "", fmt.Errorf("building catalog: %w", err) + return "", fmt.Errorf("building blueprint: %w", err) } raw, err := readIntentFile(intentFile) @@ -288,12 +288,12 @@ func RunIntentPlay(catalogPath, intentFile string) (string, error) { return target, fmt.Errorf("intent file must declare a 'token' — token: ") } - obj, err := intent.Build(raw, crd, c.Notes) + obj, err := intent.BuildCR(raw, crd, c.Functions) if err != nil { return target, fmt.Errorf("CR construction: %w", err) } - resolver := template.NewResolverFromMap(obj.Object).WithUserNotes(c.Notes).WithRequest(raw) + resolver := template.NewResolverFromMap(obj.Object).WithUserFunctions(c.Functions).WithIntent(raw) eval := resolver.TemplateEvaluator() r := gate.EvalAdmissionValidation(obj.Object, crd, resolver, eval) denied := r.Denied() @@ -316,13 +316,13 @@ func init() { return nil, fmt.Errorf("reading intent file %q: %w", req.IntentFile, err) } target, _ := raw["target"].(string) - printPlayHeaderForSimulate(req.CatalogFile, req.IntentFile, target, req.Token, req.Cycles) + printPlayHeaderForSimulate(req.BlueprintFile, req.IntentFile, target, req.Token, req.Cycles) var n narrator = quietNarrator{} if req.Verbose { n = stageNarrator{} } - obj, _, _, err := planChain(req.Catalog, raw, req.Token, req.Op, n) + obj, _, _, err := planChain(req.Blueprint, raw, req.Token, req.Op, n) if err == nil && !req.Verbose { printPlayed(req.IntentFile, obj) } diff --git a/cmd/cli/serve/chain_test.go b/cmd/cli/serve/chain_test.go index f07bde260..6fe1469fc 100644 --- a/cmd/cli/serve/chain_test.go +++ b/cmd/cli/serve/chain_test.go @@ -5,15 +5,15 @@ package serve import ( "testing" - "github.com/intentd/inrun/pkg/catalog" + "github.com/intentd/inrun/pkg/blueprint" "github.com/intentd/inrun/pkg/types" ) -// chainTestCatalog builds a single-CRD catalog for kind "ServiceRequest" — +// chainTestBlueprint builds a single-CRD blueprint for kind "ServiceRequest" — // its ServeTarget() defaults to the lowercased kind, "servicerequest", // which every test below uses directly. -func chainTestCatalog(serveName string, tokens map[string]types.ServeTokenPermissions) *catalog.Catalog { - cat := catalog.NewFromEntryPointers(map[string]*types.CRDEntry{ +func chainTestBlueprint(serveName string, tokens map[string]types.ServeTokenPermissions) *blueprint.Blueprint { + bp := blueprint.NewFromEntryPointers(map[string]*types.CRDEntry{ "servicerequest": { APITypes: types.APITypes{ Group: "demo.inrun.dev", @@ -30,26 +30,26 @@ func chainTestCatalog(serveName string, tokens map[string]types.ServeTokenPermis }, }) // Every token a test lists in serve.tokens is also a gateway token, as in a - // real Catalog; "dev" is always declared. + // real Blueprint; "dev" is always declared. declared := []types.APIToken{{Name: "dev", Token: "dev-token"}} for name := range tokens { if name != "dev" { declared = append(declared, types.APIToken{Name: name, Token: name + "-token"}) } } - cat.Gateway = &types.GatewayConfig{Enabled: true, API: &types.GatewayAPIConfig{ + bp.Gateway = &types.GatewayConfig{Enabled: true, API: &types.GatewayAPIConfig{ Enabled: true, Auth: types.APIAuth{Tokens: declared}, }} - return cat + return bp } func TestPlayChain_UndeclaredTokenDenied(t *testing.T) { // No serve.tokens on the target: any token declared in // gateway.api.auth.tokens may create, but an undeclared name may not. - cat := chainTestCatalog("", nil) + bp := chainTestBlueprint("", nil) _, _, _, err := planChain( - cat, map[string]interface{}{"target": "servicerequest", "name": "x"}, "unknown", types.ServeOpCreate, quietNarrator{}, + bp, map[string]interface{}{"target": "servicerequest", "name": "x"}, "unknown", types.ServeOpCreate, quietNarrator{}, ) if err == nil { t.Fatal("expected a token missing from gateway.api.auth.tokens to be denied") @@ -57,9 +57,9 @@ func TestPlayChain_UndeclaredTokenDenied(t *testing.T) { } func TestPlayChain_UnknownTarget(t *testing.T) { - cat := chainTestCatalog("", nil) + bp := chainTestBlueprint("", nil) _, _, _, err := planChain( - cat, map[string]interface{}{"target": "does-not-exist"}, "dev", types.ServeOpCreate, quietNarrator{}, + bp, map[string]interface{}{"target": "does-not-exist"}, "dev", types.ServeOpCreate, quietNarrator{}, ) if err == nil { t.Fatal("expected an error for an unknown target") @@ -67,9 +67,9 @@ func TestPlayChain_UnknownTarget(t *testing.T) { } func TestPlayChain_MissingTarget(t *testing.T) { - cat := chainTestCatalog("", nil) + bp := chainTestBlueprint("", nil) _, _, _, err := planChain( - cat, map[string]interface{}{"name": "x"}, "dev", types.ServeOpCreate, quietNarrator{}, + bp, map[string]interface{}{"name": "x"}, "dev", types.ServeOpCreate, quietNarrator{}, ) if err == nil { t.Fatal(`expected an error when "target" is absent`) @@ -77,11 +77,11 @@ func TestPlayChain_MissingTarget(t *testing.T) { } func TestPlayChain_TokenDenied(t *testing.T) { - cat := chainTestCatalog("", map[string]types.ServeTokenPermissions{ + bp := chainTestBlueprint("", map[string]types.ServeTokenPermissions{ "dev": {Permissions: types.ServePermissionSet{Global: []string{"get"}}}, // no create }) _, _, _, err := planChain( - cat, map[string]interface{}{"target": "servicerequest", "name": "x"}, "dev", types.ServeOpCreate, quietNarrator{}, + bp, map[string]interface{}{"target": "servicerequest", "name": "x"}, "dev", types.ServeOpCreate, quietNarrator{}, ) if err == nil { t.Fatal("expected the token to be denied create") @@ -91,11 +91,11 @@ func TestPlayChain_TokenDenied(t *testing.T) { func TestPlayChain_UnknownTokenDenied(t *testing.T) { // Once ANY token is declared, an unlisted token name is denied outright — // no implicit fallback to "allow all". - cat := chainTestCatalog("", map[string]types.ServeTokenPermissions{ + bp := chainTestBlueprint("", map[string]types.ServeTokenPermissions{ "dev": {Permissions: types.ServePermissionSet{Global: []string{"*"}}}, }) _, _, _, err := planChain( - cat, map[string]interface{}{"target": "servicerequest", "name": "x"}, "someone-else", types.ServeOpCreate, quietNarrator{}, + bp, map[string]interface{}{"target": "servicerequest", "name": "x"}, "someone-else", types.ServeOpCreate, quietNarrator{}, ) if err == nil { t.Fatal("expected an unlisted token name to be denied") @@ -103,9 +103,9 @@ func TestPlayChain_UnknownTokenDenied(t *testing.T) { } func TestPlayChain_BuildsCRWithProvenance(t *testing.T) { - cat := chainTestCatalog("", nil) // no serve.tokens: any declared gateway token may create + bp := chainTestBlueprint("", nil) // no serve.tokens: any declared gateway token may create obj, crd, alias, err := planChain( - cat, + bp, map[string]interface{}{"target": "servicerequest", "name": "payments-api"}, "dev", types.ServeOpCreate, quietNarrator{}, ) @@ -128,9 +128,9 @@ func TestPlayChain_BuildsCRWithProvenance(t *testing.T) { func TestPlayChain_MissingNameRejected(t *testing.T) { // serve.name isn't declared and no raw "name" is supplied. - cat := chainTestCatalog("", nil) + bp := chainTestBlueprint("", nil) _, _, _, err := planChain( - cat, map[string]interface{}{"target": "servicerequest"}, "dev", types.ServeOpCreate, quietNarrator{}, + bp, map[string]interface{}{"target": "servicerequest"}, "dev", types.ServeOpCreate, quietNarrator{}, ) if err == nil { t.Fatal("expected an error when neither serve.name nor a raw name is available") diff --git a/cmd/cli/serve/helper.go b/cmd/cli/serve/helper.go index 7d62ca151..0fcf9b727 100644 --- a/cmd/cli/serve/helper.go +++ b/cmd/cli/serve/helper.go @@ -13,7 +13,7 @@ import ( "github.com/intentd/inrun/cmd/cli/cmdutil" - "github.com/intentd/inrun/pkg/catalog" + "github.com/intentd/inrun/pkg/blueprint" "github.com/intentd/inrun/pkg/intent" "github.com/intentd/inrun/pkg/types" ) @@ -58,22 +58,22 @@ func aliasOrNone(alias string) string { } // resolveCRD resolves a CRD by target, kind, or name. -func resolveCRD(cat *catalog.Catalog, target, kind, name string) (*types.CRDEntry, error) { +func resolveCRD(bp *blueprint.Blueprint, target, kind, name string) (*types.CRDEntry, error) { var crd *types.CRDEntry switch { case target != "": - crd = cat.LookupByTarget(target).Entry() + crd = bp.LookupByTarget(target).Entry() if crd == nil { return nil, fmt.Errorf("%s target %q not found", cmdutil.FailureMark(), target) } case kind != "": - crd = cat.LookupByKind(kind).Entry() + crd = bp.LookupByKind(kind).Entry() if crd == nil { return nil, fmt.Errorf("%s kind %q not found", cmdutil.FailureMark(), kind) } case name != "": - crd = cat.LookupByName(name).Entry() + crd = bp.LookupByName(name).Entry() if crd == nil { return nil, fmt.Errorf("%s CRD %q not found", cmdutil.FailureMark(), name) } @@ -87,8 +87,8 @@ func resolveCRD(cat *catalog.Catalog, target, kind, name string) (*types.CRDEntr // resolveCRDByAnyTarget resolves a CRD by primary target or alias using // LookupByTargetOrAlias. Returns the CRD and the alias name (empty when the // primary target matched). Used by can-i so --target accepts alias names too. -func resolveCRDByAnyTarget(cat *catalog.Catalog, target string) (*types.CRDEntry, string, error) { - resolution := cat.LookupByTargetOrAlias(target) +func resolveCRDByAnyTarget(bp *blueprint.Blueprint, target string) (*types.CRDEntry, string, error) { + resolution := bp.LookupByTargetOrAlias(target) if resolution == nil { return nil, "", fmt.Errorf("%s target or alias %q not found", cmdutil.FailureMark(), target) } @@ -99,7 +99,7 @@ func resolveCRDByAnyTarget(cat *catalog.Catalog, target string) (*types.CRDEntry // declared in gateway.api.auth.tokens (the gateway authenticates nothing // else), and the target's serve.tokens, when set, must allow op. // returns error when token is not declared or denied -func checkToken(c *catalog.Catalog, crd *types.CRDEntry, alias, tokenName string, op types.ServeOperation, ns string) error { +func checkToken(c *blueprint.Blueprint, crd *types.CRDEntry, alias, tokenName string, op types.ServeOperation, ns string) error { if declared := c.GatewayTokenNames(); !slices.Contains(declared, tokenName) { return fmt.Errorf("unknown token %q\n declared in gateway.api.auth.tokens: %s", tokenName, strings.Join(declared, ", ")) } @@ -231,7 +231,7 @@ func printAliasesForCRD(crd *types.CRDEntry) { hasTokens = "yes" } hasResponse := "no" - if cfg != nil && cfg.ResponseConfig() != nil { + if cfg != nil && cfg.GetView() != nil { hasResponse = "yes" } fmt.Fprintf(w, " %s\t%s\t%s\n", name, hasTokens, hasResponse) @@ -242,8 +242,8 @@ func printAliasesForCRD(crd *types.CRDEntry) { // ── printServeValidationSummary ────────────────────────────────────────────── // printServeValidationSummary prints a detailed breakdown of the Serve configuration. -func printServeValidationSummary(cat *catalog.Catalog) { - enabledCRDs := cat.ServeEnabledCRDs() +func printServeValidationSummary(bp *blueprint.Blueprint) { + enabledCRDs := bp.ServeEnabledCRDs() if len(enabledCRDs) == 0 { fmt.Println("\nNo Serve-enabled CRDs found.") @@ -346,23 +346,23 @@ func printServeValidationSummary(cat *catalog.Catalog) { } else { parts = append(parts, "inherits CRD tokens") } - if alias != nil && alias.ResponseConfig() != nil { - parts = append(parts, "custom response") + if alias != nil && alias.GetView() != nil { + parts = append(parts, "custom view") } fmt.Printf(" %s %s %s\n", cmdutil.Gray("·"), cmdutil.Gray(aliasName), cmdutil.Gray("("+strings.Join(parts, ", ")+")")) } } // ── Response Config ───────────────────────────────────────────── - cfg := crd.GetServeResponseConfig() + cfg := crd.ServeView() if cfg != nil { payloadCount := len(cfg.Payload) excludeCount := len(cfg.Exclude) defaultVal := "true" - if !cfg.UseDefault() { + if !cfg.IncludesFullObject() { defaultVal = "false" } - fmt.Printf(" %s\n", cmdutil.Gray(fmt.Sprintf("response: default: %s, payload: %d, exclude: %d", defaultVal, payloadCount, excludeCount))) + fmt.Printf(" %s\n", cmdutil.Gray(fmt.Sprintf("view: fullObject: %s, payload: %d, exclude: %d", defaultVal, payloadCount, excludeCount))) if cfg.HasPoll() { pollParts := []string{} if cfg.Poll.URL != "" { @@ -374,7 +374,7 @@ func printServeValidationSummary(cat *catalog.Catalog) { fmt.Printf(" %s\n", cmdutil.Gray(fmt.Sprintf("poll: %s", strings.Join(pollParts, ", ")))) } } else { - fmt.Printf(" %s\n", cmdutil.Gray("response: none (default CR response)")) + fmt.Printf(" %s\n", cmdutil.Gray("view: none (the full CR is returned)")) } // ── Warnings ───────────────────────────────────────────────────── diff --git a/cmd/cli/serve/play.go b/cmd/cli/serve/play.go index f1d44e363..5cc593b7e 100644 --- a/cmd/cli/serve/play.go +++ b/cmd/cli/serve/play.go @@ -11,7 +11,7 @@ import ( "github.com/intentd/inrun/cmd/cli/cmdutil" - "github.com/intentd/inrun/pkg/catalog" + "github.com/intentd/inrun/pkg/blueprint" "github.com/intentd/inrun/pkg/types" "github.com/spf13/cobra" ) @@ -38,15 +38,15 @@ accepts either as a request body too. No cluster connection is required. No CR is applied or fetched. Stages (create/update): - 1. Target resolution — resolve target/alias from catalog + 1. Target resolution — resolve target/alias from blueprint 2. Token check — verify the named token can perform the operation 3. CR construction — build the full CR from field declarations 4. Provenance — stamp serve-target and serve-alias annotations 5. Admission validation — evaluate validation.rules (deny/warn) against the built CR - 6. Response payload — evaluate serve.config.response.payload expressions + 6. Response payload — evaluate serve.view.payload expressions Stages (get/list/delete): - 1. Target resolution — resolve target/alias from catalog + 1. Target resolution — resolve target/alias from blueprint 2. Token check — verify the named token can perform the operation 3. Response config — show what the caller would receive @@ -91,7 +91,7 @@ Example intent.json: return fmt.Errorf("%s --token is required", cmdutil.FailureMark()) } - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } @@ -103,26 +103,26 @@ Example intent.json: } if op == types.ServeOpCreate || op == types.ServeOpUpdate { - // Resolve first catalog path for simulate handoff (--file is a StringSlice on serve commands) - catalogFile := "" + // Resolve first blueprint path for simulate handoff (--file is a StringSlice on serve commands) + blueprintFile := "" paths, _ := cmd.Flags().GetStringSlice("file") if len(paths) == 0 { paths = cmdutil.DefaultFilePaths() } if len(paths) == 0 { - return fmt.Errorf(cmdutil.ErrNoCatalog) + return fmt.Errorf(cmdutil.ErrNoBlueprint) } - catalogFile = paths[0] + blueprintFile = paths[0] - return playWrite(cmd.Context(), cat, catalogFile, intentFile, targetOverride, tokenName, op, simulateFlag, simulateConfig, simOpts) + return playWrite(cmd.Context(), bp, blueprintFile, intentFile, targetOverride, tokenName, op, simulateFlag, simulateConfig, simOpts) } - return playRead(cat, targetOverride, tokenName, op, namespace, name) + return playRead(bp, targetOverride, tokenName, op, namespace, name) }, } // ── create / update path ───────────────────────────────────────────────────── -func playWrite(ctx context.Context, cat *catalog.Catalog, catalogFile, intentFile, targetOverride, tokenName string, op types.ServeOperation, doSimulate bool, simulateConfig string, simOpts testsuite.CliSimulateOptions) error { +func playWrite(ctx context.Context, bp *blueprint.Blueprint, blueprintFile, intentFile, targetOverride, tokenName string, op types.ServeOperation, doSimulate bool, simulateConfig string, simOpts testsuite.CliSimulateOptions) error { if intentFile == "" { intentFile = resolveDefaultIntentFile() if intentFile == "" { @@ -145,7 +145,7 @@ func playWrite(ctx context.Context, cat *catalog.Catalog, catalogFile, intentFil printPlayHeader(intentFile, target, tokenName, op) - obj, crd, alias, err := planChain(cat, raw, tokenName, op, stageNarrator{}) + obj, crd, alias, err := planChain(bp, raw, tokenName, op, stageNarrator{}) if err != nil { return err } @@ -159,14 +159,14 @@ func playWrite(ctx context.Context, cat *catalog.Catalog, catalogFile, intentFil fmt.Println() if doSimulate { - return playRunSimulate(ctx, catalogFile, obj, simulateConfig, simOpts) + return playRunSimulate(ctx, blueprintFile, obj, simulateConfig, simOpts) } return nil } // ── get / list / delete path ───────────────────────────────────────────────── -func playRead(cat *catalog.Catalog, target, tokenName string, op types.ServeOperation, namespace, name string) error { +func playRead(bp *blueprint.Blueprint, target, tokenName string, op types.ServeOperation, namespace, name string) error { if strings.TrimSpace(target) == "" { return fmt.Errorf("%s --target is required for %s", cmdutil.FailureMark(), op) } @@ -178,9 +178,9 @@ func playRead(cat *catalog.Catalog, target, tokenName string, op types.ServeOper // Stage 1: Target resolution printStage(1, "Target resolution") - resolution := cat.LookupByTargetOrAlias(target) + resolution := bp.LookupByTargetOrAlias(target) if resolution == nil { - printStageError(fmt.Sprintf("unknown target %q\n available: %s", target, strings.Join(cat.AvailableTargets(), ", "))) + printStageError(fmt.Sprintf("unknown target %q\n available: %s", target, strings.Join(bp.AvailableTargets(), ", "))) return fmt.Errorf("target %q not found", target) } crd, alias := resolution.CRD, resolution.Alias @@ -188,7 +188,7 @@ func playRead(cat *catalog.Catalog, target, tokenName string, op types.ServeOper // Stage 2: Token check printStage(2, "Token check") - if err := checkToken(cat, crd, alias, tokenName, op, namespace); err != nil { + if err := checkToken(bp, crd, alias, tokenName, op, namespace); err != nil { printStageError(err.Error()) return err } @@ -200,14 +200,14 @@ func playRead(cat *catalog.Catalog, target, tokenName string, op types.ServeOper printStageDetail(cmdutil.Gray(fmt.Sprintf("DELETE %s/%s in %s", crd.Kind(), name, namespaceOrAny(namespace)))) printStageOK("would be deleted") } else { - cfg := crd.ServeResponseConfigFor(alias) + cfg := crd.ServeViewFor(alias) if cfg == nil { - printStageDetail(cmdutil.Gray("no serve.config.response declared — full CR returned")) + printStageDetail(cmdutil.Gray("no serve.view declared — full CR returned")) } else { - if !cfg.UseDefault() { - printStageDetail(cmdutil.Gray("default: false — raw CR omitted from response")) + if !cfg.IncludesFullObject() { + printStageDetail(cmdutil.Gray("fullObject: false — the CR is left out of the view")) } else { - printStageDetail(cmdutil.Gray("default: true — full CR included in response")) + printStageDetail(cmdutil.Gray("fullObject: true — the full CR is included in the view")) } if cfg.HasPayload() { printStageDetail(cmdutil.Gray(fmt.Sprintf("payload fields: %s", strings.Join(cmdutil.SortedKeys(cfg.Payload), ", ")))) @@ -217,9 +217,9 @@ func playRead(cat *catalog.Catalog, target, tokenName string, op types.ServeOper } } if op == types.ServeOpGet { - printStageOK(fmt.Sprintf("GET %s/%s in %s — response config above applies", crd.Kind(), name, namespaceOrAny(namespace))) + printStageOK(fmt.Sprintf("GET %s/%s in %s — the view above applies", crd.Kind(), name, namespaceOrAny(namespace))) } else { - printStageOK(fmt.Sprintf("LIST %s in %s — response config applies to all items", crd.Kind(), namespaceOrAny(namespace))) + printStageOK(fmt.Sprintf("LIST %s in %s — the view applies to all items", crd.Kind(), namespaceOrAny(namespace))) } } diff --git a/cmd/cli/serve/play_test.go b/cmd/cli/serve/play_test.go index f9855701e..1e1407946 100644 --- a/cmd/cli/serve/play_test.go +++ b/cmd/cli/serve/play_test.go @@ -116,52 +116,52 @@ func TestPayloadKeys_SortsAlphabetically(t *testing.T) { } func TestPlayRead_UnknownTarget(t *testing.T) { - cat := chainTestCatalog("", nil) - if err := playRead(cat, "does-not-exist", "dev", types.ServeOpGet, "default", "x"); err == nil { + bp := chainTestBlueprint("", nil) + if err := playRead(bp, "does-not-exist", "dev", types.ServeOpGet, "default", "x"); err == nil { t.Fatal("expected an error for an unknown target") } } func TestPlayRead_MissingTargetFlag(t *testing.T) { - cat := chainTestCatalog("", nil) - if err := playRead(cat, "", "dev", types.ServeOpGet, "default", "x"); err == nil { + bp := chainTestBlueprint("", nil) + if err := playRead(bp, "", "dev", types.ServeOpGet, "default", "x"); err == nil { t.Fatal("expected an error when --target is empty") } } func TestPlayRead_MissingNameForGet(t *testing.T) { - cat := chainTestCatalog("", nil) - if err := playRead(cat, "servicerequest", "dev", types.ServeOpGet, "default", ""); err == nil { + bp := chainTestBlueprint("", nil) + if err := playRead(bp, "servicerequest", "dev", types.ServeOpGet, "default", ""); err == nil { t.Fatal("expected an error when --name is empty for get") } } func TestPlayRead_ListDoesNotRequireName(t *testing.T) { - cat := chainTestCatalog("", nil) - if err := playRead(cat, "servicerequest", "dev", types.ServeOpList, "default", ""); err != nil { + bp := chainTestBlueprint("", nil) + if err := playRead(bp, "servicerequest", "dev", types.ServeOpList, "default", ""); err != nil { t.Fatalf("unexpected error: %v", err) } } func TestPlayRead_TokenDenied(t *testing.T) { - cat := chainTestCatalog("", map[string]types.ServeTokenPermissions{ + bp := chainTestBlueprint("", map[string]types.ServeTokenPermissions{ "dev": {Permissions: types.ServePermissionSet{Global: []string{"create"}}}, // no get }) - if err := playRead(cat, "servicerequest", "dev", types.ServeOpGet, "default", "x"); err == nil { + if err := playRead(bp, "servicerequest", "dev", types.ServeOpGet, "default", "x"); err == nil { t.Fatal("expected the token to be denied get") } } func TestPlayRead_AllowedGet(t *testing.T) { - cat := chainTestCatalog("", nil) // no token restrictions -> allow all - if err := playRead(cat, "servicerequest", "dev", types.ServeOpGet, "default", "x"); err != nil { + bp := chainTestBlueprint("", nil) // no token restrictions -> allow all + if err := playRead(bp, "servicerequest", "dev", types.ServeOpGet, "default", "x"); err != nil { t.Fatalf("unexpected error: %v", err) } } func TestPlayRead_Delete(t *testing.T) { - cat := chainTestCatalog("", nil) - if err := playRead(cat, "servicerequest", "dev", types.ServeOpDelete, "default", "x"); err != nil { + bp := chainTestBlueprint("", nil) + if err := playRead(bp, "servicerequest", "dev", types.ServeOpDelete, "default", "x"); err != nil { t.Fatalf("unexpected error: %v", err) } } diff --git a/cmd/cli/serve/print.go b/cmd/cli/serve/print.go index f62504f7c..45734e538 100644 --- a/cmd/cli/serve/print.go +++ b/cmd/cli/serve/print.go @@ -44,10 +44,10 @@ func printPlayHeader(file, target, token, op string) { fmt.Println() } -func printPlayHeaderForSimulate(catalog, intentFile, target, token string, cycles int) { +func printPlayHeaderForSimulate(blueprint, intentFile, target, token string, cycles int) { fmt.Println() fmt.Printf("%s inrun simulate\n", cmdutil.Bold("▶")) - fmt.Printf(" %s %s\n", cmdutil.Gray("catalog:"), cmdutil.Cyan(relPath(catalog))) + fmt.Printf(" %s %s\n", cmdutil.Gray("blueprint:"), cmdutil.Cyan(relPath(blueprint))) fmt.Printf(" %s %s %s\n", cmdutil.Gray("intent: "), cmdutil.Cyan(relPath(intentFile)), cmdutil.Gray(fmt.Sprintf("(target %s, token %s)", target, token))) fmt.Printf(" %s %d\n", cmdutil.Gray("cycles: "), cycles) diff --git a/cmd/cli/serve/serve.go b/cmd/cli/serve/serve.go index 8fb8391a3..ceae16a24 100644 --- a/cmd/cli/serve/serve.go +++ b/cmd/cli/serve/serve.go @@ -11,7 +11,7 @@ import ( "github.com/intentd/inrun/cmd/cli/cmdutil" - "github.com/intentd/inrun/pkg/catalog/validate" + "github.com/intentd/inrun/pkg/blueprint/validate" "github.com/intentd/inrun/pkg/types" "github.com/spf13/cobra" ) @@ -32,14 +32,14 @@ what fields developers can submit, what the gateway builds, and what callers see. Subcommands: - validate Validate Serve configuration in a Catalog + validate Validate Serve configuration in a Blueprint schema Show the flat schema for a Serve target fields List all Serve fields with their paths and types tokens Show token permissions for a CRD - targets List all Serve targets in a Catalog - aliases List serve aliases in a Catalog + targets List all Serve targets in a Blueprint + aliases List serve aliases in a Blueprint can-i Check if a token can perform an operation - response Show the Serve response configuration`, + view Show the serve view a target returns`, } // ── serve validate ──────────────────────────────────────────────────────────── @@ -47,28 +47,28 @@ Subcommands: var serveValidateCmd = &cobra.Command{ Use: "validate", Short: "Validate Serve configuration", - Long: `Validate Serve configuration in a Catalog. + Long: `Validate Serve configuration in a Blueprint. This runs the same Serve-specific validations as inrun validate, but only for -Serve concerns: fields, paths, tokens, response config, and namespace rules. +Serve concerns: fields, paths, tokens, the view, and namespace rules. -It does not check the full Catalog schema — only the Serve portions. +It does not check the full Blueprint schema — only the Serve portions. With --full, shows a detailed breakdown of the Serve configuration.`, RunE: func(cmd *cobra.Command, args []string) error { - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } full, _ := cmd.Flags().GetBool("full") - if err := validate.ValidateServe(cat); err != nil { + if err := validate.ValidateServe(bp); err != nil { return err } if full { - printServeValidationSummary(cat) + printServeValidationSummary(bp) } else { fmt.Printf("\n%s Serve configuration is valid\n", cmdutil.SuccessMark()) } @@ -99,7 +99,7 @@ GET /api/v1/schema?target= endpoint.`, return errRequiresCRDSelector } - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } @@ -109,7 +109,7 @@ GET /api/v1/schema?target= endpoint.`, if alias != "" && target == "" && kind == "" && name == "" { var resolvedAlias string - crd, resolvedAlias, err = resolveCRDByAnyTarget(cat, alias) + crd, resolvedAlias, err = resolveCRDByAnyTarget(bp, alias) if err != nil { return err } @@ -118,7 +118,7 @@ GET /api/v1/schema?target= endpoint.`, } header = fmt.Sprintf("\nSchema for: %s (alias: %s → target: %s)\n", crd.Name, alias, crd.ServeTarget()) } else { - crd, err = resolveCRD(cat, target, kind, name) + crd, err = resolveCRD(bp, target, kind, name) if err != nil { return err } @@ -166,7 +166,7 @@ GET /api/v1/schema?target= endpoint.`, var serveFieldsCmd = &cobra.Command{ Use: "fields", Short: "List Serve fields with their paths and types", - Long: `List Serve fields in a Catalog with their paths and types. + Long: `List Serve fields in a Blueprint with their paths and types. This shows fields declared in serve.fields, serve.labels and serve.annotations across all Serve-enabled CRDs. @@ -187,7 +187,7 @@ With --target, --kind, or --name, shows fields for a specific CRD.`, sortBy = "name" } - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } @@ -199,7 +199,7 @@ With --target, --kind, or --name, shows fields for a specific CRD.`, if alias != "" && target == "" && kind == "" && name == "" { var resolvedAlias string - crd, resolvedAlias, err = resolveCRDByAnyTarget(cat, alias) + crd, resolvedAlias, err = resolveCRDByAnyTarget(bp, alias) if err != nil { return err } @@ -208,7 +208,7 @@ With --target, --kind, or --name, shows fields for a specific CRD.`, } header = fmt.Sprintf("\nFields for: %s (alias: %s → target: %s)\n", crd.Name, alias, crd.ServeTarget()) } else { - crd, err = resolveCRD(cat, target, kind, name) + crd, err = resolveCRD(bp, target, kind, name) if err != nil { return err } @@ -250,7 +250,7 @@ With --target, --kind, or --name, shows fields for a specific CRD.`, } // ── All fields across all CRDs ────────────────────────────────────── - crds := cat.ServeEnabledCRDs() + crds := bp.ServeEnabledCRDs() sort.Slice(crds, func(i, j int) bool { if crds[i] == nil || crds[j] == nil { return false @@ -321,7 +321,7 @@ When it inherits from the CRD, the CRD-level tokens are shown with a note.`, return errRequiresCRDSelector } - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } @@ -330,7 +330,7 @@ When it inherits from the CRD, the CRD-level tokens are shown with a note.`, if alias != "" && target == "" && kind == "" && name == "" { // --alias alone: resolve CRD from alias name var resolvedAlias string - crd, resolvedAlias, err = resolveCRDByAnyTarget(cat, alias) + crd, resolvedAlias, err = resolveCRDByAnyTarget(bp, alias) if err != nil { return err } @@ -338,7 +338,7 @@ When it inherits from the CRD, the CRD-level tokens are shown with a note.`, return fmt.Errorf("%s %q is a primary target, not an alias — use --target instead", cmdutil.FailureMark(), alias) } } else { - crd, err = resolveCRD(cat, target, kind, name) + crd, err = resolveCRD(bp, target, kind, name) if err != nil { return err } @@ -407,12 +407,12 @@ When it inherits from the CRD, the CRD-level tokens are shown with a note.`, var serveTargetsCmd = &cobra.Command{ Use: "targets", - Short: "List all Serve targets in a Catalog", - Long: `List all Serve-enabled targets in a Catalog. + Short: "List all Serve targets in a Blueprint", + Long: `List all Serve-enabled targets in a Blueprint. This shows each target, its CRD kind, and whether it has fields defined.`, RunE: func(cmd *cobra.Command, args []string) error { - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } @@ -425,7 +425,7 @@ This shows each target, its CRD kind, and whether it has fields defined.`, Aliases int } - for _, crd := range cat.ServeEnabledCRDs() { + for _, crd := range bp.ServeEnabledCRDs() { if crd == nil { continue } @@ -526,7 +526,7 @@ Examples: return errRequiresCRDSelector } - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } @@ -539,7 +539,7 @@ Examples: alias string ) if target != "" { - crd, alias, err = resolveCRDByAnyTarget(cat, target) + crd, alias, err = resolveCRDByAnyTarget(bp, target) if err != nil { return err } @@ -550,7 +550,7 @@ Examples: } } else if aliasFlag != "" && kind == "" && name == "" { var resolvedAlias string - crd, resolvedAlias, err = resolveCRDByAnyTarget(cat, aliasFlag) + crd, resolvedAlias, err = resolveCRDByAnyTarget(bp, aliasFlag) if err != nil { return err } @@ -559,7 +559,7 @@ Examples: } alias = resolvedAlias } else { - crd, err = resolveCRD(cat, target, kind, name) + crd, err = resolveCRD(bp, target, kind, name) if err != nil { return err } @@ -571,7 +571,7 @@ Examples: } // Verify the token exists at the gateway level first. - gatewayTokens := cat.GatewayTokenNames() + gatewayTokens := bp.GatewayTokenNames() tokenKnown := false for _, t := range gatewayTokens { if t == token { @@ -614,26 +614,24 @@ Examples: }, } -// ── serve response ──────────────────────────────────────────────────────────── +// ── serve view ──────────────────────────────────────────────────────────── -var serveResponseCmd = &cobra.Command{ - Use: "response", - Short: "Show the Serve response configuration", - Long: `Show the Serve response configuration for a target. +var serveViewCmd = &cobra.Command{ + Use: "view", + Short: "Show the serve view a target returns", + Long: `Show the serve view for a target: what callers read back from the +Gateway API, from serve.view. It shows: -This displays what callers will see in the Gateway API response based on -serve.config.response. It shows: - - - default: true/false + - fullObject: true/false - Payload fields (with their template expressions) - Excluded paths - Poll URL configuration -No cluster access is required — this reads the Catalog directly. +No cluster access is required — this reads the Blueprint directly. Examples: - inrun serve response --target smartapp - inrun serve response --target app --preview`, + inrun serve view --target smartapp + inrun serve view --target app --preview`, RunE: func(cmd *cobra.Command, args []string) error { preview, _ := cmd.Flags().GetBool("preview") target, _ := cmd.Flags().GetString("target") @@ -645,7 +643,7 @@ Examples: return errRequiresCRDSelector } - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } @@ -653,7 +651,7 @@ Examples: var crd *types.CRDEntry if alias != "" && target == "" && kind == "" && name == "" { var resolvedAlias string - crd, resolvedAlias, err = resolveCRDByAnyTarget(cat, alias) + crd, resolvedAlias, err = resolveCRDByAnyTarget(bp, alias) if err != nil { return err } @@ -661,7 +659,7 @@ Examples: return fmt.Errorf("%s %q is a primary target, not an alias — use --target instead", cmdutil.FailureMark(), alias) } } else { - crd, err = resolveCRD(cat, target, kind, name) + crd, err = resolveCRD(bp, target, kind, name) if err != nil { return err } @@ -671,27 +669,27 @@ Examples: return fmt.Errorf("%s CRD %q is not Serve-enabled", cmdutil.FailureMark(), crd.Name) } - cfg := crd.ServeResponseConfigFor(alias) + cfg := crd.ServeViewFor(alias) if cfg == nil { if alias != "" { - fmt.Printf("\nNo response configuration for alias %q (CRD: %s) — uses default CR response\n\n", alias, crd.Name) + fmt.Printf("\nNo view for alias %q (CRD: %s): the full CR is returned\n\n", alias, crd.Name) } else { - fmt.Printf("\nNo response configuration for CRD %q — callers receive the full CR as-is.\n", crd.Name) - fmt.Println(" Add serve.config.response to customize what callers see.") + fmt.Printf("\nNo view for CRD %q: callers receive the full CR as-is.\n", crd.Name) + fmt.Println(" Add serve.view to customize what callers see.") fmt.Println() } return nil } if alias != "" { - fmt.Printf("\nResponse configuration for alias %q (CRD: %s)\n", alias, crd.Name) + fmt.Printf("\nView for alias %q (CRD: %s)\n", alias, crd.Name) } else { - fmt.Printf("\nResponse configuration for: %s (target: %s)\n", crd.Name, crd.ServeTarget()) + fmt.Printf("\nView for: %s (target: %s)\n", crd.Name, crd.ServeTarget()) } fmt.Printf("%s\n", strings.Repeat("─", 70)) - fmt.Printf("\ndefault: %v\n", cfg.UseDefault()) - if cfg.UseDefault() && !cfg.HasExclude() { + fmt.Printf("\nfullObject: %v\n", cfg.IncludesFullObject()) + if cfg.IncludesFullObject() && !cfg.HasExclude() { if cfg.HasPayload() { fmt.Println(" Callers receive the full CR object plus the payload fields below.") } else { @@ -736,7 +734,7 @@ Examples: if preview { fmt.Printf("\n%s\n", strings.Repeat("─", 70)) - fmt.Println("\nResponse preview (templates shown as-is, not resolved):") + fmt.Println("\nView preview (templates shown as-is, not resolved):") if cfg.HasPayload() { fmt.Println() @@ -782,11 +780,11 @@ Examples: var serveAliasesCmd = &cobra.Command{ Use: "aliases", - Short: "List serve aliases in a Catalog", + Short: "List serve aliases in a Blueprint", Long: `List serve aliases across all serve-enabled CRDs, or for a specific CRD. Aliases are additional named entry points for a CRD. Each alias can independently -override token permissions and response configuration, falling back to CRD-level +override token permissions and the view, falling back to CRD-level defaults when not set. With --target, --kind, or --name, shows aliases for that specific CRD only. @@ -800,7 +798,7 @@ Examples: name, _ := cmd.Flags().GetString("name") alias, _ := cmd.Flags().GetString("alias") - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return err } @@ -811,12 +809,12 @@ Examples: if alias != "" && target == "" && kind == "" && name == "" { // Resolve by alias — show all aliases for the same CRD. - crd, _, err = resolveCRDByAnyTarget(cat, alias) + crd, _, err = resolveCRDByAnyTarget(bp, alias) if err != nil { return err } } else { - crd, err = resolveCRD(cat, target, kind, name) + crd, err = resolveCRD(bp, target, kind, name) if err != nil { return err } @@ -829,7 +827,7 @@ Examples: } // ── All CRDs ──────────────────────────────────────────────────────── - crds := cat.ServeEnabledCRDs() + crds := bp.ServeEnabledCRDs() sort.Slice(crds, func(i, j int) bool { return crds[i].Name < crds[j].Name }) var total int @@ -904,13 +902,13 @@ func init() { serveAliasesCmd.Flags().StringP("alias", "a", "", "Alias name — shows all aliases for the same CRD") serveCmd.AddCommand(serveAliasesCmd) - // Response - serveResponseCmd.Flags().StringP("target", "t", "", "Target to show response for") - serveResponseCmd.Flags().StringP("kind", "k", "", "Kind to show response for") - serveResponseCmd.Flags().StringP("name", "n", "", "CRD name to show response for") - serveResponseCmd.Flags().StringP("alias", "a", "", "Alias to show effective response config for (can be used instead of --target)") - serveResponseCmd.Flags().BoolP("preview", "p", false, "Show a sample response preview") - serveCmd.AddCommand(serveResponseCmd) + // View + serveViewCmd.Flags().StringP("target", "t", "", "Target to show the view for") + serveViewCmd.Flags().StringP("kind", "k", "", "Kind to show the view for") + serveViewCmd.Flags().StringP("name", "n", "", "CRD name to show the view for") + serveViewCmd.Flags().StringP("alias", "a", "", "Alias to show the effective view for (can be used instead of --target)") + serveViewCmd.Flags().BoolP("preview", "p", false, "Show a sample view preview") + serveCmd.AddCommand(serveViewCmd) cmdutil.RootCmd.AddCommand(serveCmd) diff --git a/cmd/cli/serve/token.go b/cmd/cli/serve/token.go index 95bd2c95d..2c4cb3b1d 100644 --- a/cmd/cli/serve/token.go +++ b/cmd/cli/serve/token.go @@ -40,9 +40,9 @@ Subcommands: var tokenVerifyCmd = &cobra.Command{ Use: "verify", Short: "Verify a JWT against the configured token entries", - Long: `Verify a JWT against gateway.api.auth.tokens in the catalog. + Long: `Verify a JWT against gateway.api.auth.tokens in the blueprint. -Local mode (default): loads the catalog, fetches JWKS from the real provider, +Local mode (default): loads the blueprint, fetches JWKS from the real provider, verifies the token signature and claims, and shows which entry matched. Live mode (--api): sends the token to a running gateway and reports accept/reject. @@ -50,7 +50,7 @@ Use inrun proxy to expose the gateway locally first. Examples: inrun token verify - inrun token verify -f catalog.yaml -t token.jwt + inrun token verify -f blueprint.yaml -t token.jwt inrun token verify --api https://gateway.myorg.io -t token.jwt inrun token verify --api http://localhost:8443`, SilenceUsage: true, @@ -112,17 +112,17 @@ func runTokenVerifyLocal(cmd *cobra.Command, jwt, audienceOverride, tokenFile st return fmt.Errorf("%s %q does not look like a JWT — could not extract iss claim", cmdutil.FailureMark(), tokenFile) } - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return fmt.Errorf("%s %w", cmdutil.FailureMark(), err) } - if !cat.IsGatewayEnabled() || !cat.Gateway.HasAPI() || cat.Gateway.API.Auth.Empty() { - return fmt.Errorf("%s no gateway token auth configured in this catalog", cmdutil.FailureMark()) + if !bp.IsGatewayEnabled() || !bp.Gateway.HasAPI() || bp.Gateway.API.Auth.Empty() { + return fmt.Errorf("%s no gateway token auth configured in this blueprint", cmdutil.FailureMark()) } var candidates []types.APIToken - for _, t := range cat.Gateway.API.Auth.Tokens { + for _, t := range bp.Gateway.API.Auth.Tokens { if t.IsOIDC() && t.OIDCIssuer() == iss { candidates = append(candidates, t) } @@ -206,7 +206,7 @@ Vault, which uses a non-standard discovery path. Example: inrun token probe --name vault-ci - inrun token probe -f catalog.yaml --name gh-ci`, + inrun token probe -f blueprint.yaml --name gh-ci`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") @@ -214,19 +214,19 @@ Example: return fmt.Errorf("%s --name is required", cmdutil.FailureMark()) } - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return fmt.Errorf("%s %w", cmdutil.FailureMark(), err) } - if !cat.IsGatewayEnabled() || !cat.Gateway.HasAPI() || cat.Gateway.API.Auth.Empty() { - return fmt.Errorf("%s no gateway token auth configured in this catalog", cmdutil.FailureMark()) + if !bp.IsGatewayEnabled() || !bp.Gateway.HasAPI() || bp.Gateway.API.Auth.Empty() { + return fmt.Errorf("%s no gateway token auth configured in this blueprint", cmdutil.FailureMark()) } var entry *types.APIToken - for i, t := range cat.Gateway.API.Auth.Tokens { + for i, t := range bp.Gateway.API.Auth.Tokens { if t.Name == name { - entry = &cat.Gateway.API.Auth.Tokens[i] + entry = &bp.Gateway.API.Auth.Tokens[i] break } } @@ -328,20 +328,20 @@ Shows each token's name, type, provider kind, and allow summary. Example: inrun token list - inrun token list -f catalog.yaml`, + inrun token list -f blueprint.yaml`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - cat, err := cmdutil.BuildCatalog(cmd) + bp, err := cmdutil.BuildBlueprint(cmd) if err != nil { return fmt.Errorf("%s %w", cmdutil.FailureMark(), err) } - if !cat.IsGatewayEnabled() || !cat.Gateway.HasAPI() || cat.Gateway.API.Auth.Empty() { + if !bp.IsGatewayEnabled() || !bp.Gateway.HasAPI() || bp.Gateway.API.Auth.Empty() { fmt.Printf("\n %s no gateway token auth configured\n\n", cmdutil.Dim("—")) return nil } - tokens := cat.Gateway.API.Auth.Tokens + tokens := bp.Gateway.API.Auth.Tokens fmt.Printf("\n %s %s\n\n", cmdutil.Gray("token list"), cmdutil.Dim(fmt.Sprintf("%d entries", len(tokens)))) type row struct{ name, typ, provider, allow string } diff --git a/cmd/cli/testsuite/e2e.go b/cmd/cli/testsuite/e2e.go index 4a4b34e2d..302028be6 100644 --- a/cmd/cli/testsuite/e2e.go +++ b/cmd/cli/testsuite/e2e.go @@ -13,7 +13,7 @@ import ( "github.com/intentd/inrun/cmd/cli/cmdutil" "github.com/intentd/inrun/cmd/cli/validate" - "github.com/intentd/inrun/pkg/catalog" + "github.com/intentd/inrun/pkg/blueprint" "github.com/intentd/inrun/pkg/registry/e2e" "gopkg.in/yaml.v3" @@ -49,7 +49,7 @@ Discovery mode — runs all *e2e.yaml files found recursively (skips pure aggreg file = args[0] } if file == "" { - file = cmdutil.FindFile(cmdutil.FileE2e, cmdutil.DirTest) + file = cmdutil.FindFile(cmdutil.FileE2e) } if file == "" { return fmt.Errorf("no %s found here or in %s/ — pass -f ", cmdutil.FileE2e, cmdutil.DirTest) @@ -65,10 +65,7 @@ Discovery mode — runs all *e2e.yaml files found recursively (skips pure aggreg } valuesFiles, _ := cmd.Flags().GetStringSlice("values") helmArgRaw, _ := cmd.Flags().GetStringSlice("set") - var helmArgs []string - for _, arg := range helmArgRaw { - helmArgs = append(helmArgs, "--set", arg) - } + helmArgs := cmdutil.HelmSetArgs(helmArgRaw) devServer, _ := cmd.Flags().GetBool("dev-server") noRuntime, _ := cmd.Flags().GetBool("no-runtime") wait, _ := cmd.Flags().GetString("wait") @@ -207,13 +204,13 @@ type crdInfo struct { var e2eInitCmd = &cobra.Command{ Use: "init", - Short: "Scaffold an e2e.yaml from the current Catalog", - Long: `Reads the Catalog in the current directory and generates a best-practice + Short: "Scaffold an e2e.yaml from the current Blueprint", + Long: `Reads the Blueprint in the current directory and generates a best-practice e2e.yaml skeleton: CR created → resources created → cleanup verified. -No cluster is needed — it reads only the Catalog for CRD names and kinds. +No cluster is needed — it reads only the Blueprint for CRD names and kinds. - inrun e2e init # auto-detect catalog.yaml - inrun e2e init -f catalog.yaml # explicit + inrun e2e init # auto-detect blueprint.yaml + inrun e2e init -f blueprint.yaml # explicit inrun e2e init --force # overwrite existing e2e.yaml inrun e2e init --dry-run # preview without writing inrun e2e init --suite # aggregate all e2e.yaml files under . @@ -226,21 +223,21 @@ No cluster is needed — it reads only the Catalog for CRD names and kinds. }, } -// e2eInitScaffold reads the Catalog and writes an e2e.yaml scaffold. +// e2eInitScaffold reads the Blueprint and writes an e2e.yaml scaffold. func e2eInitScaffold(cmd *cobra.Command) error { - catalogFile, _ := cmd.Flags().GetString("file") + blueprintFile, _ := cmd.Flags().GetString("file") force, _ := cmd.Flags().GetBool("force") dryRun, _ := cmd.Flags().GetBool("dry-run") var err error - catalogFile, err = cmdutil.ResolveCatalogFile(catalogFile) + blueprintFile, err = cmdutil.ResolveBlueprintFile(blueprintFile) if err != nil { return err } - cat, err := catalog.ParseFile(catalogFile) + bp, err := blueprint.ParseFile(blueprintFile) if err != nil { - return fmt.Errorf("parsing Catalog: %w", err) + return fmt.Errorf("parsing Blueprint: %w", err) } outPath := filepath.Join(cmdutil.DirTest, cmdutil.FileE2e) @@ -251,14 +248,14 @@ func e2eInitScaffold(cmd *cobra.Command) error { } // Paths in the spec are relative to the spec file, in test/. - relCatalog := relFromDir(cmdutil.DirTest, catalogFile) - relCRD := relFromDir(cmdutil.DirTest, projectFile(catalogFile, cmdutil.FileCrd, cmdutil.DirManifests)) - relCR := relFromDir(cmdutil.DirTest, projectFile(catalogFile, cmdutil.FileCr, cmdutil.DirManifests)) + relBlueprint := relFromDir(cmdutil.DirTest, blueprintFile) + relCRD := relFromDir(cmdutil.DirTest, projectFile(blueprintFile, cmdutil.FileCrd)) + relCR := relFromDir(cmdutil.DirTest, projectFile(blueprintFile, cmdutil.FileCr)) - crdNames := cat.CRDNames() + crdNames := bp.CRDNames() var crds []crdInfo for _, n := range crdNames { - entry, ok := cat.CRDEntry(n) + entry, ok := bp.CRDEntry(n) if !ok { continue } @@ -266,7 +263,7 @@ func e2eInitScaffold(cmd *cobra.Command) error { } sort.Slice(crds, func(i, j int) bool { return crds[i].name < crds[j].name }) - output := buildE2EScaffoldYAML(cat.Metadata().Name, relCatalog, relCRD, relCR, crds) + output := buildE2EScaffoldYAML(bp.Metadata().Name, relBlueprint, relCRD, relCR, crds) if dryRun { fmt.Print(output) @@ -393,7 +390,7 @@ func e2eInitSuite(cmd *cobra.Command, args []string) error { // buildE2EScaffoldYAML returns the scaffold YAML string for inrun e2e init. // Comments are injected directly — yaml.Marshal cannot preserve them. -func buildE2EScaffoldYAML(catalogName, catalogFile, crdFile, crFile string, crds []crdInfo) string { +func buildE2EScaffoldYAML(blueprintName, blueprintFile, crdFile, crFile string, crds []crdInfo) string { var b strings.Builder w := func(s string) { b.WriteString(s + "\n") } @@ -406,27 +403,27 @@ func buildE2EScaffoldYAML(catalogName, catalogFile, crdFile, crFile string, crds w("apiVersion: inrun.dev/v1") w("kind: E2E") w("metadata:") - w(" name: " + catalogName + "-e2e") + w(" name: " + blueprintName + "-e2e") w(` description: "Generated by inrun e2e init — edit to refine"`) w("") w("spec:") - w(" catalog: " + catalogFile) + w(" blueprint: " + blueprintFile) w(" crd: " + crdFile) w(" cr: " + crFile) w("") w(" cluster:") w(" provider: kind") - w(" name: " + catalogName + "-e2e") + w(" name: " + blueprintName + "-e2e") w(" reuse: false") if len(crds) > 1 { w("") - w(" # Multiple CRDs in this Catalog. Apply the remaining CRDs and CRs via setup.") + w(" # Multiple CRDs in this Blueprint. Apply the remaining CRDs and CRs via setup.") w(" setup:") w(" apply:") w(" - ./other-crds.yaml # CRD definitions for the remaining kinds") w(" - ./other-crs.yaml # CR instances for the remaining kinds") - w(" # Additional CRDs in this Catalog:") + w(" # Additional CRDs in this Blueprint:") for _, c := range crds[1:] { w(" # - " + c.kind) } @@ -484,7 +481,7 @@ func init() { cmdutil.RootCmd.AddCommand(e2eCmd) e2eCmd.AddCommand(e2eInitCmd) - e2eInitCmd.Flags().StringP("file", "f", "", "Path to catalog.yaml or stack.yaml") + e2eInitCmd.Flags().StringP("file", "f", "", "Path to blueprint.yaml or stack.yaml") e2eInitCmd.Flags().Bool("force", false, "Overwrite existing e2e.yaml") e2eInitCmd.Flags().Bool("dry-run", false, "Print the generated e2e.yaml to stdout instead of writing the file") e2eInitCmd.Flags().Bool("suite", false, "Aggregate all e2e.yaml leaf files found under the given dir (default: .)") @@ -511,19 +508,8 @@ func init() { cmdutil.ShadowGlobalCommandFlags(e2eCmd) } -// chartFlag returns --chart as an absolute path, or "" when unset. The path -// must be a chart directory: e2e may change directory before installing. +// chartFlag returns --chart as an absolute chart directory, or "" when unset. func chartFlag(cmd *cobra.Command) (string, error) { chart, _ := cmd.Flags().GetString("chart") - if chart == "" { - return "", nil - } - abs, err := filepath.Abs(chart) - if err != nil { - return "", fmt.Errorf("--chart %q: %w", chart, err) - } - if _, err := os.Stat(filepath.Join(abs, "Chart.yaml")); err != nil { - return "", fmt.Errorf("--chart %q: not a Helm chart directory (no Chart.yaml)", chart) - } - return abs, nil + return cmdutil.ChartDir(chart) } diff --git a/cmd/cli/testsuite/helper.go b/cmd/cli/testsuite/helper.go index 7913f49a0..c7f4eb6c8 100644 --- a/cmd/cli/testsuite/helper.go +++ b/cmd/cli/testsuite/helper.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/intentd/inrun/cmd/cli/cmdutil" - "github.com/intentd/inrun/pkg/catalog" + "github.com/intentd/inrun/pkg/blueprint" "github.com/intentd/inrun/pkg/merger" "github.com/intentd/inrun/pkg/registry/simulate" "github.com/intentd/inrun/pkg/types" @@ -51,19 +51,19 @@ func readSpecCRs(dir string, paths []string) (map[string][]*unstructured.Unstruc var PlayIntent func(PlayRequest) (*unstructured.Unstructured, error) type PlayRequest struct { - Catalog *catalog.Catalog // the catalog the intent is resolved against - CatalogFile string // path to the catalog.yaml - IntentFile string // path to the intent YAML/JSON - Token string // serve token to check the op against - Op types.ServeOperation // create / update / delete - Cycles int // reconcile cycles for simulate - Verbose bool // print each stage + Blueprint *blueprint.Blueprint // the blueprint the intent is resolved against + BlueprintFile string // path to the blueprint.yaml + IntentFile string // path to the intent YAML/JSON + Token string // serve token to check the op against + Op types.ServeOperation // create / update / delete + Cycles int // reconcile cycles for simulate + Verbose bool // print each stage } -// CatalogCRDPaths returns the CRD files the Catalog's CRDs point at with -// crdFile, resolved like the Catalog parser does. --envtest installs them when +// BlueprintCRDPaths returns the CRD files the Blueprint's CRDs point at with +// crdFile, resolved like the Blueprint parser does. --envtest installs them when // a Simulate spec lists no crd or crdFiles. -func CatalogCRDPaths(m *merger.Merger) []string { +func BlueprintCRDPaths(m *merger.Merger) []string { dir := m.FirstEntryDir() seen := map[string]bool{} var paths []string diff --git a/cmd/cli/testsuite/simulate.go b/cmd/cli/testsuite/simulate.go index ffcf9f013..1985e653a 100644 --- a/cmd/cli/testsuite/simulate.go +++ b/cmd/cli/testsuite/simulate.go @@ -7,18 +7,20 @@ import ( "context" "errors" "fmt" - "github.com/intentd/inrun/pkg/logger" - "github.com/intentd/inrun/pkg/utils" "os" "path/filepath" "strings" "time" + "github.com/intentd/inrun/pkg/logger" + "github.com/intentd/inrun/pkg/registry" + "github.com/intentd/inrun/pkg/util/term" + "github.com/intentd/inrun/cmd/cli/cmdutil" "github.com/intentd/inrun/cmd/cli/validate" - "github.com/intentd/inrun/pkg/catalog" - "github.com/intentd/inrun/pkg/catalog/pipeline" + "github.com/intentd/inrun/pkg/blueprint" + "github.com/intentd/inrun/pkg/blueprint/pipeline" "github.com/intentd/inrun/pkg/merger" "github.com/intentd/inrun/pkg/registry/e2e" "github.com/intentd/inrun/pkg/registry/simulate" @@ -52,7 +54,7 @@ should produce so the run is repeatable and verifiable: inrun simulate # simulate.yaml auto-detected inrun simulate -f simulate.yaml # explicit — assert mode when expect: is set - inrun simulate -f catalog.yaml --cr cr.yaml # direct flags; op-print only + inrun simulate -f blueprint.yaml --cr cr.yaml # direct flags; op-print only inrun simulate ./... # discovers all simulate.yaml files recursively`, RunE: func(cmd *cobra.Command, args []string) error { cliOpts := CliSimulateOptions{} @@ -78,62 +80,62 @@ should produce so the run is repeatable and verifiable: return runSimulateDiscovery(cmd.Context(), ".", skipRaw, cliOpts) } - catalogFile, _ := cmd.Flags().GetString("file") - if catalogFile == "" { - // Auto-detect: simulate.yaml (here, then test/) → catalog.yaml/stack.yaml - switch sim := cmdutil.FindFile(cmdutil.FileSimulate, cmdutil.DirTest); { + blueprintFile, _ := cmd.Flags().GetString("file") + if blueprintFile == "" { + // Auto-detect: simulate.yaml (here, then test/) → blueprint.yaml/stack.yaml + switch sim := cmdutil.FindFile(cmdutil.FileSimulate); { case sim != "": - catalogFile = sim + blueprintFile = sim default: if d := cmdutil.DefaultFilePaths(); len(d) > 0 { - catalogFile = d[0] + blueprintFile = d[0] } } } - if catalogFile == "" { - return fmt.Errorf(cmdutil.ErrNoCatalog) + if blueprintFile == "" { + return fmt.Errorf(cmdutil.ErrNoBlueprint) } // Simulate kind: assert mode - if simulate.IsSimulateDoc(catalogFile) { - return RunSimulateFromSpec(cmd.Context(), catalogFile, cliOpts) + if simulate.IsSimulateDoc(blueprintFile) { + return RunSimulateFromSpec(cmd.Context(), blueprintFile, cliOpts) } // Reject E2E files with a clear message - if e2e.IsE2EDoc(catalogFile) { - return fmt.Errorf("%s is an E2E file — use 'inrun e2e' for cluster testing, or run 'inrun simulate init' to generate a simulate.yaml", catalogFile) + if e2e.IsE2EDoc(blueprintFile) { + return fmt.Errorf("%s is an E2E file — use 'inrun e2e' for cluster testing, or run 'inrun simulate init' to generate a simulate.yaml", blueprintFile) } crFile, _ := cmd.Flags().GetString("cr") if crFile == "" { - crFile = cmdutil.FindFile(cmdutil.FileCr, cmdutil.DirManifests) + crFile = cmdutil.FindFile(cmdutil.FileCr) } if crFile == "" { return fmt.Errorf("--cr is required") } - return RunSimulate(cmd.Context(), catalogFile, crFile, cliOpts) + return RunSimulate(cmd.Context(), blueprintFile, crFile, cliOpts) }, } -func RunSimulate(ctx context.Context, catalogFile, crFile string, cliOpts CliSimulateOptions) error { +func RunSimulate(ctx context.Context, blueprintFile, crFile string, cliOpts CliSimulateOptions) error { maxCycles := cliOpts.MaxCycles if maxCycles <= 0 { maxCycles = 10 } - m := merger.New(catalogFile) + m := merger.New(blueprintFile) if err := m.Merge(); err != nil { - return fmt.Errorf("merging Catalog: %w", err) + return fmt.Errorf("merging Blueprint: %w", err) } - crdPaths := CatalogCRDPaths(m) - cat, err := pipeline.BuildExpanded(cmdutil.Cfg, m) + crdPaths := BlueprintCRDPaths(m) + bp, err := pipeline.BuildExpanded(cmdutil.Cfg, m) if err != nil { - var typedErr *catalog.TypedOperatorError + var typedErr *blueprint.TypedOperatorError if errors.As(err, &typedErr) { cmdutil.PrintTypedOperatorHint(typedErr, "inrun simulate") } - return fmt.Errorf("parsing Catalog: %w", err) + return fmt.Errorf("parsing Blueprint: %w", err) } crData, err := cmdutil.ReadLocal(crFile) @@ -152,13 +154,13 @@ func RunSimulate(ctx context.Context, catalogFile, crFile string, cliOpts CliSim if cliOpts.CRDName != "" { targets = []string{cliOpts.CRDName} } else { - targets = cat.CRDNames() + targets = bp.CRDNames() } baseOpts := simulate.RunOptions{SkipExternal: cliOpts.SkipExternal, Target: cliOpts.Target} for _, name := range targets { - crdEntry, ok := cat.CRDEntry(name) + crdEntry, ok := bp.CRDEntry(name) if !ok { continue } @@ -173,14 +175,14 @@ func RunSimulate(ctx context.Context, catalogFile, crFile string, cliOpts CliSim crdOpts := baseOpts crdOpts.Peers = in.Peers crdOpts.ExistingInstances = in.Existing - if err := SimulateOne(ctx, cat, name, in.CR, maxCycles, crdOpts, cliOpts, crdPaths, nil); err != nil { + if err := SimulateOne(ctx, bp, name, in.CR, maxCycles, crdOpts, cliOpts, crdPaths, nil); err != nil { return err } } return nil } -func SimulateOne(ctx context.Context, cat *catalog.Catalog, crdName string, cr *unstructured.Unstructured, maxCycles int, opts simulate.RunOptions, cliOpts CliSimulateOptions, crdPaths []string, expect *types.SimulateExpect) error { +func SimulateOne(ctx context.Context, bp *blueprint.Blueprint, crdName string, cr *unstructured.Unstructured, maxCycles int, opts simulate.RunOptions, cliOpts CliSimulateOptions, crdPaths []string, expect *types.SimulateExpect) error { if cliOpts.UseEnvtest { version := cliOpts.K8sVersion if version == "" { @@ -192,7 +194,7 @@ func SimulateOne(ctx context.Context, cat *catalog.Catalog, crdName string, cr * } // Emit notes for operatorBox blocks that cannot execute in the fake cluster. - crdEntry, _ := cat.CRDEntry(crdName) + crdEntry, _ := bp.CRDEntry(crdName) if crdEntry.Box().EffectiveOnReconcile() != nil && len(crdEntry.Box().EffectiveOnReconcile().External) > 0 { if opts.SkipExternal { fmt.Printf(" %s\n", cmdutil.Dim("note: external: calls stubbed — result fields will be empty")) @@ -208,7 +210,7 @@ func SimulateOne(ctx context.Context, cat *catalog.Catalog, crdName string, cr * // The reconciler's own logs show only with --verbose; errors always do. // With --verbose there is no spinner, so log lines don't break into it. - var spin *utils.Spinner + var spin *term.Spinner if cliOpts.Verbose { fmt.Printf("Running %d cycles...\n", maxCycles) } else { @@ -222,9 +224,9 @@ func SimulateOne(ctx context.Context, cat *catalog.Catalog, crdName string, cr * if len(crdPaths) == 0 { return fmt.Errorf("%s --envtest requires spec.crd or spec.crdFiles to be set", cmdutil.FailureMark()) } - result, err = simulate.RunWithEnvtest(ctx, cat, crdName, cr, maxCycles, opts, crdPaths, cliOpts.K8sVersion) + result, err = simulate.RunWithEnvtest(ctx, bp, crdName, cr, maxCycles, opts, crdPaths, cliOpts.K8sVersion) } else { - result, err = simulate.Run(ctx, cat, crdName, cr, maxCycles, opts) + result, err = simulate.Run(ctx, bp, crdName, cr, maxCycles, opts) } if err != nil { if spin != nil { @@ -368,7 +370,7 @@ func RunSimulateFromSpec(ctx context.Context, path string, cliOpts CliSimulateOp Target: effectiveTarget, } - catalogPath := filepath.Join(dir, doc.Spec.Catalog) + blueprintPath := filepath.Join(dir, doc.Spec.Blueprint) // Resolve CRD paths (for --envtest) relative to the simulate.yaml directory. crdPaths := make([]string, 0, len(doc.Spec.AllCRDPaths())) @@ -380,21 +382,21 @@ func RunSimulateFromSpec(ctx context.Context, path string, cliOpts CliSimulateOp crdPaths = append(crdPaths, abs) } - m := merger.New(catalogPath) + m := merger.New(blueprintPath) if err := m.Merge(); err != nil { - return fmt.Errorf("merging Catalog: %w", err) + return fmt.Errorf("merging Blueprint: %w", err) } if len(crdPaths) == 0 { - crdPaths = CatalogCRDPaths(m) + crdPaths = BlueprintCRDPaths(m) } - cat, err := pipeline.BuildExpanded(cmdutil.Cfg, m) + bp, err := pipeline.BuildExpanded(cmdutil.Cfg, m) if err != nil { - var typedErr *catalog.TypedOperatorError + var typedErr *blueprint.TypedOperatorError if errors.As(err, &typedErr) { cmdutil.PrintTypedOperatorHint(typedErr, "inrun simulate") } - return fmt.Errorf("parsing Catalog: %w", err) + return fmt.Errorf("parsing Blueprint: %w", err) } var crs map[string][]*unstructured.Unstructured @@ -404,13 +406,13 @@ func RunSimulateFromSpec(ctx context.Context, path string, cliOpts CliSimulateOp return fmt.Errorf("%s: spec.intent is not supported in this build", path) } obj, err := PlayIntent(PlayRequest{ - Catalog: cat, - CatalogFile: catalogPath, - IntentFile: filepath.Join(dir, doc.Spec.Intent), - Token: doc.Spec.Token, - Op: types.ServeOpCreate, - Cycles: doc.Spec.Cycles, - Verbose: cliOpts.Verbose, + Blueprint: bp, + BlueprintFile: blueprintPath, + IntentFile: filepath.Join(dir, doc.Spec.Intent), + Token: doc.Spec.Token, + Op: types.ServeOpCreate, + Cycles: doc.Spec.Cycles, + Verbose: cliOpts.Verbose, }) if err != nil { return fmt.Errorf("playing %s: %w", doc.Spec.Intent, err) @@ -428,12 +430,12 @@ func RunSimulateFromSpec(ctx context.Context, path string, cliOpts CliSimulateOp if cliOpts.CRDName != "" { targets = []string{cliOpts.CRDName} } else { - targets = cat.CRDNames() + targets = bp.CRDNames() } var failed []string for _, name := range targets { - crdEntry, ok := cat.CRDEntry(name) + crdEntry, ok := bp.CRDEntry(name) if !ok { continue } @@ -448,7 +450,7 @@ func RunSimulateFromSpec(ctx context.Context, path string, cliOpts CliSimulateOp crdOpts.Peers = in.Peers crdOpts.ExistingInstances = in.Existing expect := simulate.ExpectForCRD(doc.Spec.Expect, name) - if err := SimulateOne(ctx, cat, name, in.CR, cycles, crdOpts, cliOpts, crdPaths, expect); err != nil { + if err := SimulateOne(ctx, bp, name, in.CR, cycles, crdOpts, cliOpts, crdPaths, expect); err != nil { failed = append(failed, name) } } @@ -565,7 +567,7 @@ var simulateInitCmd = &cobra.Command{ observed cycle-1 create operations as expect: rules. Edit and refine from there. inrun simulate init - inrun simulate init -f catalog.yaml --cr cr.yaml + inrun simulate init -f blueprint.yaml --cr cr.yaml inrun simulate init --force # overwrite existing simulate.yaml inrun simulate init --suite # aggregate all simulate.yaml files under . inrun simulate init --suite ./examples/ # aggregate under a specific dir`, @@ -574,25 +576,25 @@ observed cycle-1 create operations as expect: rules. Edit and refine from there. return simulateInitSuite(cmd, args) } - catalogFile, _ := cmd.Flags().GetString("file") + blueprintFile, _ := cmd.Flags().GetString("file") crFile, _ := cmd.Flags().GetString("cr") force, _ := cmd.Flags().GetBool("force") dryRun, _ := cmd.Flags().GetBool("dry-run") var err error - catalogFile, err = cmdutil.ResolveCatalogFile(catalogFile) + blueprintFile, err = cmdutil.ResolveBlueprintFile(blueprintFile) if err != nil { return err } if crFile == "" { - crFile = projectFile(catalogFile, cmdutil.FileCr, cmdutil.DirManifests) + crFile = projectFile(blueprintFile, cmdutil.FileCr) } else if abs, err := filepath.Abs(crFile); err == nil { crFile = abs } - cat, err := catalog.ParseFile(catalogFile) + bp, err := blueprint.ParseFile(blueprintFile) if err != nil { - return fmt.Errorf("parsing Catalog: %w", err) + return fmt.Errorf("parsing Blueprint: %w", err) } crData, err := cmdutil.ReadLocal(crFile) if err != nil { @@ -611,12 +613,12 @@ observed cycle-1 create operations as expect: rules. Edit and refine from there. } opts := simulate.RunOptions{} - targets := cat.CRDNames() + targets := bp.CRDNames() var results []crdOps for _, name := range targets { - crdEntry, ok := cat.CRDEntry(name) + crdEntry, ok := bp.CRDEntry(name) if !ok { continue } @@ -627,7 +629,7 @@ observed cycle-1 create operations as expect: rules. Edit and refine from there. crdOpts := opts crdOpts.Peers = in.Peers crdOpts.ExistingInstances = in.Existing - result, err := simulate.Run(cmd.Context(), cat, name, in.CR, 10, crdOpts) + result, err := simulate.Run(cmd.Context(), bp, name, in.CR, 10, crdOpts) if err != nil && crdEntry.WithRemoteDecl() { fmt.Printf(" %s %s: remote reconciler unreachable — start it and re-run (%v)\n", cmdutil.Dim("note:"), name, err) continue @@ -645,10 +647,10 @@ observed cycle-1 create operations as expect: rules. Edit and refine from there. } // Paths in the spec are relative to the spec file, so the file is portable. - relCatalog := relFromDir(cmdutil.DirTest, catalogFile) + relBlueprint := relFromDir(cmdutil.DirTest, blueprintFile) relCR := relFromDir(cmdutil.DirTest, crFile) - doc := generateSimulateDoc(relCatalog, relCR, cat.Metadata().Name, results) + doc := generateSimulateDoc(relBlueprint, relCR, bp.Metadata().Name, results) var buf bytes.Buffer enc := yaml.NewEncoder(&buf) @@ -781,7 +783,7 @@ func simulateInitSuite(cmd *cobra.Command, args []string) error { } // generateSimulateDoc builds a Simulate document from observed cycle-1 creates. -func generateSimulateDoc(catalogPath, crPath, catalogName string, results []crdOps) map[string]interface{} { +func generateSimulateDoc(blueprintPath, crPath, blueprintName string, results []crdOps) map[string]interface{} { trueVal := true makeOps := func(ops []simulate.Op) []map[string]interface{} { @@ -801,9 +803,9 @@ func generateSimulateDoc(catalogPath, crPath, catalogName string, results []crdO } spec := map[string]interface{}{ - "catalog": catalogPath, - "cr": crPath, - "cycles": 5, + "blueprint": blueprintPath, + "cr": crPath, + "cycles": 5, } if len(results) == 1 { @@ -837,7 +839,7 @@ func generateSimulateDoc(catalogPath, crPath, catalogName string, results []crdO "apiVersion": "inrun.dev/v1", "kind": "Simulate", "metadata": map[string]interface{}{ - "name": catalogName + "-sim", + "name": blueprintName + "-sim", "description": "Generated by inrun simulate init — edit to refine", }, "spec": spec, @@ -902,16 +904,16 @@ func init() { cmdutil.RootCmd.AddCommand(simulateCmd) simulateCmd.AddCommand(simulateInitCmd) - simulateInitCmd.Flags().StringP("file", "f", "", "Path to catalog.yaml or stack.yaml") + simulateInitCmd.Flags().StringP("file", "f", "", "Path to blueprint.yaml or stack.yaml") simulateInitCmd.Flags().String("cr", "", "Path to the CR YAML file") simulateInitCmd.Flags().Bool("force", false, "Overwrite existing simulate.yaml") simulateInitCmd.Flags().Bool("dry-run", false, "Print the generated simulate.yaml to stdout instead of writing the file") simulateInitCmd.Flags().Bool("suite", false, "Aggregate all simulate.yaml leaf files found under the given dir (default: .)") simulateInitCmd.Flags().StringSlice("skip", []string{}, "Comma-separated path patterns to exclude from suite discovery") - simulateCmd.Flags().StringP("file", "f", "", "Path to catalog.yaml") + simulateCmd.Flags().StringP("file", "f", "", "Path to blueprint.yaml") simulateCmd.Flags().String("cr", "", "Path to the CR YAML file to simulate") - simulateCmd.Flags().String("crd", "", "CRD name to simulate (default: all CRDs in Catalog)") + simulateCmd.Flags().String("crd", "", "CRD name to simulate (default: all CRDs in Blueprint)") simulateCmd.Flags().Int("cycles", 10, "Maximum number of reconcile cycles") simulateCmd.Flags().StringP("target", "t", "", "Target that provides the operatorbox for simulate") simulateCmd.Flags().StringSlice("skip", []string{}, "Comma-separated path patterns to skip during ./... discovery (e.g. vendor,cr-e2e.yaml)") @@ -927,12 +929,12 @@ func init() { cmdutil.ShadowGlobalCommandFlags(simulateCmd) } -// projectFile returns name next to the Catalog, or in its sub directory dir -// when only that exists. The path next to the Catalog is the fallback. -func projectFile(catalogFile, name, dir string) string { - base := filepath.Dir(catalogFile) - if p := filepath.Join(base, dir, name); cmdutil.FileExists(p) && !cmdutil.FileExists(filepath.Join(base, name)) { - return p +// projectFile returns where name is in the Blueprint's project: next to it, +// else its place in the standard layout. Next to it is the fallback. +func projectFile(blueprintFile, name string) string { + base := filepath.Dir(blueprintFile) + if rel := registry.FindPatternFile(base, name); rel != "" { + return filepath.Join(base, rel) } return filepath.Join(base, name) } diff --git a/cmd/cli/validate/doc.go b/cmd/cli/validate/doc.go index 89cb812e9..7df3cd725 100644 --- a/cmd/cli/validate/doc.go +++ b/cmd/cli/validate/doc.go @@ -1,4 +1,4 @@ -// Package validate implements the validate command for Catalogs, Stacks, +// Package validate implements the validate command for Blueprints, Stacks, // modules and test specs, and the template command that renders the fully -// expanded runtime Catalog. +// expanded runtime Blueprint. package validate diff --git a/cmd/cli/validate/output.go b/cmd/cli/validate/output.go index d4931010b..e6a930793 100644 --- a/cmd/cli/validate/output.go +++ b/cmd/cli/validate/output.go @@ -4,7 +4,7 @@ package validate // printEnrichmentReport prints the enrichment summary as part of inrun validate output. // -// Example output for a Catalog with built-in and custom CRDs: +// Example output for a Blueprint with built-in and custom CRDs: // // ✓ deployment-governance // kind: Deployment → enriched from built-in registry @@ -26,21 +26,21 @@ import ( "github.com/intentd/inrun/cmd/cli/cmdutil" - "github.com/intentd/inrun/pkg/catalog" + "github.com/intentd/inrun/pkg/blueprint" "github.com/intentd/inrun/pkg/types" rbacv1 "k8s.io/api/rbac/v1" ) // printCRDValidationLine prints one CRD entry's validation result. // Shows enrichment clearly when it occurred, and any warnings. -func printCRDValidationLine(cat *catalog.Catalog, entry types.CRDEntry) { +func printCRDValidationLine(bp *blueprint.Blueprint, entry types.CRDEntry) { printCRDHeader(entry) - strictModeText := getStrictModeText(entry, cat.IsStrictModeEnabled()) + strictModeText := getStrictModeText(entry, bp.IsStrictModeEnabled()) printKindInfo(entry) printModeResync(entry) - printProtectionStatus(entry, cat.IsDeletionProtectionEnabled(), strictModeText) - printInfo(entry.Info, cat.Info) - printWarnings(entry.Warnings, cat.Warnings) + printProtectionStatus(entry, bp.IsDeletionProtectionEnabled(), strictModeText) + printInfo(entry.Info, bp.Info) + printWarnings(entry.Warnings, bp.Warnings) } // printCRDHeader prints the CRD name with appropriate icon. @@ -57,8 +57,8 @@ func printCRDHeader(entry types.CRDEntry) { // getStrictModeText returns a formatted string indicating strict mode status, // or an empty string if strict mode is not enforced for this CRD. -func getStrictModeText(entry types.CRDEntry, catalogStrictMode bool) string { - if catalogStrictMode && entry.IsStrictDeletionProtection(catalogStrictMode) { +func getStrictModeText(entry types.CRDEntry, blueprintStrictMode bool) string { + if blueprintStrictMode && entry.IsStrictDeletionProtection(blueprintStrictMode) { return cmdutil.InfoMark() + " (strict)" } return "" @@ -101,13 +101,13 @@ func printModeResync(entry types.CRDEntry) { // printProtectionStatus prints the protection level for custom CRDs. // For built‑ins, a simplified message is shown. // When strict mode is active, the label changes from "protection:" to "strict‑protection:". -func printProtectionStatus(entry types.CRDEntry, catalogProtected bool, strictModeText string) { +func printProtectionStatus(entry types.CRDEntry, blueprintProtected bool, strictModeText string) { if entry.IsBuiltInType() { fmt.Printf(" %s\n", cmdutil.Gray("protection: label-based (built-in)")) return } - if !catalogProtected { + if !blueprintProtected { return } @@ -141,9 +141,9 @@ func printProtectionStatus(entry types.CRDEntry, catalogProtected bool, strictMo fmt.Printf(" %s\n", cmdutil.Gray(fullText)) } -// printWarnings prints any warnings associated with the CRD or Catalog, +// printWarnings prints any warnings associated with the CRD or Blueprint, // with proper indentation. -func printWarnings(crdWarnings, catalogWarnings []string) { +func printWarnings(crdWarnings, blueprintWarnings []string) { // Print CRD-level warnings for _, w := range crdWarnings { lines := strings.Split(w, "\n") @@ -156,8 +156,8 @@ func printWarnings(crdWarnings, catalogWarnings []string) { } } - // Print Catalog-level warnings - for _, w := range catalogWarnings { + // Print Blueprint-level warnings + for _, w := range blueprintWarnings { lines := strings.Split(w, "\n") for i, line := range lines { if i == 0 { @@ -169,9 +169,9 @@ func printWarnings(crdWarnings, catalogWarnings []string) { } } -// printInfo prints any informational messages associated with the CRD or Catalog, +// printInfo prints any informational messages associated with the CRD or Blueprint, // with proper indentation. -func printInfo(crdInfo, catalogInfo []string) { +func printInfo(crdInfo, blueprintInfo []string) { // Print CRD-level info for _, msg := range crdInfo { lines := strings.Split(msg, "\n") @@ -184,8 +184,8 @@ func printInfo(crdInfo, catalogInfo []string) { } } - // Print Catalog-level info - for _, msg := range catalogInfo { + // Print Blueprint-level info + for _, msg := range blueprintInfo { lines := strings.Split(msg, "\n") for i, line := range lines { if i == 0 { @@ -227,7 +227,7 @@ func printCRDPermissions(rules []rbacv1.PolicyRule) { // printValidateDependencyGraph prints the startup-order dependency section for validate --full. // Only called when there are dependencies (dd is never nil here). -func printValidateDependencyGraph(dd *catalog.DependencyDisplay) { +func printValidateDependencyGraph(dd *blueprint.DependencyDisplay) { fmt.Println() fmt.Println(cmdutil.Bold("startup order")) maxName := 0 @@ -322,32 +322,32 @@ func rbacRes(r rbacv1.PolicyRule) string { return res } -// ── Profiles display ───────────────────────────────────────────────────────── +// ── Presets display ───────────────────────────────────────────────────────── -type profileLine struct { +type presetLine struct { typLabel string - profile string + preset string location string mixed bool } -// printCRDProfiles prints named profiles declared for a CRD under its header. -// No-op when the CRD declares no profiles. -func printCRDProfiles(entry types.CRDEntry) { - lines := collectProfileLines(entry) +// printCRDPresets prints named presets declared for a CRD under its header. +// No-op when the CRD declares no presets. +func printCRDPresets(entry types.CRDEntry) { + lines := collectPresetLines(entry) if len(lines) == 0 { return } - maxType, maxProfile := 0, 0 + maxType, maxPreset := 0, 0 for _, l := range lines { if n := len(l.typLabel); n > maxType { maxType = n } - if n := len(l.profile); n > maxProfile { - maxProfile = n + if n := len(l.preset); n > maxPreset { + maxPreset = n } } - fmt.Printf(" %s\n", cmdutil.Gray("profiles:")) + fmt.Printf(" %s\n", cmdutil.Gray("presets:")) for _, l := range lines { suffix := "" if l.mixed { @@ -356,70 +356,70 @@ func printCRDProfiles(entry types.CRDEntry) { fmt.Printf(" %s\n", cmdutil.Gray(fmt.Sprintf( "%-*s %-*s %s%s", maxType, l.typLabel, - maxProfile, l.profile, + maxPreset, l.preset, l.location, suffix, ))) } } -func collectProfileLines(entry types.CRDEntry) []profileLine { - var lines []profileLine +func collectPresetLines(entry types.CRDEntry) []presetLine { + var lines []presetLine - for _, e := range entry.CollectSecurityProfileEntries() { + for _, e := range entry.CollectSecurityPresetEntries() { label := "security (container)" if e.Kind == "pod" { label = "security (pod)" } - lines = append(lines, profileLine{ + lines = append(lines, presetLine{ typLabel: label, - profile: e.Profile, - location: profileLoc(e.Resource, e.ResourceName, e.Phase), + preset: e.Preset, + location: presetLoc(e.Resource, e.ResourceName, e.Phase), mixed: e.Mixed, }) } - for _, e := range entry.CollectResourceProfileEntries() { - lines = append(lines, profileLine{ + for _, e := range entry.CollectResourcePresetEntries() { + lines = append(lines, presetLine{ typLabel: "resources", - profile: e.Profile, - location: profileLoc(e.Resource, e.ResourceName, e.Phase), + preset: e.Preset, + location: presetLoc(e.Resource, e.ResourceName, e.Phase), mixed: e.Mixed, }) } - for _, e := range entry.CollectHPAProfileEntries() { - lines = append(lines, profileLine{ + for _, e := range entry.CollectHPAPresetEntries() { + lines = append(lines, presetLine{ typLabel: "hpa", - profile: e.Profile, - location: profileLoc("", e.ResourceName, e.Phase), + preset: e.Preset, + location: presetLoc("", e.ResourceName, e.Phase), mixed: e.Mixed, }) } - for _, e := range entry.CollectPDBProfileEntries() { - lines = append(lines, profileLine{ + for _, e := range entry.CollectPDBPresetEntries() { + lines = append(lines, presetLine{ typLabel: "pdb", - profile: e.Profile, - location: profileLoc("", e.ResourceName, e.Phase), + preset: e.Preset, + location: presetLoc("", e.ResourceName, e.Phase), mixed: e.Mixed, }) } - for _, e := range entry.CollectRollingUpdateProfileEntries() { - lines = append(lines, profileLine{ + for _, e := range entry.CollectRollingUpdatePresetEntries() { + lines = append(lines, presetLine{ typLabel: "rolling", - profile: e.Profile, - location: profileLoc("", e.ResourceName, e.Phase), + preset: e.Preset, + location: presetLoc("", e.ResourceName, e.Phase), mixed: e.Mixed, }) } - for _, e := range entry.CollectProbeProfileEntries() { - lines = append(lines, profileLine{ + for _, e := range entry.CollectProbePresetEntries() { + lines = append(lines, presetLine{ typLabel: "probes (" + e.ProbeType + ")", - profile: e.Profile, - location: profileLoc(e.Resource, e.ResourceName, e.Phase), + preset: e.Preset, + location: presetLoc(e.Resource, e.ResourceName, e.Phase), mixed: e.Mixed, }) } @@ -427,8 +427,8 @@ func collectProfileLines(entry types.CRDEntry) []profileLine { return lines } -// profileLoc builds a compact location string from resource kind, name, and phase. -func profileLoc(resource, name, phase string) string { +// presetLoc builds a compact location string from resource kind, name, and phase. +func presetLoc(resource, name, phase string) string { switch { case resource != "" && name != "": return resource + "/" + name + " [" + phase + "]" diff --git a/cmd/cli/validate/simulate_spec_test.go b/cmd/cli/validate/simulate_spec_test.go index 5d7e449d6..c1086d59c 100644 --- a/cmd/cli/validate/simulate_spec_test.go +++ b/cmd/cli/validate/simulate_spec_test.go @@ -12,13 +12,13 @@ import ( func writeSpec(t *testing.T, spec string) string { t.Helper() dir := t.TempDir() - for _, f := range []string{"catalog.yaml", "intent.json", "cr.yaml"} { + for _, f := range []string{"blueprint.yaml", "intent.json", "cr.yaml"} { if err := os.WriteFile(filepath.Join(dir, f), []byte("{}"), 0o600); err != nil { t.Fatal(err) } } p := filepath.Join(dir, "simulate.yaml") - doc := "apiVersion: inrun.dev/v1\nkind: Simulate\nmetadata:\n name: s\nspec:\n catalog: catalog.yaml\n" + spec + doc := "apiVersion: inrun.dev/v1\nkind: Simulate\nmetadata:\n name: s\nspec:\n blueprint: blueprint.yaml\n" + spec if err := os.WriteFile(p, []byte(doc), 0o600); err != nil { t.Fatal(err) } diff --git a/cmd/cli/validate/template.go b/cmd/cli/validate/template.go index 8d7d8e84e..d2f13afa4 100644 --- a/cmd/cli/validate/template.go +++ b/cmd/cli/validate/template.go @@ -10,14 +10,14 @@ import ( "github.com/intentd/inrun/cmd/cli/cmdutil" - "github.com/intentd/inrun/pkg/catalog" - "github.com/intentd/inrun/pkg/catalog/pipeline" + "github.com/intentd/inrun/pkg/blueprint" + "github.com/intentd/inrun/pkg/blueprint/pipeline" "github.com/intentd/inrun/pkg/types" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) -// templateRuntimeOutput is the serializable view of a fully-expanded Catalog. +// templateRuntimeOutput is the serializable view of a fully-expanded Blueprint. // It holds what the runtime receives after module expansion and validation. type templateRuntimeOutput struct { APIVersion string `yaml:"apiVersion" json:"apiVersion"` @@ -32,8 +32,8 @@ type templateSpecOutput struct { var templateCmd = &cobra.Command{ Use: "template", - Short: "Render and inspect the fully-expanded runtime Catalog", - Long: `Loads one or more Catalog or Stack files, fully expands all module imports, + Short: "Render and inspect the fully-expanded runtime Blueprint", + Long: `Loads one or more Blueprint or Stack files, fully expands all module imports, resolves all template inputs, validates the configuration, and shows exactly what the runtime will see — nothing more, nothing less. @@ -41,12 +41,12 @@ By default prints a human-readable summary. Use flags to switch output format or drill into a specific CRD. Examples: - inrun template -f catalog.yaml - inrun template -f catalog.yaml --yaml - inrun template -f catalog.yaml --json - inrun template -f catalog.yaml --graph - inrun template -f catalog.yaml --crd pipeline - inrun template -f catalog.yaml --yaml -o runtime.yaml + inrun template -f blueprint.yaml + inrun template -f blueprint.yaml --yaml + inrun template -f blueprint.yaml --json + inrun template -f blueprint.yaml --graph + inrun template -f blueprint.yaml --crd pipeline + inrun template -f blueprint.yaml --yaml -o runtime.yaml inrun template -f a.yaml -f b.yaml --graph`, RunE: func(cmd *cobra.Command, args []string) error { jsonOut, _ := cmd.Flags().GetBool("json") @@ -58,29 +58,29 @@ Examples: // ── Load & expand ─────────────────────────────────────────────────────── spin := cmdutil.StartSpinner("Resolving imports...") - merged, err := cmdutil.GenerateCatalog(cmd) + merged, err := cmdutil.GenerateBlueprint(cmd) if err != nil { spin.Failure() return err } spin.Stop() - var cat *catalog.Catalog + var bp *blueprint.Blueprint if noValidate { - var raw catalog.Catalog - if _, err = raw.BuildRuntimeCatalog(cmdutil.Cfg, merged.Merger); err != nil { + var raw blueprint.Blueprint + if _, err = raw.BuildRuntimeBlueprint(cmdutil.Cfg, merged.Merger); err != nil { return err } - cat = &raw + bp = &raw } else { - cat, err = pipeline.BuildExpanded(cmdutil.Cfg, merged.Merger) + bp, err = pipeline.BuildExpanded(cmdutil.Cfg, merged.Merger) if err != nil { return fmt.Errorf("validation: %w", err) } } - crds := cat.Enabled() - depGraph := catalog.NewDependencyGraph(cat) + crds := bp.Enabled() + depGraph := blueprint.NewDependencyGraph(bp) startupOrder := depGraph.StartupOrder() // ── Route to output mode ──────────────────────────────────────────────── @@ -117,9 +117,9 @@ Examples: cmdutil.PrintDependencyGraph(crds, depGraph, startupOrder) return nil - // ── --yaml / --json: full runtime Catalog ─────────────────────────────── + // ── --yaml / --json: full runtime Blueprint ─────────────────────────────── case yamlOut || jsonOut || defaultOut: - view := buildRuntimeView(cat) + view := buildRuntimeView(bp) if jsonOut { out, err = json.MarshalIndent(view, "", " ") } else { @@ -132,7 +132,7 @@ Examples: // ── default: human-readable summary ──────────────────────────────────── default: - cmdutil.PrintTemplateSummary(cat, crds, startupOrder) + cmdutil.PrintTemplateSummary(bp, crds, startupOrder) return nil } @@ -158,9 +158,9 @@ Examples: }, } -// buildRuntimeView constructs the serializable runtime view from the expanded Catalog. -func buildRuntimeView(cat *catalog.Catalog) templateRuntimeOutput { - meta := cat.Metadata() +// buildRuntimeView constructs the serializable runtime view from the expanded Blueprint. +func buildRuntimeView(bp *blueprint.Blueprint) templateRuntimeOutput { + meta := bp.Metadata() metaFields := map[string]string{} if meta.Name != "" { metaFields["name"] = meta.Name @@ -179,11 +179,11 @@ func buildRuntimeView(cat *catalog.Catalog) templateRuntimeOutput { } return templateRuntimeOutput{ - APIVersion: cat.APIVersion, - Kind: cat.Kind, + APIVersion: bp.APIVersion, + Kind: bp.Kind, Metadata: metaFields, Spec: templateSpecOutput{ - CRDs: cat.Enabled(), + CRDs: bp.Enabled(), }, } } @@ -203,8 +203,8 @@ func pruneYamlFile(view templateRuntimeOutput) ([]byte, error) { func init() { cmdutil.RootCmd.AddCommand(templateCmd) - templateCmd.Flags().BoolP("yaml", "y", false, "Output full expanded runtime Catalog as YAML") - templateCmd.Flags().BoolP("json", "j", false, "Output full expanded runtime Catalog as JSON") + templateCmd.Flags().BoolP("yaml", "y", false, "Output full expanded runtime Blueprint as YAML") + templateCmd.Flags().BoolP("json", "j", false, "Output full expanded runtime Blueprint as JSON") templateCmd.Flags().BoolP("graph", "g", false, "Show dependency graph with startup order") templateCmd.Flags().StringP("crd", "c", "", "Drill into a specific CRD by name") templateCmd.Flags().StringP("output", "o", "", "Write output to file instead of stdout") diff --git a/cmd/cli/validate/validate.go b/cmd/cli/validate/validate.go index ef1104874..55309e21f 100644 --- a/cmd/cli/validate/validate.go +++ b/cmd/cli/validate/validate.go @@ -12,8 +12,8 @@ import ( "path/filepath" - "github.com/intentd/inrun/pkg/catalog" - "github.com/intentd/inrun/pkg/catalog/pipeline" + "github.com/intentd/inrun/pkg/blueprint" + "github.com/intentd/inrun/pkg/blueprint/pipeline" "github.com/intentd/inrun/pkg/config" "github.com/intentd/inrun/pkg/registry/e2e" modulepkg "github.com/intentd/inrun/pkg/registry/module" @@ -25,17 +25,17 @@ import ( var validateCmd = &cobra.Command{ Use: "validate", - Short: "Validate an Inrun pattern (Catalog, Stack, Module, E2E, Simulate)", + Short: "Validate an Inrun pattern (Blueprint, Stack, Module, E2E, Simulate)", Long: `Validates any Inrun pattern and reports errors. The pattern kind is detected automatically from the 'kind' field: - Catalog — operator definition with CRD declarations - Stack — multi-source catalog composer + Blueprint — operator definition with CRD declarations + Stack — multi-source blueprint composer Module — reusable operator pattern E2E — declarative end-to-end test spec Simulate — declarative reconciler assertions -Reads catalog.yaml or stack.yaml from the current directory by default. +Reads blueprint.yaml or stack.yaml from the current directory by default. Pass -f to validate a different file. Examples: @@ -50,7 +50,7 @@ Examples: expanded = cmdutil.DefaultFilePaths() } if len(expanded) == 0 { - return fmt.Errorf(cmdutil.ErrNoCatalog) + return fmt.Errorf(cmdutil.ErrNoBlueprint) } var docKind string @@ -89,52 +89,52 @@ Examples: docKind = kind } - // Default path: Catalog / Stack validation + // Default path: Blueprint / Stack validation spin := cmdutil.StartSpinner("Resolving imports...") - m, err := cmdutil.GenerateCatalog(cmd) + m, err := cmdutil.GenerateBlueprint(cmd) if err != nil { spin.Failure() return err } spin.Stop() - cat, err := pipeline.BuildExpanded(cmdutil.Cfg, m.Merger) + bp, err := pipeline.BuildExpanded(cmdutil.Cfg, m.Merger) if err != nil { - var typedErr *catalog.TypedOperatorError + var typedErr *blueprint.TypedOperatorError if errors.As(err, &typedErr) { cmdutil.PrintTypedOperatorHint(typedErr, "inrun validate") } return err } - entries := cat.EnabledCRDs() + entries := bp.EnabledCRDs() - kindLabel := "Catalog" + kindLabel := "Blueprint" if config.IsStackKind(docKind) { kindLabel = "Stack" } - if cat.IsStandaloneGateway() { + if bp.IsStandaloneGateway() { kindLabel = "Gateway Standalone" } full, _ := cmd.Flags().GetBool("full") - showNotes, _ := cmd.Flags().GetBool("notes") - showProfiles, _ := cmd.Flags().GetBool("profiles") + showFunctions, _ := cmd.Flags().GetBool("functions") + showPresets, _ := cmd.Flags().GetBool("presets") - // --notes / --profiles: quiet mode — skip full validate output, print only that registry. - if showNotes || showProfiles { - if showNotes { - printValidateNotes(cat.Notes) + // --functions / --presets: quiet mode — skip full validate output, print only that registry. + if showFunctions || showPresets { + if showFunctions { + printValidateFunctions(bp.Functions) } - if showProfiles { - printValidateProfiles(cat.Profiles) + if showPresets { + printValidatePresets(bp.Presets) } return nil } var perCRDPerms map[string][]rbacv1.PolicyRule if full { - perCRDPerms = cat.GeneratePerCRDRBACRules() + perCRDPerms = bp.GeneratePerCRDRBACRules() } // Sort entries by name for stable output across runs. @@ -157,14 +157,14 @@ Examples: if config.IsStackKind(docKind) { deprecationHint = "To acknowledge this import, add it to lifecycle.accept.patterns in your Stack." } - cmdutil.PrintCatalogDeprecationWithHint(cat.Deprecation(), deprecationHint) + cmdutil.PrintBlueprintDeprecationWithHint(bp.Deprecation(), deprecationHint) // Print each CRD entry with enrichment info for _, entry := range sortedEntries { - printCRDValidationLine(cat, entry) + printCRDValidationLine(bp, entry) if full { printCRDPermissions(perCRDPerms[entry.Name]) - printCRDProfiles(entry) + printCRDPresets(entry) } fmt.Println() @@ -184,23 +184,23 @@ Examples: fmt.Println(strings.Repeat("─", 60)) fmt.Printf("%d %s valid (%d built-in, %d custom)\n", len(entries), crdText, builtIn, custom) - for _, w := range cat.CronConditionWarnings() { + for _, w := range bp.CronConditionWarnings() { fmt.Printf("\n%s %s\n", cmdutil.Yellow("⚠"), w) } if full { - if dd := cat.DependencyDisplayData(); dd != nil { + if dd := bp.DependencyDisplayData(); dd != nil { printValidateDependencyGraph(dd) } - printRuntimePermissionsSection(cat.GenerateRuntimeRBACRules()) - printGatewayPermissionsSection(cat.GenerateGatewayRBACRules()) + printRuntimePermissionsSection(bp.GenerateRuntimeRBACRules()) + printGatewayPermissionsSection(bp.GenerateGatewayRBACRules()) printRuntimeContext() fmt.Println() } // Always print non-empty registries inline. - printValidateNotes(cat.Notes) - printValidateProfiles(cat.Profiles) + printValidateFunctions(bp.Functions) + printValidatePresets(bp.Presets) return nil }, @@ -238,7 +238,7 @@ func ValidateE2EFile(path string) error { } baseDir := filepath.Dir(path) - isAggregator := len(doc.Imports) > 0 && doc.Spec.Catalog == "" && doc.Spec.Init == nil + isAggregator := len(doc.Imports) > 0 && doc.Spec.Blueprint == "" && doc.Spec.Init == nil var errs []string @@ -261,8 +261,8 @@ func ValidateE2EFile(path string) error { } expanded := doc.Spec.Expect if !isAggregator { - if doc.Spec.Catalog == "" && doc.Spec.Init == nil && !isCustom { - errs = append(errs, "spec.catalog is required (or spec.init for example packs, spec.custom.target for custom targets, or imports)") + if doc.Spec.Blueprint == "" && doc.Spec.Init == nil && !isCustom { + errs = append(errs, "spec.blueprint is required (or spec.init for example packs, spec.custom.target for custom targets, or imports)") } if doc.Spec.CRD == "" && doc.Spec.Init == nil && !isCustom { errs = append(errs, "spec.crd is required (or spec.init for example packs, spec.custom.target for custom targets, or imports)") @@ -339,8 +339,8 @@ func ValidateE2EFile(path string) error { if isCustom { fmt.Printf(" %s\n", cmdutil.Gray(fmt.Sprintf("mode : custom target (%s — Inrun install skipped)", doc.Spec.Custom.Target))) } - if doc.Spec.Catalog != "" { - fmt.Printf(" %s\n", cmdutil.Gray("catalog : "+doc.Spec.Catalog)) + if doc.Spec.Blueprint != "" { + fmt.Printf(" %s\n", cmdutil.Gray("blueprint : "+doc.Spec.Blueprint)) } if doc.Spec.CRD != "" { fmt.Printf(" %s\n", cmdutil.Gray("crd : "+doc.Spec.CRD)) @@ -456,10 +456,10 @@ func validateSimulateFileOpts(path string, quiet bool) error { } } if doc.Spec != nil { - if doc.Spec.Catalog == "" { - errs = append(errs, "spec.catalog is required") - } else if !cmdutil.FileExists(filepath.Join(baseDir, doc.Spec.Catalog)) { - errs = append(errs, "spec.catalog not found: "+doc.Spec.Catalog) + if doc.Spec.Blueprint == "" { + errs = append(errs, "spec.blueprint is required") + } else if !cmdutil.FileExists(filepath.Join(baseDir, doc.Spec.Blueprint)) { + errs = append(errs, "spec.blueprint not found: "+doc.Spec.Blueprint) } switch { case doc.Spec.Intent != "" && len(doc.Spec.AllCRPaths()) > 0: @@ -546,7 +546,7 @@ func validateSimulateFileOpts(path string, quiet bool) error { return nil } - // Success — print structured summary matching the Catalog/E2E style. + // Success — print structured summary matching the Blueprint/E2E style. fmt.Printf("%s %s\n", cmdutil.HealthIcon("ready"), cmdutil.Bold(doc.Metadata.Name)) if doc.Metadata.Description != "" { fmt.Printf(" %s\n", cmdutil.Gray(doc.Metadata.Description)) @@ -563,7 +563,7 @@ func validateSimulateFileOpts(path string, quiet bool) error { if cycles <= 0 { cycles = 10 } - fmt.Printf(" %s\n", cmdutil.Gray(fmt.Sprintf("catalog : %s", doc.Spec.Catalog))) + fmt.Printf(" %s\n", cmdutil.Gray(fmt.Sprintf("blueprint : %s", doc.Spec.Blueprint))) if doc.Spec.Intent != "" { fmt.Printf(" %s\n", cmdutil.Gray(fmt.Sprintf("intent : %s (token %s)", doc.Spec.Intent, doc.Spec.Token))) } else { @@ -615,7 +615,7 @@ func validateModuleFile(path string) error { fmt.Println(cmdutil.Bold("Validating Module...")) fmt.Println() - errs := catalog.ValidateModule(path) + errs := blueprint.ValidateModule(path) if len(errs) > 0 { for _, e := range errs { fmt.Printf(" %s %s\n", cmdutil.FailureMark(), e.Error()) @@ -648,8 +648,8 @@ func validateModuleFile(path string) error { if summary := moduleResourceSummary(m); summary != "" { fmt.Printf(" %s\n", cmdutil.Gray("resources: "+summary)) } - if summary := moduleProfileSummary(m); summary != "" { - fmt.Printf(" %s\n", cmdutil.Gray("profiles : "+summary)) + if summary := modulePresetSummary(m); summary != "" { + fmt.Printf(" %s\n", cmdutil.Gray("presets : "+summary)) } } @@ -659,10 +659,10 @@ func validateModuleFile(path string) error { return nil } -// moduleProfileSummary returns a compact string listing non-empty profile classes +// modulePresetSummary returns a compact string listing non-empty preset classes // and their counts, e.g. "networkPolicies(2) resourceQuotas(1)". -func moduleProfileSummary(m *types.Module) string { - reg := m.Profiles +func modulePresetSummary(m *types.Module) string { + reg := m.Presets if reg.Empty() { return "" } @@ -675,9 +675,9 @@ func moduleProfileSummary(m *types.Module) string { add("networkPolicies", len(reg.NetworkPolicies)) add("resourceQuotas", len(reg.ResourceQuotas)) add("limitRanges", len(reg.LimitRanges)) - add("hpa", len(reg.HPA)) - add("pdb", len(reg.PDB)) - add("rollingUpdate", len(reg.RollingUpdate)) + add("hpa", len(reg.HPAs)) + add("pdb", len(reg.PDBs)) + add("rollingUpdate", len(reg.RollingUpdates)) return strings.Join(parts, " ") } @@ -718,7 +718,7 @@ func moduleResourceSummary(m *types.Module) string { return strings.Join(parts, " ") } -func printValidateProfiles(reg types.ProfileRegistry) { +func printValidatePresets(reg types.PresetRegistry) { if reg.Empty() { return } @@ -727,19 +727,19 @@ func printValidateProfiles(reg types.ProfileRegistry) { for _, p := range reg.Resources { entries = append(entries, entry{"resources", p.Name}) } - for _, p := range reg.ContainerSecurity { + for _, p := range reg.ContainerSecurities { entries = append(entries, entry{"containerSecurity", p.Name}) } - for _, p := range reg.PodSecurity { + for _, p := range reg.PodSecurities { entries = append(entries, entry{"podSecurity", p.Name}) } - for _, p := range reg.HPA { + for _, p := range reg.HPAs { entries = append(entries, entry{"hpa", p.Name}) } - for _, p := range reg.PDB { + for _, p := range reg.PDBs { entries = append(entries, entry{"pdb", p.Name}) } - for _, p := range reg.RollingUpdate { + for _, p := range reg.RollingUpdates { entries = append(entries, entry{"rollingUpdate", p.Name}) } for _, p := range reg.Reconciler { @@ -764,7 +764,7 @@ func printValidateProfiles(reg types.ProfileRegistry) { } } fmt.Println() - fmt.Printf("%s\n", cmdutil.Bold(fmt.Sprintf("Profiles (%d)", len(entries)))) + fmt.Printf("%s\n", cmdutil.Bold(fmt.Sprintf("Presets (%d)", len(entries)))) for _, e := range entries { fmt.Printf(" %s %s\n", cmdutil.PadRight(cmdutil.Cyan(e.kind), maxLen), cmdutil.Gray(e.name)) } @@ -778,19 +778,19 @@ func printRuntimeContext() { fmt.Printf(" %s %s\n", cmdutil.PadRight(cmdutil.Cyan(".inrun.version"), 16), cmdutil.Gray("running Inrun version string")) } -func printValidateNotes(reg types.NoteRegistry) { +func printValidateFunctions(reg types.KELFunctionRegistry) { if reg.Empty() { return } maxLen := 0 - for _, n := range reg.Functions { + for _, n := range reg.Definitions { if l := len(n.Name); l > maxLen { maxLen = l } } fmt.Println() - fmt.Printf("%s\n", cmdutil.Bold(fmt.Sprintf("Notes (%d)", len(reg.Functions)))) - for _, n := range reg.Functions { + fmt.Printf("%s\n", cmdutil.Bold(fmt.Sprintf("Functions (%d)", len(reg.Definitions)))) + for _, n := range reg.Definitions { fmt.Printf(" %s %s\n", cmdutil.PadRight(cmdutil.Cyan(n.Name), maxLen), cmdutil.Gray(n.Expression)) } } @@ -800,8 +800,8 @@ func init() { validateCmd.Flags().StringSliceP("file", "f", nil, "Path to an Inrun pattern (repeatable or comma-separated)") validateCmd.Flags().Bool("full", false, "Show per-CRD permissions, dependency graph, and system-level RBAC") - validateCmd.Flags().Bool("notes", false, "Quiet mode: print only the merged note registry, skip full validate output") - validateCmd.Flags().Bool("profiles", false, "Quiet mode: print only the merged profile registry, skip full validate output") + validateCmd.Flags().Bool("functions", false, "Quiet mode: print only the merged function registry, skip full validate output") + validateCmd.Flags().Bool("presets", false, "Quiet mode: print only the merged preset registry, skip full validate output") // Shadow global flags so they don't appear under `inrun validate` cmdutil.ShadowGlobalCommandFlags(validateCmd) diff --git a/cmd/console/CONTRIBUTING.md b/cmd/console/CONTRIBUTING.md index 3b303af4b..c6bb90a69 100644 --- a/cmd/console/CONTRIBUTING.md +++ b/cmd/console/CONTRIBUTING.md @@ -36,7 +36,7 @@ We're actively looking for contributions in these areas: - [ ] **Mobile responsive improvements** #### Nice to Have -- [ ] **Catalog diff view** – Compare Catalogs across environments +- [ ] **Blueprint diff view** – Compare Blueprints across environments - [ ] **Audit logging** – Track who viewed what and when - [ ] **Bookmarkable state** – Save filter/search in URL - [ ] **Keyboard shortcuts** – Power user navigation @@ -107,11 +107,11 @@ When modifying HTML templates: ```bash # Terminal 1 – Inrun Runtime 1 export INRUN_PORT=8080 -inrun -f catalog1.yaml +inrun -f blueprint1.yaml # Terminal 2 – Inrun Runtime 2 export INRUN_PORT=8081 -inrun -f catalog2.yaml +inrun -f blueprint2.yaml # Terminal 3 – Console cd console diff --git a/cmd/console/main.go b/cmd/console/main.go index af0218db2..9921f304d 100644 --- a/cmd/console/main.go +++ b/cmd/console/main.go @@ -1,4 +1,4 @@ -// Command console is the console: a web UI that shows the Catalogs, +// Command console is the console: a web UI that shows the Blueprints, // CRDs and custom resources of one or more running runtimes, and lets // developers submit intents through the gateway's generated forms. It reads // everything over the runtime and gateway HTTP APIs and has no Kubernetes @@ -40,7 +40,7 @@ func main() { inrunURLs = flag.String("u", strings.Join(cfg.URLs, ","), "Comma-separated URLs of Inrun runtime instances") ignoreDefault = flag.Bool("ignore-default", cfg.IgnoreDefault, "Do not add the default localhost:8080 URL; start with no instances") port = flag.String("p", cfg.Port, "Port to serve the console on") - refresh = flag.Duration("refresh", cfg.RefreshInterval, "Refresh interval for fetching Catalogs") + refresh = flag.Duration("refresh", cfg.RefreshInterval, "Refresh interval for fetching Blueprints") logLevel = flag.String("log-level", cfg.LogLevel, "Log level (debug, info, warn, error)") showVersion = flag.Bool("version", false, "Show version information") ) diff --git a/cmd/console/web/assets/static/css/style.css b/cmd/console/web/assets/static/css/style.css index bcad26d99..e5822430e 100644 --- a/cmd/console/web/assets/static/css/style.css +++ b/cmd/console/web/assets/static/css/style.css @@ -347,7 +347,7 @@ code, .mono { color: var(--text-secondary); } -/* ── List view for CRD cards (catalog.html) ────────────────── */ +/* ── List view for CRD cards (blueprint.html) ────────────────── */ .console-card-grid.list-view { display: flex; flex-direction: column; @@ -1150,7 +1150,7 @@ code, .mono { overflow: hidden; } -/* ── Info Card (catalog info) ──────────────────────────────── */ +/* ── Info Card (blueprint info) ──────────────────────────────── */ .console-info-card { background: var(--bg-surface); border: 1px solid var(--border); @@ -1762,7 +1762,7 @@ details > summary::-webkit-details-marker { display: none; } } } -/* ── CRD / Catalog config popup ─────────────────────────────── */ +/* ── CRD / Blueprint config popup ─────────────────────────────── */ .console-crumb-clickable { cursor: pointer; padding: 2px 6px; @@ -1774,7 +1774,7 @@ details > summary::-webkit-details-marker { display: none; } color: var(--accent); } -.console-catalog-name-link:hover svg { +.console-blueprint-name-link:hover svg { opacity: 0.8 !important; } diff --git a/cmd/console/web/assets/static/inrun-mark.svg b/cmd/console/web/assets/static/inrun-mark.svg index d982ea3d5..a25a0de2e 100644 --- a/cmd/console/web/assets/static/inrun-mark.svg +++ b/cmd/console/web/assets/static/inrun-mark.svg @@ -1,5 +1,23 @@ Inrun - - i + + + + + + + + diff --git a/cmd/console/web/assets/static/inrun-wordmark.svg b/cmd/console/web/assets/static/inrun-wordmark.svg index becc3ccb9..c0cdf390e 100644 --- a/cmd/console/web/assets/static/inrun-wordmark.svg +++ b/cmd/console/web/assets/static/inrun-wordmark.svg @@ -1,6 +1,25 @@ - + Inrun - - i - inrun + + + + + + + + + + + inrun diff --git a/cmd/console/web/assets/static/js/console.js b/cmd/console/web/assets/static/js/console.js index 360beefba..d197c166f 100644 --- a/cmd/console/web/assets/static/js/console.js +++ b/cmd/console/web/assets/static/js/console.js @@ -200,23 +200,23 @@ if (!s) return; // ── Global stats (index page) ── - setText(document.querySelector('[data-live="totalCatalogs"]'), s.totalCatalogs); + setText(document.querySelector('[data-live="totalBlueprints"]'), s.totalBlueprints); setText(document.querySelector('[data-live="totalCRDs"]'), s.totalCRDs); setText(document.querySelector('[data-live="totalWorkers"]'), s.totalWorkers); setText(document.querySelector('[data-live="totalResources"]'), s.totalResources); - var healthyEl = document.querySelector('[data-live="healthyCatalogs"]'); - if (healthyEl) setText(healthyEl, s.healthyCatalogs + ' healthy'); + var healthyEl = document.querySelector('[data-live="healthyBlueprints"]'); + if (healthyEl) setText(healthyEl, s.healthyBlueprints + ' healthy'); - // ── Per-catalog cards (index page) ── - if (data.catalogs && data.catalogs.length) { - data.catalogs.forEach(function (cat) { - var card = document.querySelector('[data-catalog="' + CSS.escape(cat.name) + '"]'); + // ── Per-blueprint cards (index page) ── + if (data.blueprints && data.blueprints.length) { + data.blueprints.forEach(function (bp) { + var card = document.querySelector('[data-blueprint="' + CSS.escape(bp.name) + '"]'); if (!card) return; // Badge var badge = card.querySelector('[data-live="badge"]'); if (badge) { - var healthy = cat.healthy; + var healthy = bp.healthy; badge.className = 'console-badge ' + (healthy ? 'console-badge-healthy' : 'console-badge-degraded'); badge.innerHTML = healthy ? 'Healthy' @@ -226,10 +226,10 @@ // Meta values var crdEl = card.querySelector('[data-live="totalCRDs"]'); if (crdEl) { - crdEl.innerHTML = cat.totalCRDs + ' (' + cat.healthyCRDs + ' healthy)'; + crdEl.innerHTML = bp.totalCRDs + ' (' + bp.healthyCRDs + ' healthy)'; } - setText(card.querySelector('[data-live="totalWorkers"]'), cat.totalWorkers); - setText(card.querySelector('[data-live="totalResources"]'), cat.totalResources); + setText(card.querySelector('[data-live="totalWorkers"]'), bp.totalWorkers); + setText(card.querySelector('[data-live="totalResources"]'), bp.totalResources); }); } } @@ -278,7 +278,7 @@ connectSSE(); - /* ── 4. CRD filter + search (catalog page) ─────────────────── */ + /* ── 4. CRD filter + search (blueprint page) ─────────────────── */ var PAGE_SIZE = 12; var currentFilter = 'all'; var currentSearch = ''; diff --git a/cmd/console/web/assets/templates/blueprint.html b/cmd/console/web/assets/templates/blueprint.html new file mode 100644 index 000000000..4296165cd --- /dev/null +++ b/cmd/console/web/assets/templates/blueprint.html @@ -0,0 +1,595 @@ + + + + + + {{ .BlueprintName }} – Inrun Console + + + + + + + + +
+ +
+
+ + +
+ +
+
+ +
+ + + {{ if and (not .BlueprintHealthy) (eq .TotalCRDs 0) }} +
+ + + +
+
Cannot connect to Inrun runtime
+
Make sure Inrun is running and the blueprint {{ .BlueprintName }} is registered.
+
+
+ {{ end }} + + + {{ if gt .TotalCRDs 0 }} +
+
+
+
+

{{ .BlueprintName }}

+ + {{ if .BlueprintHealthy }} + Healthy + {{ else }} + Degraded + {{ end }} + {{ if .DeletionProtection }} + + + Protected + + {{ else }} + + + Unprotected + + {{ end }} +
+

+ {{ if .BlueprintDescription }}{{ .BlueprintDescription }}{{ else }}No description provided{{ end }} +

+ {{ if and (not .BlueprintHealthy) .DegradedReason }} +

+ {{ .DegradedReason }} +

+ {{ end }} +
+
+ {{ if and .BlueprintVersion .BlueprintAuthor }} +
{{ if not (hasPrefix .BlueprintVersion "v") }}v{{ end }}{{ .BlueprintVersion }} · {{ .BlueprintAuthor }}
+ {{ else if .BlueprintVersion }} +
{{ if not (hasPrefix .BlueprintVersion "v") }}v{{ end }}{{ .BlueprintVersion }}
+ {{ else if .BlueprintAuthor }} +
{{ .BlueprintAuthor }}
+ {{ end }} + {{ if .BlueprintLicense }}
{{ .BlueprintLicense }}
{{ end }} +
+
+
+ + +

Platform Health

+
+
+
Healthy
+
{{ .StatusCounts.Healthy }}
+
CRDs operational
+
+
+
+
+
+
Starting
+
{{ .StatusCounts.Started }}
+
Initializing workers
+
+
+
+
+
+
Pending
+
{{ .StatusCounts.Pending }}
+
Awaiting start
+
+
+
+
+
+
Degraded
+
{{ .StatusCounts.Degraded }}
+
Need attention
+
+
+
+
+
+ + +
+
+
CRDs Managed
+
{{ .TotalCRDs }}
+
{{ .HealthyCount }} healthy · {{ sub .TotalCRDs .HealthyCount }} unhealthy
+
+
+
Active Workers
+
{{ .TotalWorkers }}
+
Across all reconcilers
+
+
+
Live Resources
+
{{ .TotalResources }}
+
Custom resources in cache
+
+
+
Blueprint Health
+
+ {{ if .BlueprintHealthy }}Healthy{{ else }}Degraded{{ end }} +
+ {{ if and (not .BlueprintHealthy) (gt .TotalCRDs 0) }} +
{{ if .StatusCounts.Degraded }}{{ .StatusCounts.Degraded }} degraded{{ end }}
+ {{ end }} +
+
+
Runtime
+
+ {{ if .InrunReady }}Operational{{ else }}Degraded{{ end }} +
+
Super-operator
+
+
+ {{ end }} + + + {{ if gt .TotalCRDs 0 }} +
+ + + + + +
+ +
+
+ + +
+
+ + +
+ {{ range .CRDs }} + {{ $queuePercent := 0 }} + {{ if gt .MaxDepth 0 }} + {{ $queuePercent = div (mul .QueueDepth 100) .MaxDepth }} + {{ end }} + +
+
+
+

{{ .Name }}

+
+ {{- if .HasUnhealthyDependencies }} + + + Dependency Issue + + + {{- else if eq .State "gated" }} + + Gated + + + {{- else if eq .State "healthy" }} + + Healthy + + + {{- else if eq .State "pending" }} + + Pending + + + {{- else if eq .State "started" }} + + Started + + + {{- else }} + + Degraded + + {{- end }} + {{- if not .Endpoints.HealthEnabled }} + ⊘ + {{- end }} +
+
+ + {{ if .DependsOn }} +
+ Depends on: + {{ range .DependsOn }} + {{ . }} + {{ end }} +
+ {{ end }} + +
+ {{- if .Endpoints.InfoEnabled }} +
+ Workers + {{ .WorkersActive }}/{{ .Workers }} +
+
+
+ Queue + {{ .QueueDepth }}/{{ .MaxDepth }} +
+
+
+
+ {{ if gt $queuePercent 80 }} +
High queue pressure
+ {{ else if gt $queuePercent 50 }} +
Moderate queue pressure
+ {{ end }} +
+
+ Resources + {{ .ResourceCount }} +
+ {{- end }} + {{- if not .Endpoints.InfoEnabled }} +
+ ⊘ + info endpoint disabled +
+ {{- end }} + {{- if .Endpoints.HealthEnabled }} +
+ Error Rate + {{ printf "%.1f" .ErrorRate }}% +
+ {{- end }} + {{- if .Endpoints.InfoEnabled }} +
+ RBAC Rules + {{ .RBACCount }} +
+ {{- end }} + {{- if .Endpoints.HealthEnabled }} +
+ Uptime + {{ .Uptime }} +
+ {{- end }} +
+
+ +
+ {{ end }} +
+ + + {{ if gt .TotalCRDs 12 }} +
+
+ Showing 1–12 of {{ .TotalCRDs }} CRDs +
+
+ +
+ +
+
+ {{ end }} + + + + + {{ else }} + +
+
⬡
+
No CRDs reconciled yet
+

Apply a CRD and watch Inrun bring it to life.

+
+ {{ end }} + +
+
+ + + + + + + + diff --git a/cmd/console/web/assets/templates/catalog.html b/cmd/console/web/assets/templates/catalog.html deleted file mode 100644 index dc0ce2dc4..000000000 --- a/cmd/console/web/assets/templates/catalog.html +++ /dev/null @@ -1,595 +0,0 @@ - - - - - - {{ .CatalogName }} – Inrun Console - - - - - - - - -
- -
-
- - -
- -
-
- -
- - - {{ if and (not .CatalogHealthy) (eq .TotalCRDs 0) }} -
- - - -
-
Cannot connect to Inrun runtime
-
Make sure Inrun is running and the catalog {{ .CatalogName }} is registered.
-
-
- {{ end }} - - - {{ if gt .TotalCRDs 0 }} -
-
-
-
-

{{ .CatalogName }}

- - {{ if .CatalogHealthy }} - Healthy - {{ else }} - Degraded - {{ end }} - {{ if .DeletionProtection }} - - - Protected - - {{ else }} - - - Unprotected - - {{ end }} -
-

- {{ if .CatalogDescription }}{{ .CatalogDescription }}{{ else }}No description provided{{ end }} -

- {{ if and (not .CatalogHealthy) .DegradedReason }} -

- {{ .DegradedReason }} -

- {{ end }} -
-
- {{ if and .CatalogVersion .CatalogAuthor }} -
{{ if not (hasPrefix .CatalogVersion "v") }}v{{ end }}{{ .CatalogVersion }} · {{ .CatalogAuthor }}
- {{ else if .CatalogVersion }} -
{{ if not (hasPrefix .CatalogVersion "v") }}v{{ end }}{{ .CatalogVersion }}
- {{ else if .CatalogAuthor }} -
{{ .CatalogAuthor }}
- {{ end }} - {{ if .CatalogLicense }}
{{ .CatalogLicense }}
{{ end }} -
-
-
- - -

Platform Health

-
-
-
Healthy
-
{{ .StatusCounts.Healthy }}
-
CRDs operational
-
-
-
-
-
-
Starting
-
{{ .StatusCounts.Started }}
-
Initializing workers
-
-
-
-
-
-
Pending
-
{{ .StatusCounts.Pending }}
-
Awaiting start
-
-
-
-
-
-
Degraded
-
{{ .StatusCounts.Degraded }}
-
Need attention
-
-
-
-
-
- - -
-
-
CRDs Managed
-
{{ .TotalCRDs }}
-
{{ .HealthyCount }} healthy · {{ sub .TotalCRDs .HealthyCount }} unhealthy
-
-
-
Active Workers
-
{{ .TotalWorkers }}
-
Across all reconcilers
-
-
-
Live Resources
-
{{ .TotalResources }}
-
Custom resources in cache
-
-
-
Catalog Health
-
- {{ if .CatalogHealthy }}Healthy{{ else }}Degraded{{ end }} -
- {{ if and (not .CatalogHealthy) (gt .TotalCRDs 0) }} -
{{ if .StatusCounts.Degraded }}{{ .StatusCounts.Degraded }} degraded{{ end }}
- {{ end }} -
-
-
Runtime
-
- {{ if .InrunReady }}Operational{{ else }}Degraded{{ end }} -
-
Super-operator
-
-
- {{ end }} - - - {{ if gt .TotalCRDs 0 }} -
- - - - - -
- -
-
- - -
-
- - -
- {{ range .CRDs }} - {{ $queuePercent := 0 }} - {{ if gt .MaxDepth 0 }} - {{ $queuePercent = div (mul .QueueDepth 100) .MaxDepth }} - {{ end }} - -
-
-
-

{{ .Name }}

-
- {{- if .HasUnhealthyDependencies }} - - - Dependency Issue - - - {{- else if eq .State "gated" }} - - Gated - - - {{- else if eq .State "healthy" }} - - Healthy - - - {{- else if eq .State "pending" }} - - Pending - - - {{- else if eq .State "started" }} - - Started - - - {{- else }} - - Degraded - - {{- end }} - {{- if not .Endpoints.HealthEnabled }} - ⊘ - {{- end }} -
-
- - {{ if .DependsOn }} -
- Depends on: - {{ range .DependsOn }} - {{ . }} - {{ end }} -
- {{ end }} - -
- {{- if .Endpoints.InfoEnabled }} -
- Workers - {{ .WorkersActive }}/{{ .Workers }} -
-
-
- Queue - {{ .QueueDepth }}/{{ .MaxDepth }} -
-
-
-
- {{ if gt $queuePercent 80 }} -
High queue pressure
- {{ else if gt $queuePercent 50 }} -
Moderate queue pressure
- {{ end }} -
-
- Resources - {{ .ResourceCount }} -
- {{- end }} - {{- if not .Endpoints.InfoEnabled }} -
- ⊘ - info endpoint disabled -
- {{- end }} - {{- if .Endpoints.HealthEnabled }} -
- Error Rate - {{ printf "%.1f" .ErrorRate }}% -
- {{- end }} - {{- if .Endpoints.InfoEnabled }} -
- RBAC Rules - {{ .RBACCount }} -
- {{- end }} - {{- if .Endpoints.HealthEnabled }} -
- Uptime - {{ .Uptime }} -
- {{- end }} -
-
- -
- {{ end }} -
- - - {{ if gt .TotalCRDs 12 }} -
-
- Showing 1–12 of {{ .TotalCRDs }} CRDs -
-
- -
- -
-
- {{ end }} - - - - - {{ else }} - -
-
⬡
-
No CRDs reconciled yet
-

Apply a CRD and watch Inrun bring it to life.

-
- {{ end }} - -
-
- - - - - - - - diff --git a/cmd/console/web/assets/templates/cr_detail.html b/cmd/console/web/assets/templates/cr_detail.html index a226de964..7034ca252 100644 --- a/cmd/console/web/assets/templates/cr_detail.html +++ b/cmd/console/web/assets/templates/cr_detail.html @@ -29,12 +29,12 @@