Compare commits

..

24 Commits

Author SHA1 Message Date
DevCats 61b6a9c7cd Merge branch 'main' into cat/doc-check-update 2025-12-22 09:37:13 -06:00
DevCats f9e79c6692 Merge branch 'main' into cat/doc-check-update 2025-12-22 09:17:40 -06:00
DevelopmentCats 8fa3b02749 ci: add unique task naming in doc-check workflow to prevent container name conflicts 2025-12-22 08:43:32 -06:00
DevelopmentCats 010a045f34 ci: update Coder CLI setup action to specific version v1.1.0 in doc-check workflow 2025-12-19 08:04:40 -06:00
DevelopmentCats 5cdcea0edc ci: simplify Coder CLI setup in doc-check workflow by using setup-action 2025-12-19 07:57:03 -06:00
DevelopmentCats d652c3aefd chore: add output for final summary step 2025-12-18 19:18:44 -06:00
DevelopmentCats 3466cdb95f ci: enhance polling logic in doc-check workflow to ensure agent status is checked correctly 2025-12-18 19:13:19 -06:00
DevelopmentCats e978630aca ci: use agentapit for status instead 2025-12-18 19:08:56 -06:00
DevelopmentCats 38a3cfbe29 ci: standardize formatting and clarify reporting requirements in doc-check workflow 2025-12-18 18:42:39 -06:00
DevelopmentCats f1546aa822 ci: enhance doc-check workflow with detailed reporting requirements 2025-12-18 18:32:04 -06:00
DevelopmentCats f7c760b165 ci: output task messages after task completion since sse streaming is not showing in ci output 2025-12-18 18:16:34 -06:00
DevelopmentCats 31e604df34 ci: debugging to see if streaming to step summary is better 2025-12-18 18:08:29 -06:00
DevelopmentCats d779f422d6 ci: refine SSE event parsing in doc-check workflow 2025-12-18 17:57:41 -06:00
DevelopmentCats fd300cf978 ci: improve event stream handling in doc-check workflow 2025-12-18 17:46:28 -06:00
DevelopmentCats 006ac48d0f ci: add clear title line for task display name
Coder extracts display_name from first line of prompt.
Now shows 'Doc Check: PR #XXXX - Full Analysis' or 'Doc Check: PR #XXXX - Update'
instead of random Docker-style names like 'Agitated khayyam'
2025-12-18 17:33:20 -06:00
DevelopmentCats c1c987adef ci: shorten header lines to prevent wrapping 2025-12-18 16:37:54 -06:00
DevelopmentCats 4be7d7ca96 ci: require AI to show work with checkpoints before commenting
- Add explicit STEP markers with CHECKPOINT requirements
- Require showing command output as evidence
- Make commenting the FINAL step only after analysis
- Add warnings against skipping steps or making assumptions
- Require specific file/line references in decisions
2025-12-18 16:37:16 -06:00
DevelopmentCats 7e9cffa4e5 ci: add run_number to task name prefix to avoid conflicts 2025-12-18 16:28:18 -06:00
DevelopmentCats 30e9eb7557 ci: temporarily disable cleanup for debugging workspace build failure 2025-12-18 16:05:21 -06:00
DevelopmentCats 3d414db30c ci: update doc-check workflow to use tasks cli instead of exp 2025-12-18 15:47:52 -06:00
DevelopmentCats c3c0aee887 ci: refine doc-check workflow to enhance PR context detection and analysis handling 2025-12-18 15:41:41 -06:00
DevelopmentCats c760facdf3 ci: update doc-check workflow triggers for opened and synchronize types and remove labeled condition 2025-12-18 15:19:20 -06:00
DevCats 2e54f52750 Merge branch 'main' into cat/doc-check-update 2025-12-18 15:15:54 -06:00
DevelopmentCats 43d8878d89 ci: enhance documentation check for running on all PR's 2025-12-18 15:14:02 -06:00
876 changed files with 6820 additions and 21995 deletions
+4 -2
View File
@@ -7,6 +7,8 @@ runs:
- name: go install tools
shell: bash
run: |
go install tool
# NOTE: protoc-gen-go cannot be installed with `go get`
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30
go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34
go install golang.org/x/tools/cmd/goimports@v0.31.0
go install github.com/mikefarah/yq/v4@v4.44.3
go install go.uber.org/mock/mockgen@v0.5.0
-1
View File
@@ -71,7 +71,6 @@ runs:
if [[ ${RACE_DETECTION} == true ]]; then
gotestsum --junitfile="gotests.xml" --packages="${TEST_PACKAGES}" -- \
-tags=testsmallbatch \
-race \
-parallel "${TEST_NUM_PARALLEL_TESTS}" \
-p "${TEST_NUM_PARALLEL_PACKAGES}"
+15 -19
View File
@@ -343,7 +343,7 @@ jobs:
test-go-pg:
# make sure to adjust NUM_PARALLEL_PACKAGES and NUM_PARALLEL_TESTS below
# when changing runner sizes
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || matrix.os && matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'depot-macos-latest' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'depot-windows-2022-16' || matrix.os }}
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'depot-ubuntu-22.04-16' || matrix.os && matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'depot-macos-latest' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'depot-windows-2022-32' || matrix.os }}
needs: changes
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
# This timeout must be greater than the timeout set by `go test` in
@@ -470,8 +470,8 @@ jobs:
uses: ./.github/actions/test-go-pg
with:
postgres-version: "13"
# Our Linux runners have 8 cores.
test-parallelism-packages: "8"
# Our Linux runners have 16 cores.
test-parallelism-packages: "16"
test-parallelism-tests: "8"
# By default, run tests with cache for improved speed (possibly at the expense of correctness).
# On main, run tests without cache for the inverse.
@@ -500,12 +500,8 @@ jobs:
uses: ./.github/actions/test-go-pg
with:
postgres-version: "13"
# Our Windows runners have 16 cores.
# On Windows Postgres chokes up when we have 16x16=256 tests
# running in parallel, and dbtestutil.NewDB starts to take more than
# 10s to complete sometimes causing test timeouts. With 16x8=128 tests
# Postgres tends not to choke.
test-parallelism-packages: "8"
# Our Windows runners have 32 cores.
test-parallelism-packages: "32"
test-parallelism-tests: "16"
# By default, run tests with cache for improved speed (possibly at the expense of correctness).
# On main, run tests without cache for the inverse.
@@ -543,7 +539,7 @@ jobs:
api-key: ${{ secrets.DATADOG_API_KEY }}
test-go-pg-17:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }}
needs:
- changes
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
@@ -589,8 +585,8 @@ jobs:
uses: ./.github/actions/test-go-pg
with:
postgres-version: "17"
# Our Linux runners have 8 cores.
test-parallelism-packages: "8"
# Our Linux runners have 16 cores.
test-parallelism-packages: "16"
test-parallelism-tests: "8"
# By default, run tests with cache for improved speed (possibly at the expense of correctness).
# On main, run tests without cache for the inverse.
@@ -610,7 +606,7 @@ jobs:
api-key: ${{ secrets.DATADOG_API_KEY }}
test-go-race-pg:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-32' || 'ubuntu-latest' }}
needs: changes
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
timeout-minutes: 25
@@ -651,13 +647,13 @@ jobs:
# instances where tests appear to hang for multiple seconds, resulting in flaky tests when
# short timeouts are used.
# c.f. discussion on https://github.com/coder/coder/pull/15106
# Our Linux runners have 16 cores, but we reduce parallelism since race detection adds a lot of overhead.
# We aim to have parallelism match CPU count (4*4=16) to avoid making flakes worse.
# Our Linux runners have 32 cores, but we reduce parallelism since race detection adds a lot of overhead.
# We aim to have parallelism match CPU count (8*4=32) to avoid making flakes worse.
- name: Run Tests
uses: ./.github/actions/test-go-pg
with:
postgres-version: "17"
test-parallelism-packages: "4"
test-parallelism-packages: "8"
test-parallelism-tests: "4"
race-detection: "true"
@@ -1373,7 +1369,7 @@ jobs:
id: attest_main
if: github.ref == 'refs/heads/main'
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@daf44fb950173508f38bd2406030372c1d1162b1 # v3.0.0
with:
subject-name: "ghcr.io/coder/coder-preview:main"
predicate-type: "https://slsa.dev/provenance/v1"
@@ -1410,7 +1406,7 @@ jobs:
id: attest_latest
if: github.ref == 'refs/heads/main'
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@daf44fb950173508f38bd2406030372c1d1162b1 # v3.0.0
with:
subject-name: "ghcr.io/coder/coder-preview:latest"
predicate-type: "https://slsa.dev/provenance/v1"
@@ -1447,7 +1443,7 @@ jobs:
id: attest_version
if: github.ref == 'refs/heads/main'
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@daf44fb950173508f38bd2406030372c1d1162b1 # v3.0.0
with:
subject-name: "ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}"
predicate-type: "https://slsa.dev/provenance/v1"
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0
uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b # v2.4.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
+433 -49
View File
@@ -2,14 +2,15 @@
# It creates a Coder Task that uses AI to analyze the PR changes,
# search existing docs, and comment with recommendations.
#
# Triggered by: Adding the "doc-check" label to a PR, or manual dispatch.
# Triggered by: PR opened, PR updated (new commits), or manual dispatch.
name: AI Documentation Check
on:
pull_request:
types:
- labeled
- opened # New PR - full analysis
- synchronize # PR updated with new commits - brief update
workflow_dispatch:
inputs:
pr_url:
@@ -27,8 +28,7 @@ jobs:
name: Analyze PR for Documentation Updates Needed
runs-on: ubuntu-latest
if: |
(github.event.label.name == 'doc-check' || github.event_name == 'workflow_dispatch') &&
(github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch')
github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch'
timeout-minutes: 30
env:
CODER_URL: ${{ secrets.DOC_CHECK_CODER_URL }}
@@ -39,11 +39,18 @@ jobs:
actions: write
steps:
- name: Setup Coder CLI
uses: coder/setup-action@4a607a8113d4e676e2d7c34caa20a814bc88bfda # v1.1.0
with:
access_url: ${{ secrets.DOC_CHECK_CODER_URL }}
coder_session_token: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }}
- name: Determine PR Context
id: determine-context
env:
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_EVENT_NAME: ${{ github.event_name }}
GITHUB_EVENT_ACTION: ${{ github.event.action }}
GITHUB_EVENT_PR_HTML_URL: ${{ github.event.pull_request.html_url }}
GITHUB_EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
GITHUB_EVENT_SENDER_ID: ${{ github.event.sender.id }}
@@ -55,7 +62,7 @@ jobs:
echo "Using template preset: ${INPUTS_TEMPLATE_PRESET}"
echo "template_preset=${INPUTS_TEMPLATE_PRESET}" >> "${GITHUB_OUTPUT}"
# For workflow_dispatch, use the provided PR URL
# First, determine PR context based on event type
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
if ! GITHUB_USER_ID=$(gh api "users/${GITHUB_ACTOR}" --jq '.id'); then
echo "::error::Failed to get GitHub user ID for actor ${GITHUB_ACTOR}"
@@ -66,94 +73,300 @@ jobs:
echo "github_username=${GITHUB_ACTOR}" >> "${GITHUB_OUTPUT}"
echo "Using PR URL: ${INPUTS_PR_URL}"
# Convert /pull/ to /issues/ for create-task-action compatibility
ISSUE_URL="${INPUTS_PR_URL/\/pull\//\/issues\/}"
echo "pr_url=${ISSUE_URL}" >> "${GITHUB_OUTPUT}"
# Extract PR number from URL for later use
PR_NUMBER=$(echo "${INPUTS_PR_URL}" | grep -oP '(?<=pull/)\d+')
echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}"
elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
GITHUB_USER_ID=${GITHUB_EVENT_SENDER_ID}
echo "Using label adder: ${GITHUB_EVENT_SENDER_LOGIN} (ID: ${GITHUB_USER_ID})"
echo "Using PR sender: ${GITHUB_EVENT_SENDER_LOGIN} (ID: ${GITHUB_USER_ID})"
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
echo "github_username=${GITHUB_EVENT_SENDER_LOGIN}" >> "${GITHUB_OUTPUT}"
echo "Using PR URL: ${GITHUB_EVENT_PR_HTML_URL}"
# Convert /pull/ to /issues/ for create-task-action compatibility
ISSUE_URL="${GITHUB_EVENT_PR_HTML_URL/\/pull\//\/issues\/}"
echo "pr_url=${ISSUE_URL}" >> "${GITHUB_OUTPUT}"
echo "pr_number=${GITHUB_EVENT_PR_NUMBER}" >> "${GITHUB_OUTPUT}"
PR_NUMBER="${GITHUB_EVENT_PR_NUMBER}"
echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}"
else
echo "::error::Unsupported event type: ${GITHUB_EVENT_NAME}"
exit 1
fi
# Check if bot has already analyzed this PR by looking for our marker comment
# This determines whether to use full analysis or brief update prompt
echo "Checking for existing doc-check analysis on PR #${PR_NUMBER}..."
EXISTING_ANALYSIS=$(gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \
--jq '.[] | select(.body | contains("<!-- doc-check-analysis -->")) | .id' | head -1 || echo "")
if [[ -n "${EXISTING_ANALYSIS}" ]]; then
echo "Found existing analysis (comment ID: ${EXISTING_ANALYSIS})"
echo "Will use UPDATE prompt (brief)"
echo "is_new_pr=false" >> "${GITHUB_OUTPUT}"
else
echo "No existing analysis found"
echo "Will use NEW PR prompt (full analysis)"
echo "is_new_pr=true" >> "${GITHUB_OUTPUT}"
fi
echo "trigger_type=${GITHUB_EVENT_ACTION:-manual}" >> "${GITHUB_OUTPUT}"
- name: Extract changed files and build prompt
id: extract-context
env:
PR_URL: ${{ steps.determine-context.outputs.pr_url }}
PR_NUMBER: ${{ steps.determine-context.outputs.pr_number }}
TRIGGER_TYPE: ${{ steps.determine-context.outputs.trigger_type }}
IS_NEW_PR: ${{ steps.determine-context.outputs.is_new_pr }}
GH_TOKEN: ${{ github.token }}
run: |
echo "Analyzing PR #${PR_NUMBER}"
echo "Analyzing PR #${PR_NUMBER} (trigger: ${TRIGGER_TYPE}, is_new: ${IS_NEW_PR})"
# Build task prompt - using unquoted heredoc so variables expand
TASK_PROMPT=$(cat <<EOF
Review PR #${PR_NUMBER} and determine if documentation needs updating or creating.
# Generate random suffix for unique task naming (avoids container name conflicts)
RANDOM_SUFFIX=$(head -c 4 /dev/urandom | xxd -p)
echo "task_suffix=${RANDOM_SUFFIX}" >> "${GITHUB_OUTPUT}"
echo "Generated task suffix: ${RANDOM_SUFFIX}"
PR URL: ${PR_URL}
WORKFLOW:
1. Setup (repo is pre-cloned at ~/coder)
# Common setup instructions (unquoted heredoc so PR_NUMBER expands)
SETUP_INSTRUCTIONS=$(cat <<SETUPEOF
SETUP (repo is pre-cloned at ~/coder):
cd ~/coder
git fetch origin pull/${PR_NUMBER}/head:pr-${PR_NUMBER}
git checkout pr-${PR_NUMBER}
SETUPEOF
)
2. Get PR info
Use GitHub MCP tools to get PR title, body, and diff
Or use: git diff main...pr-${PR_NUMBER}
# Full analysis comment format (for new PRs)
COMMENT_FORMAT=$(cat <<'COMMENTEOF'
FULL ANALYSIS COMMENT FORMAT (for new PRs):
<!-- doc-check-analysis -->
## Documentation Check
3. Understand Changes
Read the diff and identify what changed
Ask: Is this user-facing? Does it change behavior? Is it a new feature?
**Analyzed:** [current date/time]
4. Search for Related Docs
cat ~/coder/docs/manifest.json | jq '.routes[] | {title, path}' | head -50
grep -ri "relevant_term" ~/coder/docs/ --include="*.md"
5. Decide
NEEDS DOCS if: New feature, API change, CLI change, behavior change, user-visible
NO DOCS if: Internal refactor, test-only, already documented, non-user-facing, dependency updates
FIRST check: Did this PR already update docs? If yes and complete, say "No Changes Needed"
6. Comment on the PR using this format
COMMENT FORMAT:
## 📚 Documentation Check
### ✅ Updates Needed
### Updates Needed
- **[docs/path/file.md](github_link)** - Brief what needs changing
### 📝 New Docs Needed
### New Docs Needed
- **docs/suggested/location.md** - What should be documented
### No Changes Needed
### No Changes Needed
[Reason: Documents already updated in PR | Internal changes only | Test-only | No user-facing impact]
---
*This comment was generated by an AI Agent through [Coder Tasks](https://coder.com/docs/ai-coder/tasks)*
*Analysis by [Coder Tasks](https://coder.com/docs/ai-coder/tasks) - Updates will appear as new comments*
COMMENTEOF
)
DOCS STRUCTURE:
Read ~/coder/docs/manifest.json for the complete documentation structure.
Common areas include: reference/, admin/, user-guides/, ai-coder/, install/, tutorials/
But check manifest.json - it has everything.
# Build different prompts based on trigger type
if [[ "${IS_NEW_PR}" == "true" ]]; then
echo "Building NEW PR prompt (full analysis)"
TASK_PROMPT=$(cat <<EOF
Doc Check: PR #${PR_NUMBER} - Full Analysis
═══════════════════════════════════════════════════════════
NEW PR DOCUMENTATION CHECK - Full Analysis Required
═══════════════════════════════════════════════════════════
Review PR #${PR_NUMBER} and determine if documentation needs updating or creating.
This is a NEW PR - perform a complete analysis from scratch.
PR URL: ${PR_URL}
${SETUP_INSTRUCTIONS}
⚠️ CRITICAL REQUIREMENTS:
1. You MUST complete each step below and SHOW YOUR WORK.
2. DO NOT skip steps. DO NOT make assumptions. DO NOT comment until Step 5.
3. You MUST use the coder_report_task tool to report your progress:
Tool: coder_report_task
Parameters:
- state: "working" | "idle"
- summary: string (max 160 chars, no newlines)
- link: optional URL (e.g., PR URL)
REQUIRED CALLS:
- After each STEP: coder_report_task(state="working", summary="Step N: <brief desc>")
- FINAL STEP (after posting comment): coder_report_task(state="idle", summary="Complete", link="${PR_URL}")
⚠️ The CI monitors your state. You MUST call coder_report_task(state="idle") when finished or it will timeout!
═══════════════════════════════════════════════════════════
STEP 1: Fetch and display PR info
═══════════════════════════════════════════════════════════
Run these commands and SHOW THE OUTPUT:
git fetch origin pull/${PR_NUMBER}/head:pr-${PR_NUMBER}
git checkout pr-${PR_NUMBER}
git log -1 --format="PR Title: %s%nAuthor: %an%nDate: %ad"
CHECKPOINT: State the PR title and what it claims to do.
═══════════════════════════════════════════════════════════
STEP 2: Review the actual diff
═══════════════════════════════════════════════════════════
Run and SHOW KEY PORTIONS:
git diff main...pr-${PR_NUMBER} --stat
git diff main...pr-${PR_NUMBER} -- "*.go" "*.ts" "*.md" | head -200
CHECKPOINT: List the files changed and summarize what ACTUALLY changed.
Include specific line references (e.g., "+// NewFunction added at line 45")
═══════════════════════════════════════════════════════════
STEP 3: Search existing documentation
═══════════════════════════════════════════════════════════
Based on what you found in Step 2, search for related docs:
grep -ri "relevant_term" ~/coder/docs/ --include="*.md" -l
cat ~/coder/docs/manifest.json | jq '.routes[] | {title, path}' | head -30
CHECKPOINT: List which docs you searched and what you found (or didn't find).
═══════════════════════════════════════════════════════════
STEP 4: Make your determination WITH EVIDENCE
═══════════════════════════════════════════════════════════
Answer these questions explicitly:
1. What files were modified? (list them from Step 2)
2. Is this user-facing? Why or why not?
3. Does this change any: CLI commands? API endpoints? UI behavior? Configuration?
4. Did the PR already include doc updates? Are they complete?
5. What existing docs (from Step 3) might need updates?
Decision criteria:
NEEDS DOCS: New feature, API change, CLI change, behavior change, user-visible change
NO DOCS: Internal refactor, test-only, already documented in PR, non-user-facing
CHECKPOINT: State your decision and the SPECIFIC EVIDENCE from the diff that supports it.
═══════════════════════════════════════════════════════════
STEP 5: Post your comment (FINAL STEP - only after completing 1-4)
═══════════════════════════════════════════════════════════
Now and ONLY now, post a comment on the PR using this format:
${COMMENT_FORMAT}
Include references to specific files/lines that informed your decision.
Keep headings clean (no emojis in headings). Use ✓ ⚠ ✗ sparingly for status only.
DOCS STRUCTURE (for reference):
~/coder/docs/manifest.json contains the full structure
Common areas: reference/, admin/, user-guides/, ai-coder/, install/, tutorials/
⛔ STOP AFTER COMMENTING:
- Post EXACTLY ONE comment
- Call coder_report_task(state="idle") immediately after
- DO NOT loop, retry, or post additional comments
- DO NOT continue working after reporting idle
- Your task is COMPLETE once you post the comment and report idle
EOF
)
else
echo "Building PR UPDATE prompt (incremental analysis)"
TASK_PROMPT=$(cat <<EOF
Doc Check: PR #${PR_NUMBER} - Update
═══════════════════════════════════════════════════════════
PR UPDATE - Quick Documentation Re-check
═══════════════════════════════════════════════════════════
PR #${PR_NUMBER} has been UPDATED with new commits (trigger: ${TRIGGER_TYPE}).
PR URL: ${PR_URL}
${SETUP_INSTRUCTIONS}
IMPORTANT CONTEXT:
- This PR was previously analyzed (there may be an earlier doc-check comment)
- New commits have been pushed since then
- Your job is to provide a BRIEF update, not repeat the full analysis
⚠️ CRITICAL REQUIREMENTS:
1. You MUST show your work before commenting. DO NOT skip steps.
2. You MUST use the coder_report_task tool to report your progress:
Tool: coder_report_task
Parameters:
- state: "working" | "idle"
- summary: string (max 160 chars, no newlines)
- link: optional URL
REQUIRED CALLS:
- After each STEP: coder_report_task(state="working", summary="Step N: <brief desc>")
- FINAL STEP (after posting comment): coder_report_task(state="idle", summary="Update complete", link="${PR_URL}")
⚠️ The CI monitors your state. You MUST call coder_report_task(state="idle") when finished or it will timeout!
═══════════════════════════════════════════════════════════
STEP 1: Check what changed since last analysis
═══════════════════════════════════════════════════════════
Run and SHOW OUTPUT:
git fetch origin pull/${PR_NUMBER}/head:pr-${PR_NUMBER}
git checkout pr-${PR_NUMBER}
git log --oneline -5
CHECKPOINT: List the recent commits and what they claim to do.
═══════════════════════════════════════════════════════════
STEP 2: Quick diff review
═══════════════════════════════════════════════════════════
Run and SHOW KEY CHANGES:
git diff HEAD~3..HEAD --stat
git diff HEAD~3..HEAD -- "*.md" | head -50 # Check if docs were updated
CHECKPOINT: Summarize what actually changed in the recent commits.
Note any doc file changes.
═══════════════════════════════════════════════════════════
STEP 3: Make determination and post comment (FINAL STEP)
═══════════════════════════════════════════════════════════
Based on Steps 1-2, post a BRIEF update comment:
UPDATE COMMENT FORMAT (keep it SHORT!):
<!-- doc-check-update -->
### Doc Check Update
**Commits reviewed:** [X new commits]
[Pick ONE status line based on what you found:]
✓ **No changes needed** - [brief reason: minor fix / internal change / etc.]
✓ **Docs updated** - [what was added/changed in docs]
⚠ **Still needs docs** - [brief reminder of what's outstanding]
⚠ **Updated but requesting changes** - [acknowledge changes but note what's still missing]
✗ **New issues found** - [if new commits introduce new doc requirements]
---
*[Coder Tasks](https://coder.com/docs/ai-coder/tasks)*
GUIDELINES:
- Be concise! 2-4 lines max
- Reference specific commits/files from your analysis
- Don't repeat previous analysis
- Only do full re-analysis if the PR direction changed significantly
⛔ STOP AFTER COMMENTING:
- Post EXACTLY ONE comment
- Call coder_report_task(state="idle") immediately after
- DO NOT loop, retry, or post additional comments
- DO NOT continue working after reporting idle
- Your task is COMPLETE once you post the comment and report idle
EOF
)
fi
# Output the prompt
{
echo "task_prompt<<EOFOUTPUT"
@@ -179,20 +392,24 @@ jobs:
coder-organization: "default"
coder-template-name: coder
coder-template-preset: ${{ steps.determine-context.outputs.template_preset }}
coder-task-name-prefix: doc-check
coder-task-name-prefix: doc-check-${{ steps.extract-context.outputs.task_suffix }}
coder-task-prompt: ${{ steps.extract-context.outputs.task_prompt }}
github-user-id: ${{ steps.determine-context.outputs.github_user_id }}
github-token: ${{ github.token }}
github-issue-url: ${{ steps.determine-context.outputs.pr_url }}
comment-on-issue: true
- name: Write outputs
- name: Write Task Info to Summary
env:
TASK_CREATED: ${{ steps.create_task.outputs.task-created }}
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
TASK_URL: ${{ steps.create_task.outputs.task-url }}
PR_URL: ${{ steps.determine-context.outputs.pr_url }}
run: |
echo "Task created: ${TASK_CREATED}"
echo "Task name: ${TASK_NAME}"
echo "Task URL: ${TASK_URL}"
echo "PR URL: ${PR_URL}"
{
echo "## Documentation Check Task"
echo ""
@@ -201,5 +418,172 @@ jobs:
echo "**Task name:** ${TASK_NAME}"
echo "**Task URL:** ${TASK_URL}"
echo ""
echo "The Coder task is analyzing the PR changes and will comment with documentation recommendations."
} >> "${GITHUB_STEP_SUMMARY}"
- name: Monitor Task Status
id: monitor_task
env:
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
run: |
echo "::group::Waiting for task workspace to be ready..."
# Wait for workspace to be running and agent to be ready
MAX_WAIT=300 # 5 minutes max wait for workspace
WAITED=0
while [[ $WAITED -lt $MAX_WAIT ]]; do
STATUS=$(coder task status "${TASK_NAME}" -o json 2>/dev/null || echo '{}')
WORKSPACE_STATUS=$(echo "$STATUS" | jq -r '.workspace_status // "unknown"')
AGENT_LIFECYCLE=$(echo "$STATUS" | jq -r '.workspace_agent_lifecycle // "unknown"')
echo "Workspace: ${WORKSPACE_STATUS}, Agent: ${AGENT_LIFECYCLE}"
if [[ "$WORKSPACE_STATUS" == "running" && "$AGENT_LIFECYCLE" == "ready" ]]; then
echo "Workspace and agent are ready!"
break
fi
if [[ "$WORKSPACE_STATUS" == "failed" || "$WORKSPACE_STATUS" == "canceled" ]]; then
echo "::error::Workspace failed to start: ${WORKSPACE_STATUS}"
exit 1
fi
sleep 5
WAITED=$((WAITED + 5))
done
if [[ $WAITED -ge $MAX_WAIT ]]; then
echo "::error::Timeout waiting for workspace to be ready"
exit 1
fi
echo "::endgroup::"
echo "::group::Monitoring task until completion..."
echo "╔══════════════════════════════════════════════════════════════╗"
printf "║ Task: %-53s ║\n" "${TASK_NAME}"
echo "║ Waiting for AI to complete analysis... ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
# Poll AgentAPI status: wait for running first, then stable
# AgentAPI: stable (idle) -> running (processing) -> stable (done)
MAX_WAIT=600 # 10 minutes max
WAITED=0
LAST_MSG=""
STARTED_RUNNING=false
while [[ $WAITED -lt $MAX_WAIT ]]; do
# Get task status for the message display
STATUS_JSON=$(coder task status "${TASK_NAME}" -o json 2>/dev/null || echo '{}')
TASK_STATUS=$(echo "$STATUS_JSON" | jq -r '.status // "unknown"')
CURRENT_STATE=$(echo "$STATUS_JSON" | jq -r '.current_state.state // "unknown"')
STATE_MSG=$(echo "$STATUS_JSON" | jq -r '.current_state.message // ""')
# Check AgentAPI directly for actual completion status
AGENT_STATUS=$(coder ssh "${TASK_NAME}" -- curl -s http://localhost:3284/status 2>/dev/null | jq -r '.status // "unknown"')
# Track when we first see running
if [[ "$AGENT_STATUS" == "running" ]]; then
STARTED_RUNNING=true
fi
# Only print when message changes to reduce noise
if [[ "$STATE_MSG" != "$LAST_MSG" ]]; then
echo "[${WAITED}s] ${CURRENT_STATE}: ${STATE_MSG:-no message}"
LAST_MSG="$STATE_MSG"
else
# Print a dot to show we're still polling
echo -n "."
fi
# Check if agent is done (must have been running first, then stable)
if [[ "$STARTED_RUNNING" == "true" && "$AGENT_STATUS" == "stable" ]]; then
echo ""
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Task completed (AgentAPI status: stable) ║"
echo "╚══════════════════════════════════════════════════════════════╝"
break
fi
# Also check for paused status (task finished)
if [[ "$TASK_STATUS" == "paused" ]]; then
echo ""
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Task paused/completed (state: paused) ║"
echo "╚══════════════════════════════════════════════════════════════╝"
break
fi
sleep 5
WAITED=$((WAITED + 5))
done
if [[ $WAITED -ge $MAX_WAIT ]]; then
echo ""
echo "::warning::Task monitoring timed out after ${MAX_WAIT}s - agent may not have reported completion"
fi
echo "::endgroup::"
- name: Fetch Task Messages Log
if: always()
env:
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
run: |
echo "::group::AI Conversation Log"
echo ""
if [[ -z "${TASK_NAME}" ]]; then
echo "No task name found, skipping message fetch"
echo "::endgroup::"
exit 0
fi
MESSAGES=$(coder ssh "${TASK_NAME}" -- curl -s http://localhost:3284/messages 2>/dev/null || echo '{}')
if echo "$MESSAGES" | jq -e '.messages' >/dev/null 2>&1; then
MSG_COUNT=$(echo "$MESSAGES" | jq '.messages | length')
echo "Retrieved ${MSG_COUNT} messages from task conversation"
echo ""
echo "$MESSAGES" | jq -r '.messages[] | "═══════════════════════════════════════════════════════════\n[\(.role | ascii_upcase)]\n═══════════════════════════════════════════════════════════════\n\(.content)\n"' 2>/dev/null || echo "Failed to parse messages"
else
echo "No messages retrieved or task not accessible"
fi
echo "::endgroup::"
- name: Cleanup Task and Workspace
if: always()
env:
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
run: |
echo "::group::Cleaning up task and workspace..."
if [[ -n "${TASK_NAME}" ]]; then
echo "Deleting task: ${TASK_NAME}"
coder task delete --yes "${TASK_NAME}" 2>&1 || echo "Task deletion failed or already deleted"
echo "Cleanup complete"
else
echo "No task name found, skipping cleanup"
fi
echo "::endgroup::"
- name: Write Final Summary
if: always()
env:
TASK_NAME: ${{ steps.create_task.outputs.task-name }}
TASK_URL: ${{ steps.create_task.outputs.task-url }}
PR_NUMBER: ${{ steps.determine-context.outputs.pr_number }}
run: |
{
echo ""
echo "---"
echo "### Task Completed"
echo ""
echo "- **Task:** ${TASK_NAME}"
echo "- **PR:** #${PR_NUMBER}"
echo "- **Status:** Completed and cleaned up"
echo ""
echo "The AI has analyzed the PR and commented with documentation recommendations."
} >> "${GITHUB_STEP_SUMMARY}"
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
# on version 2.29 and above.
nix_version: "2.28.5"
- uses: nix-community/cache-nix-action@b426b118b6dc86d6952988d396aa7c6b09776d08 # v7.0.0
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
with:
# restore and save a cache using this key
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
@@ -78,7 +78,7 @@ jobs:
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Login to DockerHub
if: github.ref == 'refs/heads/main'
+1 -1
View File
@@ -20,4 +20,4 @@ jobs:
egress-policy: audit
- name: Assign author
uses: toshimaru/auto-author-assign@4d585cc37690897bd9015942ed6e766aa7cdb97f # v3.0.1
uses: toshimaru/auto-author-assign@16f0022cf3d7970c106d8d1105f75a1165edb516 # v2.1.1
+3 -3
View File
@@ -454,7 +454,7 @@ jobs:
id: attest_base
if: ${{ !inputs.dry_run && steps.image-base-tag.outputs.tag != '' }}
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@daf44fb950173508f38bd2406030372c1d1162b1 # v3.0.0
with:
subject-name: ${{ steps.image-base-tag.outputs.tag }}
predicate-type: "https://slsa.dev/provenance/v1"
@@ -570,7 +570,7 @@ jobs:
id: attest_main
if: ${{ !inputs.dry_run }}
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@daf44fb950173508f38bd2406030372c1d1162b1 # v3.0.0
with:
subject-name: ${{ steps.build_docker.outputs.multiarch_image }}
predicate-type: "https://slsa.dev/provenance/v1"
@@ -614,7 +614,7 @@ jobs:
id: attest_latest
if: ${{ !inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' }}
continue-on-error: true
uses: actions/attest@7667f588f2f73a90cea6c7ac70e78266c4f76616 # v3.1.0
uses: actions/attest@daf44fb950173508f38bd2406030372c1d1162b1 # v3.0.0
with:
subject-name: ${{ steps.latest_tag.outputs.tag }}
predicate-type: "https://slsa.dev/provenance/v1"
+1 -1
View File
@@ -47,6 +47,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5
uses: github/codeql-action/upload-sarif@fe4161a26a8629af62121b670040955b330f9af2 # v3.29.5
with:
sarif_file: results.sarif
+3 -3
View File
@@ -40,7 +40,7 @@ jobs:
uses: ./.github/actions/setup-go
- name: Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v3.29.5
with:
languages: go, javascript
@@ -50,7 +50,7 @@ jobs:
rm Makefile
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v3.29.5
- name: Send Slack notification on failure
if: ${{ failure() }}
@@ -154,7 +154,7 @@ jobs:
severity: "CRITICAL,HIGH"
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v3.29.5
uses: github/codeql-action/upload-sarif@fe4161a26a8629af62121b670040955b330f9af2 # v3.29.5
with:
sarif_file: trivy-results.sarif
category: "Trivy"
-1
View File
@@ -3,7 +3,6 @@
.eslintcache
.gitpod.yml
.idea
.run
**/*.swp
gotests.coverage
gotests.xml
-8
View File
@@ -211,14 +211,6 @@ issues:
- path: scripts/rules.go
linters:
- ALL
# Boundary code is imported from github.com/coder/boundary and has different
# lint standards. Suppress lint issues in this imported code.
- path: enterprise/cli/boundary/
linters:
- revive
- gocritic
- gosec
- errorlint
fix: true
max-issues-per-linter: 0
+6 -17
View File
@@ -464,7 +464,7 @@ ifdef FILE
# Format single file
if [[ -f "$(FILE)" ]] && [[ "$(FILE)" == *.go ]] && ! grep -q "DO NOT EDIT" "$(FILE)"; then \
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/go$(RESET) $(FILE)"; \
./scripts/format_go_file.sh "$(FILE)"; \
go run mvdan.cc/gofumpt@v0.8.0 -w -l "$(FILE)"; \
fi
else
go mod tidy
@@ -473,7 +473,7 @@ else
# https://github.com/mvdan/gofumpt#visual-studio-code
find . $(FIND_EXCLUSIONS) -type f -name '*.go' -print0 | \
xargs -0 grep -E --null -L '^// Code generated .* DO NOT EDIT\.$$' | \
xargs -0 ./scripts/format_go_file.sh
xargs -0 go run mvdan.cc/gofumpt@v0.8.0 -w -l
endif
.PHONY: fmt/go
@@ -578,7 +578,7 @@ lint/go:
./scripts/check_codersdk_imports.sh
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2)
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
go tool github.com/coder/paralleltestctx/cmd/paralleltestctx -custom-funcs="testutil.Context" ./...
go run github.com/coder/paralleltestctx/cmd/paralleltestctx@v0.0.1 -custom-funcs="testutil.Context" ./...
.PHONY: lint/go
lint/examples:
@@ -604,7 +604,7 @@ lint/actions: lint/actions/actionlint lint/actions/zizmor
.PHONY: lint/actions
lint/actions/actionlint:
go tool github.com/rhysd/actionlint/cmd/actionlint
go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.7
.PHONY: lint/actions/actionlint
lint/actions/zizmor:
@@ -1018,8 +1018,7 @@ endif
# default to 8x8 parallelism to avoid overwhelming our workspaces. Hopefully we can remove these defaults
# when we get our test suite's resource utilization under control.
# Use testsmallbatch tag to reduce wireguard memory allocation in tests (from ~18GB to negligible).
GOTEST_FLAGS := -tags=testsmallbatch -v -p $(or $(TEST_NUM_PARALLEL_PACKAGES),"8") -parallel=$(or $(TEST_NUM_PARALLEL_TESTS),"8")
GOTEST_FLAGS := -v -p $(or $(TEST_NUM_PARALLEL_PACKAGES),"8") -parallel=$(or $(TEST_NUM_PARALLEL_TESTS),"8")
# The most common use is to set TEST_COUNT=1 to avoid Go's test cache.
ifdef TEST_COUNT
@@ -1034,14 +1033,6 @@ ifdef RUN
GOTEST_FLAGS += -run $(RUN)
endif
ifdef TEST_CPUPROFILE
GOTEST_FLAGS += -cpuprofile=$(TEST_CPUPROFILE)
endif
ifdef TEST_MEMPROFILE
GOTEST_FLAGS += -memprofile=$(TEST_MEMPROFILE)
endif
TEST_PACKAGES ?= ./...
test:
@@ -1090,7 +1081,6 @@ test-postgres: test-postgres-docker
--jsonfile="gotests.json" \
$(GOTESTSUM_RETRY_FLAGS) \
--packages="./..." -- \
-tags=testsmallbatch \
-timeout=20m \
-count=1
.PHONY: test-postgres
@@ -1163,7 +1153,7 @@ test-postgres-docker:
# Make sure to keep this in sync with test-go-race from .github/workflows/ci.yaml.
test-race:
$(GIT_FLAGS) gotestsum --junitfile="gotests.xml" -- -tags=testsmallbatch -race -count=1 -parallel 4 -p 4 ./...
$(GIT_FLAGS) gotestsum --junitfile="gotests.xml" -- -race -count=1 -parallel 4 -p 4 ./...
.PHONY: test-race
test-tailnet-integration:
@@ -1173,7 +1163,6 @@ test-tailnet-integration:
TS_DEBUG_NETCHECK=true \
GOTRACEBACK=single \
go test \
-tags=testsmallbatch \
-exec "sudo -E" \
-timeout=5m \
-count=1 \
+5 -43
View File
@@ -36,14 +36,13 @@ import (
"tailscale.com/types/netlogtype"
"tailscale.com/util/clientmetric"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/clistat"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentscripts"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/agent/boundarylogproxy"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/agent/proto/resourcesmonitor"
"github.com/coder/coder/v2/agent/reconnectingpty"
@@ -103,7 +102,6 @@ type Options struct {
Clock quartz.Clock
SocketServerEnabled bool
SocketPath string // Path for the agent socket server socket
BoundaryLogProxySocketPath string
}
type Client interface {
@@ -207,11 +205,10 @@ func New(options Options) Agent {
metrics: newAgentMetrics(prometheusRegistry),
execer: options.Execer,
devcontainers: options.Devcontainers,
containerAPIOptions: options.DevcontainerAPIOptions,
socketPath: options.SocketPath,
socketServerEnabled: options.SocketServerEnabled,
boundaryLogProxySocketPath: options.BoundaryLogProxySocketPath,
devcontainers: options.Devcontainers,
containerAPIOptions: options.DevcontainerAPIOptions,
socketPath: options.SocketPath,
socketServerEnabled: options.SocketServerEnabled,
}
// Initially, we have a closed channel, reflecting the fact that we are not initially connected.
// Each time we connect we replace the channel (while holding the closeMutex) with a new one
@@ -280,11 +277,6 @@ type agent struct {
logSender *agentsdk.LogSender
// boundaryLogProxy is a socket server that forwards boundary audit logs to coderd.
// It may be nil if there is a problem starting the server.
boundaryLogProxy *boundarylogproxy.Server
boundaryLogProxySocketPath string
prometheusRegistry *prometheus.Registry
// metrics are prometheus registered metrics that will be collected and
// labeled in Coder with the agent + workspace.
@@ -379,7 +371,6 @@ func (a *agent) init() {
)
a.initSocketServer()
a.startBoundaryLogProxyServer()
go a.runLoop()
}
@@ -404,19 +395,6 @@ func (a *agent) initSocketServer() {
a.logger.Debug(a.hardCtx, "socket server started", slog.F("path", a.socketPath))
}
// startBoundaryLogProxyServer starts the boundary log proxy socket server.
func (a *agent) startBoundaryLogProxyServer() {
proxy := boundarylogproxy.NewServer(a.logger, a.boundaryLogProxySocketPath)
if err := proxy.Start(); err != nil {
a.logger.Warn(a.hardCtx, "failed to start boundary log proxy", slog.Error(err))
return
}
a.boundaryLogProxy = proxy
a.logger.Info(a.hardCtx, "boundary log proxy server started",
slog.F("socket_path", a.boundaryLogProxySocketPath))
}
// runLoop attempts to start the agent in a retry loop.
// Coder may be offline temporarily, a connection issue
// may be happening, but regardless after the intermittent
@@ -1034,15 +1012,6 @@ func (a *agent) run() (retErr error) {
return err
})
// Forward boundary audit logs to coderd if boundary log forwarding is enabled.
// These are audit logs so they should continue during graceful shutdown.
if a.boundaryLogProxy != nil {
proxyFunc := func(ctx context.Context, aAPI proto.DRPCAgentClient27) error {
return a.boundaryLogProxy.RunForwarder(ctx, aAPI)
}
connMan.startAgentAPI("boundary log proxy", gracefulShutdownBehaviorRemain, proxyFunc)
}
// part of graceful shut down is reporting the final lifecycle states, e.g "ShuttingDown" so the
// lifecycle reporting has to be via gracefulShutdownBehaviorRemain
connMan.startAgentAPI("report lifecycle", gracefulShutdownBehaviorRemain, a.reportLifecycle)
@@ -2013,13 +1982,6 @@ func (a *agent) Close() error {
a.logger.Error(a.hardCtx, "container API close", slog.Error(err))
}
if a.boundaryLogProxy != nil {
err = a.boundaryLogProxy.Close()
if err != nil {
a.logger.Warn(context.Background(), "close boundary log proxy", slog.Error(err))
}
}
// Wait for the graceful shutdown to complete, but don't wait forever so
// that we don't break user expectations.
go func() {
+3 -2
View File
@@ -6,8 +6,9 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/testutil"
)
+7 -5
View File
@@ -25,6 +25,10 @@ import (
"testing"
"time"
"go.uber.org/goleak"
"tailscale.com/net/speedtest"
"tailscale.com/tailcfg"
"github.com/bramvdbogaerde/go-scp"
"github.com/google/uuid"
"github.com/ory/dockertest/v3"
@@ -36,14 +40,12 @@ import (
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
"golang.org/x/crypto/ssh"
"golang.org/x/xerrors"
"tailscale.com/net/speedtest"
"tailscale.com/tailcfg"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentssh"
+1 -1
View File
@@ -26,7 +26,7 @@ import (
"github.com/spf13/afero"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentcontainers/ignore"
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
"github.com/coder/coder/v2/agent/agentexec"
+3 -3
View File
@@ -27,9 +27,9 @@ import (
"go.uber.org/mock/gomock"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"cdr.dev/slog/v3/sloggers/slogtest"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentcontainers/acmock"
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
+2 -1
View File
@@ -10,10 +10,11 @@ package dcspec
import (
"bytes"
"encoding/json"
"errors"
)
import "encoding/json"
func UnmarshalDevContainer(data []byte) (DevContainer, error) {
var r DevContainer
err := json.Unmarshal(data, &r)
+1 -1
View File
@@ -61,7 +61,7 @@ fi
exec 3>&-
# Format the generated code.
"${PROJECT_ROOT}/scripts/format_go_file.sh" "${TMPDIR}/${DEST_FILENAME}"
go run mvdan.cc/gofumpt@v0.8.0 -w -l "${TMPDIR}/${DEST_FILENAME}"
# Add a header so that Go recognizes this as a generated file.
if grep -q -- "\[-i extension\]" < <(sed -h 2>&1); then
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/google/uuid"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/codersdk"
)
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/codersdk"
)
@@ -21,8 +21,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/codersdk"
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"runtime"
"strings"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/usershell"
"github.com/coder/coder/v2/pty"
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"github.com/spf13/afero"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
)
const (
+2 -1
View File
@@ -7,7 +7,8 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/codersdk"
)
+2 -1
View File
@@ -20,7 +20,8 @@ import (
"golang.org/x/xerrors"
"google.golang.org/protobuf/types/known/timestamppb"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/database/dbtime"
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"os/exec"
"syscall"
"cdr.dev/slog/v3"
"cdr.dev/slog"
)
func cmdSysProcAttr() *syscall.SysProcAttr {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"os/exec"
"syscall"
"cdr.dev/slog/v3"
"cdr.dev/slog"
)
func cmdSysProcAttr() *syscall.SysProcAttr {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"storj.io/drpc/drpcmux"
"storj.io/drpc/drpcserver"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentsocket/proto"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/codersdk/drpcsdk"
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/agenttest"
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentsocket/proto"
"github.com/coder/coder/v2/agent/unit"
)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentsocket"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/testutil"
+2 -1
View File
@@ -27,7 +27,8 @@ import (
gossh "golang.org/x/crypto/ssh"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentrsa"
+3 -2
View File
@@ -24,8 +24,9 @@ import (
"go.uber.org/goleak"
"golang.org/x/crypto/ssh"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/pty/ptytest"
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"os"
"syscall"
"cdr.dev/slog/v3"
"cdr.dev/slog"
)
func cmdSysProcAttr() *syscall.SysProcAttr {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"os"
"syscall"
"cdr.dev/slog/v3"
"cdr.dev/slog"
)
func cmdSysProcAttr() *syscall.SysProcAttr {
+1 -1
View File
@@ -15,7 +15,7 @@ import (
gossh "golang.org/x/crypto/ssh"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
)
// streamLocalForwardPayload describes the extra data sent in a
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"go.uber.org/atomic"
gossh "golang.org/x/crypto/ssh"
"cdr.dev/slog/v3"
"cdr.dev/slog"
)
// localForwardChannelData is copied from the ssh package.
+1 -1
View File
@@ -21,7 +21,7 @@ import (
gossh "golang.org/x/crypto/ssh"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
)
const (
+1 -1
View File
@@ -21,7 +21,7 @@ import (
"storj.io/drpc/drpcserver"
"tailscale.com/tailcfg"
"cdr.dev/slog/v3"
"cdr.dev/slog"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/quartz"
-164
View File
@@ -1,164 +0,0 @@
//go:build linux || darwin
package agent_test
import (
"context"
"net"
"path/filepath"
"sync"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/boundarylogproxy"
"github.com/coder/coder/v2/agent/boundarylogproxy/codec"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/agentapi"
"github.com/coder/coder/v2/testutil"
)
// logSink captures structured log entries for testing.
type logSink struct {
mu sync.Mutex
entries []slog.SinkEntry
}
func (s *logSink) LogEntry(_ context.Context, e slog.SinkEntry) {
s.mu.Lock()
defer s.mu.Unlock()
s.entries = append(s.entries, e)
}
func (*logSink) Sync() {}
func (s *logSink) getEntries() []slog.SinkEntry {
s.mu.Lock()
defer s.mu.Unlock()
return append([]slog.SinkEntry{}, s.entries...)
}
// getField returns the value of a field by name from a slog.Map.
func getField(fields slog.Map, name string) interface{} {
for _, f := range fields {
if f.Name == name {
return f.Value
}
}
return nil
}
func sendBoundaryLogsRequest(t *testing.T, conn net.Conn, req *agentproto.ReportBoundaryLogsRequest) {
t.Helper()
data, err := proto.Marshal(req)
require.NoError(t, err)
err = codec.WriteFrame(conn, codec.TagV1, data)
require.NoError(t, err)
}
// TestBoundaryLogs_EndToEnd is an end-to-end test that sends a protobuf
// message over the agent's unix socket (as boundary would) and verifies
// it is ultimately logged by coderd with the correct structured fields.
func TestBoundaryLogs_EndToEnd(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock")
srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath)
err := srv.Start()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, srv.Close()) })
sink := &logSink{}
logger := slog.Make(sink)
workspaceID := uuid.New()
reporter := &agentapi.BoundaryLogsAPI{
Log: logger,
WorkspaceID: workspaceID,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
forwarderDone := make(chan error, 1)
go func() {
forwarderDone <- srv.RunForwarder(ctx, reporter)
}()
conn, err := net.Dial("unix", socketPath)
require.NoError(t, err)
defer conn.Close()
// Allowed HTTP request.
req := &agentproto.ReportBoundaryLogsRequest{
Logs: []*agentproto.BoundaryLog{
{
Allowed: true,
Time: timestamppb.Now(),
Resource: &agentproto.BoundaryLog_HttpRequest_{
HttpRequest: &agentproto.BoundaryLog_HttpRequest{
Method: "GET",
Url: "https://example.com/allowed",
MatchedRule: "*.example.com",
},
},
},
},
}
sendBoundaryLogsRequest(t, conn, req)
require.Eventually(t, func() bool {
return len(sink.getEntries()) >= 1
}, testutil.WaitShort, testutil.IntervalFast)
entries := sink.getEntries()
require.Len(t, entries, 1)
entry := entries[0]
require.Equal(t, slog.LevelInfo, entry.Level)
require.Equal(t, "boundary_request", entry.Message)
require.Equal(t, "allow", getField(entry.Fields, "decision"))
require.Equal(t, workspaceID.String(), getField(entry.Fields, "workspace_id"))
require.Equal(t, "GET", getField(entry.Fields, "http_method"))
require.Equal(t, "https://example.com/allowed", getField(entry.Fields, "http_url"))
require.Equal(t, "*.example.com", getField(entry.Fields, "matched_rule"))
// Denied HTTP request.
req2 := &agentproto.ReportBoundaryLogsRequest{
Logs: []*agentproto.BoundaryLog{
{
Allowed: false,
Time: timestamppb.Now(),
Resource: &agentproto.BoundaryLog_HttpRequest_{
HttpRequest: &agentproto.BoundaryLog_HttpRequest{
Method: "POST",
Url: "https://blocked.com/denied",
},
},
},
},
}
sendBoundaryLogsRequest(t, conn, req2)
require.Eventually(t, func() bool {
return len(sink.getEntries()) >= 2
}, testutil.WaitShort, testutil.IntervalFast)
entries = sink.getEntries()
entry = entries[1]
require.Len(t, entries, 2)
require.Equal(t, slog.LevelInfo, entry.Level)
require.Equal(t, "boundary_request", entry.Message)
require.Equal(t, "deny", getField(entry.Fields, "decision"))
require.Equal(t, workspaceID.String(), getField(entry.Fields, "workspace_id"))
require.Equal(t, "POST", getField(entry.Fields, "http_method"))
require.Equal(t, "https://blocked.com/denied", getField(entry.Fields, "http_url"))
require.Equal(t, nil, getField(entry.Fields, "matched_rule"))
cancel()
<-forwarderDone
}
-127
View File
@@ -1,127 +0,0 @@
// Package codec implements the wire format for agent <-> boundary communication.
//
// Wire Format:
// - 8 bits: big-endian tag
// - 24 bits: big-endian length of the protobuf data (bit usage depends on tag)
// - length bytes: encoded protobuf data
//
// Note that while there are 24 bits available for the length, the actual maximum
// length depends on the tag. For TagV1, only 15 bits are used (MaxMessageSizeV1).
package codec
import (
"encoding/binary"
"io"
"golang.org/x/xerrors"
)
type Tag uint8
const (
// TagV1 identifies the first revision of the protocol. This version has a maximum
// data length of MaxMessageSizeV1.
TagV1 Tag = 1
)
const (
// DataLength is the number of bits used for the length of encoded protobuf data.
DataLength = 24
// tagLength is the number of bits used for the tag.
tagLength = 8
// MaxMessageSizeV1 is the maximum size of the encoded protobuf messages sent
// over the wire for the TagV1 tag. While the wire format allows 24 bits for
// length, TagV1 only uses 15 bits.
MaxMessageSizeV1 uint32 = 1 << 15
)
var (
// ErrMessageTooLarge is returned when the message exceeds the maximum size
// allowed for the tag.
ErrMessageTooLarge = xerrors.New("message too large")
// ErrUnsupportedTag is returned when an unrecognized tag is encountered.
ErrUnsupportedTag = xerrors.New("unsupported tag")
)
// WriteFrame writes a framed message with the given tag and data. The data
// must not exceed 2^DataLength in length.
func WriteFrame(w io.Writer, tag Tag, data []byte) error {
var maxSize uint32
switch tag {
case TagV1:
maxSize = MaxMessageSizeV1
default:
return xerrors.Errorf("%w: %d", ErrUnsupportedTag, tag)
}
if len(data) > int(maxSize) {
return xerrors.Errorf("%w for tag %d: %d > %d", ErrMessageTooLarge, tag, len(data), maxSize)
}
var header uint32
//nolint:gosec // The length check above ensures there's no overflow.
header |= uint32(len(data))
header |= uint32(tag) << DataLength
if err := binary.Write(w, binary.BigEndian, header); err != nil {
return xerrors.Errorf("write header error: %w", err)
}
if _, err := w.Write(data); err != nil {
return xerrors.Errorf("write data error: %w", err)
}
return nil
}
// ReadFrame reads a framed message, returning the decoded tag and data. If the
// message size exceeds MaxMessageSizeV1, ErrMessageTooLarge is returned. The
// provided buf is used if it has sufficient capacity; otherwise a new buffer is
// allocated. To reuse the buffer across calls, pass in the returned data slice:
//
// buf := make([]byte, initialSize)
// for {
// _, buf, _ = ReadFrame(r, buf)
// }
func ReadFrame(r io.Reader, buf []byte) (Tag, []byte, error) {
var header uint32
if err := binary.Read(r, binary.BigEndian, &header); err != nil {
return 0, nil, xerrors.Errorf("read header error: %w", err)
}
const lengthMask = (1 << DataLength) - 1
length := header & lengthMask
const tagMask = (1 << tagLength) - 1 // 0xFF
shifted := (header >> DataLength) & tagMask
if shifted > tagMask {
// This is really only here to satisfy the gosec linter. We know from above that
// shifted <= tagMask.
return 0, nil, xerrors.Errorf("invalid tag: %d", shifted)
}
tag := Tag(shifted)
var maxSize uint32
switch tag {
case TagV1:
maxSize = MaxMessageSizeV1
default:
return 0, nil, xerrors.Errorf("%w: %d", ErrUnsupportedTag, tag)
}
if length > maxSize {
return 0, nil, ErrMessageTooLarge
}
if cap(buf) < int(length) {
buf = make([]byte, length)
} else {
buf = buf[:length:cap(buf)]
}
if _, err := io.ReadFull(r, buf[:length]); err != nil {
return 0, nil, xerrors.Errorf("read full error: %w", err)
}
return tag, buf[:length], nil
}
-145
View File
@@ -1,145 +0,0 @@
package codec_test
import (
"bytes"
"encoding/binary"
"io"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent/boundarylogproxy/codec"
)
func TestRoundTrip(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tag codec.Tag
data []byte
}{
{
name: "empty data",
tag: codec.TagV1,
data: []byte{},
},
{
name: "simple data",
tag: codec.TagV1,
data: []byte("hello world"),
},
{
name: "binary data",
tag: codec.TagV1,
data: []byte{0x00, 0x01, 0x02, 0xff, 0xfe},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
err := codec.WriteFrame(&buf, tt.tag, tt.data)
require.NoError(t, err)
readBuf := make([]byte, codec.MaxMessageSizeV1)
tag, data, err := codec.ReadFrame(&buf, readBuf)
require.NoError(t, err)
require.Equal(t, tt.tag, tag)
require.Equal(t, tt.data, data)
})
}
}
func TestReadFrameTooLarge(t *testing.T) {
t.Parallel()
// Hand construct a header that indicates the message size exceeds the maximum
// message size for codec.TagV1 by one. We just write the header to buf because
// we expect codec.ReadFrame to bail out when reading the invalid length.
header := uint32(codec.TagV1)<<codec.DataLength | (codec.MaxMessageSizeV1 + 1)
data := make([]byte, 4)
binary.BigEndian.PutUint32(data, header)
var buf bytes.Buffer
_, err := buf.Write(data)
require.NoError(t, err)
readBuf := make([]byte, 1)
_, _, err = codec.ReadFrame(&buf, readBuf)
require.ErrorIs(t, err, codec.ErrMessageTooLarge)
}
func TestReadFrameEmptyReader(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
readBuf := make([]byte, codec.MaxMessageSizeV1)
_, _, err := codec.ReadFrame(&buf, readBuf)
require.ErrorIs(t, err, io.EOF)
}
func TestReadFrameInvalidTag(t *testing.T) {
t.Parallel()
// Hand construct a header that indicates a tag we don't know about. We just
// write the header to buf because we expect codec.ReadFrame to bail out when
// reading the invalid tag.
const (
dataLength uint32 = 10
bogusTag uint32 = 2
)
header := bogusTag<<codec.DataLength | dataLength
data := make([]byte, 4)
binary.BigEndian.PutUint32(data, header)
var buf bytes.Buffer
_, err := buf.Write(data)
require.NoError(t, err)
readBuf := make([]byte, 1)
_, _, err = codec.ReadFrame(&buf, readBuf)
require.ErrorIs(t, err, codec.ErrUnsupportedTag)
}
func TestReadFrameAllocatesWhenNeeded(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
data := []byte("this message is longer than the buffer")
err := codec.WriteFrame(&buf, codec.TagV1, data)
require.NoError(t, err)
// Buffer with insufficient capacity triggers allocation.
readBuf := make([]byte, 4)
tag, got, err := codec.ReadFrame(&buf, readBuf)
require.NoError(t, err)
require.Equal(t, codec.TagV1, tag)
require.Equal(t, data, got)
}
func TestWriteFrameDataSize(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
data := make([]byte, codec.MaxMessageSizeV1)
err := codec.WriteFrame(&buf, codec.TagV1, data)
require.NoError(t, err)
//nolint: makezero // This intentionally increases the slice length.
data = append(data, 0) // One byte over the maximum
err = codec.WriteFrame(&buf, codec.TagV1, data)
require.ErrorIs(t, err, codec.ErrMessageTooLarge)
}
func TestWriteFrameInvalidTag(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
data := make([]byte, 1)
const bogusTag = 2
err := codec.WriteFrame(&buf, codec.Tag(bogusTag), data)
require.ErrorIs(t, err, codec.ErrUnsupportedTag)
}
+3 -201
View File
@@ -2,204 +2,6 @@
// audit logs and forwards them to coderd via the agent API.
package boundarylogproxy
import (
"context"
"errors"
"io"
"net"
"os"
"path/filepath"
"sync"
"golang.org/x/xerrors"
"google.golang.org/protobuf/proto"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/boundarylogproxy/codec"
agentproto "github.com/coder/coder/v2/agent/proto"
)
const (
// logBufferSize is the size of the channel buffer for incoming log requests
// from workspaces. This buffer size is intended to handle short bursts of workspaces
// forwarding batches of logs in parallel.
logBufferSize = 100
)
// DefaultSocketPath returns the default path for the boundary audit log socket.
func DefaultSocketPath() string {
return filepath.Join(os.TempDir(), "boundary-audit.sock")
}
// Reporter reports boundary logs from workspaces.
type Reporter interface {
ReportBoundaryLogs(ctx context.Context, req *agentproto.ReportBoundaryLogsRequest) (*agentproto.ReportBoundaryLogsResponse, error)
}
// Server listens on a Unix socket for boundary log messages and buffers them
// for forwarding to coderd. The socket server and the forwarder are decoupled:
// - Start() creates the socket and accepts a connection from boundary
// - RunForwarder() drains the buffer and sends logs to coderd via AgentAPI
type Server struct {
logger slog.Logger
socketPath string
listener net.Listener
cancel context.CancelFunc
wg sync.WaitGroup
// logs buffers incoming log requests for the forwarder to drain.
logs chan *agentproto.ReportBoundaryLogsRequest
}
// NewServer creates a new boundary log proxy server.
func NewServer(logger slog.Logger, socketPath string) *Server {
return &Server{
logger: logger.Named("boundary-log-proxy"),
socketPath: socketPath,
logs: make(chan *agentproto.ReportBoundaryLogsRequest, logBufferSize),
}
}
// Start begins listening for connections on the Unix socket, and handles new
// connections in a separate goroutine. Incoming logs from connections are
// buffered until RunForwarder drains them.
func (s *Server) Start() error {
if err := os.Remove(s.socketPath); err != nil && !os.IsNotExist(err) {
return xerrors.Errorf("remove existing socket: %w", err)
}
listener, err := net.Listen("unix", s.socketPath)
if err != nil {
return xerrors.Errorf("listen on socket: %w", err)
}
s.listener = listener
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.wg.Add(1)
go s.acceptLoop(ctx)
s.logger.Info(ctx, "boundary log proxy started", slog.F("socket_path", s.socketPath))
return nil
}
// RunForwarder drains the log buffer and forwards logs to coderd.
// It blocks until ctx is canceled.
func (s *Server) RunForwarder(ctx context.Context, sender Reporter) error {
s.logger.Debug(ctx, "boundary log forwarder started")
for {
select {
case <-ctx.Done():
return ctx.Err()
case req := <-s.logs:
_, err := sender.ReportBoundaryLogs(ctx, req)
if err != nil {
s.logger.Warn(ctx, "failed to forward boundary logs",
slog.Error(err),
slog.F("log_count", len(req.Logs)))
// Continue forwarding other logs. The current batch is lost,
// but the socket stays alive.
}
}
}
}
func (s *Server) acceptLoop(ctx context.Context) {
defer s.wg.Done()
for {
conn, err := s.listener.Accept()
if err != nil {
if ctx.Err() != nil {
s.logger.Warn(ctx, "accept loop terminated", slog.Error(ctx.Err()))
return
}
s.logger.Warn(ctx, "socket accept error", slog.Error(err))
continue
}
s.wg.Add(1)
go s.handleConnection(ctx, conn)
}
}
func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
defer s.wg.Done()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
s.wg.Add(1)
go func() {
defer s.wg.Done()
<-ctx.Done()
_ = conn.Close()
}()
// This is intended to be a sane starting point for the read buffer size. It may be
// grown by codec.ReadFrame if necessary.
const initBufSize = 1 << 10
buf := make([]byte, initBufSize)
for {
select {
case <-ctx.Done():
return
default:
}
var (
tag codec.Tag
err error
)
tag, buf, err = codec.ReadFrame(conn, buf)
switch {
case errors.Is(err, io.EOF) || errors.Is(err, net.ErrClosed):
return
case err != nil:
s.logger.Warn(ctx, "read frame error", slog.Error(err))
return
}
if tag != codec.TagV1 {
s.logger.Warn(ctx, "invalid tag value", slog.F("tag", tag))
return
}
var req agentproto.ReportBoundaryLogsRequest
if err := proto.Unmarshal(buf, &req); err != nil {
s.logger.Warn(ctx, "proto unmarshal error", slog.Error(err))
continue
}
select {
case s.logs <- &req:
default:
s.logger.Warn(ctx, "dropping boundary logs, buffer full",
slog.F("log_count", len(req.Logs)))
}
}
}
// Close stops the server and blocks until resources have been cleaned up.
// It must be called after Start.
func (s *Server) Close() error {
if s.cancel != nil {
s.cancel()
}
if s.listener != nil {
_ = s.listener.Close()
}
s.wg.Wait()
err := os.Remove(s.socketPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
// Server a placeholder for the server that will listen on a Unix socket for
// boundary logs to be forwarded.
type Server struct{}
-578
View File
@@ -1,578 +0,0 @@
//go:build linux || darwin
package boundarylogproxy_test
import (
"context"
"encoding/binary"
"net"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/coder/coder/v2/agent/boundarylogproxy"
"github.com/coder/coder/v2/agent/boundarylogproxy/codec"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/testutil"
)
// sendMessage writes a framed protobuf message to the connection.
func sendMessage(t *testing.T, conn net.Conn, req *agentproto.ReportBoundaryLogsRequest) {
t.Helper()
data, err := proto.Marshal(req)
if err != nil {
//nolint:gocritic // In tests we're not worried about conn being nil.
t.Errorf("%s marshal req: %s", conn.LocalAddr().String(), err)
}
err = codec.WriteFrame(conn, codec.TagV1, data)
if err != nil {
//nolint:gocritic // In tests we're not worried about conn being nil.
t.Errorf("%s write frame: %s", conn.LocalAddr().String(), err)
}
}
// fakeReporter implements boundarylogproxy.Reporter for testing.
type fakeReporter struct {
mu sync.Mutex
logs []*agentproto.ReportBoundaryLogsRequest
err error
errOnce bool // only error once, then succeed
// reportCb is called when a ReportBoundaryLogsRequest is processed. It must not
// block.
reportCb func()
}
func (f *fakeReporter) ReportBoundaryLogs(_ context.Context, req *agentproto.ReportBoundaryLogsRequest) (*agentproto.ReportBoundaryLogsResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.reportCb != nil {
f.reportCb()
}
if f.err != nil {
if f.errOnce {
err := f.err
f.err = nil
return nil, err
}
return nil, f.err
}
f.logs = append(f.logs, req)
return &agentproto.ReportBoundaryLogsResponse{}, nil
}
func (f *fakeReporter) getLogs() []*agentproto.ReportBoundaryLogsRequest {
f.mu.Lock()
defer f.mu.Unlock()
return append([]*agentproto.ReportBoundaryLogsRequest{}, f.logs...)
}
func TestServer_StartAndClose(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock")
srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath)
err := srv.Start()
require.NoError(t, err)
// Verify socket exists and is connectable.
conn, err := net.Dial("unix", socketPath)
require.NoError(t, err)
err = conn.Close()
require.NoError(t, err)
err = srv.Close()
require.NoError(t, err)
}
func TestServer_ReceiveAndForwardLogs(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock")
srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := srv.Start()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, srv.Close()) })
reporter := &fakeReporter{}
// Start forwarder in background.
forwarderDone := make(chan error, 1)
go func() {
forwarderDone <- srv.RunForwarder(ctx, reporter)
}()
// Connect and send a log message.
conn, err := net.Dial("unix", socketPath)
require.NoError(t, err)
defer conn.Close()
req := &agentproto.ReportBoundaryLogsRequest{
Logs: []*agentproto.BoundaryLog{
{
Allowed: true,
Time: timestamppb.Now(),
Resource: &agentproto.BoundaryLog_HttpRequest_{
HttpRequest: &agentproto.BoundaryLog_HttpRequest{
Method: "GET",
Url: "https://example.com",
},
},
},
},
}
sendMessage(t, conn, req)
// Wait for the reporter to receive the log.
require.Eventually(t, func() bool {
logs := reporter.getLogs()
return len(logs) == 1
}, testutil.WaitShort, testutil.IntervalFast)
logs := reporter.getLogs()
require.Len(t, logs, 1)
require.Len(t, logs[0].Logs, 1)
require.True(t, logs[0].Logs[0].Allowed)
require.Equal(t, "GET", logs[0].Logs[0].GetHttpRequest().Method)
require.Equal(t, "https://example.com", logs[0].Logs[0].GetHttpRequest().Url)
cancel()
<-forwarderDone
}
func TestServer_MultipleMessages(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock")
srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := srv.Start()
require.NoError(t, err)
defer srv.Close()
reporter := &fakeReporter{}
forwarderDone := make(chan error, 1)
go func() {
forwarderDone <- srv.RunForwarder(ctx, reporter)
}()
conn, err := net.Dial("unix", socketPath)
require.NoError(t, err)
defer conn.Close()
// Send multiple messages and verify they are all received.
for range 5 {
req := &agentproto.ReportBoundaryLogsRequest{
Logs: []*agentproto.BoundaryLog{
{
Allowed: true,
Time: timestamppb.Now(),
Resource: &agentproto.BoundaryLog_HttpRequest_{
HttpRequest: &agentproto.BoundaryLog_HttpRequest{
Method: "POST",
Url: "https://example.com/api",
},
},
},
},
}
sendMessage(t, conn, req)
}
require.Eventually(t, func() bool {
logs := reporter.getLogs()
return len(logs) == 5
}, testutil.WaitShort, testutil.IntervalFast)
cancel()
<-forwarderDone
}
func TestServer_MultipleConnections(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock")
srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := srv.Start()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, srv.Close()) })
reporter := &fakeReporter{}
forwarderDone := make(chan error, 1)
go func() {
forwarderDone <- srv.RunForwarder(ctx, reporter)
}()
// Create multiple connections and send from each.
const numConns = 3
var wg sync.WaitGroup
wg.Add(numConns)
for i := range numConns {
go func(connID int) {
defer wg.Done()
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Errorf("conn %d dial: %s", connID, err)
}
defer conn.Close()
req := &agentproto.ReportBoundaryLogsRequest{
Logs: []*agentproto.BoundaryLog{
{
Allowed: true,
Time: timestamppb.Now(),
Resource: &agentproto.BoundaryLog_HttpRequest_{
HttpRequest: &agentproto.BoundaryLog_HttpRequest{
Method: "GET",
Url: "https://example.com",
},
},
},
},
}
sendMessage(t, conn, req)
}(i)
}
wg.Wait()
require.Eventually(t, func() bool {
logs := reporter.getLogs()
return len(logs) == numConns
}, testutil.WaitShort, testutil.IntervalFast)
cancel()
<-forwarderDone
}
func TestServer_MessageTooLarge(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock")
srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath)
err := srv.Start()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, srv.Close()) })
conn, err := net.Dial("unix", socketPath)
require.NoError(t, err)
defer conn.Close()
// Send a message claiming to be larger than the max message size.
var length uint32 = codec.MaxMessageSizeV1 + 1
err = binary.Write(conn, binary.BigEndian, length)
require.NoError(t, err)
// The server should close the connection after receiving an oversized
// message length.
buf := make([]byte, 1)
err = conn.SetReadDeadline(time.Now().Add(time.Second))
require.NoError(t, err)
_, err = conn.Read(buf)
require.Error(t, err) // Should get EOF or closed connection.
}
func TestServer_ForwarderContinuesAfterError(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock")
srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath)
err := srv.Start()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, srv.Close()) })
reportNotify := make(chan struct{}, 1)
reporter := &fakeReporter{
// Simulate an error on the first call.
err: context.DeadlineExceeded,
errOnce: true,
reportCb: func() {
reportNotify <- struct{}{}
},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
forwarderDone := make(chan error, 1)
go func() {
forwarderDone <- srv.RunForwarder(ctx, reporter)
}()
conn, err := net.Dial("unix", socketPath)
require.NoError(t, err)
defer conn.Close()
// Send the first message to be processed and wait for failure.
req1 := &agentproto.ReportBoundaryLogsRequest{
Logs: []*agentproto.BoundaryLog{
{
Allowed: true,
Time: timestamppb.Now(),
Resource: &agentproto.BoundaryLog_HttpRequest_{
HttpRequest: &agentproto.BoundaryLog_HttpRequest{
Method: "GET",
Url: "https://example.com/first",
},
},
},
},
}
sendMessage(t, conn, req1)
select {
case <-reportNotify:
case <-time.After(testutil.WaitShort):
t.Fatal("timed out waiting for first message to be processed")
}
// Send the second message, which should succeed.
req2 := &agentproto.ReportBoundaryLogsRequest{
Logs: []*agentproto.BoundaryLog{
{
Allowed: false,
Time: timestamppb.Now(),
Resource: &agentproto.BoundaryLog_HttpRequest_{
HttpRequest: &agentproto.BoundaryLog_HttpRequest{
Method: "POST",
Url: "https://example.com/second",
},
},
},
},
}
sendMessage(t, conn, req2)
// Only the second message should be recorded.
require.Eventually(t, func() bool {
logs := reporter.getLogs()
return len(logs) == 1
}, testutil.WaitShort, testutil.IntervalFast)
logs := reporter.getLogs()
require.Len(t, logs, 1)
require.Equal(t, "https://example.com/second", logs[0].Logs[0].GetHttpRequest().Url)
cancel()
<-forwarderDone
}
func TestServer_CloseStopsForwarder(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock")
srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath)
err := srv.Start()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, srv.Close()) })
reporter := &fakeReporter{}
forwarderCtx, forwarderCancel := context.WithCancel(context.Background())
forwarderDone := make(chan error, 1)
go func() {
forwarderDone <- srv.RunForwarder(forwarderCtx, reporter)
}()
// Cancel the forwarder context and verify it stops.
forwarderCancel()
select {
case err := <-forwarderDone:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(testutil.WaitShort):
t.Fatal("forwarder did not stop")
}
}
func TestServer_InvalidProtobuf(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock")
srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath)
err := srv.Start()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, srv.Close()) })
reporter := &fakeReporter{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
forwarderDone := make(chan error, 1)
go func() {
forwarderDone <- srv.RunForwarder(ctx, reporter)
}()
conn, err := net.Dial("unix", socketPath)
require.NoError(t, err)
defer conn.Close()
// Send a valid header with garbage protobuf data.
// The server should log an unmarshal error but continue processing.
invalidProto := []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF}
//nolint: gosec // codec.DataLength is always less than the size of the header.
header := (uint32(codec.TagV1) << codec.DataLength) | uint32(len(invalidProto))
err = binary.Write(conn, binary.BigEndian, header)
require.NoError(t, err)
_, err = conn.Write(invalidProto)
require.NoError(t, err)
// Now send a valid message. The server should continue processing.
req := &agentproto.ReportBoundaryLogsRequest{
Logs: []*agentproto.BoundaryLog{
{
Allowed: true,
Time: timestamppb.Now(),
Resource: &agentproto.BoundaryLog_HttpRequest_{
HttpRequest: &agentproto.BoundaryLog_HttpRequest{
Method: "GET",
Url: "https://example.com/valid",
},
},
},
},
}
sendMessage(t, conn, req)
require.Eventually(t, func() bool {
logs := reporter.getLogs()
return len(logs) == 1
}, testutil.WaitShort, testutil.IntervalFast)
cancel()
<-forwarderDone
}
func TestServer_InvalidHeader(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock")
srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath)
err := srv.Start()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, srv.Close()) })
reporter := &fakeReporter{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
forwarderDone := make(chan error, 1)
go func() {
forwarderDone <- srv.RunForwarder(ctx, reporter)
}()
// sendInvalidHeader sends a header and verifies the server closes the
// connection.
sendInvalidHeader := func(t *testing.T, name string, header uint32) {
t.Helper()
conn, err := net.Dial("unix", socketPath)
require.NoError(t, err)
defer conn.Close()
err = binary.Write(conn, binary.BigEndian, header)
require.NoError(t, err, name)
// The server closes the connection on invalid header, so the next
// write should fail with a broken pipe error.
require.Eventually(t, func() bool {
_, err := conn.Write([]byte{0x00})
return err != nil
}, testutil.WaitShort, testutil.IntervalFast, name)
}
// TagV1 with length exceeding MaxMessageSizeV1.
sendInvalidHeader(t, "v1 too large", (uint32(codec.TagV1)<<codec.DataLength)|(codec.MaxMessageSizeV1+1))
// Unknown tag.
const bogusTag = 0xFF
sendInvalidHeader(t, "unknown tag too large", (bogusTag<<codec.DataLength)|(codec.MaxMessageSizeV1+1))
cancel()
<-forwarderDone
}
func TestServer_AllowRequest(t *testing.T) {
t.Parallel()
socketPath := filepath.Join(testutil.TempDirUnixSocket(t), "boundary.sock")
srv := boundarylogproxy.NewServer(testutil.Logger(t), socketPath)
err := srv.Start()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, srv.Close()) })
reporter := &fakeReporter{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
forwarderDone := make(chan error, 1)
go func() {
forwarderDone <- srv.RunForwarder(ctx, reporter)
}()
conn, err := net.Dial("unix", socketPath)
require.NoError(t, err)
defer conn.Close()
// Send an allowed request with a matched rule.
logTime := timestamppb.Now()
req := &agentproto.ReportBoundaryLogsRequest{
Logs: []*agentproto.BoundaryLog{
{
Allowed: true,
Time: logTime,
Resource: &agentproto.BoundaryLog_HttpRequest_{
HttpRequest: &agentproto.BoundaryLog_HttpRequest{
Method: "GET",
Url: "https://malicious.com/attack",
MatchedRule: "*.malicious.com",
},
},
},
},
}
sendMessage(t, conn, req)
require.Eventually(t, func() bool {
logs := reporter.getLogs()
return len(logs) == 1
}, testutil.WaitShort, testutil.IntervalFast)
logs := reporter.getLogs()
require.Len(t, logs, 1)
require.True(t, logs[0].Logs[0].Allowed)
require.Equal(t, logTime.Seconds, logs[0].Logs[0].Time.Seconds)
require.Equal(t, logTime.Nanos, logs[0].Logs[0].Time.Nanos)
require.Equal(t, "*.malicious.com", logs[0].Logs[0].GetHttpRequest().MatchedRule)
cancel()
<-forwarderDone
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"runtime"
"sync"
"cdr.dev/slog/v3"
"cdr.dev/slog"
)
// checkpoint allows a goroutine to communicate when it is OK to proceed beyond some async condition
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3/sloggers/slogtest"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/testutil"
)
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"golang.org/x/text/transform"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
+1 -1
View File
@@ -9,7 +9,7 @@ import (
prompb "github.com/prometheus/client_model/go"
"tailscale.com/util/clientmetric"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/proto"
)
+2 -3
View File
@@ -4538,9 +4538,8 @@ type BoundaryLog_HttpRequest struct {
Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"`
Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"`
// The rule that resulted in this HTTP request being allowed. Only populated
// when allowed = true because boundary denies requests by default and
// requires rule(s) that allow requests.
// The rule that resulted in this HTTP request not being allowed.
// Only populated when allowed = false.
MatchedRule string `protobuf:"bytes,3,opt,name=matched_rule,json=matchedRule,proto3" json:"matched_rule,omitempty"`
}
+2 -3
View File
@@ -466,9 +466,8 @@ message BoundaryLog {
message HttpRequest {
string method = 1;
string url = 2;
// The rule that resulted in this HTTP request being allowed. Only populated
// when allowed = true because boundary denies requests by default and
// requires rule(s) that allow requests.
// The rule that resulted in this HTTP request not being allowed.
// Only populated when allowed = false.
string matched_rule = 3;
}
@@ -4,7 +4,7 @@ import (
"context"
"time"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/quartz"
)
@@ -8,8 +8,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/agent/proto/resourcesmonitor"
"github.com/coder/quartz"
+2 -1
View File
@@ -12,7 +12,8 @@ import (
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/pty"
)
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/pty"
+1 -1
View File
@@ -18,7 +18,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/pty"
)
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/agent/usershell"
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"golang.org/x/xerrors"
"tailscale.com/types/netlogtype"
"cdr.dev/slog/v3"
"cdr.dev/slog"
"github.com/coder/coder/v2/agent/proto"
)
+1
View File
@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/durationpb"
"tailscale.com/types/ipproto"
"tailscale.com/types/netlogtype"
"github.com/coder/coder/v2/agent/proto"
+2 -3
View File
@@ -36,13 +36,12 @@
"useAsConstAssertion": "error",
"useEnumInitializers": "error",
"useSingleVarDeclarator": "error",
"useConsistentCurlyBraces": "error",
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
"noUselessElse": "error",
"noRestrictedImports": {
"level": "error",
"noRestrictedImports": {
"level": "error",
"options": {
"paths": {
// "@mui/material/Alert": "Use components/Alert/Alert instead.",
+10 -18
View File
@@ -16,25 +16,26 @@ import (
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/xerrors"
"gopkg.in/natefinch/lumberjack.v2"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"cdr.dev/slog/v3/sloggers/slogjson"
"cdr.dev/slog/v3/sloggers/slogstackdriver"
"github.com/prometheus/client_golang/prometheus"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"cdr.dev/slog/sloggers/slogjson"
"cdr.dev/slog/sloggers/slogstackdriver"
"github.com/coder/serpent"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/agent/boundarylogproxy"
"github.com/coder/coder/v2/agent/reaper"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clilog"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/serpent"
)
func workspaceAgent() *serpent.Command {
@@ -58,7 +59,6 @@ func workspaceAgent() *serpent.Command {
devcontainerDiscoveryAutostart bool
socketServerEnabled bool
socketPath string
boundaryLogProxySocketPath string
)
agentAuth := &AgentAuth{}
cmd := &serpent.Command{
@@ -319,9 +319,8 @@ func workspaceAgent() *serpent.Command {
agentcontainers.WithProjectDiscovery(devcontainerProjectDiscovery),
agentcontainers.WithDiscoveryAutostart(devcontainerDiscoveryAutostart),
},
SocketPath: socketPath,
SocketServerEnabled: socketServerEnabled,
BoundaryLogProxySocketPath: boundaryLogProxySocketPath,
SocketPath: socketPath,
SocketServerEnabled: socketServerEnabled,
})
if debugAddress != "" {
@@ -495,13 +494,6 @@ func workspaceAgent() *serpent.Command {
Description: "Specify the path for the agent socket.",
Value: serpent.StringOf(&socketPath),
},
{
Flag: "boundary-log-proxy-socket-path",
Default: boundarylogproxy.DefaultSocketPath(),
Env: "CODER_AGENT_BOUNDARY_LOG_PROXY_SOCKET_PATH",
Description: "The path for the boundary log proxy server Unix socket. Boundary should write audit logs to this socket.",
Value: serpent.StringOf(&boundaryLogProxySocketPath),
},
}
agentAuth.AttachOptions(cmd, false)
return cmd
+4 -4
View File
@@ -11,10 +11,10 @@ import (
"golang.org/x/xerrors"
"gopkg.in/natefinch/lumberjack.v2"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"cdr.dev/slog/v3/sloggers/slogjson"
"cdr.dev/slog/v3/sloggers/slogstackdriver"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"cdr.dev/slog/sloggers/slogjson"
"cdr.dev/slog/sloggers/slogstackdriver"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
+3 -3
View File
@@ -7,13 +7,13 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clilog"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuilder(t *testing.T) {
+2 -2
View File
@@ -17,8 +17,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/cli/config"
"github.com/coder/coder/v2/codersdk"
+1 -11
View File
@@ -138,17 +138,6 @@ func normalizeGoldenFile(t *testing.T, byt []byte) []byte {
// The home directory changes depending on the test environment.
byt = bytes.ReplaceAll(byt, []byte(homeDir), []byte("~"))
// Normalize the temp directory. os.TempDir() may include a trailing slash
// (macOS) or not (Linux/Windows), and the temp directory may be followed by
// more filepath elements with an OS-specific separator. We handle all cases
// by replacing tempdir+separator first, then tempdir alone.
tempDir := filepath.Clean(os.TempDir())
byt = bytes.ReplaceAll(byt, []byte(tempDir+string(filepath.Separator)), []byte("/tmp/"))
byt = bytes.ReplaceAll(byt, []byte(tempDir), []byte("/tmp"))
// Clean up trailing slash when temp dir is used standalone (e.g., "/tmp/)" -> "/tmp)").
byt = bytes.ReplaceAll(byt, []byte("/tmp/)"), []byte("/tmp)"))
for _, r := range []struct {
old string
new string
@@ -156,6 +145,7 @@ func normalizeGoldenFile(t *testing.T, byt []byte) []byte {
{"\r\n", "\n"},
{`~\.cache\coder`, "~/.cache/coder"},
{`C:\Users\RUNNER~1\AppData\Local\Temp`, "/tmp"},
{os.TempDir(), "/tmp"},
} {
byt = bytes.ReplaceAll(byt, []byte(r.old), []byte(r.new))
}
+2 -1
View File
@@ -13,11 +13,12 @@ import (
"github.com/stretchr/testify/assert"
"github.com/coder/coder/v2/testutil"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
+2 -1
View File
@@ -22,9 +22,10 @@ import (
"golang.org/x/exp/constraints"
"golang.org/x/xerrors"
"github.com/coder/serpent"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
const (
+2 -1
View File
@@ -1,8 +1,9 @@
package cli
import (
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/serpent"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
func (r *RootCmd) connectCmd() *serpent.Command {
+2 -1
View File
@@ -9,10 +9,11 @@ import (
"github.com/stretchr/testify/require"
"tailscale.com/net/tsaddr"
"github.com/coder/serpent"
"github.com/coder/coder/v2/cli"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestConnectExists_Running(t *testing.T) {
+2 -1
View File
@@ -12,12 +12,13 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/pretty"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/cliutil"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
+6 -4
View File
@@ -10,21 +10,23 @@ import (
"time"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/quartz"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestDelete(t *testing.T) {
+2 -1
View File
@@ -13,8 +13,9 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/pretty"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/serpent"
)
+4 -10
View File
@@ -1,18 +1,12 @@
package cli
import (
"golang.org/x/xerrors"
boundarycli "github.com/coder/boundary/cli"
"github.com/coder/serpent"
)
func (*RootCmd) boundary() *serpent.Command {
return &serpent.Command{
Use: "boundary",
Short: "Network isolation tool for monitoring and restricting HTTP/HTTPS requests (enterprise)",
Long: `boundary creates an isolated network environment for target processes. This is an enterprise feature.`,
Handler: func(_ *serpent.Invocation) error {
return xerrors.New("boundary is an enterprise feature; upgrade to use this command")
},
}
cmd := boundarycli.BaseCommand() // Package coder/boundary/cli exports a "base command" designed to be integrated as a subcommand.
cmd.Use += " [args...]" // The base command looks like `boundary -- command`. Serpent adds the flags piece, but we need to add the args.
return cmd
}
+7 -3
View File
@@ -5,13 +5,15 @@ import (
"github.com/stretchr/testify/assert"
boundarycli "github.com/coder/boundary/cli"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
// Here we want to test that integrating boundary as a subcommand doesn't break anything.
// The full boundary functionality is tested in enterprise/cli.
// Actually testing the functionality of coder/boundary takes place in the
// coder/boundary repo, since it's a dependency of coder.
// Here we want to test basically that integrating it as a subcommand doesn't break anything.
func TestBoundarySubcommand(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
@@ -25,5 +27,7 @@ func TestBoundarySubcommand(t *testing.T) {
}()
// Expect the --help output to include the short description.
pty.ExpectMatch("Network isolation tool")
// We're simply confirming that `coder boundary --help` ran without a runtime error as
// a good chunk of serpents self validation logic happens at runtime.
pty.ExpectMatch(boundarycli.BaseCommand().Short)
}
+3 -2
View File
@@ -7,8 +7,6 @@ import (
"github.com/google/uuid"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentcontainers"
@@ -17,6 +15,9 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExpRpty(t *testing.T) {
+3 -2
View File
@@ -24,8 +24,9 @@ import (
"go.opentelemetry.io/otel/trace"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/tracing"
+6 -4
View File
@@ -10,12 +10,14 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"github.com/coder/coder/v2/scaletest/loadtestutil"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/serpent"
"github.com/coder/coder/v2/scaletest/dynamicparameters"
"github.com/coder/coder/v2/scaletest/harness"
"github.com/coder/coder/v2/scaletest/loadtestutil"
"github.com/coder/serpent"
)
const (
+4 -2
View File
@@ -18,12 +18,14 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/scaletest/loadtestutil"
"cdr.dev/slog"
notificationsLib "github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/scaletest/createusers"
"github.com/coder/coder/v2/scaletest/harness"
"github.com/coder/coder/v2/scaletest/loadtestutil"
"github.com/coder/coder/v2/scaletest/notifications"
"github.com/coder/serpent"
)
+2 -1
View File
@@ -13,9 +13,10 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/scaletest/loadtestutil"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/scaletest/harness"
"github.com/coder/coder/v2/scaletest/loadtestutil"
"github.com/coder/coder/v2/scaletest/prebuilds"
"github.com/coder/quartz"
"github.com/coder/serpent"
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/scaletest/smtpmock"
"github.com/coder/serpent"
)
+6 -4
View File
@@ -14,13 +14,15 @@ import (
"github.com/prometheus/client_golang/prometheus/promhttp"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"github.com/coder/coder/v2/scaletest/loadtestutil"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/serpent"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/scaletest/harness"
"github.com/coder/coder/v2/scaletest/loadtestutil"
"github.com/coder/coder/v2/scaletest/taskstatus"
"github.com/coder/serpent"
)
const (
+2 -1
View File
@@ -7,7 +7,8 @@ import (
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/pty/ptytest"
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3/sloggers/slogtest"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
+2 -2
View File
@@ -4,12 +4,12 @@ import (
"bytes"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/stretchr/testify/require"
)
func TestFavoriteUnfavorite(t *testing.T) {
+2 -1
View File
@@ -16,11 +16,12 @@ import (
"github.com/pkg/browser"
"golang.org/x/xerrors"
"github.com/coder/pretty"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/sessionstore"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
+2 -1
View File
@@ -11,13 +11,14 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/pretty"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
"github.com/coder/pretty"
)
func TestLogin(t *testing.T) {
-270
View File
@@ -1,270 +0,0 @@
package cli
import (
"context"
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) logs() *serpent.Command {
var (
buildNumberArg int64
followArg bool
)
cmd := &serpent.Command{
Use: "logs <workspace>",
Short: "View logs for a workspace",
Long: "View logs for a workspace",
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Options: serpent.OptionSet{
{
Name: "Build Number",
Flag: "build-number",
FlagShorthand: "n",
Description: "Only show logs for a specific build number. Defaults to 0, which maps to the most recent build (build numbers start at 1). Negative values are treated as offsets—for example, -1 refers to the previous build.",
Value: serpent.Int64Of(&buildNumberArg),
Default: "0",
},
{
Name: "Follow",
Flag: "follow",
FlagShorthand: "f",
Description: "Follow logs as they are emitted.",
Value: serpent.BoolOf(&followArg),
Default: "false",
},
},
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
client, err := r.InitClient(inv)
if err != nil {
return err
}
ws, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return xerrors.Errorf("failed to get workspace: %w", err)
}
bld := ws.LatestBuild
buildNumber := buildNumberArg
// User supplied a negative build number, treat it as an offset from the latest build
if buildNumber < 0 {
buildNumber = int64(ws.LatestBuild.BuildNumber) + buildNumberArg
if buildNumber < 1 {
return xerrors.Errorf("invalid build number offset: %d latest build number: %d", buildNumberArg, ws.LatestBuild.BuildNumber)
}
}
// Fetch specific build if requested
if buildNumber > 0 {
wb, err := client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(ctx, ws.OwnerName, ws.Name, strconv.FormatInt(buildNumber, 10))
if err != nil {
return xerrors.Errorf("failed to get build %d: %w", buildNumberArg, err)
}
bld = wb
}
cliui.Infof(inv.Stdout, "--- Logs for workspace build #%d (ID: %s Template Version: %s) ---", bld.BuildNumber, bld.ID, bld.TemplateVersionName)
logs, logsCh, err := workspaceLogs(ctx, client, bld, followArg)
if err != nil {
return err
}
for _, log := range logs {
_, _ = fmt.Fprintln(inv.Stdout, log.String())
}
if followArg {
_, _ = fmt.Fprintln(inv.Stdout, "--- Streaming logs ---")
for log := range logsCh {
_, _ = fmt.Fprintln(inv.Stdout, log.String())
}
}
return nil
},
}
return cmd
}
type logLine struct {
ts time.Time
Content string
}
func (l *logLine) String() string {
var sb strings.Builder
_, _ = sb.WriteString(l.ts.Format(time.RFC3339))
_, _ = sb.WriteString(l.Content)
return sb.String()
}
// workspaceLogs fetches logs for the given workspace build. If follow is true,
// the returned channel will stream new logs as they are emitted. Otherwise,
// the channel will be closed immediately.
// nolint: revive // control flag is appropriate here
func workspaceLogs(ctx context.Context, client *codersdk.Client, wb codersdk.WorkspaceBuild, follow bool) ([]logLine, <-chan logLine, error) {
logs := make([]logLine, 0)
logsCh := make(chan logLine)
followCh := make(chan logLine)
var fetchGroup, followGroup errgroup.Group
buildLogsAfterCh := make(chan int64)
fetchGroup.Go(func() error {
var afterID int64
defer func() {
if !follow {
return
}
buildLogsAfterCh <- afterID
}()
buildLogsC, closer, err := client.WorkspaceBuildLogsAfter(ctx, wb.ID, 0)
if err != nil {
return xerrors.Errorf("failed to get build logs: %w", err)
}
defer closer.Close()
for log := range buildLogsC {
afterID = log.ID
logsCh <- logLine{
ts: log.CreatedAt,
Content: buildLogToString(log),
}
}
return nil
})
if follow {
followGroup.Go(func() error {
afterID := <-buildLogsAfterCh
buildLogsC, closer, err := client.WorkspaceBuildLogsAfter(ctx, wb.ID, afterID)
if err != nil {
return xerrors.Errorf("failed to follow build logs: %w", err)
}
defer closer.Close()
for log := range buildLogsC {
followCh <- logLine{
ts: log.CreatedAt,
Content: buildLogToString(log),
}
}
return nil
})
}
for _, res := range wb.Resources {
for _, agt := range res.Agents {
logSrcNames := make(map[uuid.UUID]string)
for _, src := range agt.LogSources {
logSrcNames[src.ID] = src.DisplayName
}
agentLogsAfterCh := make(chan int64)
var afterID int64
fetchGroup.Go(func() error {
defer func() {
if !follow {
return
}
agentLogsAfterCh <- afterID
}()
agentLogsCh, closer, err := client.WorkspaceAgentLogsAfter(ctx, agt.ID, 0, false)
if err != nil {
return xerrors.Errorf("failed to get agent logs: %w", err)
}
defer closer.Close()
for logChunk := range agentLogsCh {
for _, log := range logChunk {
afterID = log.ID
logsCh <- logLine{
ts: log.CreatedAt,
Content: workspaceAgentLogToString(log, agt.Name, logSrcNames[log.SourceID]),
}
}
}
return nil
})
if follow {
followGroup.Go(func() error {
afterID := <-agentLogsAfterCh
agentLogsCh, closer, err := client.WorkspaceAgentLogsAfter(ctx, agt.ID, afterID, true)
if err != nil {
return xerrors.Errorf("failed to follow agent logs: %w", err)
}
defer closer.Close()
for logChunk := range agentLogsCh {
for _, log := range logChunk {
followCh <- logLine{
ts: log.CreatedAt,
Content: workspaceAgentLogToString(log, agt.Name, logSrcNames[log.SourceID]),
}
}
}
return nil
})
}
}
}
logsDone := make(chan struct{})
go func() {
defer close(logsDone)
for log := range logsCh {
logs = append(logs, log)
}
}()
err := fetchGroup.Wait()
close(logsCh)
<-logsDone
slices.SortFunc(logs, func(a, b logLine) int {
return a.ts.Compare(b.ts)
})
if follow {
go func() {
_ = followGroup.Wait()
close(followCh)
}()
} else {
close(followCh)
}
return logs, followCh, err
}
func buildLogToString(log codersdk.ProvisionerJobLog) string {
var sb strings.Builder
_, _ = sb.WriteString(" [")
_, _ = sb.WriteString(string(log.Level))
_, _ = sb.WriteString("] [")
_, _ = sb.WriteString("provisioner|")
_, _ = sb.WriteString(log.Stage)
_, _ = sb.WriteString("] ")
_, _ = sb.WriteString(log.Output)
return sb.String()
}
func workspaceAgentLogToString(log codersdk.WorkspaceAgentLog, agtName, srcName string) string {
var sb strings.Builder
_, _ = sb.WriteString(" [")
_, _ = sb.WriteString(string(log.Level))
_, _ = sb.WriteString("] [")
_, _ = sb.WriteString("agent.")
_, _ = sb.WriteString(agtName)
_, _ = sb.WriteString("|")
_, _ = sb.WriteString(srcName)
_, _ = sb.WriteString("] ")
_, _ = sb.WriteString(log.Output)
return sb.String()
}
-115
View File
@@ -1,115 +0,0 @@
package cli_test
import (
"fmt"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/testutil"
)
func TestLogsCmd(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{})
owner := coderdtest.CreateFirstUser(t, client)
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
testWorkspace := func(t testing.TB, db database.Store, ownerID, orgID uuid.UUID) dbfake.WorkspaceResponse {
wb := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OwnerID: memberUser.ID,
OrganizationID: owner.OrganizationID,
}).WithAgent().Do()
_ = dbgen.ProvisionerJobLog(t, db, database.ProvisionerJobLog{
JobID: wb.Build.JobID,
Output: "test provisioner log for build " + wb.Build.ID.String(),
})
for _, agt := range wb.Agents {
_ = dbgen.WorkspaceAgentLog(t, db, database.WorkspaceAgentLog{
AgentID: agt.ID,
Output: "test agent log for agent " + agt.ID.String(),
})
}
return wb
}
assertLogOutput := func(t testing.TB, wb dbfake.WorkspaceResponse, output string) {
t.Helper()
require.Contains(t, output, "test provisioner log for build "+wb.Build.ID.String())
for _, agt := range wb.Agents {
require.Contains(t, output, "test agent log for agent "+agt.ID.String())
}
}
assertAntagonist := func(t testing.TB, wb dbfake.WorkspaceResponse, output string) {
t.Helper()
require.NotContains(t, output, "test provisioner log for build "+wb.Build.ID.String())
for _, agt := range wb.Agents {
require.NotContains(t, output, "test agent log for agent "+agt.ID.String())
}
}
wb1 := testWorkspace(t, db, memberUser.ID, owner.OrganizationID)
wb2 := testWorkspace(t, db, owner.UserID, owner.OrganizationID)
t.Run("workspace not found", func(t *testing.T) {
t.Parallel()
inv, root := clitest.New(t, "logs", "doesnotexist")
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
var stdout strings.Builder
inv.Stdout = &stdout
err := inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "Resource not found or you do not have access to this resource")
})
// Note: not testing with --follow as it is inherently racy.
t.Run("current build", func(t *testing.T) {
t.Parallel()
inv, root := clitest.New(t, "logs", wb1.Workspace.Name)
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
var stdout strings.Builder
inv.Stdout = &stdout
err := inv.WithContext(ctx).Run()
require.NoError(t, err, "failed to fetch logs for current build")
assertLogOutput(t, wb1, stdout.String())
assertAntagonist(t, wb2, stdout.String())
})
t.Run("specific build", func(t *testing.T) {
t.Parallel()
inv, root := clitest.New(t, "logs", wb1.Workspace.Name, "-n", fmt.Sprintf("%d", wb1.Build.BuildNumber))
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
var stdout strings.Builder
inv.Stdout = &stdout
err := inv.WithContext(ctx).Run()
require.NoError(t, err, "failed to fetch logs for specific build")
assertLogOutput(t, wb1, stdout.String())
assertAntagonist(t, wb2, stdout.String())
})
t.Run("build out of range", func(t *testing.T) {
t.Parallel()
inv, root := clitest.New(t, "logs", wb1.Workspace.Name, "-n", "-9999")
clitest.SetupConfig(t, memberClient, root)
ctx := testutil.Context(t, testutil.WaitShort)
var stdout strings.Builder
inv.Stdout = &stdout
err := inv.WithContext(ctx).Run()
require.ErrorContains(t, err, "invalid build number offset")
})
}
+2 -1
View File
@@ -5,8 +5,9 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
"github.com/coder/coder/v2/codersdk"
)
func (r *RootCmd) notifications() *serpent.Command {
+3 -9
View File
@@ -169,8 +169,8 @@ func (r *RootCmd) openVSCode() *serpent.Command {
// Note that this is irrelevant for devcontainer sub agents, as
// they always have a directory set.
if workspaceAgent.Directory != "" {
workspace, workspaceAgent, err = waitForAgentCond(ctx, client, workspace, workspaceAgent, func(wa codersdk.WorkspaceAgent) bool {
return wa.LifecycleState != codersdk.WorkspaceAgentLifecycleCreated
workspace, workspaceAgent, err = waitForAgentCond(ctx, client, workspace, workspaceAgent, func(_ codersdk.WorkspaceAgent) bool {
return workspaceAgent.LifecycleState != codersdk.WorkspaceAgentLifecycleCreated
})
if err != nil {
return xerrors.Errorf("wait for agent: %w", err)
@@ -183,13 +183,7 @@ func (r *RootCmd) openVSCode() *serpent.Command {
directory = inv.Args[1]
}
// If we're opening into a dev container, we should use the directory of the dev container.
workingDirectory := workspaceAgent.ExpandedDirectory
if workingDirectory == "" && devcontainer.Agent != nil {
workingDirectory = devcontainer.Agent.Directory
}
directory, err = resolveAgentAbsPath(workingDirectory, directory, workspaceAgent.OperatingSystem, insideThisWorkspace)
directory, err = resolveAgentAbsPath(workspaceAgent.ExpandedDirectory, directory, workspaceAgent.OperatingSystem, insideThisWorkspace)
if err != nil {
return xerrors.Errorf("resolve agent path: %w", err)
}
+9 -5
View File
@@ -10,21 +10,25 @@ import (
"strings"
"time"
"github.com/briandowns/spinner"
"golang.org/x/xerrors"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/briandowns/spinner"
"github.com/coder/pretty"
"github.com/coder/serpent"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/cliutil"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/healthsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
type pingSummary struct {
+3 -2
View File
@@ -15,8 +15,9 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
+3 -2
View File
@@ -5,10 +5,11 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
)
func (r *RootCmd) publickey() *serpent.Command {
+3 -2
View File
@@ -5,10 +5,11 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
)
func (r *RootCmd) rename() *serpent.Command {
+6 -5
View File
@@ -7,15 +7,16 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/database"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/coderd/database/awsiamrds"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/userpassword"
)
func (*RootCmd) resetPassword() *serpent.Command {
+4 -20
View File
@@ -29,6 +29,10 @@ import (
"golang.org/x/mod/semver"
"golang.org/x/xerrors"
"github.com/coder/pretty"
"github.com/coder/serpent"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/config"
@@ -37,8 +41,6 @@ import (
"github.com/coder/coder/v2/cli/telemetry"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/pretty"
"github.com/coder/serpent"
)
var (
@@ -115,7 +117,6 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
r.deleteWorkspace(),
r.favorite(),
r.list(),
r.logs(),
r.open(),
r.ping(),
r.rename(),
@@ -684,7 +685,6 @@ func (r *RootCmd) HeaderTransport(ctx context.Context, serverURL *url.URL) (*cod
func (r *RootCmd) createHTTPClient(ctx context.Context, serverURL *url.URL, inv *serpent.Invocation) (*http.Client, error) {
transport := http.DefaultTransport
transport = wrapTransportWithTelemetryHeader(transport, inv)
transport = wrapTransportWithUserAgentHeader(transport, inv)
if !r.noVersionCheck {
transport = wrapTransportWithVersionMismatchCheck(transport, inv, buildinfo.Version(), func(ctx context.Context) (codersdk.BuildInfoResponse, error) {
// Create a new client without any wrapped transport
@@ -1498,22 +1498,6 @@ func wrapTransportWithTelemetryHeader(transport http.RoundTripper, inv *serpent.
})
}
// wrapTransportWithUserAgentHeader sets a User-Agent header for all CLI requests
// that includes the CLI version, os/arch, and the specific command being run.
func wrapTransportWithUserAgentHeader(transport http.RoundTripper, inv *serpent.Invocation) http.RoundTripper {
var (
userAgent string
once sync.Once
)
return roundTripper(func(req *http.Request) (*http.Response, error) {
once.Do(func() {
userAgent = fmt.Sprintf("coder-cli/%s (%s/%s; %s)", buildinfo.Version(), runtime.GOOS, runtime.GOARCH, inv.Command.FullName())
})
req.Header.Set("User-Agent", userAgent)
return transport.RoundTrip(req)
})
}
type roundTripper func(req *http.Request) (*http.Response, error)
func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
-56
View File
@@ -380,59 +380,3 @@ func agentClientCommand(clientRef **agentsdk.Client) *serpent.Command {
agentAuth.AttachOptions(cmd, false)
return cmd
}
func TestWrapTransportWithUserAgentHeader(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
cmdArgs []string
cmdEnv map[string]string
expectedUserAgentHeader string
}{
{
name: "top-level command",
cmdArgs: []string{"login"},
expectedUserAgentHeader: fmt.Sprintf("coder-cli/%s (%s/%s; coder login)", buildinfo.Version(), runtime.GOOS, runtime.GOARCH),
},
{
name: "nested commands",
cmdArgs: []string{"templates", "list"},
expectedUserAgentHeader: fmt.Sprintf("coder-cli/%s (%s/%s; coder templates list)", buildinfo.Version(), runtime.GOOS, runtime.GOARCH),
},
{
name: "does not include positional args, flags, or env",
cmdArgs: []string{"templates", "push", "my-template", "-d", "/path/to/template", "--yes", "--var", "myvar=myvalue"},
cmdEnv: map[string]string{"SECRET_KEY": "secret_value"},
expectedUserAgentHeader: fmt.Sprintf("coder-cli/%s (%s/%s; coder templates push)", buildinfo.Version(), runtime.GOOS, runtime.GOARCH),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ch := make(chan string, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case ch <- r.Header.Get("User-Agent"):
default: // already sent
}
}))
t.Cleanup(srv.Close)
args := append([]string{}, tc.cmdArgs...)
inv, _ := clitest.New(t, args...)
inv.Environ.Set("CODER_URL", srv.URL)
for k, v := range tc.cmdEnv {
inv.Environ.Set(k, v)
}
ctx := testutil.Context(t, testutil.WaitShort)
_ = inv.WithContext(ctx).Run() // Ignore error as we only care about headers.
actual := testutil.RequireReceive(ctx, t, ch)
require.Equal(t, tc.expectedUserAgentHeader, actual, "User-Agent should match expected format exactly")
})
}
}
+26 -58
View File
@@ -54,8 +54,15 @@ import (
"gopkg.in/yaml.v3"
"tailscale.com/tailcfg"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
"github.com/coder/coder/v2/coderd/pproflabel"
"github.com/coder/pretty"
"github.com/coder/quartz"
"github.com/coder/retry"
"github.com/coder/serpent"
"github.com/coder/wgtunnel/tunnelsdk"
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/clilog"
"github.com/coder/coder/v2/cli/cliui"
@@ -79,7 +86,6 @@ import (
"github.com/coder/coder/v2/coderd/notifications"
"github.com/coder/coder/v2/coderd/notifications/reports"
"github.com/coder/coder/v2/coderd/oauthpki"
"github.com/coder/coder/v2/coderd/pproflabel"
"github.com/coder/coder/v2/coderd/prometheusmetrics"
"github.com/coder/coder/v2/coderd/prometheusmetrics/insights"
"github.com/coder/coder/v2/coderd/promoauth"
@@ -105,11 +111,6 @@ import (
"github.com/coder/coder/v2/provisionersdk"
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/pretty"
"github.com/coder/quartz"
"github.com/coder/retry"
"github.com/coder/serpent"
"github.com/coder/wgtunnel/tunnelsdk"
)
func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.DeploymentValues) (*coderd.OIDCConfig, error) {
@@ -747,16 +748,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
// "bare" read on this channel.
var pubsubWatchdogTimeout <-chan struct{}
maxOpenConns := int(vals.PostgresConnMaxOpen.Value())
maxIdleConns, err := codersdk.ComputeMaxIdleConns(maxOpenConns, vals.PostgresConnMaxIdle.Value())
if err != nil {
return xerrors.Errorf("compute max idle connections: %w", err)
}
logger.Debug(ctx, "creating database connection pool", slog.F("max_open_conns", maxOpenConns), slog.F("max_idle_conns", maxIdleConns))
sqlDB, dbURL, err := getAndMigratePostgresDB(ctx, logger, vals.PostgresURL.String(), codersdk.PostgresAuth(vals.PostgresAuth), sqlDriver,
WithMaxOpenConns(maxOpenConns),
WithMaxIdleConns(maxIdleConns),
)
sqlDB, dbURL, err := getAndMigratePostgresDB(ctx, logger, vals.PostgresURL.String(), codersdk.PostgresAuth(vals.PostgresAuth), sqlDriver)
if err != nil {
return xerrors.Errorf("connect to postgres: %w", err)
}
@@ -2333,29 +2325,6 @@ func IsLocalhost(host string) bool {
return host == "localhost" || host == "127.0.0.1" || host == "::1"
}
// PostgresConnectOptions contains options for connecting to Postgres.
type PostgresConnectOptions struct {
MaxOpenConns int
MaxIdleConns int
}
// PostgresConnectOption is a functional option for ConnectToPostgres.
type PostgresConnectOption func(*PostgresConnectOptions)
// WithMaxOpenConns sets the maximum number of open connections to the database.
func WithMaxOpenConns(n int) PostgresConnectOption {
return func(o *PostgresConnectOptions) {
o.MaxOpenConns = n
}
}
// WithMaxIdleConns sets the maximum number of idle connections in the pool.
func WithMaxIdleConns(n int) PostgresConnectOption {
return func(o *PostgresConnectOptions) {
o.MaxIdleConns = n
}
}
// ConnectToPostgres takes in the migration command to run on the database once
// it connects. To avoid running migrations, pass in `nil` or a no-op function.
// Regardless of the passed in migration function, if the database is not fully
@@ -2363,15 +2332,7 @@ func WithMaxIdleConns(n int) PostgresConnectOption {
// future or past migration version.
//
// If no error is returned, the database is fully migrated and up to date.
func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string, migrate func(db *sql.DB) error, opts ...PostgresConnectOption) (*sql.DB, error) {
// Apply defaults.
options := PostgresConnectOptions{
MaxOpenConns: 10,
MaxIdleConns: 3,
}
for _, opt := range opts {
opt(&options)
}
func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, dbURL string, migrate func(db *sql.DB) error) (*sql.DB, error) {
logger.Debug(ctx, "connecting to postgresql")
var err error
@@ -2454,12 +2415,19 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d
// cannot accept new connections, so we try to limit that here.
// Requests will wait for a new connection instead of a hard error
// if a limit is set.
sqlDB.SetMaxOpenConns(options.MaxOpenConns)
// Limit idle connections to reduce connection churn while keeping some
// connections ready for reuse. When a connection is returned to the pool
// but the idle pool is full, it's closed immediately - which can cause
// connection establishment overhead when load fluctuates.
sqlDB.SetMaxIdleConns(options.MaxIdleConns)
sqlDB.SetMaxOpenConns(10)
// Allow a max of 3 idle connections at a time. Lower values end up
// creating a lot of connection churn. Since each connection uses about
// 10MB of memory, we're allocating 30MB to Postgres connections per
// replica, but is better than causing Postgres to spawn a thread 15-20
// times/sec. PGBouncer's transaction pooling is not the greatest so
// it's not optimal for us to deploy.
//
// This was set to 10 before we started doing HA deployments, but 3 was
// later determined to be a better middle ground as to not use up all
// of PGs default connection limit while simultaneously avoiding a lot
// of connection churn.
sqlDB.SetMaxIdleConns(3)
dbNeedsClosing = false
return sqlDB, nil
@@ -2863,7 +2831,7 @@ func signalNotifyContext(ctx context.Context, inv *serpent.Invocation, sig ...os
return inv.SignalNotifyContext(ctx, sig...)
}
func getAndMigratePostgresDB(ctx context.Context, logger slog.Logger, postgresURL string, auth codersdk.PostgresAuth, sqlDriver string, opts ...PostgresConnectOption) (*sql.DB, string, error) {
func getAndMigratePostgresDB(ctx context.Context, logger slog.Logger, postgresURL string, auth codersdk.PostgresAuth, sqlDriver string) (*sql.DB, string, error) {
dbURL, err := escapePostgresURLUserInfo(postgresURL)
if err != nil {
return nil, "", xerrors.Errorf("escaping postgres URL: %w", err)
@@ -2876,7 +2844,7 @@ func getAndMigratePostgresDB(ctx context.Context, logger slog.Logger, postgresUR
}
}
sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, dbURL, migrations.Up, opts...)
sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, dbURL, migrations.Up)
if err != nil {
return nil, "", xerrors.Errorf("connect to postgres: %w", err)
}

Some files were not shown because too many files have changed in this diff Show More