diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 000000000..0d24961bf --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,41 @@ +# Configures GitHub auto-generated release notes. +# https://docs.github.com/repositories/releasing-projects-on-github/automatically-generated-release-notes +# +# Categorization is label-based. Contributors should add a single release-note/* +# label to each PR; the label should match the PR title prefix +# (e.g. feat: -> release-note/feature, fix: -> release-note/fix). +# PRs with no release-note label fall into "Other Changes". +changelog: + exclude: + labels: + - ignore-for-release + - release-note/none + categories: + - title: Breaking Changes + labels: + - release-note/breaking + - title: Security + labels: + - release-note/security + - title: New Features + labels: + - release-note/feature + - title: Bug Fixes + labels: + - release-note/fix + - title: Documentation + labels: + - release-note/docs + - title: Tests + labels: + - release-note/test + - title: Refactors + labels: + - release-note/refactor + - title: Maintenance and Dependencies + labels: + - release-note/chore + - dependencies + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/chart.yml b/.github/workflows/chart.yml index e5c780fc0..4f285b52b 100644 --- a/.github/workflows/chart.yml +++ b/.github/workflows/chart.yml @@ -2,8 +2,13 @@ name: Helm Chart Publisher on: push: + # Pre-release tags (e.g. v0.4.0-rc.1) build images via release.yml but + # must not land in the public Helm index. The negative pattern below + # filters them out; workflow_dispatch can still publish a specific + # tag manually if ever needed. tags: - "v*.*.*" + - "!v*-rc.*" workflow_dispatch: inputs: tag: @@ -26,8 +31,15 @@ jobs: publish-github-pages: needs: export-registry runs-on: ubuntu-latest + # Only the gh-pages publish needs serialization: helm-gh-pages always + # rewrites the gh-pages branch, so concurrent runs for different tags + # would race. The OCI publish below pushes immutable per-tag blobs and + # is safe to run in parallel across tags, so it stays unguarded. + concurrency: + group: helm-chart-publish-gh-pages + cancel-in-progress: false steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: true fetch-depth: 0 @@ -44,7 +56,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Login to GitHub Container Registry uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05fa1163d..81708768b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,11 +41,11 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Ginkgo CLI run: | - go install github.com/onsi/ginkgo/v2/ginkgo@v2.19.1 + go install github.com/onsi/ginkgo/v2/ginkgo@v2.23.4 - name: Prepare necessary environment variables run: | @@ -116,7 +116,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Move Docker data directory to /mnt # The default storage device on GitHub-hosted runners is running low during e2e tests. diff --git a/.github/workflows/code-lint.yml b/.github/workflows/code-lint.yml index 82caa2f1f..e0f853b36 100644 --- a/.github/workflows/code-lint.yml +++ b/.github/workflows/code-lint.yml @@ -42,7 +42,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: submodules: true @@ -63,7 +63,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: golangci-lint run: make lint @@ -76,7 +76,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index ae8e07dc4..b03cef53b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 8a710484b..f5d349197 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -16,7 +16,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v4.1.7 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.1.7 - uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # master with: check_filenames: true diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml index 2d96457c5..0fba45539 100644 --- a/.github/workflows/markdown-lint.yml +++ b/.github/workflows/markdown-lint.yml @@ -10,7 +10,7 @@ jobs: markdown-link-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: tcort/github-action-markdown-link-check@e7c7a18363c842693fadde5d41a3bd3573a7a225 # v1 with: # this will only show errors in the output diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7db4cebea..cd499a31c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,14 @@ permissions: contents: read packages: write +# Serialize releases per ref so concurrent tag pushes can't race on image +# pushes to the same ${REGISTRY}/${IMAGE}:${TAG}. Different tags can still +# run in parallel. We never want cancel-in-progress here: aborting a +# half-pushed image is worse than letting it finish. +concurrency: + group: release-images-${{ github.ref }} + cancel-in-progress: false + env: REGISTRY: ghcr.io HUB_AGENT_IMAGE_NAME: hub-agent @@ -41,7 +49,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.export-registry.outputs.tag }} @@ -56,10 +64,18 @@ jobs: run: | make push + # The short-tag (e.g. ":0.4.0") aliases the long form for stable releases + # only. RC images are published under the long form (":v0.4.0-rc.1") for + # testers, but we deliberately do NOT alias them to a short tag - the + # short-tag namespace is reserved for stable releases that consumers can + # safely pin to (and "imagetools create" with an RC alias would publish + # "0.4.0-rc.1" into that namespace, muddying it). - name: Tag and push images without v prefix + if: ${{ !contains(needs.export-registry.outputs.tag, '-rc.') }} env: VERSION: ${{ needs.export-registry.outputs.version }} run: | + set -euo pipefail for IMAGE in ${{ env.HUB_AGENT_IMAGE_NAME }} ${{ env.MEMBER_AGENT_IMAGE_NAME }} ${{ env.REFRESH_TOKEN_IMAGE_NAME }}; do docker buildx imagetools create \ --tag "${{ env.REGISTRY }}/${IMAGE}:${VERSION}" \ @@ -70,10 +86,11 @@ jobs: env: VERSION: ${{ needs.export-registry.outputs.version }} run: | + set -euo pipefail echo "✅ Published images:" - echo " - ${{ env.REGISTRY }}/${{ env.HUB_AGENT_IMAGE_NAME }}:${{ env.TAG }}" - echo " - ${{ env.REGISTRY }}/${{ env.HUB_AGENT_IMAGE_NAME }}:${VERSION}" - echo " - ${{ env.REGISTRY }}/${{ env.MEMBER_AGENT_IMAGE_NAME }}:${{ env.TAG }}" - echo " - ${{ env.REGISTRY }}/${{ env.MEMBER_AGENT_IMAGE_NAME }}:${VERSION}" - echo " - ${{ env.REGISTRY }}/${{ env.REFRESH_TOKEN_IMAGE_NAME }}:${{ env.TAG }}" - echo " - ${{ env.REGISTRY }}/${{ env.REFRESH_TOKEN_IMAGE_NAME }}:${VERSION}" + for IMAGE in ${{ env.HUB_AGENT_IMAGE_NAME }} ${{ env.MEMBER_AGENT_IMAGE_NAME }} ${{ env.REFRESH_TOKEN_IMAGE_NAME }}; do + echo " - ${{ env.REGISTRY }}/${IMAGE}:${{ env.TAG }}" + if [[ "${{ env.TAG }}" != *-rc.* ]]; then + echo " - ${{ env.REGISTRY }}/${IMAGE}:${VERSION}" + fi + done diff --git a/.github/workflows/setup-release.yml b/.github/workflows/setup-release.yml index 685050d71..dfa0a3ef8 100644 --- a/.github/workflows/setup-release.yml +++ b/.github/workflows/setup-release.yml @@ -32,8 +32,8 @@ jobs: - id: setup run: | TAG="${{ inputs.tag }}" - if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+ ]]; then - echo "Error: Invalid release tag '${TAG}'. Expected format: v*.*.*" + if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Error: Invalid release tag '${TAG}'. Expected format: vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rc.N" exit 1 fi diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml new file mode 100644 index 000000000..923e97d8d --- /dev/null +++ b/.github/workflows/squad-ci.yml @@ -0,0 +1,28 @@ +name: Squad CI +# go project — configure build/test commands below + +on: + pull_request: + branches: [dev, preview, main, insider] + types: [opened, synchronize, reopened] + push: + branches: [dev, insider] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Build and test + run: | + # TODO: Add your go build/test commands here + # Go: go test ./... + # Python: pip install -r requirements.txt && pytest + # .NET: dotnet test + # Java (Maven): mvn test + # Java (Gradle): ./gradlew test + echo "No build commands configured — update squad-ci.yml" diff --git a/.github/workflows/squad-docs.yml b/.github/workflows/squad-docs.yml new file mode 100644 index 000000000..21e80ae2b --- /dev/null +++ b/.github/workflows/squad-docs.yml @@ -0,0 +1,27 @@ +name: Squad Docs — Build & Deploy +# go project — configure documentation build commands below + +on: + workflow_dispatch: + push: + branches: [preview] + paths: + - 'docs/**' + - '.github/workflows/squad-docs.yml' + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Build docs + run: | + # TODO: Add your documentation build commands here + # This workflow is optional — remove or customize it for your project + echo "No docs build commands configured — update or remove squad-docs.yml" diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml new file mode 100644 index 000000000..edc1eac28 --- /dev/null +++ b/.github/workflows/squad-heartbeat.yml @@ -0,0 +1,167 @@ +name: Squad Heartbeat (Ralph) +# ⚠️ SYNC: This workflow is maintained in 4 locations. Changes must be applied to all: +# - templates/workflows/squad-heartbeat.yml (source template) +# - packages/squad-cli/templates/workflows/squad-heartbeat.yml (CLI package) +# - .squad/templates/workflows/squad-heartbeat.yml (installed template) +# - .github/workflows/squad-heartbeat.yml (active workflow) +# Run 'squad upgrade' to sync installed copies from source templates. + +on: + # React to completed work or new squad work + issues: + types: [closed, labeled] + pull_request: + types: [closed] + + # Manual trigger + workflow_dispatch: + +permissions: + issues: write + contents: read + pull-requests: read + +jobs: + heartbeat: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check triage script + id: check-script + run: | + if [ -f ".squad/templates/ralph-triage.js" ]; then + echo "has_script=true" >> $GITHUB_OUTPUT + else + echo "has_script=false" >> $GITHUB_OUTPUT + echo "⚠️ ralph-triage.js not found — run 'squad upgrade' to install" + fi + + - name: Ralph — Smart triage + if: steps.check-script.outputs.has_script == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node .squad/templates/ralph-triage.js \ + --squad-dir .squad \ + --output triage-results.json + + - name: Ralph — Apply triage decisions + if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != '' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = 'triage-results.json'; + if (!fs.existsSync(path)) { + core.info('No triage results — board is clear'); + return; + } + + const results = JSON.parse(fs.readFileSync(path, 'utf8')); + if (results.length === 0) { + core.info('📋 Board is clear — Ralph found no untriaged issues'); + return; + } + + for (const decision of results) { + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: decision.issueNumber, + labels: [decision.label] + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: decision.issueNumber, + body: [ + '### 🔄 Ralph — Auto-Triage', + '', + `**Assigned to:** ${decision.assignTo}`, + `**Reason:** ${decision.reason}`, + `**Source:** ${decision.source}`, + '', + '> Ralph auto-triaged this issue using routing rules.', + '> To reassign, swap the `squad:*` label.' + ].join('\n') + }); + + core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`); + } catch (e) { + core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`); + } + } + + core.info(`🔄 Ralph triaged ${results.length} issue(s)`); + + # Copilot auto-assign step (uses PAT if available) + - name: Ralph — Assign @copilot issues + if: success() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + + let teamFile = '.squad/team.md'; + if (!fs.existsSync(teamFile)) { + teamFile = '.ai-team/team.md'; + } + if (!fs.existsSync(teamFile)) return; + + const content = fs.readFileSync(teamFile, 'utf8'); + + // Check if @copilot is on the team with auto-assign + const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot'); + const autoAssign = content.includes(''); + if (!hasCopilot || !autoAssign) return; + + // Find issues labeled squad:copilot with no assignee + try { + const { data: copilotIssues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: 'squad:copilot', + state: 'open', + per_page: 5 + }); + + const unassigned = copilotIssues.filter(i => + !i.assignees || i.assignees.length === 0 + ); + + if (unassigned.length === 0) { + core.info('No unassigned squad:copilot issues'); + return; + } + + // Get repo default branch + const { data: repoData } = await github.rest.repos.get({ + owner: context.repo.owner, + repo: context.repo.repo + }); + + for (const issue of unassigned) { + try { + await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: ['copilot-swe-agent[bot]'], + agent_assignment: { + target_repo: `${context.repo.owner}/${context.repo.repo}`, + base_branch: repoData.default_branch, + custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.` + } + }); + core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`); + } catch (e) { + core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`); + } + } + } catch (e) { + core.info(`No squad:copilot label found or error: ${e.message}`); + } diff --git a/.github/workflows/squad-insider-release.yml b/.github/workflows/squad-insider-release.yml new file mode 100644 index 000000000..c54d0f2e9 --- /dev/null +++ b/.github/workflows/squad-insider-release.yml @@ -0,0 +1,34 @@ +name: Squad Insider Release +# go project — configure build, test, and insider release commands below + +on: + push: + branches: [insider] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Build and test + run: | + # TODO: Add your go build/test commands here + # Go: go test ./... + # Python: pip install -r requirements.txt && pytest + # .NET: dotnet test + # Java (Maven): mvn test + # Java (Gradle): ./gradlew test + echo "No build commands configured — update squad-insider-release.yml" + + - name: Create insider release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # TODO: Add your insider/pre-release commands here + echo "No release commands configured — update squad-insider-release.yml" diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml new file mode 100644 index 000000000..0137b6d4e --- /dev/null +++ b/.github/workflows/squad-issue-assign.yml @@ -0,0 +1,161 @@ +name: Squad Issue Assign + +on: + issues: + types: [labeled] + +permissions: + issues: write + contents: read + +jobs: + assign-work: + # Only trigger on squad:{member} labels (not the base "squad" label) + if: startsWith(github.event.label.name, 'squad:') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Identify assigned member and trigger work + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const issue = context.payload.issue; + const label = context.payload.label.name; + + // Extract member name from label (e.g., "squad:ripley" → "ripley") + const memberName = label.replace('squad:', '').toLowerCase(); + + // Read team roster — check .squad/ first, fall back to .ai-team/ + let teamFile = '.squad/team.md'; + if (!fs.existsSync(teamFile)) { + teamFile = '.ai-team/team.md'; + } + if (!fs.existsSync(teamFile)) { + core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work'); + return; + } + + const content = fs.readFileSync(teamFile, 'utf8'); + const lines = content.split('\n'); + + // Check if this is a coding agent assignment + const isCopilotAssignment = memberName === 'copilot'; + + let assignedMember = null; + if (isCopilotAssignment) { + assignedMember = { name: '@copilot', role: 'Coding Agent' }; + } else { + let inMembersTable = false; + for (const line of lines) { + if (line.match(/^##\s+(Members|Team Roster)/i)) { + inMembersTable = true; + continue; + } + if (inMembersTable && line.startsWith('## ')) { + break; + } + if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { + const cells = line.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length >= 2 && cells[0].toLowerCase() === memberName) { + assignedMember = { name: cells[0], role: cells[1] }; + break; + } + } + } + } + + if (!assignedMember) { + core.warning(`No member found matching label "${label}"`); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `⚠️ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.` + }); + return; + } + + // Post assignment acknowledgment + let comment; + if (isCopilotAssignment) { + comment = [ + `### 🤖 Routed to @copilot (Coding Agent)`, + '', + `**Issue:** #${issue.number} — ${issue.title}`, + '', + `@copilot has been assigned and will pick this up automatically.`, + '', + `> The coding agent will create a \`copilot/*\` branch and open a draft PR.`, + `> Review the PR as you would any team member's work.`, + ].join('\n'); + } else { + comment = [ + `### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`, + '', + `**Issue:** #${issue.number} — ${issue.title}`, + '', + `${assignedMember.name} will pick this up in the next Copilot session.`, + '', + `> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`, + `> Otherwise, start a Copilot session and say:`, + `> \`${assignedMember.name}, work on issue #${issue.number}\``, + ].join('\n'); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: comment + }); + + core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`); + + # Separate step: assign @copilot using PAT (required for coding agent) + - name: Assign @copilot coding agent + if: github.event.label.name == 'squad:copilot' + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const issue_number = context.payload.issue.number; + + // Get the default branch name (main, master, etc.) + const { data: repoData } = await github.rest.repos.get({ owner, repo }); + const baseBranch = repoData.default_branch; + + try { + await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { + owner, + repo, + issue_number, + assignees: ['copilot-swe-agent[bot]'], + agent_assignment: { + target_repo: `${owner}/${repo}`, + base_branch: baseBranch, + custom_instructions: '', + custom_agent: '', + model: '' + }, + headers: { + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`); + } catch (err) { + core.warning(`Assignment with agent_assignment failed: ${err.message}`); + // Fallback: try without agent_assignment + try { + await github.rest.issues.addAssignees({ + owner, repo, issue_number, + assignees: ['copilot-swe-agent'] + }); + core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`); + } catch (err2) { + core.warning(`Fallback also failed: ${err2.message}`); + } + } diff --git a/.github/workflows/squad-label-enforce.yml b/.github/workflows/squad-label-enforce.yml new file mode 100644 index 000000000..df6ef8a03 --- /dev/null +++ b/.github/workflows/squad-label-enforce.yml @@ -0,0 +1,181 @@ +name: Squad Label Enforce + +on: + issues: + types: [labeled] + +permissions: + issues: write + contents: read + +jobs: + enforce: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Enforce mutual exclusivity + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const appliedLabel = context.payload.label.name; + + // Namespaces with mutual exclusivity rules + const EXCLUSIVE_PREFIXES = ['go:', 'release:', 'type:', 'priority:']; + + // Skip if not a managed namespace label + if (!EXCLUSIVE_PREFIXES.some(p => appliedLabel.startsWith(p))) { + core.info(`Label ${appliedLabel} is not in a managed namespace — skipping`); + return; + } + + const allLabels = issue.labels.map(l => l.name); + + // Handle go: namespace (mutual exclusivity) + if (appliedLabel.startsWith('go:')) { + const otherGoLabels = allLabels.filter(l => + l.startsWith('go:') && l !== appliedLabel + ); + + if (otherGoLabels.length > 0) { + // Remove conflicting go: labels + for (const label of otherGoLabels) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: label + }); + core.info(`Removed conflicting label: ${label}`); + } + + // Post update comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `🏷️ Triage verdict updated → \`${appliedLabel}\`` + }); + } + + // Auto-apply release:backlog if go:yes and no release target + if (appliedLabel === 'go:yes') { + const hasReleaseLabel = allLabels.some(l => l.startsWith('release:')); + if (!hasReleaseLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ['release:backlog'] + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `📋 Marked as \`release:backlog\` — assign a release target when ready.` + }); + + core.info('Applied release:backlog for go:yes issue'); + } + } + + // Remove release: labels if go:no + if (appliedLabel === 'go:no') { + const releaseLabels = allLabels.filter(l => l.startsWith('release:')); + if (releaseLabels.length > 0) { + for (const label of releaseLabels) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: label + }); + core.info(`Removed release label from go:no issue: ${label}`); + } + } + } + } + + // Handle release: namespace (mutual exclusivity) + if (appliedLabel.startsWith('release:')) { + const otherReleaseLabels = allLabels.filter(l => + l.startsWith('release:') && l !== appliedLabel + ); + + if (otherReleaseLabels.length > 0) { + // Remove conflicting release: labels + for (const label of otherReleaseLabels) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: label + }); + core.info(`Removed conflicting label: ${label}`); + } + + // Post update comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `🏷️ Release target updated → \`${appliedLabel}\`` + }); + } + } + + // Handle type: namespace (mutual exclusivity) + if (appliedLabel.startsWith('type:')) { + const otherTypeLabels = allLabels.filter(l => + l.startsWith('type:') && l !== appliedLabel + ); + + if (otherTypeLabels.length > 0) { + for (const label of otherTypeLabels) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: label + }); + core.info(`Removed conflicting label: ${label}`); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `🏷️ Issue type updated → \`${appliedLabel}\`` + }); + } + } + + // Handle priority: namespace (mutual exclusivity) + if (appliedLabel.startsWith('priority:')) { + const otherPriorityLabels = allLabels.filter(l => + l.startsWith('priority:') && l !== appliedLabel + ); + + if (otherPriorityLabels.length > 0) { + for (const label of otherPriorityLabels) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: label + }); + core.info(`Removed conflicting label: ${label}`); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: `🏷️ Priority updated → \`${appliedLabel}\`` + }); + } + } + + core.info(`Label enforcement complete for ${appliedLabel}`); diff --git a/.github/workflows/squad-preview.yml b/.github/workflows/squad-preview.yml new file mode 100644 index 000000000..11465a651 --- /dev/null +++ b/.github/workflows/squad-preview.yml @@ -0,0 +1,30 @@ +name: Squad Preview Validation +# go project — configure build, test, and validation commands below + +on: + push: + branches: [preview] + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Build and test + run: | + # TODO: Add your go build/test commands here + # Go: go test ./... + # Python: pip install -r requirements.txt && pytest + # .NET: dotnet test + # Java (Maven): mvn test + # Java (Gradle): ./gradlew test + echo "No build commands configured — update squad-preview.yml" + + - name: Validate + run: | + # TODO: Add pre-release validation commands here + echo "No validation commands configured — update squad-preview.yml" diff --git a/.github/workflows/squad-promote.yml b/.github/workflows/squad-promote.yml new file mode 100644 index 000000000..2406d51b4 --- /dev/null +++ b/.github/workflows/squad-promote.yml @@ -0,0 +1,120 @@ +name: Squad Promote + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run — show what would happen without pushing' + required: false + default: 'false' + type: choice + options: ['false', 'true'] + +permissions: + contents: write + +jobs: + dev-to-preview: + name: Promote dev → preview + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Fetch all branches + run: git fetch --all + + - name: Show current state (dry run info) + run: | + echo "=== dev HEAD ===" && git log origin/dev -1 --oneline + echo "=== preview HEAD ===" && git log origin/preview -1 --oneline + echo "=== Files that would be stripped ===" + git diff origin/preview..origin/dev --name-only | grep -E "^(\.(ai-team|squad|ai-team-templates)|team-docs/|docs/proposals/)" || echo "(none)" + + - name: Merge dev → preview (strip forbidden paths) + if: ${{ inputs.dry_run == 'false' }} + run: | + git checkout preview + git merge origin/dev --no-commit --no-ff -X theirs || true + + # Strip forbidden paths from merge commit + git rm -rf --cached --ignore-unmatch \ + .ai-team/ \ + .squad/ \ + .ai-team-templates/ \ + team-docs/ \ + "docs/proposals/" || true + + # Commit if there are staged changes + if ! git diff --cached --quiet; then + git commit -m "chore: promote dev → preview (v$(node -e "console.log(require('./package.json').version)"))" + git push origin preview + echo "✅ Pushed preview branch" + else + echo "ℹ️ Nothing to commit — preview is already up to date" + fi + + - name: Dry run complete + if: ${{ inputs.dry_run == 'true' }} + run: echo "🔍 Dry run complete — no changes pushed." + + preview-to-main: + name: Promote preview → main (release) + needs: dev-to-preview + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Fetch all branches + run: git fetch --all + + - name: Show current state + run: | + echo "=== preview HEAD ===" && git log origin/preview -1 --oneline + echo "=== main HEAD ===" && git log origin/main -1 --oneline + echo "=== Version ===" && node -e "console.log('v' + require('./package.json').version)" + + - name: Validate preview is release-ready + run: | + git checkout preview + VERSION=$(node -e "console.log(require('./package.json').version)") + if ! grep -q "## \[$VERSION\]" CHANGELOG.md 2>/dev/null; then + echo "::error::Version $VERSION not found in CHANGELOG.md — update before releasing" + exit 1 + fi + echo "✅ Version $VERSION has CHANGELOG entry" + + # Verify no forbidden files on preview + FORBIDDEN=$(git ls-files | grep -E "^(\.(ai-team|squad|ai-team-templates)/|team-docs/|docs/proposals/)" || true) + if [ -n "$FORBIDDEN" ]; then + echo "::error::Forbidden files found on preview: $FORBIDDEN" + exit 1 + fi + echo "✅ No forbidden files on preview" + + - name: Merge preview → main + if: ${{ inputs.dry_run == 'false' }} + run: | + git checkout main + git merge origin/preview --no-ff -m "chore: promote preview → main (v$(node -e "console.log(require('./package.json').version)"))" + git push origin main + echo "✅ Pushed main — squad-release.yml will tag and publish the release" + + - name: Dry run complete + if: ${{ inputs.dry_run == 'true' }} + run: echo "🔍 Dry run complete — no changes pushed." diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml new file mode 100644 index 000000000..069ba384e --- /dev/null +++ b/.github/workflows/squad-release.yml @@ -0,0 +1,34 @@ +name: Squad Release +# go project — configure build, test, and release commands below + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Build and test + run: | + # TODO: Add your go build/test commands here + # Go: go test ./... + # Python: pip install -r requirements.txt && pytest + # .NET: dotnet test + # Java (Maven): mvn test + # Java (Gradle): ./gradlew test + echo "No build commands configured — update squad-release.yml" + + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # TODO: Add your release commands here (e.g., git tag, gh release create) + echo "No release commands configured — update squad-release.yml" diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml new file mode 100644 index 000000000..ad50639c6 --- /dev/null +++ b/.github/workflows/squad-triage.yml @@ -0,0 +1,262 @@ +name: Squad Triage + +on: + issues: + types: [labeled] + +permissions: + issues: write + contents: read + +jobs: + triage: + if: github.event.label.name == 'squad' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Triage issue via Lead agent + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const issue = context.payload.issue; + + // Read team roster — check .squad/ first, fall back to .ai-team/ + let teamFile = '.squad/team.md'; + if (!fs.existsSync(teamFile)) { + teamFile = '.ai-team/team.md'; + } + if (!fs.existsSync(teamFile)) { + core.warning('No .squad/team.md or .ai-team/team.md found — cannot triage'); + return; + } + + const content = fs.readFileSync(teamFile, 'utf8'); + const lines = content.split('\n'); + + // Check if @copilot is on the team + const hasCopilot = content.includes('🤖 Coding Agent'); + const copilotAutoAssign = content.includes(''); + + // Parse @copilot capability profile + let goodFitKeywords = []; + let needsReviewKeywords = []; + let notSuitableKeywords = []; + + if (hasCopilot) { + // Extract capability tiers from team.md + const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i); + const needsReviewMatch = content.match(/🟡\s*Needs review[^:]*:\s*(.+)/i); + const notSuitableMatch = content.match(/🔴\s*Not suitable[^:]*:\s*(.+)/i); + + if (goodFitMatch) { + goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim()); + } else { + goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation']; + } + if (needsReviewMatch) { + needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim()); + } else { + needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration']; + } + if (notSuitableMatch) { + notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim()); + } else { + notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance']; + } + } + + const members = []; + let inMembersTable = false; + for (const line of lines) { + if (line.match(/^##\s+(Members|Team Roster)/i)) { + inMembersTable = true; + continue; + } + if (inMembersTable && line.startsWith('## ')) { + break; + } + if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { + const cells = line.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length >= 2 && cells[0] !== 'Scribe') { + members.push({ + name: cells[0], + role: cells[1] + }); + } + } + } + + // Read routing rules — check .squad/ first, fall back to .ai-team/ + let routingFile = '.squad/routing.md'; + if (!fs.existsSync(routingFile)) { + routingFile = '.ai-team/routing.md'; + } + let routingContent = ''; + if (fs.existsSync(routingFile)) { + routingContent = fs.readFileSync(routingFile, 'utf8'); + } + + // Find the Lead + const lead = members.find(m => + m.role.toLowerCase().includes('lead') || + m.role.toLowerCase().includes('architect') || + m.role.toLowerCase().includes('coordinator') + ); + + if (!lead) { + core.warning('No Lead role found in team roster — cannot triage'); + return; + } + + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + + // Build triage context + const memberList = members.map(m => + `- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\`` + ).join('\n'); + + // Determine best assignee based on issue content and routing + const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase(); + + let assignedMember = null; + let triageReason = ''; + let copilotTier = null; + + // First, evaluate @copilot fit if enabled + if (hasCopilot) { + const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw)); + const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw)); + const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw)); + + if (isGoodFit) { + copilotTier = 'good-fit'; + assignedMember = { name: '@copilot', role: 'Coding Agent' }; + triageReason = '🟢 Good fit for @copilot — matches capability profile'; + } else if (isNeedsReview) { + copilotTier = 'needs-review'; + assignedMember = { name: '@copilot', role: 'Coding Agent' }; + triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR'; + } else if (isNotSuitable) { + copilotTier = 'not-suitable'; + // Fall through to normal routing + } + } + + // If not routed to @copilot, use keyword-based routing + if (!assignedMember) { + for (const member of members) { + const role = member.role.toLowerCase(); + if ((role.includes('frontend') || role.includes('ui')) && + (issueText.includes('ui') || issueText.includes('frontend') || + issueText.includes('css') || issueText.includes('component') || + issueText.includes('button') || issueText.includes('page') || + issueText.includes('layout') || issueText.includes('design'))) { + assignedMember = member; + triageReason = 'Issue relates to frontend/UI work'; + break; + } + if ((role.includes('backend') || role.includes('api') || role.includes('server')) && + (issueText.includes('api') || issueText.includes('backend') || + issueText.includes('database') || issueText.includes('endpoint') || + issueText.includes('server') || issueText.includes('auth'))) { + assignedMember = member; + triageReason = 'Issue relates to backend/API work'; + break; + } + if ((role.includes('test') || role.includes('qa') || role.includes('quality')) && + (issueText.includes('test') || issueText.includes('bug') || + issueText.includes('fix') || issueText.includes('regression') || + issueText.includes('coverage'))) { + assignedMember = member; + triageReason = 'Issue relates to testing/quality work'; + break; + } + if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) && + (issueText.includes('deploy') || issueText.includes('ci') || + issueText.includes('pipeline') || issueText.includes('docker') || + issueText.includes('infrastructure'))) { + assignedMember = member; + triageReason = 'Issue relates to DevOps/infrastructure work'; + break; + } + } + } + + // Default to Lead if no routing match + if (!assignedMember) { + assignedMember = lead; + triageReason = 'No specific domain match — assigned to Lead for further analysis'; + } + + const isCopilot = assignedMember.name === '@copilot'; + const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`; + + // Add the member-specific label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [assignLabel] + }); + + // Apply default triage verdict + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: ['go:needs-research'] + }); + + // Auto-assign @copilot if enabled + if (isCopilot && copilotAutoAssign) { + try { + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: ['copilot'] + }); + } catch (err) { + core.warning(`Could not auto-assign @copilot: ${err.message}`); + } + } + + // Build copilot evaluation note + let copilotNote = ''; + if (hasCopilot && !isCopilot) { + if (copilotTier === 'not-suitable') { + copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`; + } else { + copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`; + } + } + + // Post triage comment + const comment = [ + `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`, + '', + `**Issue:** #${issue.number} — ${issue.title}`, + `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`, + `**Reason:** ${triageReason}`, + copilotTier === 'needs-review' ? `\n⚠️ **PR review recommended** — a squad member should review @copilot's work on this one.` : '', + copilotNote, + '', + `---`, + '', + `**Team roster:**`, + memberList, + hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '', + '', + `> To reassign, remove the current \`squad:*\` label and add the correct one.`, + ].filter(Boolean).join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: comment + }); + + core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`); diff --git a/.github/workflows/sync-squad-labels.yml b/.github/workflows/sync-squad-labels.yml new file mode 100644 index 000000000..999bed8ef --- /dev/null +++ b/.github/workflows/sync-squad-labels.yml @@ -0,0 +1,171 @@ +name: Sync Squad Labels + +on: + push: + paths: + - '.squad/team.md' + - '.ai-team/team.md' + workflow_dispatch: + +permissions: + issues: write + contents: read + +jobs: + sync-labels: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Parse roster and sync labels + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + let teamFile = '.squad/team.md'; + if (!fs.existsSync(teamFile)) { + teamFile = '.ai-team/team.md'; + } + + if (!fs.existsSync(teamFile)) { + core.info('No .squad/team.md or .ai-team/team.md found — skipping label sync'); + return; + } + + const content = fs.readFileSync(teamFile, 'utf8'); + const lines = content.split('\n'); + + // Parse the Members table for agent names + const members = []; + let inMembersTable = false; + for (const line of lines) { + if (line.match(/^##\s+(Members|Team Roster)/i)) { + inMembersTable = true; + continue; + } + if (inMembersTable && line.startsWith('## ')) { + break; + } + if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) { + const cells = line.split('|').map(c => c.trim()).filter(Boolean); + if (cells.length >= 2 && cells[0] !== 'Scribe') { + members.push({ + name: cells[0], + role: cells[1] + }); + } + } + } + + core.info(`Found ${members.length} squad members: ${members.map(m => m.name).join(', ')}`); + + // Check if @copilot is on the team + const hasCopilot = content.includes('🤖 Coding Agent'); + + // Define label color palette for squad labels + const SQUAD_COLOR = '9B8FCC'; + const MEMBER_COLOR = '9B8FCC'; + const COPILOT_COLOR = '10b981'; + + // Define go: and release: labels (static) + const GO_LABELS = [ + { name: 'go:yes', color: '0E8A16', description: 'Ready to implement' }, + { name: 'go:no', color: 'B60205', description: 'Not pursuing' }, + { name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' } + ]; + + const RELEASE_LABELS = [ + { name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' }, + { name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' }, + { name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' }, + { name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' }, + { name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' } + ]; + + const TYPE_LABELS = [ + { name: 'type:feature', color: 'DDD1F2', description: 'New capability' }, + { name: 'type:bug', color: 'FF0422', description: 'Something broken' }, + { name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' }, + { name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' }, + { name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' }, + { name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' } + ]; + + // High-signal labels — these MUST visually dominate all others + const SIGNAL_LABELS = [ + { name: 'bug', color: 'FF0422', description: 'Something isn\'t working' }, + { name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' } + ]; + + const PRIORITY_LABELS = [ + { name: 'priority:p0', color: 'B60205', description: 'Blocking release' }, + { name: 'priority:p1', color: 'D93F0B', description: 'This sprint' }, + { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } + ]; + + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + + // Ensure the base "squad" triage label exists + const labels = [ + { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } + ]; + + for (const member of members) { + labels.push({ + name: `squad:${slugify(member.name)}`, + color: MEMBER_COLOR, + description: `Assigned to ${member.name} (${member.role})` + }); + } + + // Add @copilot label if coding agent is on the team + if (hasCopilot) { + labels.push({ + name: 'squad:copilot', + color: COPILOT_COLOR, + description: 'Assigned to @copilot (Coding Agent) for autonomous work' + }); + } + + // Add go:, release:, type:, priority:, and high-signal labels + labels.push(...GO_LABELS); + labels.push(...RELEASE_LABELS); + labels.push(...TYPE_LABELS); + labels.push(...PRIORITY_LABELS); + labels.push(...SIGNAL_LABELS); + + // Sync labels (create or update) + for (const label of labels) { + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name + }); + // Label exists — update it + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description + }); + core.info(`Updated label: ${label.name}`); + } catch (err) { + if (err.status === 404) { + // Label doesn't exist — create it + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description + }); + core.info(`Created label: ${label.name}`); + } else { + throw err; + } + } + } + + core.info(`Label sync complete: ${labels.length} labels synced`); diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 09d72b32d..b669bd5e6 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -44,7 +44,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Login to ${{ env.REGISTRY }} uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml index 990962835..70d2a61a4 100644 --- a/.github/workflows/upgrade.yml +++ b/.github/workflows/upgrade.yml @@ -44,7 +44,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Fetch the history of all branches and tags. # This is needed for the test suite to switch between releases. @@ -71,7 +71,7 @@ jobs: - name: Set up Ginkgo CLI run: | - go install github.com/onsi/ginkgo/v2/ginkgo@v2.19.1 + go install github.com/onsi/ginkgo/v2/ginkgo@v2.23.4 - name: Travel back in time to the before upgrade version run: | @@ -88,7 +88,7 @@ jobs: echo "Fetch all tags..." git fetch --all - GIT_TAG=$(git describe --tags $(git rev-list --tags --max-count=1)) + GIT_TAG=$(git describe --tags --exclude='*-rc.*' $(git rev-list --tags --exclude='*-rc.*' --max-count=1)) else echo "A tag is specified; go back to the state tracked by the specified tag." echo "Fetch all tags..." @@ -146,7 +146,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Fetch the history of all branches and tags. # This is needed for the test suite to switch between releases. @@ -173,7 +173,7 @@ jobs: - name: Set up Ginkgo CLI run: | - go install github.com/onsi/ginkgo/v2/ginkgo@v2.19.1 + go install github.com/onsi/ginkgo/v2/ginkgo@v2.23.4 - name: Travel back in time to the before upgrade version run: | @@ -190,7 +190,7 @@ jobs: echo "Fetch all tags..." git fetch --all - GIT_TAG=$(git describe --tags $(git rev-list --tags --max-count=1)) + GIT_TAG=$(git describe --tags --exclude='*-rc.*' $(git rev-list --tags --exclude='*-rc.*' --max-count=1)) else echo "A tag is specified; go back to the state tracked by the specified tag." echo "Fetch all tags..." @@ -248,7 +248,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: # Fetch the history of all branches and tags. # This is needed for the test suite to switch between releases. @@ -275,7 +275,7 @@ jobs: - name: Set up Ginkgo CLI run: | - go install github.com/onsi/ginkgo/v2/ginkgo@v2.19.1 + go install github.com/onsi/ginkgo/v2/ginkgo@v2.23.4 - name: Travel back in time to the before upgrade version run: | @@ -292,7 +292,7 @@ jobs: echo "Fetch all tags..." git fetch --all - GIT_TAG=$(git describe --tags $(git rev-list --tags --max-count=1)) + GIT_TAG=$(git describe --tags --exclude='*-rc.*' $(git rev-list --tags --exclude='*-rc.*' --max-count=1)) else echo "A tag is specified; go back to the state tracked by the specified tag." echo "Fetch all tags..." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68416e842..0e8ba8dd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,3 +33,33 @@ The KubeFleet project has adopted the CNCF Code of Conduct. Refer to our [Commun ## Issue and pull request management Anyone can comment on issues and submit reviews for pull requests. In order to be assigned an issue or pull request, you can leave a `/assign ` comment on the issue or pull request. + +## Pull request titles + +PR titles must begin with one of the following prefixes (enforced by [`pr-title-lint.yml`](.github/workflows/pr-title-lint.yml)): + +`feat:`, `fix:`, `docs:`, `test:`, `style:`, `interface:`, `util:`, `chore:`, `ci:`, `perf:`, `refactor:`, `revert:` + +Add `make reviewable` to your workflow before opening a PR — the PR template will remind you, but running it locally first saves a round trip. + +## Release note labels + +Each PR should carry one base `release-note/*` label matching its title prefix; additive labels (below) may be stacked on top. + +| PR title prefix | Label | +| --- | --- | +| `feat:` / `perf:` | `release-note/feature` | +| `fix:` / `revert:` | `release-note/fix` | +| `docs:` | `release-note/docs` | +| `test:` | `release-note/test` | +| `chore:` / `ci:` / `style:` / `interface:` / `util:` | `release-note/chore` | +| `refactor:` | `release-note/refactor` | + +Additive labels (stack on top of the above when applicable): + +- `release-note/breaking` — a change that requires user action to upgrade (manifest edit, CRD reapply, RBAC reapply, webhook config update, member-cluster re-join, etc.) **or** that alters scheduling, override, or apply semantics in a way that re-ranks or re-applies existing placements without a manifest change. Pre-1.0, internal refactors of any alpha or beta API shape that don't require migration steps or change observable semantics do not qualify. +- `release-note/security` — security fixes or vulnerability disclosures +- `release-note/none` — suppresses the entry in release notes but keeps the PR visible in GitHub's auto-notes drafter UI +- `ignore-for-release` — hides the PR entirely from auto-generated notes. Default to this for CI-only or internal-cleanup PRs with no user impact. + +PRs with no `release-note/*` label fall into "Other Changes" in the generated notes. Dependabot PRs are labeled `dependencies` automatically and land under "Maintenance and Dependencies" without a `release-note/*` label. diff --git a/SECURITY.md b/SECURITY.md index 50262c4af..f00647786 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,11 +1,69 @@ # Security + + The KubeFleet maintainers takes the security of the project very seriously; we greatly welcomes and appreciates any responsible disclosures of security vulnerabilities. If you believe you have found a security vulnerability in the repository, please follow the steps below to report it to the KubeFleet team. +## Supported versions + +KubeFleet is pre-1.0 and *targets* an `N`/`N-1` support window: the latest minor release and +the one immediately preceding it receive security patches. The project has maintained a roughly +2–3 month minor-release cadence since `v0.2`, giving approximately four to six months of patch +coverage from the GA of any given minor. Minor cadence slippage is possible while we are pre-1.0. + +| Version | Supported | +| --- | --- | +| Latest minor (e.g. `v0.Y.x`) | Yes | +| Previous minor (e.g. `v0.Y-1.x`) | Yes | +| Older minors | No | + +"Supported" here refers to security patch backports only. As a pre-1.0 project, KubeFleet does +not guarantee API stability across minor releases. Users on unsupported minors should upgrade to +a supported minor following the project's upgrade documentation; patches are not backported to +EOL releases. + +## Response SLO + +We commit to the following response targets, measured from the time a report is acknowledged by +the maintainers to the time a patched release is published across all supported minors: + +| Severity (CVSS v3.1) | Target time-to-patch | +| --- | --- | +| Critical (9.0+) | 14 days | +| High (7.0–8.9) | 45 days | +| Medium / Low | Best-effort, no committed SLO | + +These targets are aspirational while we ramp up to consistent release cadence; we will revisit +them after one full quarterly cycle. + +## Coordinated disclosure + +KubeFleet follows responsible-disclosure norms but the operational specifics below are still +being finalized: + +- **Embargo window: TBD.** We have not yet committed to a fixed number of days between + vulnerability acknowledgement and public disclosure. At minimum, reporters will be notified + before public disclosure. The intent is to follow standard CNCF coordinated disclosure + practice (typically 1–7 days for downstream coordination). +- **Vendor advance notification: TBD.** Projects with downstream consumers commonly operate + a distributors mailing list for embargo coordination with packagers and downstream forks + (see the [CNCF TAG-Security `SECURITY.md` template](https://github.com/cncf/tag-security/blob/main/project-resources/templates/SECURITY.md) + for the conventional `cncf--distributors-announce@lists.cncf.io` form). Whether + KubeFleet stands one up depends on demonstrated downstream demand. +- **GitHub private vulnerability reporting:** to be enabled on this repository as the + preferred reporting channel; the maintainer mailing list (see below) remains the fallback + until it is. + +This section will be updated as each item is decided. + ## Reporting Security Issues **Please do not report security vulnerabilities through public GitHub issues.** Instead, diff --git a/docker/hub-agent.Dockerfile b/docker/hub-agent.Dockerfile index 79b7cde0d..4345ed2e2 100644 --- a/docker/hub-agent.Dockerfile +++ b/docker/hub-agent.Dockerfile @@ -1,5 +1,5 @@ # Build the hubagent binary -FROM mcr.microsoft.com/oss/go/microsoft/golang:1.25.11 AS builder +FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.4 AS builder ARG GOOS=linux ARG GOARCH=amd64 diff --git a/docker/member-agent.Dockerfile b/docker/member-agent.Dockerfile index 6a03f63e0..c4b5619cd 100644 --- a/docker/member-agent.Dockerfile +++ b/docker/member-agent.Dockerfile @@ -1,5 +1,5 @@ # Build the memberagent binary -FROM mcr.microsoft.com/oss/go/microsoft/golang:1.25.11 AS builder +FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.4 AS builder ARG GOOS=linux ARG GOARCH=amd64 diff --git a/docker/refresh-token.Dockerfile b/docker/refresh-token.Dockerfile index 4770e06d5..a3bf1bf1d 100644 --- a/docker/refresh-token.Dockerfile +++ b/docker/refresh-token.Dockerfile @@ -1,5 +1,5 @@ # Build the refreshtoken binary -FROM mcr.microsoft.com/oss/go/microsoft/golang:1.25.11 AS builder +FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.4 AS builder ARG GOOS="linux" ARG GOARCH="amd64" diff --git a/go.mod b/go.mod index 37c69a3ef..71ed3e055 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( go.goms.io/fleet-networking v0.3.3 go.uber.org/atomic v1.11.0 go.uber.org/zap v1.27.0 - golang.org/x/sync v0.19.0 + golang.org/x/sync v0.20.0 golang.org/x/time v0.11.0 gomodules.xyz/jsonpatch/v2 v2.4.0 google.golang.org/grpc v1.79.3 @@ -113,14 +113,14 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect diff --git a/go.sum b/go.sum index cbf381791..c868857c1 100644 --- a/go.sum +++ b/go.sum @@ -332,8 +332,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -342,35 +342,35 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/admissionpolicymanager/cel.go b/pkg/admissionpolicymanager/cel.go new file mode 100644 index 000000000..64bba9b1b --- /dev/null +++ b/pkg/admissionpolicymanager/cel.go @@ -0,0 +1,194 @@ +/* +Copyright 2026 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admissionpolicymanager + +import ( + "fmt" + "strings" +) + +// CELExprTreeNode is the interface for CEL tree nodes. It helps build CEL expressions in a +// structured way. +type CELExprTreeNode interface { + // Build returns the CEL expression represented by the node and its children. + Build() (string, error) + // Children returns the child nodes of the current node. + Children() []CELExprTreeNode +} + +// CELExprTreeEditableNode is the interface for CEL tree nodes that can be edited (i.e., have child nodes added to them). +// +// This is added to simplify cases where a CEL tree node need to be built step-by-step. +type CELExprTreeEditableNode interface { + CELExprTreeNode + // Add adds a child node to the current node. + Add(child CELExprTreeNode) +} + +var ( + // Verify that all node types implement the CELExprTreeNode interface. + _ CELExprTreeNode = &rawCELExprTreeNode{} + _ CELExprTreeNode = &orCELExprTreeNode{} + _ CELExprTreeNode = &andCELExprTreeNode{} + _ CELExprTreeNode = ¬CELExprTreeNode{} + + _ CELExprTreeEditableNode = &orCELExprTreeNode{} + _ CELExprTreeEditableNode = &andCELExprTreeNode{} +) + +// rawCELExprTreeNode represents a raw CEL expression. It is a leaf node in the CEL expression tree. +type rawCELExprTreeNode struct { + expr string +} + +// Build returns the raw CEL expression. +func (n *rawCELExprTreeNode) Build() (string, error) { + if len(n.expr) == 0 { + return "", fmt.Errorf("raw CEL expression cannot be empty") + } + return n.expr, nil +} + +// Children returns the child nodes of the current node. +// +// For rawCELExprTreeNode, it always returns nil. +func (n *rawCELExprTreeNode) Children() []CELExprTreeNode { + return nil +} + +// RawCELExpr returns a new rawCELExprTreeNode with the given expression. +func RawCELExpr(expr string) CELExprTreeNode { + return &rawCELExprTreeNode{expr: expr} +} + +// orCELExprTreeNode represents a logical OR of its child nodes' expressions. +type orCELExprTreeNode struct { + children []CELExprTreeNode +} + +// Build returns the CEL expression representing the logical OR of its child nodes' expressions. +func (n *orCELExprTreeNode) Build() (string, error) { + exprs := make([]string, len(n.children)) + for i, child := range n.children { + if child == nil { + return "", fmt.Errorf("a child node is nil") + } + expr, err := child.Build() + if err != nil { + return "", fmt.Errorf("failed to build child node: %w", err) + } + if len(expr) == 0 { + return "", fmt.Errorf("a child node built to an empty expression") + } + exprs[i] = fmt.Sprintf("(%s)", expr) + } + + res := strings.Join(exprs, " || ") + if len(res) == 0 { + return "", fmt.Errorf("built to an empty expression") + } + return res, nil +} + +// Children returns the child nodes of the current node. +func (n *orCELExprTreeNode) Children() []CELExprTreeNode { + return n.children +} + +// Add adds a child node to the current node. +func (n *orCELExprTreeNode) Add(child CELExprTreeNode) { + n.children = append(n.children, child) +} + +// LogicalOr returns a new orCELExprTreeNode with the given child nodes. +func LogicalOr(children ...CELExprTreeNode) CELExprTreeEditableNode { + return &orCELExprTreeNode{children: children} +} + +// andCELExprTreeNode represents a logical AND of its child nodes' expressions. +type andCELExprTreeNode struct { + children []CELExprTreeNode +} + +// Build returns the CEL expression representing the logical AND of its child nodes' expressions. +func (n *andCELExprTreeNode) Build() (string, error) { + exprs := make([]string, len(n.children)) + for i, child := range n.children { + if child == nil { + return "", fmt.Errorf("a child node is nil") + } + expr, err := child.Build() + if err != nil { + return "", fmt.Errorf("failed to build child node: %w", err) + } + if len(expr) == 0 { + return "", fmt.Errorf("a child node built to an empty expression") + } + exprs[i] = fmt.Sprintf("(%s)", expr) + } + + res := strings.Join(exprs, " && ") + if len(res) == 0 { + return "", fmt.Errorf("built to an empty expression") + } + return res, nil +} + +// Children returns the child nodes of the current node. +func (n *andCELExprTreeNode) Children() []CELExprTreeNode { + return n.children +} + +// Add adds a child node to the current node. +func (n *andCELExprTreeNode) Add(child CELExprTreeNode) { + n.children = append(n.children, child) +} + +// LogicalAnd returns a new andCELExprTreeNode with the given child nodes. +func LogicalAnd(children ...CELExprTreeNode) CELExprTreeEditableNode { + return &andCELExprTreeNode{children: children} +} + +// notCELExprTreeNode represents a logical NOT of its child node's expression. +type notCELExprTreeNode struct { + child CELExprTreeNode +} + +// Build returns the CEL expression representing the logical NOT of its child node's expression. +func (n *notCELExprTreeNode) Build() (string, error) { + if n.child == nil { + return "", fmt.Errorf("child node is nil") + } + expr, err := n.child.Build() + if err != nil { + return "", fmt.Errorf("failed to build child node: %w", err) + } + if len(expr) == 0 { + return "", fmt.Errorf("child node built to an empty expression") + } + return fmt.Sprintf("!(%s)", expr), nil +} + +// Children returns the child node of the current node. +func (n *notCELExprTreeNode) Children() []CELExprTreeNode { + return []CELExprTreeNode{n.child} +} + +// LogicalNot returns a new notCELExprTreeNode with the given child node. +func LogicalNot(child CELExprTreeNode) CELExprTreeNode { + return ¬CELExprTreeNode{child: child} +} diff --git a/pkg/admissionpolicymanager/cel_test.go b/pkg/admissionpolicymanager/cel_test.go new file mode 100644 index 000000000..e92c546f6 --- /dev/null +++ b/pkg/admissionpolicymanager/cel_test.go @@ -0,0 +1,532 @@ +/* +Copyright 2026 The KubeFleet Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package admissionpolicymanager + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +// TestCELExprTreeNode_Raw tests the RawCELExprTreeNode implementation of the CELExprTreeNode interface. +func TestCELExprTreeNode_Raw(t *testing.T) { + testCases := []struct { + name string + expr string + wantBuilt string + wantErred bool + wantErrSubstring string + }{ + { + name: "valid", + expr: "x = y", + wantBuilt: "x = y", + wantErred: false, + }, + { + name: "empty expression", + expr: "", + wantBuilt: "", + wantErred: true, + wantErrSubstring: "raw CEL expression cannot be empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + node := RawCELExpr(tc.expr) + + gotBuilt, err := node.Build() + if tc.wantErred { + if err == nil { + t.Fatal("Build() = nil, want erred") + } + if !strings.Contains(err.Error(), tc.wantErrSubstring) { + t.Errorf("Build() error = %v, want error with substring %s", err, tc.wantErrSubstring) + } + return + } + if err != nil { + t.Fatalf("Build() = %v, want no error", err) + } + if !cmp.Equal(gotBuilt, tc.wantBuilt) { + t.Errorf("Build() = %v, want %v", gotBuilt, tc.wantBuilt) + } + + gotChildren := node.Children() + wantChildren := []CELExprTreeNode(nil) + if !cmp.Equal(gotChildren, wantChildren) { + t.Errorf("Children() = %v, want %v", gotChildren, wantChildren) + } + }) + } +} + +// TestCELExprTreeNode_LogicalOr tests the orCELExprTreeNode implementation of the CELExprTreeNode interface. +func TestCELExprTreeNode_LogicalOr(t *testing.T) { + testCases := []struct { + name string + children []CELExprTreeNode + wantBuilt string + wantChildren []CELExprTreeNode + }{ + { + name: "single child", + children: []CELExprTreeNode{RawCELExpr("x = y")}, + wantBuilt: "(x = y)", + wantChildren: []CELExprTreeNode{RawCELExpr("x = y")}, + }, + { + name: "multiple children", + children: []CELExprTreeNode{ + RawCELExpr("x = y"), + RawCELExpr("y = z"), + RawCELExpr("z = a"), + }, + wantBuilt: "(x = y) || (y = z) || (z = a)", + wantChildren: []CELExprTreeNode{ + RawCELExpr("x = y"), + RawCELExpr("y = z"), + RawCELExpr("z = a"), + }, + }, + { + name: "nested child", + children: []CELExprTreeNode{ + RawCELExpr("x = y"), + LogicalAnd(RawCELExpr("y = z"), RawCELExpr("z = a")), + }, + wantBuilt: "(x = y) || ((y = z) && (z = a))", + wantChildren: []CELExprTreeNode{ + RawCELExpr("x = y"), + LogicalAnd(RawCELExpr("y = z"), RawCELExpr("z = a")), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + node := LogicalOr(tc.children...) + + gotBuilt, err := node.Build() + if err != nil { + t.Fatalf("Build() = %v", err) + } + if !cmp.Equal(gotBuilt, tc.wantBuilt) { + t.Errorf("Build() = %v, want %v", gotBuilt, tc.wantBuilt) + } + + gotChildren := node.Children() + if !cmp.Equal(gotChildren, tc.wantChildren, + cmp.AllowUnexported(rawCELExprTreeNode{}), + cmp.AllowUnexported(andCELExprTreeNode{}), + cmp.AllowUnexported(orCELExprTreeNode{}), + cmp.AllowUnexported(notCELExprTreeNode{}), + ) { + t.Errorf("Children() = %v, want %v", gotChildren, tc.wantChildren) + } + }) + } +} + +// TestCELExprTreeNode_LogicalOr_Erred tests LogicalOr error scenarios. +func TestCELExprTreeNode_LogicalOr_Erred(t *testing.T) { + testCases := []struct { + name string + children []CELExprTreeNode + wantErrSubstring string + }{ + { + name: "no children", + children: nil, + wantErrSubstring: "built to an empty expression", + }, + { + name: "nil child", + children: []CELExprTreeNode{RawCELExpr("x = y"), nil}, + wantErrSubstring: "a child node is nil", + }, + { + name: "child parse error", + children: []CELExprTreeNode{RawCELExpr("x = y"), RawCELExpr("")}, + wantErrSubstring: "failed to build child node: raw CEL expression cannot be empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + node := LogicalOr(tc.children...) + + gotBuilt, err := node.Build() + if err == nil { + t.Fatalf("Build() = nil, want error") + } + if !strings.Contains(err.Error(), tc.wantErrSubstring) { + t.Errorf("Build() error = %v, want error with substring %s", err, tc.wantErrSubstring) + } + if gotBuilt != "" { + t.Errorf("Build() = %v, want empty string on error", gotBuilt) + } + }) + } +} + +// TestCELExprTreeNode_LogicalOr_Add tests Add behavior for LogicalOr nodes. +func TestCELExprTreeNode_LogicalOr_Add(t *testing.T) { + testCases := []struct { + name string + childrenToAdd []CELExprTreeNode + wantBuilt string + wantChildren []CELExprTreeNode + wantErred bool + wantErrSubstring string + }{ + { + name: "append valid children", + childrenToAdd: []CELExprTreeNode{RawCELExpr("x = y"), RawCELExpr("y = z")}, + wantBuilt: "(x = y) || (y = z)", + wantChildren: []CELExprTreeNode{RawCELExpr("x = y"), RawCELExpr("y = z")}, + wantErred: false, + }, + { + name: "append nil child", + childrenToAdd: []CELExprTreeNode{RawCELExpr("x = y"), nil}, + wantBuilt: "", + wantChildren: []CELExprTreeNode{RawCELExpr("x = y"), nil}, + wantErred: true, + wantErrSubstring: "a child node is nil", + }, + { + name: "append child that fails parse", + childrenToAdd: []CELExprTreeNode{RawCELExpr("x = y"), RawCELExpr("")}, + wantBuilt: "", + wantChildren: []CELExprTreeNode{RawCELExpr("x = y"), RawCELExpr("")}, + wantErred: true, + wantErrSubstring: "failed to build child node: raw CEL expression cannot be empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + orNode := LogicalOr() + + for _, child := range tc.childrenToAdd { + orNode.Add(child) + } + + gotBuilt, err := orNode.Build() + if tc.wantErred { + if err == nil { + t.Fatal("Build() = nil, want erred") + } + if !strings.Contains(err.Error(), tc.wantErrSubstring) { + t.Errorf("Build() error = %v, want error with substring %s", err, tc.wantErrSubstring) + } + } else { + if err != nil { + t.Fatalf("Build() = %v, want no error", err) + } + if !cmp.Equal(gotBuilt, tc.wantBuilt) { + t.Errorf("Build() = %v, want %v", gotBuilt, tc.wantBuilt) + } + } + + gotChildren := orNode.Children() + if !cmp.Equal(gotChildren, tc.wantChildren, + cmp.AllowUnexported(rawCELExprTreeNode{}), + cmp.AllowUnexported(andCELExprTreeNode{}), + cmp.AllowUnexported(orCELExprTreeNode{}), + cmp.AllowUnexported(notCELExprTreeNode{}), + ) { + t.Errorf("Children() = %v, want %v", gotChildren, tc.wantChildren) + } + }) + } +} + +// TestCELExprTreeNode_LogicalAnd tests the andCELExprTreeNode implementation of the CELExprTreeNode interface. +func TestCELExprTreeNode_LogicalAnd(t *testing.T) { + testCases := []struct { + name string + children []CELExprTreeNode + wantBuilt string + wantChildren []CELExprTreeNode + }{ + { + name: "single child", + children: []CELExprTreeNode{RawCELExpr("x = y")}, + wantBuilt: "(x = y)", + wantChildren: []CELExprTreeNode{RawCELExpr("x = y")}, + }, + { + name: "multiple children", + children: []CELExprTreeNode{ + RawCELExpr("x = y"), + RawCELExpr("y = z"), + RawCELExpr("z = a"), + }, + wantBuilt: "(x = y) && (y = z) && (z = a)", + wantChildren: []CELExprTreeNode{ + RawCELExpr("x = y"), + RawCELExpr("y = z"), + RawCELExpr("z = a"), + }, + }, + { + name: "nested child", + children: []CELExprTreeNode{ + RawCELExpr("x = y"), + LogicalOr(RawCELExpr("y = z"), RawCELExpr("z = a")), + }, + wantBuilt: "(x = y) && ((y = z) || (z = a))", + wantChildren: []CELExprTreeNode{ + RawCELExpr("x = y"), + LogicalOr(RawCELExpr("y = z"), RawCELExpr("z = a")), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + node := LogicalAnd(tc.children...) + + gotBuilt, err := node.Build() + if err != nil { + t.Fatalf("Build() = %v", err) + } + if !cmp.Equal(gotBuilt, tc.wantBuilt) { + t.Errorf("Build() = %v, want %v", gotBuilt, tc.wantBuilt) + } + + gotChildren := node.Children() + if !cmp.Equal(gotChildren, tc.wantChildren, + cmp.AllowUnexported(rawCELExprTreeNode{}), + cmp.AllowUnexported(andCELExprTreeNode{}), + cmp.AllowUnexported(orCELExprTreeNode{}), + cmp.AllowUnexported(notCELExprTreeNode{}), + ) { + t.Errorf("Children() = %v, want %v", gotChildren, tc.wantChildren) + } + }) + } +} + +// TestCELExprTreeNode_LogicalAnd_Erred tests LogicalAnd error scenarios. +func TestCELExprTreeNode_LogicalAnd_Erred(t *testing.T) { + testCases := []struct { + name string + children []CELExprTreeNode + wantErrSubstring string + }{ + { + name: "no children", + children: nil, + wantErrSubstring: "built to an empty expression", + }, + { + name: "nil child", + children: []CELExprTreeNode{RawCELExpr("x = y"), nil}, + wantErrSubstring: "a child node is nil", + }, + { + name: "child parse error", + children: []CELExprTreeNode{RawCELExpr("x = y"), RawCELExpr("")}, + wantErrSubstring: "failed to build child node: raw CEL expression cannot be empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + node := LogicalAnd(tc.children...) + + gotBuilt, err := node.Build() + if err == nil { + t.Fatalf("Build() = nil, want error") + } + if !strings.Contains(err.Error(), tc.wantErrSubstring) { + t.Errorf("Build() error = %v, want error with substring %s", err, tc.wantErrSubstring) + } + if gotBuilt != "" { + t.Errorf("Build() = %v, want empty string on error", gotBuilt) + } + }) + } +} + +// TestCELExprTreeNode_LogicalAnd_Add tests Add behavior for LogicalAnd nodes. +func TestCELExprTreeNode_LogicalAnd_Add(t *testing.T) { + testCases := []struct { + name string + childrenToAdd []CELExprTreeNode + wantBuilt string + wantChildren []CELExprTreeNode + wantErred bool + wantErrSubstring string + }{ + { + name: "append valid children", + childrenToAdd: []CELExprTreeNode{RawCELExpr("x = y"), RawCELExpr("y = z")}, + wantBuilt: "(x = y) && (y = z)", + wantChildren: []CELExprTreeNode{RawCELExpr("x = y"), RawCELExpr("y = z")}, + wantErred: false, + }, + { + name: "append nil child", + childrenToAdd: []CELExprTreeNode{RawCELExpr("x = y"), nil}, + wantBuilt: "", + wantChildren: []CELExprTreeNode{RawCELExpr("x = y"), nil}, + wantErred: true, + wantErrSubstring: "a child node is nil", + }, + { + name: "append child that fails parse", + childrenToAdd: []CELExprTreeNode{RawCELExpr("x = y"), RawCELExpr("")}, + wantBuilt: "", + wantChildren: []CELExprTreeNode{RawCELExpr("x = y"), RawCELExpr("")}, + wantErred: true, + wantErrSubstring: "failed to build child node: raw CEL expression cannot be empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + andNode := LogicalAnd() + + for _, child := range tc.childrenToAdd { + andNode.Add(child) + } + + gotBuilt, err := andNode.Build() + if tc.wantErred { + if err == nil { + t.Fatal("Build() = nil, want erred") + } + if !strings.Contains(err.Error(), tc.wantErrSubstring) { + t.Errorf("Build() error = %v, want error with substring %s", err, tc.wantErrSubstring) + } + } else { + if err != nil { + t.Fatalf("Build() = %v, want no error", err) + } + if !cmp.Equal(gotBuilt, tc.wantBuilt) { + t.Errorf("Build() = %v, want %v", gotBuilt, tc.wantBuilt) + } + } + + gotChildren := andNode.Children() + if !cmp.Equal(gotChildren, tc.wantChildren, + cmp.AllowUnexported(rawCELExprTreeNode{}), + cmp.AllowUnexported(andCELExprTreeNode{}), + cmp.AllowUnexported(orCELExprTreeNode{}), + cmp.AllowUnexported(notCELExprTreeNode{}), + ) { + t.Errorf("Children() = %v, want %v", gotChildren, tc.wantChildren) + } + }) + } +} + +// TestCELExprTreeNode_LogicalNot tests the notCELExprTreeNode implementation of the CELExprTreeNode interface. +func TestCELExprTreeNode_LogicalNot(t *testing.T) { + testCases := []struct { + name string + child CELExprTreeNode + wantBuilt string + wantChildren []CELExprTreeNode + }{ + { + name: "raw child", + child: RawCELExpr("x = y"), + wantBuilt: "!(x = y)", + wantChildren: []CELExprTreeNode{RawCELExpr("x = y")}, + }, + { + name: "and child", + child: LogicalAnd(RawCELExpr("x = y"), RawCELExpr("y = z")), + wantBuilt: "!((x = y) && (y = z))", + wantChildren: []CELExprTreeNode{LogicalAnd(RawCELExpr("x = y"), RawCELExpr("y = z"))}, + }, + { + name: "or child", + child: LogicalOr(RawCELExpr("x = y"), RawCELExpr("y = z")), + wantBuilt: "!((x = y) || (y = z))", + wantChildren: []CELExprTreeNode{LogicalOr(RawCELExpr("x = y"), RawCELExpr("y = z"))}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + node := LogicalNot(tc.child) + + gotBuilt, err := node.Build() + if err != nil { + t.Fatalf("Build() = %v", err) + } + if !cmp.Equal(gotBuilt, tc.wantBuilt) { + t.Errorf("Build() = %v, want %v", gotBuilt, tc.wantBuilt) + } + + gotChildren := node.Children() + if !cmp.Equal(gotChildren, tc.wantChildren, + cmp.AllowUnexported(rawCELExprTreeNode{}), + cmp.AllowUnexported(andCELExprTreeNode{}), + cmp.AllowUnexported(orCELExprTreeNode{}), + cmp.AllowUnexported(notCELExprTreeNode{}), + ) { + t.Errorf("Children() = %v, want %v", gotChildren, tc.wantChildren) + } + }) + } +} + +// TestCELExprTreeNode_LogicalNot_Erred tests LogicalNot error scenarios. +func TestCELExprTreeNode_LogicalNot_Erred(t *testing.T) { + testCases := []struct { + name string + child CELExprTreeNode + wantErrSubstring string + }{ + { + name: "nil child", + child: nil, + wantErrSubstring: "child node is nil", + }, + { + name: "child parse error", + child: RawCELExpr(""), + wantErrSubstring: "failed to build child node: raw CEL expression cannot be empty", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + node := LogicalNot(tc.child) + + gotBuilt, err := node.Build() + if err == nil { + t.Fatalf("Build() = nil, want error") + } + if !strings.Contains(err.Error(), tc.wantErrSubstring) { + t.Errorf("Build() error = %v, want error with substring %s", err, tc.wantErrSubstring) + } + if gotBuilt != "" { + t.Errorf("Build() = %v, want empty string on error", gotBuilt) + } + }) + } +} diff --git a/pkg/admissionpolicymanager/commons.go b/pkg/admissionpolicymanager/commons.go index ac2f05226..53156301c 100644 --- a/pkg/admissionpolicymanager/commons.go +++ b/pkg/admissionpolicymanager/commons.go @@ -18,6 +18,7 @@ package admissionpolicymanager import ( "context" + "fmt" "regexp" "strings" @@ -86,3 +87,15 @@ func validateCELStringLiterals(strs ...string) error { } return nil } + +func isInNamespaceWithPrefix(p string) CELExprTreeNode { + return RawCELExpr(fmt.Sprintf(`request.namespace.startsWith("%s")`, p)) +} + +func isFromUsername(u string) CELExprTreeNode { + return RawCELExpr(fmt.Sprintf(`request.userInfo.username == "%s"`, u)) +} + +func isFromUserGroup(g string) CELExprTreeNode { + return RawCELExpr(fmt.Sprintf(`"%s" in request.userInfo.groups`, g)) +} diff --git a/pkg/admissionpolicymanager/manager.go b/pkg/admissionpolicymanager/manager.go index ce0ad3f41..178645919 100644 --- a/pkg/admissionpolicymanager/manager.go +++ b/pkg/admissionpolicymanager/manager.go @@ -88,7 +88,7 @@ type PolicyWithBindings struct { type ValidatingAdmissionPolicyGenerator interface { Name() string Validate() error - PoliciesWithBindings() []PolicyWithBindings + PoliciesWithBindings() ([]PolicyWithBindings, error) } type PolicyManager struct { @@ -165,7 +165,10 @@ func (m *PolicyManager) createOrUpdatePoliciesAndBindingsForEnabledGenerators(ct return nil, nil, errors.Wraps(err, "policy generator is invalid", "generator", gen.Name()) } - policiesWithBindings := gen.PoliciesWithBindings() + policiesWithBindings, err := gen.PoliciesWithBindings() + if err != nil { + return nil, nil, errors.Wraps(err, "failed to build policies with bindings", "generator", gen.Name()) + } for _, pb := range policiesWithBindings { policy := pb.Policy diff --git a/pkg/admissionpolicymanager/podsnreplicasets.go b/pkg/admissionpolicymanager/podsnreplicasets.go index d1b1f9e63..9877e77de 100644 --- a/pkg/admissionpolicymanager/podsnreplicasets.go +++ b/pkg/admissionpolicymanager/podsnreplicasets.go @@ -81,7 +81,7 @@ func (g *PodsAndReplicaSetsValidatingAdmissionPolicyGenerator) Validate() error // replicasets in non-reserved namespaces. // // For simplicity reasons, the code here assumes that the generator has been validated before PoliciesWithBindings() is called. -func (g *PodsAndReplicaSetsValidatingAdmissionPolicyGenerator) PoliciesWithBindings() []PolicyWithBindings { +func (g *PodsAndReplicaSetsValidatingAdmissionPolicyGenerator) PoliciesWithBindings() ([]PolicyWithBindings, error) { celExprSegs := []string{} for _, prefix := range g.ReservedNamespacePrefixes { celExprSegs = append(celExprSegs, fmt.Sprintf(`request.namespace.startsWith("%s")`, prefix)) @@ -158,5 +158,5 @@ func (g *PodsAndReplicaSetsValidatingAdmissionPolicyGenerator) PoliciesWithBindi Policy: policy, Bindings: []*admissionregistrationv1.ValidatingAdmissionPolicyBinding{binding}, }, - } + }, nil } diff --git a/pkg/admissionpolicymanager/svcaccountsntokenreqs.go b/pkg/admissionpolicymanager/svcaccountsntokenreqs.go index 19a1df577..6700a6656 100644 --- a/pkg/admissionpolicymanager/svcaccountsntokenreqs.go +++ b/pkg/admissionpolicymanager/svcaccountsntokenreqs.go @@ -20,9 +20,6 @@ limitations under the License. package admissionpolicymanager import ( - "fmt" - "strings" - admissionregistrationv1 "k8s.io/api/admissionregistration/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" @@ -100,17 +97,7 @@ func (g *ServiceAccountsAndTokenRequestsValidatingAdmissionPolicyGenerator) Vali // except for requests from certain whitelisted users and user groups. // // For simplicity reasons, the code here assumes that the generator has been validated before PoliciesWithBindings() is called. -func (g *ServiceAccountsAndTokenRequestsValidatingAdmissionPolicyGenerator) PoliciesWithBindings() []PolicyWithBindings { - celExprAccSegs := []string{} - - // Exempt whitelisted users from this admission policy. - for _, username := range g.WhitelistedUsernames { - celExprAccSegs = append(celExprAccSegs, fmt.Sprintf(`request.userInfo.username == "%s"`, username)) - } - // Exempt whitelisted user groups from this admission policy. - for _, userGroup := range g.WhitelistedUserGroups { - celExprAccSegs = append(celExprAccSegs, fmt.Sprintf(`"%s" in request.userInfo.groups`, userGroup)) - } +func (g *ServiceAccountsAndTokenRequestsValidatingAdmissionPolicyGenerator) PoliciesWithBindings() ([]PolicyWithBindings, error) { // Exempt requests from the Kubernetes scheduler, any of the nodes, and (esp.) the // Kubernetes controller manager from this admission policy. // @@ -118,29 +105,58 @@ func (g *ServiceAccountsAndTokenRequestsValidatingAdmissionPolicyGenerator) Poli // --use-service-account-credentials=true, creates a service account token for many of its controllers // and uses those tokens to authenticate to the Kubernetes API server. It retrieves a token // via the TokenRequest API; failure to exempt this scenario may lead to critical errors. - celExprAccSegs = append(celExprAccSegs, fmt.Sprintf(`request.userInfo.username == "%s"`, kubeSchedulerUserName)) - celExprAccSegs = append(celExprAccSegs, fmt.Sprintf(`request.userInfo.username == "%s"`, kubeControllerManagerUserName)) - celExprAccSegs = append(celExprAccSegs, fmt.Sprintf(`"%s" in request.userInfo.groups`, kubeNodeUserGroup)) + isFromKubeScheduler := isFromUsername(kubeSchedulerUserName) + isFromKubeControllerManager := isFromUsername(kubeControllerManagerUserName) + isFromNodeUserGroup := isFromUserGroup(kubeNodeUserGroup) + // Exempt requests from cluster admin users from this admission policy. - celExprAccSegs = append(celExprAccSegs, fmt.Sprintf(`"%s" in request.userInfo.groups`, adminUserGroup)) + isFromClusterAdmins := isFromUserGroup(adminUserGroup) + // Exempt kubeadm cluster admins from this policy as well, so that bootstrapping a hub cluster with // kubeadm credentials can proceed without being blocked. - celExprAccSegs = append(celExprAccSegs, fmt.Sprintf(`"%s" in request.userInfo.groups`, kubeadmAdminUserGroup)) + isFromKubeadmClusterAdmins := isFromUserGroup(kubeadmAdminUserGroup) + // Exempt service accounts from this admission policy. Note that VAP check happens after authentication and // authorization have been performed. This is added to keep things consistent with the original webhook behavior, // and also for the reason that some controller manager components (e.g., the service account controller) // need to create service accounts as part of their normal operations. - celExprAccSegs = append(celExprAccSegs, fmt.Sprintf(`"%s" in request.userInfo.groups`, svcAccountUserGroup)) + isFromSvcAccounts := isFromUserGroup(svcAccountUserGroup) + + isFromAllowedRequesters := LogicalOr( + isFromKubeScheduler, + isFromKubeControllerManager, + isFromNodeUserGroup, + isFromClusterAdmins, + isFromKubeadmClusterAdmins, + isFromSvcAccounts, + ) + + // Exempt additionally configured users from this admission policy. + for _, username := range g.WhitelistedUsernames { + isFromAllowedRequesters.Add(isFromUsername(username)) + } - celExprAcc := strings.Join(celExprAccSegs, " || ") + // Exempt additionally configured user groups from this admission policy. + for _, userGroup := range g.WhitelistedUserGroups { + isFromAllowedRequesters.Add(isFromUserGroup(userGroup)) + } - celExprNSSegs := []string{} + // Allow the request if it is not targeting reserved namespaces. + isInReservedNamespaces := LogicalOr() for _, prefix := range g.ReservedNamespacePrefixes { - celExprNSSegs = append(celExprNSSegs, fmt.Sprintf(`request.namespace.startsWith("%s")`, prefix)) + isInReservedNamespaces.Add(isInNamespaceWithPrefix(prefix)) } - celExprNS := strings.Join(celExprNSSegs, " || ") - celExpr := fmt.Sprintf("!(%s) || (%s)", celExprNS, celExprAcc) + celExprTree := LogicalOr( + LogicalNot( + isInReservedNamespaces, + ), + isFromAllowedRequesters, + ) + celExpr, err := celExprTree.Build() + if err != nil { + return nil, errors.Wraps(err, "failed to build CEL expression") + } policy := &admissionregistrationv1.ValidatingAdmissionPolicy{ ObjectMeta: metav1.ObjectMeta{ @@ -209,5 +225,5 @@ func (g *ServiceAccountsAndTokenRequestsValidatingAdmissionPolicyGenerator) Poli Policy: policy, Bindings: []*admissionregistrationv1.ValidatingAdmissionPolicyBinding{binding}, }, - } + }, nil }