Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 483f4d5efb | |||
| 800dd9cc66 | |||
| 035ad33faf | |||
| 230b55bfa0 | |||
| b2d6a18861 | |||
| c0cd32c2c4 | |||
| c2414d5287 | |||
| ff69ed69df |
@@ -1,126 +0,0 @@
|
||||
# Coder Architecture
|
||||
|
||||
This document provides an overview of Coder's architecture and core systems.
|
||||
|
||||
## What is Coder?
|
||||
|
||||
Coder is a platform for creating, managing, and using remote development environments (also known as Cloud Development Environments or CDEs). It leverages Terraform to define and provision these environments, which are referred to as "workspaces" within the project. The system is designed to be extensible, secure, and provide developers with a seamless remote development experience.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
The heart of Coder is a control plane that orchestrates the creation and management of workspaces. This control plane interacts with separate Provisioner processes over gRPC to handle workspace builds. The Provisioners consume workspace definitions and use Terraform to create the actual infrastructure.
|
||||
|
||||
The CLI package serves dual purposes - it can be used to launch the control plane itself and also provides client functionality for users to interact with an existing control plane instance. All user-facing frontend code is developed in TypeScript using React and lives in the `site/` directory.
|
||||
|
||||
The database layer uses PostgreSQL with SQLC for generating type-safe database code. Database migrations are carefully managed to ensure both forward and backward compatibility through paired `.up.sql` and `.down.sql` files.
|
||||
|
||||
## API Design
|
||||
|
||||
Coder's API architecture combines REST and gRPC approaches. The REST API is defined in `coderd/coderd.go` and uses Chi for HTTP routing. This provides the primary interface for the frontend and external integrations.
|
||||
|
||||
Internal communication with Provisioners occurs over gRPC, with service definitions maintained in `.proto` files. This separation allows for efficient binary communication with the components responsible for infrastructure management while providing a standard REST interface for human-facing applications.
|
||||
|
||||
## Network Architecture
|
||||
|
||||
Coder implements a secure networking layer based on Tailscale's Wireguard implementation. The `tailnet` package provides connectivity between workspace agents and clients through DERP (Designated Encrypted Relay for Packets) servers when direct connections aren't possible. This creates a secure overlay network allowing access to workspaces regardless of network topology, firewalls, or NAT configurations.
|
||||
|
||||
### Tailnet and DERP System
|
||||
|
||||
The networking system has three key components:
|
||||
|
||||
1. **Tailnet**: An overlay network implemented in the `tailnet` package that provides secure, end-to-end encrypted connections between clients, the Coder server, and workspace agents.
|
||||
|
||||
2. **DERP Servers**: These relay traffic when direct connections aren't possible. Coder provides several options:
|
||||
- A built-in DERP server that runs on the Coder control plane
|
||||
- Integration with Tailscale's global DERP infrastructure
|
||||
- Support for custom DERP servers for lower latency or offline deployments
|
||||
|
||||
3. **Direct Connections**: When possible, the system establishes peer-to-peer connections between clients and workspaces using STUN for NAT traversal. This requires both endpoints to send UDP traffic on ephemeral ports.
|
||||
|
||||
### Workspace Proxies
|
||||
|
||||
Workspace proxies (in the Enterprise edition) provide regional relay points for browser-based connections, reducing latency for geo-distributed teams. Key characteristics:
|
||||
|
||||
- Deployed as independent servers that authenticate with the Coder control plane
|
||||
- Relay connections for SSH, workspace apps, port forwarding, and web terminals
|
||||
- Do not make direct database connections
|
||||
- Managed through the `coder wsproxy` commands
|
||||
- Implemented primarily in the `enterprise/wsproxy/` package
|
||||
|
||||
## Agent System
|
||||
|
||||
The workspace agent runs within each provisioned workspace and provides core functionality including:
|
||||
|
||||
- SSH access to workspaces via the `agentssh` package
|
||||
- Port forwarding
|
||||
- Terminal connectivity via the `pty` package for pseudo-terminal support
|
||||
- Application serving
|
||||
- Healthcheck monitoring
|
||||
- Resource usage reporting
|
||||
|
||||
Agents communicate with the control plane using the tailnet system and authenticate using secure tokens.
|
||||
|
||||
## Workspace Applications
|
||||
|
||||
Workspace applications (or "apps") provide browser-based access to services running within workspaces. The system supports:
|
||||
|
||||
- HTTP(S) and WebSocket connections
|
||||
- Path-based or subdomain-based access URLs
|
||||
- Health checks to monitor application availability
|
||||
- Different sharing levels (owner-only, authenticated users, or public)
|
||||
- Custom icons and display settings
|
||||
|
||||
The implementation is primarily in the `coderd/workspaceapps/` directory with components for URL generation, proxying connections, and managing application state.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The project structure separates frontend and backend concerns. React components and pages are organized in the `site/src/` directory, with Jest used for testing. The backend is primarily written in Go, with a strong emphasis on error handling patterns and test coverage.
|
||||
|
||||
Database interactions are carefully managed through migrations in `coderd/database/migrations/` and queries in `coderd/database/queries/`. All new queries require proper database authorization (dbauthz) implementation to ensure that only users with appropriate permissions can access specific resources.
|
||||
|
||||
## Authorization System
|
||||
|
||||
The database authorization (dbauthz) system enforces fine-grained access control across all database operations. It uses role-based access control (RBAC) to validate user permissions before executing database operations. The `dbauthz` package wraps the database store and performs authorization checks before returning data. All database operations must pass through this layer to ensure security.
|
||||
|
||||
## Testing Framework
|
||||
|
||||
The codebase has a comprehensive testing approach with several key components:
|
||||
|
||||
1. **Parallel Testing**: All tests must use `t.Parallel()` to run concurrently, which improves test suite performance and helps identify race conditions.
|
||||
|
||||
2. **coderdtest Package**: This package in `coderd/coderdtest/` provides utilities for creating test instances of the Coder server, setting up test users and workspaces, and mocking external components.
|
||||
|
||||
3. **Integration Tests**: Tests often span multiple components to verify system behavior, such as template creation, workspace provisioning, and agent connectivity.
|
||||
|
||||
4. **Enterprise Testing**: Enterprise features have dedicated test utilities in the `coderdenttest` package.
|
||||
|
||||
## Open Source and Enterprise Components
|
||||
|
||||
The repository contains both open source and enterprise components:
|
||||
|
||||
- Enterprise code lives primarily in the `enterprise/` directory
|
||||
- Enterprise features focus on governance, scalability (high availability), and advanced deployment options like workspace proxies
|
||||
- The boundary between open source and enterprise is managed through a licensing system
|
||||
- The same core codebase supports both editions, with enterprise features conditionally enabled
|
||||
|
||||
## Development Philosophy
|
||||
|
||||
Coder emphasizes clear error handling, with specific patterns required:
|
||||
|
||||
- Concise error messages that avoid phrases like "failed to"
|
||||
- Wrapping errors with `%w` to maintain error chains
|
||||
- Using sentinel errors with the "err" prefix (e.g., `errNotFound`)
|
||||
|
||||
All tests should run in parallel using `t.Parallel()` to ensure efficient testing and expose potential race conditions. The codebase is rigorously linted with golangci-lint to maintain consistent code quality.
|
||||
|
||||
Git contributions follow a standard format with commit messages structured as `type: <message>`, where type is one of `feat`, `fix`, or `chore`.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
Development can be initiated using `scripts/develop.sh` to start the application after making changes. Database schema updates should be performed through the migration system using `create_migration.sh <name>` to generate migration files, with each `.up.sql` migration paired with a corresponding `.down.sql` that properly reverts all changes.
|
||||
|
||||
If the development database gets into a bad state, it can be completely reset by removing the PostgreSQL data directory with `rm -rf .coderv2/postgres`. This will destroy all data in the development database, requiring you to recreate any test users, templates, or workspaces after restarting the application.
|
||||
|
||||
Code generation for the database layer uses `coderd/database/generate.sh`, and developers should refer to `sqlc.yaml` for the appropriate style and patterns to follow when creating new queries or tables.
|
||||
|
||||
The focus should always be on maintaining security through proper database authorization, clean error handling, and comprehensive test coverage to ensure the platform remains robust and reliable.
|
||||
@@ -91,9 +91,6 @@
|
||||
|
||||
## Systematic Debugging Approach
|
||||
|
||||
YOU MUST ALWAYS find the root cause of any issue you are debugging
|
||||
YOU MUST NEVER fix a symptom or add a workaround instead of finding a root cause, even if it is faster.
|
||||
|
||||
### Multi-Issue Problem Solving
|
||||
|
||||
When facing multiple failing tests or complex integration issues:
|
||||
@@ -101,21 +98,16 @@ When facing multiple failing tests or complex integration issues:
|
||||
1. **Identify Root Causes**:
|
||||
- Run failing tests individually to isolate issues
|
||||
- Use LSP tools to trace through call chains
|
||||
- Read Error Messages Carefully: Check both compilation and runtime errors
|
||||
- Reproduce Consistently: Ensure you can reliably reproduce the issue before investigating
|
||||
- Check Recent Changes: What changed that could have caused this? Git diff, recent commits, etc.
|
||||
- When You Don't Know: Say "I don't understand X" rather than pretending to know
|
||||
- Check both compilation and runtime errors
|
||||
|
||||
2. **Fix in Logical Order**:
|
||||
- Address compilation issues first (imports, syntax)
|
||||
- Fix authorization and RBAC issues next
|
||||
- Resolve business logic and validation issues
|
||||
- Handle edge cases and race conditions last
|
||||
- IF your first fix doesn't work, STOP and re-analyze rather than adding more fixes
|
||||
|
||||
3. **Verification Strategy**:
|
||||
- Always Test each fix individually before moving to next issue
|
||||
- Verify Before Continuing: Did your test work? If not, form new hypothesis - don't add more fixes
|
||||
- Test each fix individually before moving to next issue
|
||||
- Use `make lint` and `make gen` after database changes
|
||||
- Verify RFC compliance with actual specifications
|
||||
- Run comprehensive test suites before considering complete
|
||||
|
||||
@@ -40,15 +40,11 @@
|
||||
- Use proper error types
|
||||
- Pattern: `xerrors.Errorf("failed to X: %w", err)`
|
||||
|
||||
## Naming Conventions
|
||||
### Naming Conventions
|
||||
|
||||
- Names MUST tell what code does, not how it's implemented or its history
|
||||
- Follow Go and TypeScript naming conventions
|
||||
- When changing code, never document the old behavior or the behavior change
|
||||
- NEVER use implementation details in names (e.g., "ZodValidator", "MCPWrapper", "JSONParser")
|
||||
- NEVER use temporal/historical context in names (e.g., "LegacyHandler", "UnifiedTool", "ImprovedInterface", "EnhancedParser")
|
||||
- NEVER use pattern names unless they add clarity (e.g., prefer "Tool" over "ToolFactory")
|
||||
- Use clear, descriptive names
|
||||
- Abbreviate only when obvious
|
||||
- Follow Go and TypeScript naming conventions
|
||||
|
||||
### Comments
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
# Cursor Rules
|
||||
|
||||
This project is called "Coder" - an application for managing remote development environments.
|
||||
|
||||
Coder provides a platform for creating, managing, and using remote development environments (also known as Cloud Development Environments or CDEs). It leverages Terraform to define and provision these environments, which are referred to as "workspaces" within the project. The system is designed to be extensible, secure, and provide developers with a seamless remote development experience.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
The heart of Coder is a control plane that orchestrates the creation and management of workspaces. This control plane interacts with separate Provisioner processes over gRPC to handle workspace builds. The Provisioners consume workspace definitions and use Terraform to create the actual infrastructure.
|
||||
|
||||
The CLI package serves dual purposes - it can be used to launch the control plane itself and also provides client functionality for users to interact with an existing control plane instance. All user-facing frontend code is developed in TypeScript using React and lives in the `site/` directory.
|
||||
|
||||
The database layer uses PostgreSQL with SQLC for generating type-safe database code. Database migrations are carefully managed to ensure both forward and backward compatibility through paired `.up.sql` and `.down.sql` files.
|
||||
|
||||
## API Design
|
||||
|
||||
Coder's API architecture combines REST and gRPC approaches. The REST API is defined in `coderd/coderd.go` and uses Chi for HTTP routing. This provides the primary interface for the frontend and external integrations.
|
||||
|
||||
Internal communication with Provisioners occurs over gRPC, with service definitions maintained in `.proto` files. This separation allows for efficient binary communication with the components responsible for infrastructure management while providing a standard REST interface for human-facing applications.
|
||||
|
||||
## Network Architecture
|
||||
|
||||
Coder implements a secure networking layer based on Tailscale's Wireguard implementation. The `tailnet` package provides connectivity between workspace agents and clients through DERP (Designated Encrypted Relay for Packets) servers when direct connections aren't possible. This creates a secure overlay network allowing access to workspaces regardless of network topology, firewalls, or NAT configurations.
|
||||
|
||||
### Tailnet and DERP System
|
||||
|
||||
The networking system has three key components:
|
||||
|
||||
1. **Tailnet**: An overlay network implemented in the `tailnet` package that provides secure, end-to-end encrypted connections between clients, the Coder server, and workspace agents.
|
||||
|
||||
2. **DERP Servers**: These relay traffic when direct connections aren't possible. Coder provides several options:
|
||||
- A built-in DERP server that runs on the Coder control plane
|
||||
- Integration with Tailscale's global DERP infrastructure
|
||||
- Support for custom DERP servers for lower latency or offline deployments
|
||||
|
||||
3. **Direct Connections**: When possible, the system establishes peer-to-peer connections between clients and workspaces using STUN for NAT traversal. This requires both endpoints to send UDP traffic on ephemeral ports.
|
||||
|
||||
### Workspace Proxies
|
||||
|
||||
Workspace proxies (in the Enterprise edition) provide regional relay points for browser-based connections, reducing latency for geo-distributed teams. Key characteristics:
|
||||
|
||||
- Deployed as independent servers that authenticate with the Coder control plane
|
||||
- Relay connections for SSH, workspace apps, port forwarding, and web terminals
|
||||
- Do not make direct database connections
|
||||
- Managed through the `coder wsproxy` commands
|
||||
- Implemented primarily in the `enterprise/wsproxy/` package
|
||||
|
||||
## Agent System
|
||||
|
||||
The workspace agent runs within each provisioned workspace and provides core functionality including:
|
||||
|
||||
- SSH access to workspaces via the `agentssh` package
|
||||
- Port forwarding
|
||||
- Terminal connectivity via the `pty` package for pseudo-terminal support
|
||||
- Application serving
|
||||
- Healthcheck monitoring
|
||||
- Resource usage reporting
|
||||
|
||||
Agents communicate with the control plane using the tailnet system and authenticate using secure tokens.
|
||||
|
||||
## Workspace Applications
|
||||
|
||||
Workspace applications (or "apps") provide browser-based access to services running within workspaces. The system supports:
|
||||
|
||||
- HTTP(S) and WebSocket connections
|
||||
- Path-based or subdomain-based access URLs
|
||||
- Health checks to monitor application availability
|
||||
- Different sharing levels (owner-only, authenticated users, or public)
|
||||
- Custom icons and display settings
|
||||
|
||||
The implementation is primarily in the `coderd/workspaceapps/` directory with components for URL generation, proxying connections, and managing application state.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The project structure separates frontend and backend concerns. React components and pages are organized in the `site/src/` directory, with Jest used for testing. The backend is primarily written in Go, with a strong emphasis on error handling patterns and test coverage.
|
||||
|
||||
Database interactions are carefully managed through migrations in `coderd/database/migrations/` and queries in `coderd/database/queries/`. All new queries require proper database authorization (dbauthz) implementation to ensure that only users with appropriate permissions can access specific resources.
|
||||
|
||||
## Authorization System
|
||||
|
||||
The database authorization (dbauthz) system enforces fine-grained access control across all database operations. It uses role-based access control (RBAC) to validate user permissions before executing database operations. The `dbauthz` package wraps the database store and performs authorization checks before returning data. All database operations must pass through this layer to ensure security.
|
||||
|
||||
## Testing Framework
|
||||
|
||||
The codebase has a comprehensive testing approach with several key components:
|
||||
|
||||
1. **Parallel Testing**: All tests must use `t.Parallel()` to run concurrently, which improves test suite performance and helps identify race conditions.
|
||||
|
||||
2. **coderdtest Package**: This package in `coderd/coderdtest/` provides utilities for creating test instances of the Coder server, setting up test users and workspaces, and mocking external components.
|
||||
|
||||
3. **Integration Tests**: Tests often span multiple components to verify system behavior, such as template creation, workspace provisioning, and agent connectivity.
|
||||
|
||||
4. **Enterprise Testing**: Enterprise features have dedicated test utilities in the `coderdenttest` package.
|
||||
|
||||
## Open Source and Enterprise Components
|
||||
|
||||
The repository contains both open source and enterprise components:
|
||||
|
||||
- Enterprise code lives primarily in the `enterprise/` directory
|
||||
- Enterprise features focus on governance, scalability (high availability), and advanced deployment options like workspace proxies
|
||||
- The boundary between open source and enterprise is managed through a licensing system
|
||||
- The same core codebase supports both editions, with enterprise features conditionally enabled
|
||||
|
||||
## Development Philosophy
|
||||
|
||||
Coder emphasizes clear error handling, with specific patterns required:
|
||||
|
||||
- Concise error messages that avoid phrases like "failed to"
|
||||
- Wrapping errors with `%w` to maintain error chains
|
||||
- Using sentinel errors with the "err" prefix (e.g., `errNotFound`)
|
||||
|
||||
All tests should run in parallel using `t.Parallel()` to ensure efficient testing and expose potential race conditions. The codebase is rigorously linted with golangci-lint to maintain consistent code quality.
|
||||
|
||||
Git contributions follow a standard format with commit messages structured as `type: <message>`, where type is one of `feat`, `fix`, or `chore`.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
Development can be initiated using `scripts/develop.sh` to start the application after making changes. Database schema updates should be performed through the migration system using `create_migration.sh <name>` to generate migration files, with each `.up.sql` migration paired with a corresponding `.down.sql` that properly reverts all changes.
|
||||
|
||||
If the development database gets into a bad state, it can be completely reset by removing the PostgreSQL data directory with `rm -rf .coderv2/postgres`. This will destroy all data in the development database, requiring you to recreate any test users, templates, or workspaces after restarting the application.
|
||||
|
||||
Code generation for the database layer uses `coderd/database/generate.sh`, and developers should refer to `sqlc.yaml` for the appropriate style and patterns to follow when creating new queries or tables.
|
||||
|
||||
The focus should always be on maintaining security through proper database authorization, clean error handling, and comprehensive test coverage to ensure the platform remains robust and reliable.
|
||||
@@ -10,12 +10,8 @@ install_devcontainer_cli() {
|
||||
|
||||
install_ssh_config() {
|
||||
echo "🔑 Installing SSH configuration..."
|
||||
if [ -d /mnt/home/coder/.ssh ]; then
|
||||
rsync -a /mnt/home/coder/.ssh/ ~/.ssh/
|
||||
chmod 0700 ~/.ssh
|
||||
else
|
||||
echo "⚠️ SSH directory not found."
|
||||
fi
|
||||
rsync -a /mnt/home/coder/.ssh/ ~/.ssh/
|
||||
chmod 0700 ~/.ssh
|
||||
}
|
||||
|
||||
install_git_config() {
|
||||
|
||||
@@ -26,8 +26,5 @@ ignorePatterns:
|
||||
- pattern: "claude.ai"
|
||||
- pattern: "splunk.com"
|
||||
- pattern: "stackoverflow.com/questions"
|
||||
- pattern: "developer.hashicorp.com/terraform/language"
|
||||
- pattern: "platform.openai.com"
|
||||
- pattern: "api.openai.com"
|
||||
aliveStatusCodes:
|
||||
- 200
|
||||
|
||||
@@ -5,13 +5,6 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup sqlc
|
||||
# uses: sqlc-dev/setup-sqlc@c0209b9199cd1cce6a14fc27cabcec491b651761 # v4.0.0
|
||||
# with:
|
||||
# sqlc-version: "1.30.0"
|
||||
|
||||
# Switched to coder/sqlc fork to fix ambiguous column bug, see:
|
||||
# - https://github.com/coder/sqlc/pull/1
|
||||
# - https://github.com/sqlc-dev/sqlc/pull/4159
|
||||
shell: bash
|
||||
run: |
|
||||
CGO_ENABLED=1 go install github.com/coder/sqlc/cmd/sqlc@aab4e865a51df0c43e1839f81a9d349b41d14f05
|
||||
uses: sqlc-dev/setup-sqlc@c0209b9199cd1cce6a14fc27cabcec491b651761 # v4.0.0
|
||||
with:
|
||||
sqlc-version: "1.27.0"
|
||||
|
||||
@@ -7,5 +7,5 @@ runs:
|
||||
- name: Install Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
with:
|
||||
terraform_version: 1.13.4
|
||||
terraform_version: 1.13.0
|
||||
terraform_wrapper: false
|
||||
|
||||
@@ -6,8 +6,6 @@ updates:
|
||||
interval: "weekly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
labels: []
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
@@ -70,8 +68,8 @@ updates:
|
||||
interval: "monthly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
reviewers:
|
||||
- "coder/ts"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels: []
|
||||
@@ -121,9 +119,9 @@ updates:
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
groups:
|
||||
coder-modules:
|
||||
coder:
|
||||
patterns:
|
||||
- "coder/*/coder"
|
||||
- "registry.coder.com/coder/*/coder"
|
||||
labels: []
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
app = "sao-paulo-coder"
|
||||
primary_region = "gru"
|
||||
|
||||
[experimental]
|
||||
entrypoint = ["/bin/sh", "-c", "CODER_DERP_SERVER_RELAY_URL=\"http://[${FLY_PRIVATE_IP}]:3000\" /opt/coder wsproxy server"]
|
||||
auto_rollback = true
|
||||
|
||||
[build]
|
||||
image = "ghcr.io/coder/coder-preview:main"
|
||||
|
||||
[env]
|
||||
CODER_ACCESS_URL = "https://sao-paulo.fly.dev.coder.com"
|
||||
CODER_HTTP_ADDRESS = "0.0.0.0:3000"
|
||||
CODER_PRIMARY_ACCESS_URL = "https://dev.coder.com"
|
||||
CODER_WILDCARD_ACCESS_URL = "*--apps.sao-paulo.fly.dev.coder.com"
|
||||
CODER_VERBOSE = "true"
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = true
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
|
||||
# Ref: https://fly.io/docs/reference/configuration/#http_service-concurrency
|
||||
[http_service.concurrency]
|
||||
type = "requests"
|
||||
soft_limit = 50
|
||||
hard_limit = 100
|
||||
|
||||
[[vm]]
|
||||
cpu_kind = "shared"
|
||||
cpus = 2
|
||||
memory_mb = 512
|
||||
+62
-58
@@ -35,12 +35,12 @@ jobs:
|
||||
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
# runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
# uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
# with:
|
||||
# fetch-depth: 1
|
||||
# # See: https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#commits-made-by-this-action-do-not-trigger-new-workflow-runs
|
||||
@@ -157,12 +157,12 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
echo "LINT_CACHE_DIR=$dir" >> "$GITHUB_ENV"
|
||||
|
||||
- name: golangci-lint cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
${{ env.LINT_CACHE_DIR }}
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
|
||||
# Check for any typos
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 # v1.40.0
|
||||
uses: crate-ci/typos@85f62a8a84f939ae994ab3763f01a0296d61a7ee # v1.36.2
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
|
||||
@@ -230,17 +230,17 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
gen:
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 8
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
if: ${{ !cancelled() }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -271,7 +271,6 @@ jobs:
|
||||
popd
|
||||
|
||||
- name: make gen
|
||||
timeout-minutes: 8
|
||||
run: |
|
||||
# Remove golden files to detect discrepancy in generated files.
|
||||
make clean/golden-files
|
||||
@@ -289,15 +288,15 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.offlinedocs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 7
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -316,7 +315,6 @@ jobs:
|
||||
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0
|
||||
|
||||
- name: make fmt
|
||||
timeout-minutes: 7
|
||||
run: |
|
||||
PATH="${PATH}:$(go env GOPATH)/bin" \
|
||||
make --output-sync -j -B fmt
|
||||
@@ -343,7 +341,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -369,7 +367,7 @@ jobs:
|
||||
uses: coder/setup-ramdisk-action@e1100847ab2d7bcd9d14bcda8f2d1b0f07b36f1b # v0.1.0
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -378,6 +376,13 @@ jobs:
|
||||
id: go-paths
|
||||
uses: ./.github/actions/setup-go-paths
|
||||
|
||||
- name: Download Go Build Cache
|
||||
id: download-go-build-cache
|
||||
uses: ./.github/actions/test-cache/download
|
||||
with:
|
||||
key-prefix: test-go-build-${{ runner.os }}-${{ runner.arch }}
|
||||
cache-path: ${{ steps.go-paths.outputs.cached-dirs }}
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
with:
|
||||
@@ -385,7 +390,8 @@ jobs:
|
||||
# download the toolchain configured in go.mod, so we don't
|
||||
# need to reinstall it. It's faster on Windows runners.
|
||||
use-preinstalled-go: ${{ runner.os == 'Windows' }}
|
||||
use-cache: true
|
||||
# Cache is already downloaded above
|
||||
use-cache: false
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
@@ -494,11 +500,17 @@ jobs:
|
||||
make test
|
||||
|
||||
- name: Upload failed test db dumps
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: failed-test-db-dump-${{matrix.os}}
|
||||
path: "**/*.test.sql"
|
||||
|
||||
- name: Upload Go Build Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
with:
|
||||
cache-key: ${{ steps.download-go-build-cache.outputs.cache-key }}
|
||||
cache-path: ${{ steps.go-paths.outputs.cached-dirs }}
|
||||
|
||||
- name: Upload Test Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
with:
|
||||
@@ -532,12 +544,12 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -581,12 +593,12 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -641,12 +653,12 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -668,12 +680,12 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -701,12 +713,12 @@ jobs:
|
||||
name: ${{ matrix.variant.name }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -750,23 +762,15 @@ jobs:
|
||||
|
||||
- name: Upload Playwright Failed Tests
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: failed-test-videos${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/*.webm
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload debug log
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: coderd-debug-logs${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/e2e/test-results/debug.log
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload pprof dumps
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: debug-pprof-dumps${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/debug-pprof-*.txt
|
||||
@@ -781,12 +785,12 @@ jobs:
|
||||
if: needs.changes.outputs.site == 'true' || needs.changes.outputs.ci == 'true'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
# 👇 Ensures Chromatic can read your full git history
|
||||
fetch-depth: 0
|
||||
@@ -802,7 +806,7 @@ jobs:
|
||||
# the check to pass. This is desired in PRs, but not in mainline.
|
||||
- name: Publish to Chromatic (non-mainline)
|
||||
if: github.ref != 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||
uses: chromaui/action@20c7e42e1b2f6becd5d188df9acb02f3e2f51519 # v13.2.0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -834,7 +838,7 @@ jobs:
|
||||
# infinitely "in progress" in mainline unless we re-review each build.
|
||||
- name: Publish to Chromatic (mainline)
|
||||
if: github.ref == 'refs/heads/main' && github.repository_owner == 'coder'
|
||||
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||
uses: chromaui/action@20c7e42e1b2f6becd5d188df9acb02f3e2f51519 # v13.2.0
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
STORYBOOK: true
|
||||
@@ -862,12 +866,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
# 0 is required here for version.sh to work.
|
||||
fetch-depth: 0
|
||||
@@ -933,7 +937,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -971,7 +975,7 @@ jobs:
|
||||
steps:
|
||||
# Harden Runner doesn't work on macOS
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -1032,7 +1036,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: ${{ github.repository_owner == 'coder' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/')) }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: dylibs
|
||||
path: |
|
||||
@@ -1053,12 +1057,12 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -1108,18 +1112,18 @@ jobs:
|
||||
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -1197,7 +1201,7 @@ jobs:
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: dylibs
|
||||
path: ./build
|
||||
@@ -1464,7 +1468,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: coder
|
||||
path: |
|
||||
@@ -1505,12 +1509,12 @@ jobs:
|
||||
if: needs.changes.outputs.db == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -1533,7 +1537,7 @@ jobs:
|
||||
steps:
|
||||
- name: Send Slack notification
|
||||
run: |
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U09LQ75AHKR> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U08TJ4YNCA3> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{
|
||||
"blocks": [
|
||||
|
||||
@@ -28,7 +28,6 @@ jobs:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Approve the PR
|
||||
if: steps.metadata.outputs.package-ecosystem != 'github-actions'
|
||||
run: |
|
||||
echo "Approving $PR_URL"
|
||||
gh pr review --approve "$PR_URL"
|
||||
@@ -37,7 +36,6 @@ jobs:
|
||||
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
- name: Enable auto-merge
|
||||
if: steps.metadata.outputs.package-ecosystem != 'github-actions'
|
||||
run: |
|
||||
echo "Enabling auto-merge for $PR_URL"
|
||||
gh pr merge --auto --squash "$PR_URL"
|
||||
@@ -47,11 +45,6 @@ jobs:
|
||||
|
||||
- name: Send Slack notification
|
||||
run: |
|
||||
if [ "$PACKAGE_ECOSYSTEM" = "github-actions" ]; then
|
||||
STATUS_TEXT=":pr-opened: Dependabot opened PR #${PR_NUMBER} (GitHub Actions changes are not auto-merged)"
|
||||
else
|
||||
STATUS_TEXT=":pr-merged: Auto merge enabled for Dependabot PR #${PR_NUMBER}"
|
||||
fi
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{
|
||||
"username": "dependabot",
|
||||
@@ -61,7 +54,7 @@ jobs:
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "'"${STATUS_TEXT}"'",
|
||||
"text": ":pr-merged: Auto merge enabled for Dependabot PR #'"${PR_NUMBER}"'",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
@@ -91,7 +84,6 @@ jobs:
|
||||
}' "${{ secrets.DEPENDABOT_PRS_SLACK_WEBHOOK }}"
|
||||
env:
|
||||
SLACK_WEBHOOK: ${{ secrets.DEPENDABOT_PRS_SLACK_WEBHOOK }}
|
||||
PACKAGE_ECOSYSTEM: ${{ steps.metadata.outputs.package-ecosystem }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
|
||||
@@ -36,12 +36,12 @@ jobs:
|
||||
verdict: ${{ steps.check.outputs.verdict }} # DEPLOY or NOOP
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -65,18 +65,18 @@ jobs:
|
||||
packages: write # to retag image as dogfood
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
|
||||
- name: Set up Flux CLI
|
||||
uses: fluxcd/flux2/action@8454b02a32e48d775b9f563cb51fdcb1787b5b93 # v2.7.5
|
||||
uses: fluxcd/flux2/action@6bf37f6a560fd84982d67f853162e4b3c2235edb # v2.6.4
|
||||
with:
|
||||
# Keep this and the github action up to date with the version of flux installed in dogfood cluster
|
||||
version: "2.7.0"
|
||||
@@ -121,8 +121,6 @@ jobs:
|
||||
flux --namespace flux-system reconcile source chart coder-coder-provisioner
|
||||
flux --namespace coder reconcile helmrelease coder
|
||||
flux --namespace coder reconcile helmrelease coder-provisioner
|
||||
flux --namespace coder reconcile helmrelease coder-provisioner-tagged
|
||||
flux --namespace coder reconcile helmrelease coder-provisioner-tagged-prebuilds
|
||||
|
||||
# Just updating Flux is usually not enough. The Helm release may get
|
||||
# redeployed, but unless something causes the Deployment to update the
|
||||
@@ -138,20 +136,18 @@ jobs:
|
||||
kubectl --namespace coder rollout status deployment/coder-provisioner
|
||||
kubectl --namespace coder rollout restart deployment/coder-provisioner-tagged
|
||||
kubectl --namespace coder rollout status deployment/coder-provisioner-tagged
|
||||
kubectl --namespace coder rollout restart deployment/coder-provisioner-tagged-prebuilds
|
||||
kubectl --namespace coder rollout status deployment/coder-provisioner-tagged-prebuilds
|
||||
|
||||
deploy-wsproxies:
|
||||
runs-on: ubuntu-latest
|
||||
needs: deploy
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -163,10 +159,12 @@ jobs:
|
||||
run: |
|
||||
flyctl deploy --image "$IMAGE" --app paris-coder --config ./.github/fly-wsproxies/paris-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_PARIS" --yes
|
||||
flyctl deploy --image "$IMAGE" --app sydney-coder --config ./.github/fly-wsproxies/sydney-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_SYDNEY" --yes
|
||||
flyctl deploy --image "$IMAGE" --app sao-paulo-coder --config ./.github/fly-wsproxies/sao-paulo-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_SAO_PAULO" --yes
|
||||
flyctl deploy --image "$IMAGE" --app jnb-coder --config ./.github/fly-wsproxies/jnb-coder.toml --env "CODER_PROXY_SESSION_TOKEN=$TOKEN_JNB" --yes
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
IMAGE: ${{ inputs.image }}
|
||||
TOKEN_PARIS: ${{ secrets.FLY_PARIS_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_SYDNEY: ${{ secrets.FLY_SYDNEY_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_SAO_PAULO: ${{ secrets.FLY_SAO_PAULO_CODER_PROXY_SESSION_TOKEN }}
|
||||
TOKEN_JNB: ${{ secrets.FLY_JNB_CODER_PROXY_SESSION_TOKEN }}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
# This workflow checks if a PR requires documentation updates.
|
||||
# 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.
|
||||
|
||||
name: AI Documentation Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_url:
|
||||
description: "Pull Request URL to check"
|
||||
required: true
|
||||
type: string
|
||||
template_preset:
|
||||
description: "Template preset to use"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
doc-check:
|
||||
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')
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CODER_URL: ${{ secrets.DOC_CHECK_CODER_URL }}
|
||||
CODER_SESSION_TOKEN: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
- name: Determine PR Context
|
||||
id: determine-context
|
||||
env:
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
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 }}
|
||||
GITHUB_EVENT_SENDER_LOGIN: ${{ github.event.sender.login }}
|
||||
INPUTS_PR_URL: ${{ inputs.pr_url }}
|
||||
INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || '' }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Using template preset: ${INPUTS_TEMPLATE_PRESET}"
|
||||
echo "template_preset=${INPUTS_TEMPLATE_PRESET}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# For workflow_dispatch, use the provided PR URL
|
||||
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}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using workflow_dispatch actor: ${GITHUB_ACTOR} (ID: ${GITHUB_USER_ID})"
|
||||
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
|
||||
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 "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}"
|
||||
|
||||
else
|
||||
echo "::error::Unsupported event type: ${GITHUB_EVENT_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- 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 }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Analyzing PR #${PR_NUMBER}"
|
||||
|
||||
# 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.
|
||||
|
||||
PR URL: ${PR_URL}
|
||||
|
||||
WORKFLOW:
|
||||
1. Setup (repo is pre-cloned at ~/coder)
|
||||
cd ~/coder
|
||||
git fetch origin pull/${PR_NUMBER}/head:pr-${PR_NUMBER}
|
||||
git checkout pr-${PR_NUMBER}
|
||||
|
||||
2. Get PR info
|
||||
Use GitHub MCP tools to get PR title, body, and diff
|
||||
Or use: git diff main...pr-${PR_NUMBER}
|
||||
|
||||
3. Understand Changes
|
||||
Read the diff and identify what changed
|
||||
Ask: Is this user-facing? Does it change behavior? Is it a new feature?
|
||||
|
||||
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
|
||||
- **[docs/path/file.md](github_link)** - Brief what needs changing
|
||||
|
||||
### 📝 New Docs Needed
|
||||
- **docs/suggested/location.md** - What should be documented
|
||||
|
||||
### ✨ 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)*
|
||||
|
||||
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.
|
||||
|
||||
EOF
|
||||
)
|
||||
|
||||
# Output the prompt
|
||||
{
|
||||
echo "task_prompt<<EOFOUTPUT"
|
||||
echo "${TASK_PROMPT}"
|
||||
echo "EOFOUTPUT"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Checkout create-task-action
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
path: ./.github/actions/create-task-action
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
repository: coder/create-task-action
|
||||
|
||||
- name: Create Coder Task for Documentation Check
|
||||
id: create_task
|
||||
uses: ./.github/actions/create-task-action
|
||||
with:
|
||||
coder-url: ${{ secrets.DOC_CHECK_CODER_URL }}
|
||||
coder-token: ${{ secrets.DOC_CHECK_CODER_SESSION_TOKEN }}
|
||||
coder-organization: "default"
|
||||
coder-template-name: coder
|
||||
coder-template-preset: ${{ steps.determine-context.outputs.template_preset }}
|
||||
coder-task-name-prefix: doc-check
|
||||
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
|
||||
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 "## Documentation Check Task"
|
||||
echo ""
|
||||
echo "**PR:** ${PR_URL}"
|
||||
echo "**Task created:** ${TASK_CREATED}"
|
||||
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}"
|
||||
@@ -38,17 +38,17 @@ jobs:
|
||||
if: github.repository_owner == 'coder'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -23,14 +23,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- uses: tj-actions/changed-files@abdd2f68ea150cee8f236d4a9fb4e0f2491abf1b # v45.0.7
|
||||
- uses: tj-actions/changed-files@4563c729c555b4141fac99c80f699f571219b836 # v45.0.7
|
||||
id: changed-files
|
||||
with:
|
||||
files: |
|
||||
|
||||
@@ -26,21 +26,21 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
uses: nixbuild/nix-quick-install-action@1f095fee853b33114486cfdeae62fa099cda35a9 # v33
|
||||
with:
|
||||
# Pinning to 2.28 here, as Nix gets a "error: [json.exception.type_error.302] type must be array, but is string"
|
||||
# on version 2.29 and above.
|
||||
nix_version: "2.28.5"
|
||||
nix_version: "2.28.4"
|
||||
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
with:
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -125,12 +125,12 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
uses: coder/setup-ramdisk-action@e1100847ab2d7bcd9d14bcda8f2d1b0f07b36f1b # v0.1.0
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
steps:
|
||||
- name: Send Slack notification
|
||||
run: |
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U09LQ75AHKR> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
ESCAPED_PROMPT=$(printf "%s" "<@U08TJ4YNCA3> $BLINK_CI_FAILURE_PROMPT" | jq -Rsa .)
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{
|
||||
"blocks": [
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -39,12 +39,12 @@ jobs:
|
||||
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -76,12 +76,12 @@ jobs:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -184,12 +184,12 @@ jobs:
|
||||
pull-requests: write # needed for commenting on PRs
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR
|
||||
id: comment_id
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
|
||||
@@ -228,12 +228,12 @@ jobs:
|
||||
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -248,7 +248,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -288,7 +288,7 @@ jobs:
|
||||
PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
kubectl create namespace "pr${PR_NUMBER}"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -491,7 +491,7 @@ jobs:
|
||||
PASSWORD: ${{ steps.setup_deployment.outputs.password }}
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
|
||||
id: fc
|
||||
with:
|
||||
issue-number: ${{ env.PR_NUMBER }}
|
||||
@@ -500,7 +500,7 @@ jobs:
|
||||
direction: last
|
||||
|
||||
- name: Comment on PR
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
env:
|
||||
STATUS: ${{ needs.get_info.outputs.NEW == 'true' && 'Created' || 'Updated' }}
|
||||
with:
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
steps:
|
||||
# Harden Runner doesn't work on macOS.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: dylibs
|
||||
path: |
|
||||
@@ -164,12 +164,12 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -239,7 +239,7 @@ jobs:
|
||||
cat "$CODER_RELEASE_NOTES_FILE"
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: dylibs
|
||||
path: ./build
|
||||
@@ -761,7 +761,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts to actions (if dry-run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: release-artifacts
|
||||
path: |
|
||||
@@ -777,7 +777,7 @@ jobs:
|
||||
|
||||
- name: Upload latest sbom artifact to actions (if dry-run)
|
||||
if: inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: latest-sbom-artifact
|
||||
path: ./coder_latest_sbom.spdx.json
|
||||
@@ -785,7 +785,7 @@ jobs:
|
||||
|
||||
- name: Send repository-dispatch event
|
||||
if: ${{ !inputs.dry_run }}
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
|
||||
with:
|
||||
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
repository: coder/packages
|
||||
@@ -802,7 +802,7 @@ jobs:
|
||||
# TODO: skip this if it's not a new release (i.e. a backport). This is
|
||||
# fine right now because it just makes a PR that we can close.
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -878,7 +878,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -888,7 +888,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -971,12 +971,12 @@ jobs:
|
||||
if: ${{ !inputs.dry_run }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
@@ -20,17 +20,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
# Upload the results as artifacts.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
@@ -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@fe4161a26a8629af62121b670040955b330f9af2 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -27,12 +27,12 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v3.29.5
|
||||
uses: github/codeql-action/init@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
with:
|
||||
languages: go, javascript
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
rm Makefile
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v3.29.5
|
||||
uses: github/codeql-action/analyze@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
|
||||
- name: Send Slack notification on failure
|
||||
if: ${{ failure() }}
|
||||
@@ -69,12 +69,12 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -154,13 +154,13 @@ jobs:
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@fe4161a26a8629af62121b670040955b330f9af2 # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3 # v3.29.5
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: "Trivy"
|
||||
|
||||
- name: Upload Trivy scan results as an artifact
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: trivy
|
||||
path: trivy-results.sarif
|
||||
|
||||
@@ -18,12 +18,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: stale
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
|
||||
with:
|
||||
stale-issue-label: "stale"
|
||||
stale-pr-label: "stale"
|
||||
@@ -96,12 +96,12 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run delete-old-branches-action
|
||||
@@ -120,12 +120,12 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Delete PR Cleanup workflow runs
|
||||
uses: Mattraks/delete-workflow-runs@5bf9a1dac5c4d041c029f0a8370ddf0c5cb5aeb7 # v2.1.0
|
||||
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
delete_workflow_pattern: pr-cleanup.yaml
|
||||
|
||||
- name: Delete PR Deploy workflow skipped runs
|
||||
uses: Mattraks/delete-workflow-runs@5bf9a1dac5c4d041c029f0a8370ddf0c5cb5aeb7 # v2.1.0
|
||||
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
repository: ${{ github.repository }}
|
||||
|
||||
+133
-138
@@ -1,9 +1,6 @@
|
||||
name: AI Triage Automation
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_url:
|
||||
@@ -13,178 +10,176 @@ on:
|
||||
template_name:
|
||||
description: "Coder template to use for workspace"
|
||||
required: true
|
||||
default: "coder"
|
||||
default: "traiage"
|
||||
type: string
|
||||
template_preset:
|
||||
description: "Template preset to use"
|
||||
required: false
|
||||
default: ""
|
||||
required: true
|
||||
default: "Default"
|
||||
type: string
|
||||
prefix:
|
||||
description: "Prefix for workspace name"
|
||||
required: false
|
||||
default: "traiage"
|
||||
type: string
|
||||
cleanup:
|
||||
description: "Cleanup workspace after triage."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
traiage:
|
||||
name: Triage GitHub Issue with Claude Code
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.label.name == 'traiage' || github.event_name == 'workflow_dispatch'
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CODER_URL: ${{ secrets.TRAIAGE_CODER_URL }}
|
||||
CODER_SESSION_TOKEN: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
|
||||
TEMPLATE_NAME: ${{ inputs.template_name }}
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
# This is only required for testing locally using nektos/act, so leaving commented out.
|
||||
# An alternative is to use a larger or custom image.
|
||||
# - name: Install Github CLI
|
||||
# id: install-gh
|
||||
# run: |
|
||||
# (type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \
|
||||
# && sudo mkdir -p -m 755 /etc/apt/keyrings \
|
||||
# && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
# && cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
|
||||
# && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||
# && sudo mkdir -p -m 755 /etc/apt/sources.list.d \
|
||||
# && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||
# && sudo apt update \
|
||||
# && sudo apt install gh -y
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine Inputs
|
||||
id: determine-inputs
|
||||
if: always()
|
||||
env:
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
GITHUB_EVENT_ISSUE_HTML_URL: ${{ github.event.issue.html_url }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_EVENT_USER_ID: ${{ github.event.sender.id }}
|
||||
GITHUB_EVENT_USER_LOGIN: ${{ github.event.sender.login }}
|
||||
INPUTS_ISSUE_URL: ${{ inputs.issue_url }}
|
||||
INPUTS_TEMPLATE_NAME: ${{ inputs.template_name || 'coder' }}
|
||||
INPUTS_TEMPLATE_PRESET: ${{ inputs.template_preset || ''}}
|
||||
INPUTS_PREFIX: ${{ inputs.prefix || 'traiage' }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Using template name: ${INPUTS_TEMPLATE_NAME}"
|
||||
echo "template_name=${INPUTS_TEMPLATE_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using template preset: ${INPUTS_TEMPLATE_PRESET}"
|
||||
echo "template_preset=${INPUTS_TEMPLATE_PRESET}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using prefix: ${INPUTS_PREFIX}"
|
||||
echo "prefix=${INPUTS_PREFIX}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# For workflow_dispatch, use the actor who triggered it
|
||||
# For issues events, use the issue author.
|
||||
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}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using workflow_dispatch actor: ${GITHUB_ACTOR} (ID: ${GITHUB_USER_ID})"
|
||||
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
|
||||
echo "github_username=${GITHUB_ACTOR}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using issue URL: ${INPUTS_ISSUE_URL}"
|
||||
echo "issue_url=${INPUTS_ISSUE_URL}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
exit 0
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
|
||||
GITHUB_USER_ID=${GITHUB_EVENT_USER_ID}
|
||||
echo "Using issue author: ${GITHUB_EVENT_USER_LOGIN} (ID: ${GITHUB_USER_ID})"
|
||||
echo "github_user_id=${GITHUB_USER_ID}" >> "${GITHUB_OUTPUT}"
|
||||
echo "github_username=${GITHUB_EVENT_USER_LOGIN}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
echo "Using issue URL: ${GITHUB_EVENT_ISSUE_HTML_URL}"
|
||||
echo "issue_url=${GITHUB_EVENT_ISSUE_HTML_URL}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
exit 0
|
||||
else
|
||||
echo "::error::Unsupported event type: ${GITHUB_EVENT_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify push access
|
||||
env:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_USERNAME: ${{ steps.determine-inputs.outputs.github_username }}
|
||||
GITHUB_USER_ID: ${{ steps.determine-inputs.outputs.github_user_id }}
|
||||
run: |
|
||||
# Query the actor’s permission on this repo
|
||||
can_push="$(gh api "/repos/${GITHUB_REPOSITORY}/collaborators/${GITHUB_USERNAME}/permission" --jq '.user.permissions.push')"
|
||||
if [[ "${can_push}" != "true" ]]; then
|
||||
echo "::error title=Access Denied::${GITHUB_USERNAME} does not have push access to ${GITHUB_REPOSITORY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Extract context key and description from issue
|
||||
- name: Extract context key from issue
|
||||
id: extract-context
|
||||
env:
|
||||
ISSUE_URL: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ISSUE_URL: ${{ inputs.issue_url }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
issue_number="$(gh issue view "${ISSUE_URL}" --json number --jq '.number')"
|
||||
context_key="gh-${issue_number}"
|
||||
echo "context_key=${context_key}" >> "${GITHUB_OUTPUT}"
|
||||
echo "CONTEXT_KEY=${context_key}" >> "${GITHUB_ENV}"
|
||||
|
||||
TASK_PROMPT=$(cat <<EOF
|
||||
Fix ${ISSUE_URL}
|
||||
- name: Download and install Coder binary
|
||||
shell: bash
|
||||
env:
|
||||
CODER_URL: ${{ secrets.TRAIAGE_CODER_URL }}
|
||||
run: |
|
||||
if [ "${{ runner.arch }}" == "ARM64" ]; then
|
||||
ARCH="arm64"
|
||||
else
|
||||
ARCH="amd64"
|
||||
fi
|
||||
mkdir -p "${HOME}/.local/bin"
|
||||
curl -fsSL --compressed "$CODER_URL/bin/coder-linux-${ARCH}" -o "${HOME}/.local/bin/coder"
|
||||
chmod +x "${HOME}/.local/bin/coder"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
coder version
|
||||
coder whoami
|
||||
echo "$HOME/.local/bin" >> "${GITHUB_PATH}"
|
||||
|
||||
1. Use the gh CLI to read the issue description and comments.
|
||||
2. Think carefully and try to understand the root cause. If the issue is unclear or not well defined, ask me to clarify and provide more information.
|
||||
3. Write a proposed implementation plan to PLAN.md for me to review before starting implementation. Your plan should use TDD and only make the minimal changes necessary to fix the root cause.
|
||||
4. When I approve your plan, start working on it. If you encounter issues with the plan, ask me for clarification and update the plan as required.
|
||||
5. When you have finished implementation according to the plan, commit and push your changes, and create a PR using the gh CLI for me to review.
|
||||
- name: Get Coder username from GitHub actor
|
||||
id: get-coder-username
|
||||
env:
|
||||
CODER_SESSION_TOKEN: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
|
||||
GITHUB_USER_ID: ${{
|
||||
(github.event_name == 'workflow_dispatch' && github.actor_id)
|
||||
}}
|
||||
run: |
|
||||
[[ -z "${GITHUB_USER_ID}" || "${GITHUB_USER_ID}" == "null" ]] && echo "No GitHub actor ID found" && exit 1
|
||||
user_json=$(
|
||||
coder users list --github-user-id="${GITHUB_USER_ID}" --output=json
|
||||
)
|
||||
coder_username=$(jq -r 'first | .username' <<< "$user_json")
|
||||
[[ -z "${coder_username}" || "${coder_username}" == "null" ]] && echo "No Coder user with GitHub user ID ${GITHUB_USER_ID} found" && exit 1
|
||||
echo "coder_username=${coder_username}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# TODO(Cian): this is a good use-case for 'recipes'
|
||||
- name: Create Coder task
|
||||
id: create-task
|
||||
env:
|
||||
CODER_USERNAME: ${{ steps.get-coder-username.outputs.coder_username }}
|
||||
CONTEXT_KEY: ${{ steps.extract-context.outputs.context_key }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
ISSUE_URL: ${{ inputs.issue_url }}
|
||||
PREFIX: ${{ inputs.prefix }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
TEMPLATE_PARAMETERS: ${{ secrets.TRAIAGE_TEMPLATE_PARAMETERS }}
|
||||
TEMPLATE_PRESET: ${{ inputs.template_preset }}
|
||||
run: |
|
||||
# Fetch issue description using `gh` CLI
|
||||
issue_description=$(gh issue view "${ISSUE_URL}")
|
||||
|
||||
# Write a prompt to PROMPT_FILE
|
||||
PROMPT=$(cat <<EOF
|
||||
Analyze the below GitHub issue description, understand the root cause, and make appropriate changes to resolve the issue.
|
||||
|
||||
ISSUE URL: ${ISSUE_URL}
|
||||
ISSUE DESCRIPTION BELOW:
|
||||
|
||||
${issue_description}
|
||||
EOF
|
||||
)
|
||||
export PROMPT
|
||||
|
||||
echo "context_key=${context_key}" >> "${GITHUB_OUTPUT}"
|
||||
{
|
||||
echo "TASK_PROMPT<<EOF"
|
||||
echo "${TASK_PROMPT}"
|
||||
echo "EOF"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
export TASK_NAME="${PREFIX}-${CONTEXT_KEY}-${RUN_ID}"
|
||||
echo "Creating task: $TASK_NAME"
|
||||
./scripts/traiage.sh create
|
||||
coder exp task status "${CODER_USERNAME}/$TASK_NAME" --watch
|
||||
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
echo "TASK_NAME=${CODER_USERNAME}/${TASK_NAME}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
path: ./.github/actions/create-task-action
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
repository: coder/create-task-action
|
||||
|
||||
- name: Create Coder Task
|
||||
id: create_task
|
||||
uses: ./.github/actions/create-task-action
|
||||
with:
|
||||
coder-url: ${{ secrets.TRAIAGE_CODER_URL }}
|
||||
coder-token: ${{ secrets.TRAIAGE_CODER_SESSION_TOKEN }}
|
||||
coder-organization: "default"
|
||||
coder-template-name: coder
|
||||
coder-template-preset: ${{ steps.determine-inputs.outputs.template_preset }}
|
||||
coder-task-name-prefix: gh-coder
|
||||
coder-task-prompt: ${{ steps.extract-context.outputs.task_prompt }}
|
||||
github-user-id: ${{ steps.determine-inputs.outputs.github_user_id }}
|
||||
github-token: ${{ github.token }}
|
||||
github-issue-url: ${{ steps.determine-inputs.outputs.issue_url }}
|
||||
comment-on-issue: ${{ startsWith(steps.determine-inputs.outputs.issue_url, format('{0}/{1}', github.server_url, github.repository)) }}
|
||||
|
||||
- name: Write outputs
|
||||
- name: Create and upload archive
|
||||
id: create-archive
|
||||
if: inputs.cleanup
|
||||
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 }}
|
||||
BUCKET_PREFIX: "gs://coder-traiage-outputs/traiage"
|
||||
run: |
|
||||
echo "Creating archive for workspace: $TASK_NAME"
|
||||
./scripts/traiage.sh archive
|
||||
echo "archive_url=${BUCKET_PREFIX%%/}/$TASK_NAME.tar.gz" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Generate a summary of the changes and post a comment on GitHub.
|
||||
id: generate-summary
|
||||
if: inputs.cleanup
|
||||
env:
|
||||
ARCHIVE_URL: ${{ steps.create-archive.outputs.archive_url }}
|
||||
BUCKET_PREFIX: "gs://coder-traiage-outputs/traiage"
|
||||
CONTEXT_KEY: ${{ steps.extract-context.outputs.context_key }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
ISSUE_URL: ${{ inputs.issue_url }}
|
||||
TASK_NAME: ${{ steps.create-task.outputs.TASK_NAME }}
|
||||
run: |
|
||||
SUMMARY_FILE=$(mktemp)
|
||||
trap 'rm -f "${SUMMARY_FILE}"' EXIT
|
||||
AUTO_SUMMARY=$(./scripts/traiage.sh summary)
|
||||
{
|
||||
echo "**Task created:** ${TASK_CREATED}"
|
||||
echo "**Task name:** ${TASK_NAME}"
|
||||
echo "**Task URL**: ${TASK_URL}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "## TrAIage Results"
|
||||
echo "- **Issue URL:** ${ISSUE_URL}"
|
||||
echo "- **Context Key:** ${CONTEXT_KEY}"
|
||||
echo "- **Workspace:** ${TASK_NAME}"
|
||||
echo "- **Archive URL:** ${ARCHIVE_URL}"
|
||||
echo
|
||||
echo "${AUTO_SUMMARY}"
|
||||
echo
|
||||
echo "To fetch the output to your own workspace:"
|
||||
echo
|
||||
echo '```bash'
|
||||
echo "BUCKET_PREFIX=${BUCKET_PREFIX} TASK_NAME=${TASK_NAME} ./scripts/traiage.sh resume"
|
||||
echo '```'
|
||||
echo
|
||||
} >> "${SUMMARY_FILE}"
|
||||
|
||||
if [[ "${ISSUE_URL}" == "https://github.com/${GITHUB_REPOSITORY}"* ]]; then
|
||||
gh issue comment "${ISSUE_URL}" --body-file "${SUMMARY_FILE}" --create-if-none --edit-last
|
||||
else
|
||||
echo "Skipping comment on other repo."
|
||||
fi
|
||||
cat "${SUMMARY_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Cleanup task
|
||||
if: inputs.cleanup && steps.create-task.outputs.TASK_NAME != '' && steps.create-archive.outputs.archive_url != ''
|
||||
run: |
|
||||
echo "Cleaning up task: $TASK_NAME"
|
||||
./scripts/traiage.sh delete || true
|
||||
|
||||
@@ -9,7 +9,6 @@ IST = "IST"
|
||||
MacOS = "macOS"
|
||||
AKS = "AKS"
|
||||
O_WRONLY = "O_WRONLY"
|
||||
AIBridge = "AI Bridge"
|
||||
|
||||
[default.extend-words]
|
||||
AKS = "AKS"
|
||||
|
||||
@@ -21,17 +21,17 @@ jobs:
|
||||
pull-requests: write # required to post PR review comments by the action
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check Markdown links
|
||||
uses: umbrelladocs/action-linkspector@652f85bc57bb1e7d4327260decc10aa68f7694c3 # v1.4.0
|
||||
uses: umbrelladocs/action-linkspector@874d01cae9fd488e3077b08952093235bd626977 # v1.3.7
|
||||
id: markdown-link-check
|
||||
# checks all markdown files from /docs including all subfolders
|
||||
with:
|
||||
|
||||
-11
@@ -12,9 +12,6 @@ node_modules/
|
||||
vendor/
|
||||
yarn-error.log
|
||||
|
||||
# Test output files
|
||||
test-output/
|
||||
|
||||
# VSCode settings.
|
||||
**/.vscode/*
|
||||
# Allow VSCode recommendations and default settings in project root.
|
||||
@@ -89,11 +86,3 @@ result
|
||||
__debug_bin*
|
||||
|
||||
**/.claude/settings.local.json
|
||||
|
||||
# Local agent configuration
|
||||
AGENTS.local.md
|
||||
|
||||
/.env
|
||||
|
||||
# Ignore plans written by AI agents.
|
||||
PLAN.md
|
||||
|
||||
+1
-11
@@ -169,16 +169,6 @@ linters-settings:
|
||||
- name: var-declaration
|
||||
- name: var-naming
|
||||
- name: waitgroup-by-value
|
||||
usetesting:
|
||||
# Only os-setenv is enabled because we migrated to usetesting from another linter that
|
||||
# only covered os-setenv.
|
||||
os-setenv: true
|
||||
os-create-temp: false
|
||||
os-mkdir-temp: false
|
||||
os-temp-dir: false
|
||||
os-chdir: false
|
||||
context-background: false
|
||||
context-todo: false
|
||||
|
||||
# irrelevant as of Go v1.22: https://go.dev/blog/loopvar-preview
|
||||
govet:
|
||||
@@ -262,6 +252,7 @@ linters:
|
||||
# - wastedassign
|
||||
|
||||
- staticcheck
|
||||
- tenv
|
||||
# In Go, it's possible for a package to test it's internal functionality
|
||||
# without testing any exported functions. This is enabled to promote
|
||||
# decomposing a package before testing it's internals. A function caller
|
||||
@@ -274,5 +265,4 @@ linters:
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- usetesting
|
||||
- dupl
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"ignores": ["PLAN.md"],
|
||||
}
|
||||
Vendored
+1
-2
@@ -61,6 +61,5 @@
|
||||
"typos.config": ".github/workflows/typos.toml",
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
|
||||
},
|
||||
"biome.lsp.bin": "site/node_modules/.bin/biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,11 @@
|
||||
# Coder Development Guidelines
|
||||
|
||||
You are an experienced, pragmatic software engineer. You don't over-engineer a solution when a simple one is possible.
|
||||
Rule #1: If you want exception to ANY rule, YOU MUST STOP and get explicit permission first. BREAKING THE LETTER OR SPIRIT OF THE RULES IS FAILURE.
|
||||
|
||||
## Foundational rules
|
||||
|
||||
- Doing it right is better than doing it fast. You are not in a rush. NEVER skip steps or take shortcuts.
|
||||
- Tedious, systematic work is often the correct solution. Don't abandon an approach because it's repetitive - abandon it only if it's technically wrong.
|
||||
- Honesty is a core value.
|
||||
|
||||
## Our relationship
|
||||
|
||||
- Act as a critical peer reviewer. Your job is to disagree with me when I'm wrong, not to please me. Prioritize accuracy and reasoning over agreement.
|
||||
- YOU MUST speak up immediately when you don't know something or we're in over our heads
|
||||
- YOU MUST call out bad ideas, unreasonable expectations, and mistakes - I depend on this
|
||||
- NEVER be agreeable just to be nice - I NEED your HONEST technical judgment
|
||||
- NEVER write the phrase "You're absolutely right!" You are not a sycophant. We're working together because I value your opinion. Do not agree with me unless you can justify it with evidence or reasoning.
|
||||
- YOU MUST ALWAYS STOP and ask for clarification rather than making assumptions.
|
||||
- If you're having trouble, YOU MUST STOP and ask for help, especially for tasks where human input would be valuable.
|
||||
- When you disagree with my approach, YOU MUST push back. Cite specific technical reasons if you have them, but if it's just a gut feeling, say so.
|
||||
- If you're uncomfortable pushing back out loud, just say "Houston, we have a problem". I'll know what you mean
|
||||
- We discuss architectutral decisions (framework changes, major refactoring, system design) together before implementation. Routine fixes and clear implementations don't need discussion.
|
||||
|
||||
## Proactiveness
|
||||
|
||||
When asked to do something, just do it - including obvious follow-up actions needed to complete the task properly.
|
||||
Only pause to ask for confirmation when:
|
||||
|
||||
- Multiple valid approaches exist and the choice matters
|
||||
- The action would delete or significantly restructure existing code
|
||||
- You genuinely don't understand what's being asked
|
||||
- Your partner asked a question (answer the question, don't jump to implementation)
|
||||
|
||||
@.claude/docs/WORKFLOWS.md
|
||||
@.cursorrules
|
||||
@README.md
|
||||
@package.json
|
||||
|
||||
## Essential Commands
|
||||
## 🚀 Essential Commands
|
||||
|
||||
| Task | Command | Notes |
|
||||
|-------------------|--------------------------|----------------------------------|
|
||||
@@ -51,13 +21,22 @@ Only pause to ask for confirmation when:
|
||||
| **Format** | `make fmt` | Auto-format code |
|
||||
| **Clean** | `make clean` | Clean build artifacts |
|
||||
|
||||
### Frontend Commands (site directory)
|
||||
|
||||
- `pnpm build` - Build frontend
|
||||
- `pnpm dev` - Run development server
|
||||
- `pnpm check` - Run code checks
|
||||
- `pnpm format` - Format frontend code
|
||||
- `pnpm lint` - Lint frontend code
|
||||
- `pnpm test` - Run frontend tests
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
- `pnpm run format-docs` - Format markdown tables in docs
|
||||
- `pnpm run lint-docs` - Lint and fix markdown files
|
||||
- `pnpm run storybook` - Run Storybook (from site directory)
|
||||
|
||||
## Critical Patterns
|
||||
## 🔧 Critical Patterns
|
||||
|
||||
### Database Changes (ALWAYS FOLLOW)
|
||||
|
||||
@@ -99,7 +78,7 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestrict
|
||||
app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
## 📋 Quick Reference
|
||||
|
||||
### Full workflows available in imported WORKFLOWS.md
|
||||
|
||||
@@ -109,14 +88,14 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
|
||||
- [ ] Check if feature touches database - you'll need migrations
|
||||
- [ ] Check if feature touches audit logs - update `enterprise/audit/table.go`
|
||||
|
||||
## Architecture
|
||||
## 🏗️ Architecture
|
||||
|
||||
- **coderd**: Main API service
|
||||
- **provisionerd**: Infrastructure provisioning
|
||||
- **Agents**: Workspace services (SSH, port forwarding)
|
||||
- **Database**: PostgreSQL with `dbauthz` authorization
|
||||
|
||||
## Testing
|
||||
## 🧪 Testing
|
||||
|
||||
### Race Condition Prevention
|
||||
|
||||
@@ -133,78 +112,21 @@ app, err := api.Database.GetOAuth2ProviderAppByClientID(ctx, clientID)
|
||||
NEVER use `time.Sleep` to mitigate timing issues. If an issue
|
||||
seems like it should use `time.Sleep`, read through https://github.com/coder/quartz and specifically the [README](https://github.com/coder/quartz/blob/main/README.md) to better understand how to handle timing issues.
|
||||
|
||||
## Code Style
|
||||
## 🎯 Code Style
|
||||
|
||||
### Detailed guidelines in imported WORKFLOWS.md
|
||||
|
||||
- Follow [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md)
|
||||
- Commit format: `type(scope): message`
|
||||
|
||||
### Writing Comments
|
||||
## 📚 Detailed Development Guides
|
||||
|
||||
Code comments should be clear, well-formatted, and add meaningful context.
|
||||
|
||||
**Proper sentence structure**: Comments are sentences and should end with
|
||||
periods or other appropriate punctuation. This improves readability and
|
||||
maintains professional code standards.
|
||||
|
||||
**Explain why, not what**: Good comments explain the reasoning behind code
|
||||
rather than describing what the code does. The code itself should be
|
||||
self-documenting through clear naming and structure. Focus your comments on
|
||||
non-obvious decisions, edge cases, or business logic that isn't immediately
|
||||
apparent from reading the implementation.
|
||||
|
||||
**Line length and wrapping**: Keep comment lines to 80 characters wide
|
||||
(including the comment prefix like `//` or `#`). When a comment spans multiple
|
||||
lines, wrap it naturally at word boundaries rather than writing one sentence
|
||||
per line. This creates more readable, paragraph-like blocks of documentation.
|
||||
|
||||
```go
|
||||
// Good: Explains the rationale with proper sentence structure.
|
||||
// We need a custom timeout here because workspace builds can take several
|
||||
// minutes on slow networks, and the default 30s timeout causes false
|
||||
// failures during initial template imports.
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
|
||||
// Bad: Describes what the code does without punctuation or wrapping
|
||||
// Set a custom timeout
|
||||
// Workspace builds can take a long time
|
||||
// Default timeout is too short
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
```
|
||||
|
||||
### Avoid Unnecessary Changes
|
||||
|
||||
When fixing a bug or adding a feature, don't modify code unrelated to your
|
||||
task. Unnecessary changes make PRs harder to review and can introduce
|
||||
regressions.
|
||||
|
||||
**Don't reword existing comments or code** unless the change is directly
|
||||
motivated by your task. Rewording comments to be shorter or "cleaner" wastes
|
||||
reviewer time and clutters the diff.
|
||||
|
||||
**Don't delete existing comments** that explain non-obvious behavior. These
|
||||
comments preserve important context about why code works a certain way.
|
||||
|
||||
**When adding tests for new behavior**, add new test cases instead of modifying
|
||||
existing ones. This preserves coverage for the original behavior and makes it
|
||||
clear what the new test covers.
|
||||
|
||||
## Detailed Development Guides
|
||||
|
||||
@.claude/docs/ARCHITECTURE.md
|
||||
@.claude/docs/OAUTH2.md
|
||||
@.claude/docs/TESTING.md
|
||||
@.claude/docs/TROUBLESHOOTING.md
|
||||
@.claude/docs/DATABASE.md
|
||||
|
||||
## Local Configuration
|
||||
|
||||
These files may be gitignored, read manually if not auto-loaded.
|
||||
|
||||
@AGENTS.local.md
|
||||
|
||||
## Common Pitfalls
|
||||
## 🚨 Common Pitfalls
|
||||
|
||||
1. **Audit table errors** → Update `enterprise/audit/table.go`
|
||||
2. **OAuth2 errors** → Return RFC-compliant format
|
||||
|
||||
+12
-2
@@ -18,6 +18,18 @@ coderd/rbac/ @Emyrk
|
||||
scripts/apitypings/ @Emyrk
|
||||
scripts/gensite/ @aslilac
|
||||
|
||||
site/ @aslilac @Parkreiner
|
||||
site/src/hooks/ @Parkreiner
|
||||
# These rules intentionally do not specify any owners. More specific rules
|
||||
# override less specific rules, so these files are "ignored" by the site/ rule.
|
||||
site/e2e/google/protobuf/timestampGenerated.ts
|
||||
site/e2e/provisionerGenerated.ts
|
||||
site/src/api/countriesGenerated.ts
|
||||
site/src/api/rbacresourcesGenerated.ts
|
||||
site/src/api/typesGenerated.ts
|
||||
site/src/testHelpers/entities.ts
|
||||
site/CLAUDE.md
|
||||
|
||||
# The blood and guts of the autostop algorithm, which is quite complex and
|
||||
# requires elite ball knowledge of most of the scheduling code to make changes
|
||||
# without inadvertently affecting other parts of the codebase.
|
||||
@@ -27,5 +39,3 @@ coderd/schedule/autostop.go @deansheather @DanielleMaywood
|
||||
# well as guidance from revenue.
|
||||
coderd/usage/ @deansheather @spikecurtis
|
||||
enterprise/coderd/usage/ @deansheather @spikecurtis
|
||||
|
||||
.github/ @jdomeracki-coder
|
||||
|
||||
@@ -636,17 +636,16 @@ TAILNETTEST_MOCKS := \
|
||||
tailnet/tailnettest/subscriptionmock.go
|
||||
|
||||
AIBRIDGED_MOCKS := \
|
||||
enterprise/aibridged/aibridgedmock/clientmock.go \
|
||||
enterprise/aibridged/aibridgedmock/poolmock.go
|
||||
enterprise/x/aibridged/aibridgedmock/clientmock.go \
|
||||
enterprise/x/aibridged/aibridgedmock/poolmock.go
|
||||
|
||||
GEN_FILES := \
|
||||
tailnet/proto/tailnet.pb.go \
|
||||
agent/proto/agent.pb.go \
|
||||
agent/agentsocket/proto/agentsocket.pb.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
vpn/vpn.pb.go \
|
||||
enterprise/aibridged/proto/aibridged.pb.go \
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go \
|
||||
$(DB_GEN_FILES) \
|
||||
$(SITE_GEN_FILES) \
|
||||
coderd/rbac/object_gen.go \
|
||||
@@ -677,7 +676,6 @@ gen/db: $(DB_GEN_FILES)
|
||||
.PHONY: gen/db
|
||||
|
||||
gen/golden-files: \
|
||||
agent/unit/testdata/.gen-golden \
|
||||
cli/testdata/.gen-golden \
|
||||
coderd/.gen-golden \
|
||||
coderd/notifications/.gen-golden \
|
||||
@@ -697,9 +695,8 @@ gen/mark-fresh:
|
||||
agent/proto/agent.pb.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
agent/agentsocket/proto/agentsocket.pb.go \
|
||||
vpn/vpn.pb.go \
|
||||
enterprise/aibridged/proto/aibridged.pb.go \
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go \
|
||||
coderd/database/dump.sql \
|
||||
$(DB_GEN_FILES) \
|
||||
site/src/api/typesGenerated.ts \
|
||||
@@ -770,8 +767,8 @@ codersdk/workspacesdk/agentconnmock/agentconnmock.go: codersdk/workspacesdk/agen
|
||||
go generate ./codersdk/workspacesdk/agentconnmock/
|
||||
touch "$@"
|
||||
|
||||
$(AIBRIDGED_MOCKS): enterprise/aibridged/client.go enterprise/aibridged/pool.go
|
||||
go generate ./enterprise/aibridged/aibridgedmock/
|
||||
$(AIBRIDGED_MOCKS): enterprise/x/aibridged/client.go enterprise/x/aibridged/pool.go
|
||||
go generate ./enterprise/x/aibridged/aibridgedmock/
|
||||
touch "$@"
|
||||
|
||||
agent/agentcontainers/dcspec/dcspec_gen.go: \
|
||||
@@ -802,14 +799,6 @@ agent/proto/agent.pb.go: agent/proto/agent.proto
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./agent/proto/agent.proto
|
||||
|
||||
agent/agentsocket/proto/agentsocket.pb.go: agent/agentsocket/proto/agentsocket.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./agent/agentsocket/proto/agentsocket.proto
|
||||
|
||||
provisionersdk/proto/provisioner.pb.go: provisionersdk/proto/provisioner.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
@@ -832,13 +821,13 @@ vpn/vpn.pb.go: vpn/vpn.proto
|
||||
--go_opt=paths=source_relative \
|
||||
./vpn/vpn.proto
|
||||
|
||||
enterprise/aibridged/proto/aibridged.pb.go: enterprise/aibridged/proto/aibridged.proto
|
||||
enterprise/x/aibridged/proto/aibridged.pb.go: enterprise/x/aibridged/proto/aibridged.proto
|
||||
protoc \
|
||||
--go_out=. \
|
||||
--go_opt=paths=source_relative \
|
||||
--go-drpc_out=. \
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./enterprise/aibridged/proto/aibridged.proto
|
||||
./enterprise/x/aibridged/proto/aibridged.proto
|
||||
|
||||
site/src/api/typesGenerated.ts: site/node_modules/.installed $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
# -C sets the directory for the go run command
|
||||
@@ -963,10 +952,6 @@ clean/golden-files:
|
||||
-type f -name '*.golden' -delete
|
||||
.PHONY: clean/golden-files
|
||||
|
||||
agent/unit/testdata/.gen-golden: $(wildcard agent/unit/testdata/*.golden) $(GO_SRC_FILES) $(wildcard agent/unit/*_test.go)
|
||||
TZ=UTC go test ./agent/unit -run="TestGraph" -update
|
||||
touch "$@"
|
||||
|
||||
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go)
|
||||
TZ=UTC go test ./cli -run="Test(CommandHelp|ServerYAML|ErrorExamples|.*Golden)" -update
|
||||
touch "$@"
|
||||
@@ -1035,11 +1020,19 @@ endif
|
||||
|
||||
TEST_PACKAGES ?= ./...
|
||||
|
||||
test:
|
||||
warm-go-cache-db-cleaner:
|
||||
# ensure Go's build cache for the cleanercmd is fresh so that tests don't have to build from scratch. This
|
||||
# could take some time and counts against the test's timeout, which can lead to flakes.
|
||||
# c.f. https://github.com/coder/internal/issues/1026
|
||||
mkdir -p build
|
||||
$(GIT_FLAGS) go build -o ./build/cleaner github.com/coder/coder/v2/coderd/database/dbtestutil/cleanercmd
|
||||
.PHONY: warm-go-cache-db-cleaner
|
||||
|
||||
test: warm-go-cache-db-cleaner
|
||||
$(GIT_FLAGS) gotestsum --format standard-quiet $(GOTESTSUM_RETRY_FLAGS) --packages="$(TEST_PACKAGES)" -- $(GOTEST_FLAGS)
|
||||
.PHONY: test
|
||||
|
||||
test-cli:
|
||||
test-cli: warm-go-cache-db-cleaner
|
||||
$(MAKE) test TEST_PACKAGES="./cli..."
|
||||
.PHONY: test-cli
|
||||
|
||||
@@ -1192,8 +1185,3 @@ endif
|
||||
|
||||
dogfood/coder/nix.hash: flake.nix flake.lock
|
||||
sha256sum flake.nix flake.lock >./dogfood/coder/nix.hash
|
||||
|
||||
# Count the number of test databases created per test package.
|
||||
count-test-databases:
|
||||
PGPASSWORD=postgres psql -h localhost -U postgres -d coder_testing -P pager=off -c 'SELECT test_package, count(*) as count from test_databases GROUP BY test_package ORDER BY count DESC'
|
||||
.PHONY: count-test-databases
|
||||
|
||||
+54
-112
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -41,7 +40,6 @@ import (
|
||||
"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/proto"
|
||||
"github.com/coder/coder/v2/agent/proto/resourcesmonitor"
|
||||
@@ -72,21 +70,16 @@ const (
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Filesystem afero.Fs
|
||||
LogDir string
|
||||
TempDir string
|
||||
ScriptDataDir string
|
||||
Client Client
|
||||
ReconnectingPTYTimeout time.Duration
|
||||
EnvironmentVariables map[string]string
|
||||
Logger slog.Logger
|
||||
// IgnorePorts tells the api handler which ports to ignore when
|
||||
// listing all listening ports. This is helpful to hide ports that
|
||||
// are used by the agent, that the user does not care about.
|
||||
IgnorePorts map[int]string
|
||||
// ListeningPortsGetter is used to get the list of listening ports. Only
|
||||
// tests should set this. If unset, a default that queries the OS will be used.
|
||||
ListeningPortsGetter ListeningPortsGetter
|
||||
Filesystem afero.Fs
|
||||
LogDir string
|
||||
TempDir string
|
||||
ScriptDataDir string
|
||||
Client Client
|
||||
ReconnectingPTYTimeout time.Duration
|
||||
EnvironmentVariables map[string]string
|
||||
Logger slog.Logger
|
||||
IgnorePorts map[int]string
|
||||
PortCacheDuration time.Duration
|
||||
SSHMaxTimeout time.Duration
|
||||
TailnetListenPort uint16
|
||||
Subsystems []codersdk.AgentSubsystem
|
||||
@@ -98,8 +91,6 @@ type Options struct {
|
||||
Devcontainers bool
|
||||
DevcontainerAPIOptions []agentcontainers.Option // Enable Devcontainers for these to be effective.
|
||||
Clock quartz.Clock
|
||||
SocketServerEnabled bool
|
||||
SocketPath string // Path for the agent socket server socket
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
@@ -146,7 +137,9 @@ func New(options Options) Agent {
|
||||
if options.ServiceBannerRefreshInterval == 0 {
|
||||
options.ServiceBannerRefreshInterval = 2 * time.Minute
|
||||
}
|
||||
|
||||
if options.PortCacheDuration == 0 {
|
||||
options.PortCacheDuration = 1 * time.Second
|
||||
}
|
||||
if options.Clock == nil {
|
||||
options.Clock = quartz.NewReal()
|
||||
}
|
||||
@@ -160,38 +153,30 @@ func New(options Options) Agent {
|
||||
options.Execer = agentexec.DefaultExecer
|
||||
}
|
||||
|
||||
if options.ListeningPortsGetter == nil {
|
||||
options.ListeningPortsGetter = &osListeningPortsGetter{
|
||||
cacheDuration: 1 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
hardCtx, hardCancel := context.WithCancel(context.Background())
|
||||
gracefulCtx, gracefulCancel := context.WithCancel(hardCtx)
|
||||
a := &agent{
|
||||
clock: options.Clock,
|
||||
tailnetListenPort: options.TailnetListenPort,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
gracefulCtx: gracefulCtx,
|
||||
gracefulCancel: gracefulCancel,
|
||||
hardCtx: hardCtx,
|
||||
hardCancel: hardCancel,
|
||||
coordDisconnected: make(chan struct{}),
|
||||
environmentVariables: options.EnvironmentVariables,
|
||||
client: options.Client,
|
||||
filesystem: options.Filesystem,
|
||||
logDir: options.LogDir,
|
||||
tempDir: options.TempDir,
|
||||
scriptDataDir: options.ScriptDataDir,
|
||||
lifecycleUpdate: make(chan struct{}, 1),
|
||||
lifecycleReported: make(chan codersdk.WorkspaceAgentLifecycle, 1),
|
||||
lifecycleStates: []agentsdk.PostLifecycleRequest{{State: codersdk.WorkspaceAgentLifecycleCreated}},
|
||||
reportConnectionsUpdate: make(chan struct{}, 1),
|
||||
listeningPortsHandler: listeningPortsHandler{
|
||||
getter: options.ListeningPortsGetter,
|
||||
ignorePorts: maps.Clone(options.IgnorePorts),
|
||||
},
|
||||
clock: options.Clock,
|
||||
tailnetListenPort: options.TailnetListenPort,
|
||||
reconnectingPTYTimeout: options.ReconnectingPTYTimeout,
|
||||
logger: options.Logger,
|
||||
gracefulCtx: gracefulCtx,
|
||||
gracefulCancel: gracefulCancel,
|
||||
hardCtx: hardCtx,
|
||||
hardCancel: hardCancel,
|
||||
coordDisconnected: make(chan struct{}),
|
||||
environmentVariables: options.EnvironmentVariables,
|
||||
client: options.Client,
|
||||
filesystem: options.Filesystem,
|
||||
logDir: options.LogDir,
|
||||
tempDir: options.TempDir,
|
||||
scriptDataDir: options.ScriptDataDir,
|
||||
lifecycleUpdate: make(chan struct{}, 1),
|
||||
lifecycleReported: make(chan codersdk.WorkspaceAgentLifecycle, 1),
|
||||
lifecycleStates: []agentsdk.PostLifecycleRequest{{State: codersdk.WorkspaceAgentLifecycleCreated}},
|
||||
reportConnectionsUpdate: make(chan struct{}, 1),
|
||||
ignorePorts: options.IgnorePorts,
|
||||
portCacheDuration: options.PortCacheDuration,
|
||||
reportMetadataInterval: options.ReportMetadataInterval,
|
||||
announcementBannersRefreshInterval: options.ServiceBannerRefreshInterval,
|
||||
sshMaxTimeout: options.SSHMaxTimeout,
|
||||
@@ -205,8 +190,6 @@ func New(options Options) Agent {
|
||||
|
||||
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
|
||||
@@ -219,16 +202,20 @@ func New(options Options) Agent {
|
||||
}
|
||||
|
||||
type agent struct {
|
||||
clock quartz.Clock
|
||||
logger slog.Logger
|
||||
client Client
|
||||
tailnetListenPort uint16
|
||||
filesystem afero.Fs
|
||||
logDir string
|
||||
tempDir string
|
||||
scriptDataDir string
|
||||
listeningPortsHandler listeningPortsHandler
|
||||
subsystems []codersdk.AgentSubsystem
|
||||
clock quartz.Clock
|
||||
logger slog.Logger
|
||||
client Client
|
||||
tailnetListenPort uint16
|
||||
filesystem afero.Fs
|
||||
logDir string
|
||||
tempDir string
|
||||
scriptDataDir string
|
||||
// ignorePorts tells the api handler which ports to ignore when
|
||||
// listing all listening ports. This is helpful to hide ports that
|
||||
// are used by the agent, that the user does not care about.
|
||||
ignorePorts map[int]string
|
||||
portCacheDuration time.Duration
|
||||
subsystems []codersdk.AgentSubsystem
|
||||
|
||||
reconnectingPTYTimeout time.Duration
|
||||
reconnectingPTYServer *reconnectingpty.Server
|
||||
@@ -284,10 +271,6 @@ type agent struct {
|
||||
devcontainers bool
|
||||
containerAPIOptions []agentcontainers.Option
|
||||
containerAPI *agentcontainers.API
|
||||
|
||||
socketServerEnabled bool
|
||||
socketPath string
|
||||
socketServer *agentsocket.Server
|
||||
}
|
||||
|
||||
func (a *agent) TailnetConn() *tailnet.Conn {
|
||||
@@ -367,32 +350,9 @@ func (a *agent) init() {
|
||||
s.ExperimentalContainers = a.devcontainers
|
||||
},
|
||||
)
|
||||
|
||||
a.initSocketServer()
|
||||
|
||||
go a.runLoop()
|
||||
}
|
||||
|
||||
// initSocketServer initializes server that allows direct communication with a workspace agent using IPC.
|
||||
func (a *agent) initSocketServer() {
|
||||
if !a.socketServerEnabled {
|
||||
a.logger.Info(a.hardCtx, "socket server is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
server, err := agentsocket.NewServer(
|
||||
a.logger.Named("socket"),
|
||||
agentsocket.WithPath(a.socketPath),
|
||||
)
|
||||
if err != nil {
|
||||
a.logger.Warn(a.hardCtx, "failed to create socket server", slog.Error(err), slog.F("path", a.socketPath))
|
||||
return
|
||||
}
|
||||
|
||||
a.socketServer = server
|
||||
a.logger.Debug(a.hardCtx, "socket server started", slog.F("path", a.socketPath))
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -821,15 +781,11 @@ func (a *agent) reportConnectionsLoop(ctx context.Context, aAPI proto.DRPCAgentC
|
||||
logger.Debug(ctx, "reporting connection")
|
||||
_, err := aAPI.ReportConnection(ctx, payload)
|
||||
if err != nil {
|
||||
// Do not fail the loop if we fail to report a connection, just
|
||||
// log a warning.
|
||||
// Related to https://github.com/coder/coder/issues/20194
|
||||
logger.Warn(ctx, "failed to report connection to server", slog.Error(err))
|
||||
// keep going, we still need to remove it from the slice
|
||||
} else {
|
||||
logger.Debug(ctx, "successfully reported connection")
|
||||
return xerrors.Errorf("failed to report connection: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug(ctx, "successfully reported connection")
|
||||
|
||||
// Remove the payload we sent.
|
||||
a.reportConnectionsMu.Lock()
|
||||
a.reportConnections[0] = nil // Release the pointer from the underlying array.
|
||||
@@ -860,13 +816,6 @@ func (a *agent) reportConnection(id uuid.UUID, connectionType proto.Connection_T
|
||||
ip = host
|
||||
}
|
||||
|
||||
// If the IP is "localhost" (which it can be in some cases), set it to
|
||||
// 127.0.0.1 instead.
|
||||
// Related to https://github.com/coder/coder/issues/20194
|
||||
if ip == "localhost" {
|
||||
ip = "127.0.0.1"
|
||||
}
|
||||
|
||||
a.reportConnectionsMu.Lock()
|
||||
defer a.reportConnectionsMu.Unlock()
|
||||
|
||||
@@ -1127,7 +1076,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch metadata: %w", err)
|
||||
}
|
||||
a.logger.Info(ctx, "fetched manifest")
|
||||
a.logger.Info(ctx, "fetched manifest", slog.F("manifest", mp))
|
||||
manifest, err := agentsdk.ManifestFromProto(mp)
|
||||
if err != nil {
|
||||
a.logger.Critical(ctx, "failed to convert manifest", slog.F("manifest", mp), slog.Error(err))
|
||||
@@ -1576,8 +1525,8 @@ func (a *agent) createTailnet(
|
||||
break
|
||||
}
|
||||
clog := a.logger.Named("speedtest").With(
|
||||
slog.F("remote", conn.RemoteAddr()),
|
||||
slog.F("local", conn.LocalAddr()))
|
||||
slog.F("remote", conn.RemoteAddr().String()),
|
||||
slog.F("local", conn.LocalAddr().String()))
|
||||
clog.Info(ctx, "accepted conn")
|
||||
wg.Add(1)
|
||||
closed := make(chan struct{})
|
||||
@@ -1960,7 +1909,6 @@ func (a *agent) Close() error {
|
||||
lifecycleState = codersdk.WorkspaceAgentLifecycleShutdownError
|
||||
}
|
||||
}
|
||||
|
||||
a.setLifecycle(lifecycleState)
|
||||
|
||||
err = a.scriptRunner.Close()
|
||||
@@ -1968,12 +1916,6 @@ func (a *agent) Close() error {
|
||||
a.logger.Error(a.hardCtx, "script runner close", slog.Error(err))
|
||||
}
|
||||
|
||||
if a.socketServer != nil {
|
||||
if err := a.socketServer.Close(); err != nil {
|
||||
a.logger.Error(a.hardCtx, "socket server close", slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.containerAPI.Close(); err != nil {
|
||||
a.logger.Error(a.hardCtx, "container API close", slog.Error(err))
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// TestReportConnectionEmpty tests that reportConnection() doesn't choke if given an empty IP string, which is what we
|
||||
// send if we cannot get the remote address.
|
||||
func TestReportConnectionEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
connID := uuid.UUID{1}
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
uut := &agent{
|
||||
hardCtx: ctx,
|
||||
logger: logger,
|
||||
}
|
||||
disconnected := uut.reportConnection(connID, proto.Connection_TYPE_UNSPECIFIED, "")
|
||||
|
||||
require.Len(t, uut.reportConnections, 1)
|
||||
req0 := uut.reportConnections[0]
|
||||
require.Equal(t, proto.Connection_TYPE_UNSPECIFIED, req0.GetConnection().GetType())
|
||||
require.Equal(t, "", req0.GetConnection().Ip)
|
||||
require.Equal(t, connID[:], req0.GetConnection().GetId())
|
||||
require.Equal(t, proto.Connection_CONNECT, req0.GetConnection().GetAction())
|
||||
|
||||
disconnected(0, "because")
|
||||
require.Len(t, uut.reportConnections, 2)
|
||||
req1 := uut.reportConnections[1]
|
||||
require.Equal(t, proto.Connection_TYPE_UNSPECIFIED, req1.GetConnection().GetType())
|
||||
require.Equal(t, "", req1.GetConnection().Ip)
|
||||
require.Equal(t, connID[:], req1.GetConnection().GetId())
|
||||
require.Equal(t, proto.Connection_DISCONNECT, req1.GetConnection().GetAction())
|
||||
require.Equal(t, "because", req1.GetConnection().GetReason())
|
||||
}
|
||||
+35
-80
@@ -1807,12 +1807,11 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
|
||||
|
||||
//nolint:dogsled
|
||||
conn, agentClient, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
idConnectionReport := uuid.New()
|
||||
id := uuid.New()
|
||||
|
||||
// Test that the connection is reported. This must be tested in the
|
||||
// first connection because we care about verifying all of these.
|
||||
netConn0, err := conn.ReconnectingPTY(ctx, idConnectionReport, 80, 80, "bash --norc")
|
||||
netConn0, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash --norc")
|
||||
require.NoError(t, err)
|
||||
_ = netConn0.Close()
|
||||
assertConnectionReport(t, agentClient, proto.Connection_RECONNECTING_PTY, 0, "")
|
||||
@@ -2028,8 +2027,7 @@ func runSubAgentMain() int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "agent connection failed: %v\n", err)
|
||||
return 11
|
||||
@@ -3462,7 +3460,11 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
registry := prometheus.NewRegistry()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
// Make sure we always get a DERP connection for
|
||||
// currently_reachable_peers.
|
||||
DisableDirectConnections: true,
|
||||
}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.PrometheusRegistry = registry
|
||||
})
|
||||
|
||||
@@ -3477,31 +3479,16 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
err = session.Shell()
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := []struct {
|
||||
Name string
|
||||
Type proto.Stats_Metric_Type
|
||||
CheckFn func(float64) error
|
||||
Labels []*proto.Stats_Metric_Label
|
||||
}{
|
||||
expected := []*proto.Stats_Metric{
|
||||
{
|
||||
Name: "agent_reconnecting_pty_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
Name: "agent_reconnecting_pty_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_sessions_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 1 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 1, got %f", v)
|
||||
},
|
||||
Name: "agent_sessions_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 1,
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "magic_type",
|
||||
@@ -3514,44 +3501,24 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_failed_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
Name: "agent_ssh_server_failed_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_sftp_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
Name: "agent_ssh_server_sftp_connections_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_sftp_server_errors_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
CheckFn: func(v float64) error {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected 0, got %f", v)
|
||||
},
|
||||
Name: "agent_ssh_server_sftp_server_errors_total",
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
CheckFn: func(float64) error {
|
||||
// We can't reliably ping a peer here, and networking is out of
|
||||
// scope of this test, so we just test that the metric exists
|
||||
// with the correct labels.
|
||||
return nil
|
||||
},
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 1,
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "connection_type",
|
||||
@@ -3560,11 +3527,9 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
CheckFn: func(float64) error {
|
||||
return nil
|
||||
},
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 0,
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "connection_type",
|
||||
@@ -3573,20 +3538,9 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_startup_script_seconds",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
CheckFn: func(f float64) error {
|
||||
if f >= 0 {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("expected >= 0, got %f", f)
|
||||
},
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "success",
|
||||
Value: "true",
|
||||
},
|
||||
},
|
||||
Name: "coderd_agentstats_startup_script_seconds",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 1,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3608,10 +3562,11 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
for _, m := range mf.GetMetric() {
|
||||
assert.Equal(t, expected[i].Name, mf.GetName())
|
||||
assert.Equal(t, expected[i].Type.String(), mf.GetType().String())
|
||||
// Value is max expected
|
||||
if expected[i].Type == proto.Stats_Metric_GAUGE {
|
||||
assert.NoError(t, expected[i].CheckFn(m.GetGauge().GetValue()), "check fn for %s failed", expected[i].Name)
|
||||
assert.GreaterOrEqualf(t, expected[i].Value, m.GetGauge().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetGauge().GetValue())
|
||||
} else if expected[i].Type == proto.Stats_Metric_COUNTER {
|
||||
assert.NoError(t, expected[i].CheckFn(m.GetCounter().GetValue()), "check fn for %s failed", expected[i].Name)
|
||||
assert.GreaterOrEqualf(t, expected[i].Value, m.GetCounter().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetCounter().GetValue())
|
||||
}
|
||||
for j, lbl := range expected[i].Labels {
|
||||
assert.Equal(t, m.GetLabel()[j], &promgo.LabelPair{
|
||||
|
||||
@@ -682,6 +682,8 @@ func (api *API) updaterLoop() {
|
||||
} else {
|
||||
prevErr = nil
|
||||
}
|
||||
default:
|
||||
api.logger.Debug(api.ctx, "updater loop ticker skipped, update in progress")
|
||||
}
|
||||
|
||||
return nil // Always nil to keep the ticker going.
|
||||
@@ -1039,10 +1041,6 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code
|
||||
logger.Error(ctx, "inject subagent into container failed", slog.Error(err))
|
||||
dc.Error = err.Error()
|
||||
} else {
|
||||
// TODO(mafredri): Preserve the error from devcontainer
|
||||
// up if it was a lifecycle script error. Currently
|
||||
// this results in a brief flicker for the user if
|
||||
// injection is fast, as the error is shown then erased.
|
||||
dc.Error = ""
|
||||
}
|
||||
}
|
||||
@@ -1351,41 +1349,27 @@ func (api *API) CreateDevcontainer(workspaceFolder, configPath string, opts ...D
|
||||
upOptions := []DevcontainerCLIUpOptions{WithUpOutput(infoW, errW)}
|
||||
upOptions = append(upOptions, opts...)
|
||||
|
||||
containerID, upErr := api.dccli.Up(ctx, dc.WorkspaceFolder, configPath, upOptions...)
|
||||
if upErr != nil {
|
||||
_, err := api.dccli.Up(ctx, dc.WorkspaceFolder, configPath, upOptions...)
|
||||
if err != nil {
|
||||
// No need to log if the API is closing (context canceled), as this
|
||||
// is expected behavior when the API is shutting down.
|
||||
if !errors.Is(upErr, context.Canceled) {
|
||||
logger.Error(ctx, "devcontainer creation failed", slog.Error(upErr))
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
logger.Error(ctx, "devcontainer creation failed", slog.Error(err))
|
||||
}
|
||||
|
||||
// If we don't have a container ID, the error is fatal, so we
|
||||
// should mark the devcontainer as errored and return.
|
||||
if containerID == "" {
|
||||
api.mu.Lock()
|
||||
dc = api.knownDevcontainers[dc.WorkspaceFolder]
|
||||
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusError
|
||||
dc.Error = upErr.Error()
|
||||
api.knownDevcontainers[dc.WorkspaceFolder] = dc
|
||||
api.recreateErrorTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "errorTimes")
|
||||
api.broadcastUpdatesLocked()
|
||||
api.mu.Unlock()
|
||||
api.mu.Lock()
|
||||
dc = api.knownDevcontainers[dc.WorkspaceFolder]
|
||||
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusError
|
||||
dc.Error = err.Error()
|
||||
api.knownDevcontainers[dc.WorkspaceFolder] = dc
|
||||
api.recreateErrorTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "errorTimes")
|
||||
api.mu.Unlock()
|
||||
|
||||
return xerrors.Errorf("start devcontainer: %w", upErr)
|
||||
}
|
||||
|
||||
// If we have a container ID, it means the container was created
|
||||
// but a lifecycle script (e.g. postCreateCommand) failed. In this
|
||||
// case, we still want to refresh containers to pick up the new
|
||||
// container, inject the agent, and allow the user to debug the
|
||||
// issue. We store the error to surface it to the user.
|
||||
logger.Warn(ctx, "devcontainer created with errors (e.g. lifecycle script failure), container is available",
|
||||
slog.F("container_id", containerID),
|
||||
)
|
||||
} else {
|
||||
logger.Info(ctx, "devcontainer created successfully")
|
||||
return xerrors.Errorf("start devcontainer: %w", err)
|
||||
}
|
||||
|
||||
logger.Info(ctx, "devcontainer created successfully")
|
||||
|
||||
api.mu.Lock()
|
||||
dc = api.knownDevcontainers[dc.WorkspaceFolder]
|
||||
// Update the devcontainer status to Running or Stopped based on the
|
||||
@@ -1394,18 +1378,13 @@ func (api *API) CreateDevcontainer(workspaceFolder, configPath string, opts ...D
|
||||
// to minimize the time between API consistency, we guess the status
|
||||
// based on the container state.
|
||||
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusStopped
|
||||
if dc.Container != nil && dc.Container.Running {
|
||||
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusRunning
|
||||
if dc.Container != nil {
|
||||
if dc.Container.Running {
|
||||
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusRunning
|
||||
}
|
||||
}
|
||||
dc.Dirty = false
|
||||
if upErr != nil {
|
||||
// If there was a lifecycle script error but we have a container ID,
|
||||
// the container is running so we should set the status to Running.
|
||||
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusRunning
|
||||
dc.Error = upErr.Error()
|
||||
} else {
|
||||
dc.Error = ""
|
||||
}
|
||||
dc.Error = ""
|
||||
api.recreateSuccessTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "successTimes")
|
||||
api.knownDevcontainers[dc.WorkspaceFolder] = dc
|
||||
api.broadcastUpdatesLocked()
|
||||
@@ -1457,8 +1436,6 @@ func (api *API) markDevcontainerDirty(configPath string, modifiedAt time.Time) {
|
||||
|
||||
api.knownDevcontainers[dc.WorkspaceFolder] = dc
|
||||
}
|
||||
|
||||
api.broadcastUpdatesLocked()
|
||||
}
|
||||
|
||||
// cleanupSubAgents removes subagents that are no longer managed by
|
||||
|
||||
@@ -234,8 +234,6 @@ func (w *fakeWatcher) sendEventWaitNextCalled(ctx context.Context, event fsnotif
|
||||
// fakeSubAgentClient implements SubAgentClient for testing purposes.
|
||||
type fakeSubAgentClient struct {
|
||||
logger slog.Logger
|
||||
|
||||
mu sync.Mutex // Protects following.
|
||||
agents map[uuid.UUID]agentcontainers.SubAgent
|
||||
|
||||
listErrC chan error // If set, send to return error, close to return nil.
|
||||
@@ -256,8 +254,6 @@ func (m *fakeSubAgentClient) List(ctx context.Context) ([]agentcontainers.SubAge
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var agents []agentcontainers.SubAgent
|
||||
for _, agent := range m.agents {
|
||||
agents = append(agents, agent)
|
||||
@@ -287,9 +283,6 @@ func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.S
|
||||
return agentcontainers.SubAgent{}, xerrors.New("operating system must be set")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for _, a := range m.agents {
|
||||
if a.Name == agent.Name {
|
||||
return agentcontainers.SubAgent{}, &pq.Error{
|
||||
@@ -321,8 +314,6 @@ func (m *fakeSubAgentClient) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.agents == nil {
|
||||
m.agents = make(map[uuid.UUID]agentcontainers.SubAgent)
|
||||
}
|
||||
@@ -1641,77 +1632,6 @@ func TestAPI(t *testing.T) {
|
||||
require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil")
|
||||
})
|
||||
|
||||
// Verify that modifying a config file broadcasts the dirty status
|
||||
// over websocket immediately.
|
||||
t.Run("FileWatcherDirtyBroadcast", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
configPath := "/workspace/project/.devcontainer/devcontainer.json"
|
||||
fWatcher := newFakeWatcher(t)
|
||||
fLister := &fakeContainerCLI{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
ID: "container-id",
|
||||
FriendlyName: "container-name",
|
||||
Running: true,
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
|
||||
agentcontainers.DevcontainerConfigFileLabel: configPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mClock := quartz.NewMock(t)
|
||||
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
|
||||
|
||||
api := agentcontainers.NewAPI(
|
||||
slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
agentcontainers.WithContainerCLI(fLister),
|
||||
agentcontainers.WithWatcher(fWatcher),
|
||||
agentcontainers.WithClock(mClock),
|
||||
)
|
||||
api.Start()
|
||||
defer api.Close()
|
||||
|
||||
srv := httptest.NewServer(api.Routes())
|
||||
defer srv.Close()
|
||||
|
||||
tickerTrap.MustWait(ctx).MustRelease(ctx)
|
||||
tickerTrap.Close()
|
||||
|
||||
wsConn, resp, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http")+"/watch", nil)
|
||||
require.NoError(t, err)
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
defer wsConn.Close(websocket.StatusNormalClosure, "")
|
||||
|
||||
// Read and discard initial state.
|
||||
_, _, err = wsConn.Read(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
fWatcher.waitNext(ctx)
|
||||
fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{
|
||||
Name: configPath,
|
||||
Op: fsnotify.Write,
|
||||
})
|
||||
|
||||
// Verify dirty status is broadcast without advancing the clock.
|
||||
_, msg, err := wsConn.Read(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
var response codersdk.WorkspaceAgentListContainersResponse
|
||||
err = json.Unmarshal(msg, &response)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, response.Devcontainers, 1)
|
||||
assert.True(t, response.Devcontainers[0].Dirty,
|
||||
"devcontainer should be marked as dirty after config file modification")
|
||||
})
|
||||
|
||||
t.Run("SubAgentLifecycle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2150,122 +2070,6 @@ func TestAPI(t *testing.T) {
|
||||
require.Equal(t, "", response.Devcontainers[0].Error)
|
||||
})
|
||||
|
||||
// This test verifies that when devcontainer up fails due to a
|
||||
// lifecycle script error (such as postCreateCommand failing) but the
|
||||
// container was successfully created, we still proceed with the
|
||||
// devcontainer. The container should be available for use and the
|
||||
// agent should be injected.
|
||||
t.Run("DuringUpWithContainerID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitMedium)
|
||||
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
mClock = quartz.NewMock(t)
|
||||
|
||||
testContainer = codersdk.WorkspaceAgentContainer{
|
||||
ID: "test-container-id",
|
||||
FriendlyName: "test-container",
|
||||
Image: "test-image",
|
||||
Running: true,
|
||||
CreatedAt: time.Now(),
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspaces/project",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/workspaces/project/.devcontainer/devcontainer.json",
|
||||
},
|
||||
}
|
||||
fCCLI = &fakeContainerCLI{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{testContainer},
|
||||
},
|
||||
arch: "amd64",
|
||||
}
|
||||
fDCCLI = &fakeDevcontainerCLI{
|
||||
upID: testContainer.ID,
|
||||
upErrC: make(chan func() error, 1),
|
||||
}
|
||||
fSAC = &fakeSubAgentClient{
|
||||
logger: logger.Named("fakeSubAgentClient"),
|
||||
}
|
||||
|
||||
testDevcontainer = codersdk.WorkspaceAgentDevcontainer{
|
||||
ID: uuid.New(),
|
||||
Name: "test-devcontainer",
|
||||
WorkspaceFolder: "/workspaces/project",
|
||||
ConfigPath: "/workspaces/project/.devcontainer/devcontainer.json",
|
||||
Status: codersdk.WorkspaceAgentDevcontainerStatusStopped,
|
||||
}
|
||||
)
|
||||
|
||||
mClock.Set(time.Now()).MustWait(ctx)
|
||||
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
|
||||
nowRecreateSuccessTrap := mClock.Trap().Now("recreate", "successTimes")
|
||||
|
||||
api := agentcontainers.NewAPI(logger,
|
||||
agentcontainers.WithClock(mClock),
|
||||
agentcontainers.WithContainerCLI(fCCLI),
|
||||
agentcontainers.WithDevcontainerCLI(fDCCLI),
|
||||
agentcontainers.WithDevcontainers(
|
||||
[]codersdk.WorkspaceAgentDevcontainer{testDevcontainer},
|
||||
[]codersdk.WorkspaceAgentScript{{ID: testDevcontainer.ID, LogSourceID: uuid.New()}},
|
||||
),
|
||||
agentcontainers.WithSubAgentClient(fSAC),
|
||||
agentcontainers.WithSubAgentURL("test-subagent-url"),
|
||||
agentcontainers.WithWatcher(watcher.NewNoop()),
|
||||
)
|
||||
api.Start()
|
||||
defer func() {
|
||||
close(fDCCLI.upErrC)
|
||||
api.Close()
|
||||
}()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/", api.Routes())
|
||||
|
||||
tickerTrap.MustWait(ctx).MustRelease(ctx)
|
||||
tickerTrap.Close()
|
||||
|
||||
// Send a recreate request to trigger devcontainer up.
|
||||
req := httptest.NewRequest(http.MethodPost, "/devcontainers/"+testDevcontainer.ID.String()+"/recreate", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusAccepted, rec.Code)
|
||||
|
||||
// Simulate a lifecycle script failure. The devcontainer CLI
|
||||
// will return an error but also provide a container ID since
|
||||
// the container was created before the script failed.
|
||||
simulatedError := xerrors.New("postCreateCommand failed with exit code 1")
|
||||
testutil.RequireSend(ctx, t, fDCCLI.upErrC, func() error { return simulatedError })
|
||||
|
||||
// Wait for the recreate operation to complete. We expect it to
|
||||
// record a success time because the container was created.
|
||||
nowRecreateSuccessTrap.MustWait(ctx).MustRelease(ctx)
|
||||
nowRecreateSuccessTrap.Close()
|
||||
|
||||
// Advance the clock to run the devcontainer state update routine.
|
||||
_, aw := mClock.AdvanceNext()
|
||||
aw.MustWait(ctx)
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response codersdk.WorkspaceAgentListContainersResponse
|
||||
err := json.NewDecoder(rec.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify that the devcontainer is running and has the container
|
||||
// associated with it despite the lifecycle script error. The
|
||||
// error may be cleared during refresh if agent injection
|
||||
// succeeds, but the important thing is that the container is
|
||||
// available for use.
|
||||
require.Len(t, response.Devcontainers, 1)
|
||||
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, response.Devcontainers[0].Status)
|
||||
require.NotNil(t, response.Devcontainers[0].Container)
|
||||
assert.Equal(t, testContainer.ID, response.Devcontainers[0].Container.ID)
|
||||
})
|
||||
|
||||
t.Run("DuringInjection", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -263,14 +263,11 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
result, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes())
|
||||
_, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes())
|
||||
if err2 != nil {
|
||||
err = errors.Join(err, err2)
|
||||
}
|
||||
// Return the container ID if available, even if there was an error.
|
||||
// This can happen if the container was created successfully but a
|
||||
// lifecycle script (e.g. postCreateCommand) failed.
|
||||
return result.ContainerID, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
result, err := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes())
|
||||
@@ -278,13 +275,6 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Check if the result indicates an error (e.g. lifecycle script failure)
|
||||
// but still has a container ID, allowing the caller to potentially
|
||||
// continue with the container that was created.
|
||||
if err := result.Err(); err != nil {
|
||||
return result.ContainerID, err
|
||||
}
|
||||
|
||||
return result.ContainerID, nil
|
||||
}
|
||||
|
||||
@@ -404,10 +394,7 @@ func parseDevcontainerCLILastLine[T any](ctx context.Context, logger slog.Logger
|
||||
type devcontainerCLIResult struct {
|
||||
Outcome string `json:"outcome"` // "error", "success".
|
||||
|
||||
// The following fields are typically set if outcome is success, but
|
||||
// ContainerID may also be present when outcome is error if the
|
||||
// container was created but a lifecycle script (e.g. postCreateCommand)
|
||||
// failed.
|
||||
// The following fields are set if outcome is success.
|
||||
ContainerID string `json:"containerId"`
|
||||
RemoteUser string `json:"remoteUser"`
|
||||
RemoteWorkspaceFolder string `json:"remoteWorkspaceFolder"`
|
||||
@@ -417,6 +404,18 @@ type devcontainerCLIResult struct {
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (r *devcontainerCLIResult) UnmarshalJSON(data []byte) error {
|
||||
type wrapperResult devcontainerCLIResult
|
||||
|
||||
var wrappedResult wrapperResult
|
||||
if err := json.Unmarshal(data, &wrappedResult); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*r = devcontainerCLIResult(wrappedResult)
|
||||
return r.Err()
|
||||
}
|
||||
|
||||
func (r devcontainerCLIResult) Err() error {
|
||||
if r.Outcome == "success" {
|
||||
return nil
|
||||
|
||||
@@ -42,63 +42,56 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
logFile string
|
||||
workspace string
|
||||
config string
|
||||
opts []agentcontainers.DevcontainerCLIUpOptions
|
||||
wantArgs string
|
||||
wantError bool
|
||||
wantContainerID bool // If true, expect a container ID even when wantError is true.
|
||||
name string
|
||||
logFile string
|
||||
workspace string
|
||||
config string
|
||||
opts []agentcontainers.DevcontainerCLIUpOptions
|
||||
wantArgs string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
logFile: "up.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: false,
|
||||
wantContainerID: true,
|
||||
name: "success",
|
||||
logFile: "up.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "success with config",
|
||||
logFile: "up.log",
|
||||
workspace: "/test/workspace",
|
||||
config: "/test/config.json",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace --config /test/config.json",
|
||||
wantError: false,
|
||||
wantContainerID: true,
|
||||
name: "success with config",
|
||||
logFile: "up.log",
|
||||
workspace: "/test/workspace",
|
||||
config: "/test/config.json",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace --config /test/config.json",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "already exists",
|
||||
logFile: "up-already-exists.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: false,
|
||||
wantContainerID: true,
|
||||
name: "already exists",
|
||||
logFile: "up-already-exists.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "docker error",
|
||||
logFile: "up-error-docker.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: true,
|
||||
wantContainerID: false,
|
||||
name: "docker error",
|
||||
logFile: "up-error-docker.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "bad outcome",
|
||||
logFile: "up-error-bad-outcome.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: true,
|
||||
wantContainerID: false,
|
||||
name: "bad outcome",
|
||||
logFile: "up-error-bad-outcome.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "does not exist",
|
||||
logFile: "up-error-does-not-exist.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: true,
|
||||
wantContainerID: false,
|
||||
name: "does not exist",
|
||||
logFile: "up-error-does-not-exist.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "with remove existing container",
|
||||
@@ -107,21 +100,8 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) {
|
||||
opts: []agentcontainers.DevcontainerCLIUpOptions{
|
||||
agentcontainers.WithRemoveExistingContainer(),
|
||||
},
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace --remove-existing-container",
|
||||
wantError: false,
|
||||
wantContainerID: true,
|
||||
},
|
||||
{
|
||||
// This test verifies that when a lifecycle script like
|
||||
// postCreateCommand fails, the CLI returns both an error
|
||||
// and a container ID. The caller can then proceed with
|
||||
// agent injection into the created container.
|
||||
name: "lifecycle script failure with container",
|
||||
logFile: "up-error-lifecycle-script.log",
|
||||
workspace: "/test/workspace",
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace",
|
||||
wantError: true,
|
||||
wantContainerID: true,
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace --remove-existing-container",
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -142,13 +122,10 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) {
|
||||
containerID, err := dccli.Up(ctx, tt.workspace, tt.config, tt.opts...)
|
||||
if tt.wantError {
|
||||
assert.Error(t, err, "want error")
|
||||
assert.Empty(t, containerID, "expected empty container ID")
|
||||
} else {
|
||||
assert.NoError(t, err, "want no error")
|
||||
}
|
||||
if tt.wantContainerID {
|
||||
assert.NotEmpty(t, containerID, "expected non-empty container ID")
|
||||
} else {
|
||||
assert.Empty(t, containerID, "expected empty container ID")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Generated
Vendored
-147
File diff suppressed because one or more lines are too long
@@ -1,146 +0,0 @@
|
||||
package agentsocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"storj.io/drpc"
|
||||
"storj.io/drpc/drpcconn"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentsocket/proto"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
)
|
||||
|
||||
// Option represents a configuration option for NewClient.
|
||||
type Option func(*options)
|
||||
|
||||
type options struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// WithPath sets the socket path. If not provided or empty, the client will
|
||||
// auto-discover the default socket path.
|
||||
func WithPath(path string) Option {
|
||||
return func(opts *options) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
opts.path = path
|
||||
}
|
||||
}
|
||||
|
||||
// Client provides a client for communicating with the workspace agentsocket API.
|
||||
type Client struct {
|
||||
client proto.DRPCAgentSocketClient
|
||||
conn drpc.Conn
|
||||
}
|
||||
|
||||
// NewClient creates a new socket client and opens a connection to the socket.
|
||||
// If path is not provided via WithPath or is empty, it will auto-discover the
|
||||
// default socket path.
|
||||
func NewClient(ctx context.Context, opts ...Option) (*Client, error) {
|
||||
options := &options{}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
conn, err := dialSocket(ctx, options.path)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("connect to socket: %w", err)
|
||||
}
|
||||
|
||||
drpcConn := drpcconn.New(conn)
|
||||
client := proto.NewDRPCAgentSocketClient(drpcConn)
|
||||
|
||||
return &Client{
|
||||
client: client,
|
||||
conn: drpcConn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close closes the socket connection.
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
// Ping sends a ping request to the agent.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
_, err := c.client.Ping(ctx, &proto.PingRequest{})
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncStart starts a unit in the dependency graph.
|
||||
func (c *Client) SyncStart(ctx context.Context, unitName unit.ID) error {
|
||||
_, err := c.client.SyncStart(ctx, &proto.SyncStartRequest{
|
||||
Unit: string(unitName),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncWant declares a dependency between units.
|
||||
func (c *Client) SyncWant(ctx context.Context, unitName, dependsOn unit.ID) error {
|
||||
_, err := c.client.SyncWant(ctx, &proto.SyncWantRequest{
|
||||
Unit: string(unitName),
|
||||
DependsOn: string(dependsOn),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncComplete marks a unit as complete in the dependency graph.
|
||||
func (c *Client) SyncComplete(ctx context.Context, unitName unit.ID) error {
|
||||
_, err := c.client.SyncComplete(ctx, &proto.SyncCompleteRequest{
|
||||
Unit: string(unitName),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncReady requests whether a unit is ready to be started. That is, all dependencies are satisfied.
|
||||
func (c *Client) SyncReady(ctx context.Context, unitName unit.ID) (bool, error) {
|
||||
resp, err := c.client.SyncReady(ctx, &proto.SyncReadyRequest{
|
||||
Unit: string(unitName),
|
||||
})
|
||||
return resp.Ready, err
|
||||
}
|
||||
|
||||
// SyncStatus gets the status of a unit and its dependencies.
|
||||
func (c *Client) SyncStatus(ctx context.Context, unitName unit.ID) (SyncStatusResponse, error) {
|
||||
resp, err := c.client.SyncStatus(ctx, &proto.SyncStatusRequest{
|
||||
Unit: string(unitName),
|
||||
})
|
||||
if err != nil {
|
||||
return SyncStatusResponse{}, err
|
||||
}
|
||||
|
||||
var dependencies []DependencyInfo
|
||||
for _, dep := range resp.Dependencies {
|
||||
dependencies = append(dependencies, DependencyInfo{
|
||||
DependsOn: unit.ID(dep.DependsOn),
|
||||
RequiredStatus: unit.Status(dep.RequiredStatus),
|
||||
CurrentStatus: unit.Status(dep.CurrentStatus),
|
||||
IsSatisfied: dep.IsSatisfied,
|
||||
})
|
||||
}
|
||||
|
||||
return SyncStatusResponse{
|
||||
UnitName: unitName,
|
||||
Status: unit.Status(resp.Status),
|
||||
IsReady: resp.IsReady,
|
||||
Dependencies: dependencies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SyncStatusResponse contains the status information for a unit.
|
||||
type SyncStatusResponse struct {
|
||||
UnitName unit.ID `table:"unit,default_sort" json:"unit_name"`
|
||||
Status unit.Status `table:"status" json:"status"`
|
||||
IsReady bool `table:"ready" json:"is_ready"`
|
||||
Dependencies []DependencyInfo `table:"dependencies" json:"dependencies"`
|
||||
}
|
||||
|
||||
// DependencyInfo contains information about a unit dependency.
|
||||
type DependencyInfo struct {
|
||||
DependsOn unit.ID `table:"depends on,default_sort" json:"depends_on"`
|
||||
RequiredStatus unit.Status `table:"required status" json:"required_status"`
|
||||
CurrentStatus unit.Status `table:"current status" json:"current_status"`
|
||||
IsSatisfied bool `table:"satisfied" json:"is_satisfied"`
|
||||
}
|
||||
@@ -1,968 +0,0 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.30.0
|
||||
// protoc v4.23.4
|
||||
// source: agent/agentsocket/proto/agentsocket.proto
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// Verify that this generated code is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type PingRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *PingRequest) Reset() {
|
||||
*x = PingRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *PingRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PingRequest) ProtoMessage() {}
|
||||
|
||||
func (x *PingRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PingRequest.ProtoReflect.Descriptor instead.
|
||||
func (*PingRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
type PingResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *PingResponse) Reset() {
|
||||
*x = PingResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *PingResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PingResponse) ProtoMessage() {}
|
||||
|
||||
func (x *PingResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PingResponse.ProtoReflect.Descriptor instead.
|
||||
func (*PingResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
type SyncStartRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncStartRequest) Reset() {
|
||||
*x = SyncStartRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncStartRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncStartRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStartRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncStartRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStartRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *SyncStartRequest) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SyncStartResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *SyncStartResponse) Reset() {
|
||||
*x = SyncStartResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncStartResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncStartResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStartResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[3]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncStartResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStartResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
type SyncWantRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
DependsOn string `protobuf:"bytes,2,opt,name=depends_on,json=dependsOn,proto3" json:"depends_on,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncWantRequest) Reset() {
|
||||
*x = SyncWantRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncWantRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncWantRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncWantRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[4]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncWantRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncWantRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *SyncWantRequest) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SyncWantRequest) GetDependsOn() string {
|
||||
if x != nil {
|
||||
return x.DependsOn
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SyncWantResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *SyncWantResponse) Reset() {
|
||||
*x = SyncWantResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncWantResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncWantResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncWantResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[5]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncWantResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SyncWantResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
type SyncCompleteRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncCompleteRequest) Reset() {
|
||||
*x = SyncCompleteRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncCompleteRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncCompleteRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncCompleteRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[6]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncCompleteRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncCompleteRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *SyncCompleteRequest) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SyncCompleteResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
}
|
||||
|
||||
func (x *SyncCompleteResponse) Reset() {
|
||||
*x = SyncCompleteResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncCompleteResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncCompleteResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncCompleteResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[7]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncCompleteResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SyncCompleteResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
type SyncReadyRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncReadyRequest) Reset() {
|
||||
*x = SyncReadyRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncReadyRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncReadyRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncReadyRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[8]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncReadyRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncReadyRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *SyncReadyRequest) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SyncReadyResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Ready bool `protobuf:"varint,1,opt,name=ready,proto3" json:"ready,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncReadyResponse) Reset() {
|
||||
*x = SyncReadyResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncReadyResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncReadyResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncReadyResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[9]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncReadyResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SyncReadyResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *SyncReadyResponse) GetReady() bool {
|
||||
if x != nil {
|
||||
return x.Ready
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type SyncStatusRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncStatusRequest) Reset() {
|
||||
*x = SyncStatusRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncStatusRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncStatusRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStatusRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[10]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncStatusRequest.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStatusRequest) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *SyncStatusRequest) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type DependencyInfo struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Unit string `protobuf:"bytes,1,opt,name=unit,proto3" json:"unit,omitempty"`
|
||||
DependsOn string `protobuf:"bytes,2,opt,name=depends_on,json=dependsOn,proto3" json:"depends_on,omitempty"`
|
||||
RequiredStatus string `protobuf:"bytes,3,opt,name=required_status,json=requiredStatus,proto3" json:"required_status,omitempty"`
|
||||
CurrentStatus string `protobuf:"bytes,4,opt,name=current_status,json=currentStatus,proto3" json:"current_status,omitempty"`
|
||||
IsSatisfied bool `protobuf:"varint,5,opt,name=is_satisfied,json=isSatisfied,proto3" json:"is_satisfied,omitempty"`
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) Reset() {
|
||||
*x = DependencyInfo{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*DependencyInfo) ProtoMessage() {}
|
||||
|
||||
func (x *DependencyInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[11]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use DependencyInfo.ProtoReflect.Descriptor instead.
|
||||
func (*DependencyInfo) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) GetUnit() string {
|
||||
if x != nil {
|
||||
return x.Unit
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) GetDependsOn() string {
|
||||
if x != nil {
|
||||
return x.DependsOn
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) GetRequiredStatus() string {
|
||||
if x != nil {
|
||||
return x.RequiredStatus
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) GetCurrentStatus() string {
|
||||
if x != nil {
|
||||
return x.CurrentStatus
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DependencyInfo) GetIsSatisfied() bool {
|
||||
if x != nil {
|
||||
return x.IsSatisfied
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type SyncStatusResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
|
||||
IsReady bool `protobuf:"varint,2,opt,name=is_ready,json=isReady,proto3" json:"is_ready,omitempty"`
|
||||
Dependencies []*DependencyInfo `protobuf:"bytes,3,rep,name=dependencies,proto3" json:"dependencies,omitempty"`
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) Reset() {
|
||||
*x = SyncStatusResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*SyncStatusResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SyncStatusResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_agent_agentsocket_proto_agentsocket_proto_msgTypes[12]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use SyncStatusResponse.ProtoReflect.Descriptor instead.
|
||||
func (*SyncStatusResponse) Descriptor() ([]byte, []int) {
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) GetIsReady() bool {
|
||||
if x != nil {
|
||||
return x.IsReady
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *SyncStatusResponse) GetDependencies() []*DependencyInfo {
|
||||
if x != nil {
|
||||
return x.Dependencies
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_agent_agentsocket_proto_agentsocket_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_rawDesc = []byte{
|
||||
0x0a, 0x29, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73,
|
||||
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x14, 0x63, 0x6f, 0x64,
|
||||
0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76,
|
||||
0x31, 0x22, 0x0d, 0x0a, 0x0b, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x22, 0x0e, 0x0a, 0x0c, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x22, 0x26, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x22, 0x13, 0x0a, 0x11, 0x53, 0x79, 0x6e, 0x63,
|
||||
0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x44, 0x0a,
|
||||
0x0f, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||
0x75, 0x6e, 0x69, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x5f,
|
||||
0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64,
|
||||
0x73, 0x4f, 0x6e, 0x22, 0x12, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x29, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x43,
|
||||
0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12,
|
||||
0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e,
|
||||
0x69, 0x74, 0x22, 0x16, 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65,
|
||||
0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x0a, 0x10, 0x53, 0x79,
|
||||
0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12,
|
||||
0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e,
|
||||
0x69, 0x74, 0x22, 0x29, 0x0a, 0x11, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79, 0x22, 0x27, 0x0a,
|
||||
0x11, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x22, 0xb6, 0x01, 0x0a, 0x0e, 0x44, 0x65, 0x70, 0x65, 0x6e,
|
||||
0x64, 0x65, 0x6e, 0x63, 0x79, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x6e, 0x69,
|
||||
0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x6e, 0x69, 0x74, 0x12, 0x1d, 0x0a,
|
||||
0x0a, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x5f, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x09, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x73, 0x4f, 0x6e, 0x12, 0x27, 0x0a, 0x0f,
|
||||
0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x53,
|
||||
0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74,
|
||||
0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x63,
|
||||
0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x21, 0x0a, 0x0c,
|
||||
0x69, 0x73, 0x5f, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01,
|
||||
0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x53, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x22,
|
||||
0x91, 0x01, 0x0a, 0x12, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x19,
|
||||
0x0a, 0x08, 0x69, 0x73, 0x5f, 0x72, 0x65, 0x61, 0x64, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08,
|
||||
0x52, 0x07, 0x69, 0x73, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, 0x48, 0x0a, 0x0c, 0x64, 0x65, 0x70,
|
||||
0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32,
|
||||
0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63,
|
||||
0x79, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0c, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63,
|
||||
0x69, 0x65, 0x73, 0x32, 0xbb, 0x04, 0x0a, 0x0b, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x12, 0x4d, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x21, 0x2e, 0x63, 0x6f,
|
||||
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e,
|
||||
0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22,
|
||||
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
|
||||
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12,
|
||||
0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x59, 0x0a, 0x08, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63,
|
||||
0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
|
||||
0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57, 0x61, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e,
|
||||
0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x57,
|
||||
0x61, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a, 0x0c, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x63, 0x6f,
|
||||
0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e,
|
||||
0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79,
|
||||
0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x5c, 0x0a, 0x09, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12,
|
||||
0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63,
|
||||
0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x5f, 0x0a, 0x0a, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x27,
|
||||
0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b,
|
||||
0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e,
|
||||
0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x53,
|
||||
0x79, 0x6e, 0x63, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x42, 0x33, 0x5a, 0x31, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
|
||||
0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61,
|
||||
0x67, 0x65, 0x6e, 0x74, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x6f, 0x63, 0x6b, 0x65, 0x74,
|
||||
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescOnce sync.Once
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescData = file_agent_agentsocket_proto_agentsocket_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_agent_agentsocket_proto_agentsocket_proto_rawDescGZIP() []byte {
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescOnce.Do(func() {
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDescData = protoimpl.X.CompressGZIP(file_agent_agentsocket_proto_agentsocket_proto_rawDescData)
|
||||
})
|
||||
return file_agent_agentsocket_proto_agentsocket_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_goTypes = []interface{}{
|
||||
(*PingRequest)(nil), // 0: coder.agentsocket.v1.PingRequest
|
||||
(*PingResponse)(nil), // 1: coder.agentsocket.v1.PingResponse
|
||||
(*SyncStartRequest)(nil), // 2: coder.agentsocket.v1.SyncStartRequest
|
||||
(*SyncStartResponse)(nil), // 3: coder.agentsocket.v1.SyncStartResponse
|
||||
(*SyncWantRequest)(nil), // 4: coder.agentsocket.v1.SyncWantRequest
|
||||
(*SyncWantResponse)(nil), // 5: coder.agentsocket.v1.SyncWantResponse
|
||||
(*SyncCompleteRequest)(nil), // 6: coder.agentsocket.v1.SyncCompleteRequest
|
||||
(*SyncCompleteResponse)(nil), // 7: coder.agentsocket.v1.SyncCompleteResponse
|
||||
(*SyncReadyRequest)(nil), // 8: coder.agentsocket.v1.SyncReadyRequest
|
||||
(*SyncReadyResponse)(nil), // 9: coder.agentsocket.v1.SyncReadyResponse
|
||||
(*SyncStatusRequest)(nil), // 10: coder.agentsocket.v1.SyncStatusRequest
|
||||
(*DependencyInfo)(nil), // 11: coder.agentsocket.v1.DependencyInfo
|
||||
(*SyncStatusResponse)(nil), // 12: coder.agentsocket.v1.SyncStatusResponse
|
||||
}
|
||||
var file_agent_agentsocket_proto_agentsocket_proto_depIdxs = []int32{
|
||||
11, // 0: coder.agentsocket.v1.SyncStatusResponse.dependencies:type_name -> coder.agentsocket.v1.DependencyInfo
|
||||
0, // 1: coder.agentsocket.v1.AgentSocket.Ping:input_type -> coder.agentsocket.v1.PingRequest
|
||||
2, // 2: coder.agentsocket.v1.AgentSocket.SyncStart:input_type -> coder.agentsocket.v1.SyncStartRequest
|
||||
4, // 3: coder.agentsocket.v1.AgentSocket.SyncWant:input_type -> coder.agentsocket.v1.SyncWantRequest
|
||||
6, // 4: coder.agentsocket.v1.AgentSocket.SyncComplete:input_type -> coder.agentsocket.v1.SyncCompleteRequest
|
||||
8, // 5: coder.agentsocket.v1.AgentSocket.SyncReady:input_type -> coder.agentsocket.v1.SyncReadyRequest
|
||||
10, // 6: coder.agentsocket.v1.AgentSocket.SyncStatus:input_type -> coder.agentsocket.v1.SyncStatusRequest
|
||||
1, // 7: coder.agentsocket.v1.AgentSocket.Ping:output_type -> coder.agentsocket.v1.PingResponse
|
||||
3, // 8: coder.agentsocket.v1.AgentSocket.SyncStart:output_type -> coder.agentsocket.v1.SyncStartResponse
|
||||
5, // 9: coder.agentsocket.v1.AgentSocket.SyncWant:output_type -> coder.agentsocket.v1.SyncWantResponse
|
||||
7, // 10: coder.agentsocket.v1.AgentSocket.SyncComplete:output_type -> coder.agentsocket.v1.SyncCompleteResponse
|
||||
9, // 11: coder.agentsocket.v1.AgentSocket.SyncReady:output_type -> coder.agentsocket.v1.SyncReadyResponse
|
||||
12, // 12: coder.agentsocket.v1.AgentSocket.SyncStatus:output_type -> coder.agentsocket.v1.SyncStatusResponse
|
||||
7, // [7:13] is the sub-list for method output_type
|
||||
1, // [1:7] is the sub-list for method input_type
|
||||
1, // [1:1] is the sub-list for extension type_name
|
||||
1, // [1:1] is the sub-list for extension extendee
|
||||
0, // [0:1] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_agent_agentsocket_proto_agentsocket_proto_init() }
|
||||
func file_agent_agentsocket_proto_agentsocket_proto_init() {
|
||||
if File_agent_agentsocket_proto_agentsocket_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*PingRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*PingResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStartRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStartResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncWantRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncWantResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncCompleteRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncCompleteResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncReadyRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncReadyResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStatusRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*DependencyInfo); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_agent_agentsocket_proto_agentsocket_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*SyncStatusResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_agent_agentsocket_proto_agentsocket_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 13,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
GoTypes: file_agent_agentsocket_proto_agentsocket_proto_goTypes,
|
||||
DependencyIndexes: file_agent_agentsocket_proto_agentsocket_proto_depIdxs,
|
||||
MessageInfos: file_agent_agentsocket_proto_agentsocket_proto_msgTypes,
|
||||
}.Build()
|
||||
File_agent_agentsocket_proto_agentsocket_proto = out.File
|
||||
file_agent_agentsocket_proto_agentsocket_proto_rawDesc = nil
|
||||
file_agent_agentsocket_proto_agentsocket_proto_goTypes = nil
|
||||
file_agent_agentsocket_proto_agentsocket_proto_depIdxs = nil
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
syntax = "proto3";
|
||||
option go_package = "github.com/coder/coder/v2/agent/agentsocket/proto";
|
||||
|
||||
package coder.agentsocket.v1;
|
||||
|
||||
message PingRequest {}
|
||||
|
||||
message PingResponse {}
|
||||
|
||||
message SyncStartRequest {
|
||||
string unit = 1;
|
||||
}
|
||||
|
||||
message SyncStartResponse {}
|
||||
|
||||
message SyncWantRequest {
|
||||
string unit = 1;
|
||||
string depends_on = 2;
|
||||
}
|
||||
|
||||
message SyncWantResponse {}
|
||||
|
||||
message SyncCompleteRequest {
|
||||
string unit = 1;
|
||||
}
|
||||
|
||||
message SyncCompleteResponse {}
|
||||
|
||||
message SyncReadyRequest {
|
||||
string unit = 1;
|
||||
}
|
||||
|
||||
message SyncReadyResponse {
|
||||
bool ready = 1;
|
||||
}
|
||||
|
||||
message SyncStatusRequest {
|
||||
string unit = 1;
|
||||
}
|
||||
|
||||
message DependencyInfo {
|
||||
string unit = 1;
|
||||
string depends_on = 2;
|
||||
string required_status = 3;
|
||||
string current_status = 4;
|
||||
bool is_satisfied = 5;
|
||||
}
|
||||
|
||||
message SyncStatusResponse {
|
||||
string status = 1;
|
||||
bool is_ready = 2;
|
||||
repeated DependencyInfo dependencies = 3;
|
||||
}
|
||||
|
||||
// AgentSocket provides direct access to the agent over local IPC.
|
||||
service AgentSocket {
|
||||
// Ping the agent to check if it is alive.
|
||||
rpc Ping(PingRequest) returns (PingResponse);
|
||||
// Report the start of a unit.
|
||||
rpc SyncStart(SyncStartRequest) returns (SyncStartResponse);
|
||||
// Declare a dependency between units.
|
||||
rpc SyncWant(SyncWantRequest) returns (SyncWantResponse);
|
||||
// Report the completion of a unit.
|
||||
rpc SyncComplete(SyncCompleteRequest) returns (SyncCompleteResponse);
|
||||
// Request whether a unit is ready to be started. That is, all dependencies are satisfied.
|
||||
rpc SyncReady(SyncReadyRequest) returns (SyncReadyResponse);
|
||||
// Get the status of a unit and list its dependencies.
|
||||
rpc SyncStatus(SyncStatusRequest) returns (SyncStatusResponse);
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
// Code generated by protoc-gen-go-drpc. DO NOT EDIT.
|
||||
// protoc-gen-go-drpc version: v0.0.34
|
||||
// source: agent/agentsocket/proto/agentsocket.proto
|
||||
|
||||
package proto
|
||||
|
||||
import (
|
||||
context "context"
|
||||
errors "errors"
|
||||
protojson "google.golang.org/protobuf/encoding/protojson"
|
||||
proto "google.golang.org/protobuf/proto"
|
||||
drpc "storj.io/drpc"
|
||||
drpcerr "storj.io/drpc/drpcerr"
|
||||
)
|
||||
|
||||
type drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto struct{}
|
||||
|
||||
func (drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto) Marshal(msg drpc.Message) ([]byte, error) {
|
||||
return proto.Marshal(msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto) MarshalAppend(buf []byte, msg drpc.Message) ([]byte, error) {
|
||||
return proto.MarshalOptions{}.MarshalAppend(buf, msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto) Unmarshal(buf []byte, msg drpc.Message) error {
|
||||
return proto.Unmarshal(buf, msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto) JSONMarshal(msg drpc.Message) ([]byte, error) {
|
||||
return protojson.Marshal(msg.(proto.Message))
|
||||
}
|
||||
|
||||
func (drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto) JSONUnmarshal(buf []byte, msg drpc.Message) error {
|
||||
return protojson.Unmarshal(buf, msg.(proto.Message))
|
||||
}
|
||||
|
||||
type DRPCAgentSocketClient interface {
|
||||
DRPCConn() drpc.Conn
|
||||
|
||||
Ping(ctx context.Context, in *PingRequest) (*PingResponse, error)
|
||||
SyncStart(ctx context.Context, in *SyncStartRequest) (*SyncStartResponse, error)
|
||||
SyncWant(ctx context.Context, in *SyncWantRequest) (*SyncWantResponse, error)
|
||||
SyncComplete(ctx context.Context, in *SyncCompleteRequest) (*SyncCompleteResponse, error)
|
||||
SyncReady(ctx context.Context, in *SyncReadyRequest) (*SyncReadyResponse, error)
|
||||
SyncStatus(ctx context.Context, in *SyncStatusRequest) (*SyncStatusResponse, error)
|
||||
}
|
||||
|
||||
type drpcAgentSocketClient struct {
|
||||
cc drpc.Conn
|
||||
}
|
||||
|
||||
func NewDRPCAgentSocketClient(cc drpc.Conn) DRPCAgentSocketClient {
|
||||
return &drpcAgentSocketClient{cc}
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) DRPCConn() drpc.Conn { return c.cc }
|
||||
|
||||
func (c *drpcAgentSocketClient) Ping(ctx context.Context, in *PingRequest) (*PingResponse, error) {
|
||||
out := new(PingResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/Ping", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) SyncStart(ctx context.Context, in *SyncStartRequest) (*SyncStartResponse, error) {
|
||||
out := new(SyncStartResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/SyncStart", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) SyncWant(ctx context.Context, in *SyncWantRequest) (*SyncWantResponse, error) {
|
||||
out := new(SyncWantResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/SyncWant", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) SyncComplete(ctx context.Context, in *SyncCompleteRequest) (*SyncCompleteResponse, error) {
|
||||
out := new(SyncCompleteResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/SyncComplete", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) SyncReady(ctx context.Context, in *SyncReadyRequest) (*SyncReadyResponse, error) {
|
||||
out := new(SyncReadyResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/SyncReady", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentSocketClient) SyncStatus(ctx context.Context, in *SyncStatusRequest) (*SyncStatusResponse, error) {
|
||||
out := new(SyncStatusResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agentsocket.v1.AgentSocket/SyncStatus", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type DRPCAgentSocketServer interface {
|
||||
Ping(context.Context, *PingRequest) (*PingResponse, error)
|
||||
SyncStart(context.Context, *SyncStartRequest) (*SyncStartResponse, error)
|
||||
SyncWant(context.Context, *SyncWantRequest) (*SyncWantResponse, error)
|
||||
SyncComplete(context.Context, *SyncCompleteRequest) (*SyncCompleteResponse, error)
|
||||
SyncReady(context.Context, *SyncReadyRequest) (*SyncReadyResponse, error)
|
||||
SyncStatus(context.Context, *SyncStatusRequest) (*SyncStatusResponse, error)
|
||||
}
|
||||
|
||||
type DRPCAgentSocketUnimplementedServer struct{}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) Ping(context.Context, *PingRequest) (*PingResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) SyncStart(context.Context, *SyncStartRequest) (*SyncStartResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) SyncWant(context.Context, *SyncWantRequest) (*SyncWantResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) SyncComplete(context.Context, *SyncCompleteRequest) (*SyncCompleteResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) SyncReady(context.Context, *SyncReadyRequest) (*SyncReadyResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentSocketUnimplementedServer) SyncStatus(context.Context, *SyncStatusRequest) (*SyncStatusResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
type DRPCAgentSocketDescription struct{}
|
||||
|
||||
func (DRPCAgentSocketDescription) NumMethods() int { return 6 }
|
||||
|
||||
func (DRPCAgentSocketDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
|
||||
switch n {
|
||||
case 0:
|
||||
return "/coder.agentsocket.v1.AgentSocket/Ping", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
Ping(
|
||||
ctx,
|
||||
in1.(*PingRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.Ping, true
|
||||
case 1:
|
||||
return "/coder.agentsocket.v1.AgentSocket/SyncStart", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
SyncStart(
|
||||
ctx,
|
||||
in1.(*SyncStartRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.SyncStart, true
|
||||
case 2:
|
||||
return "/coder.agentsocket.v1.AgentSocket/SyncWant", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
SyncWant(
|
||||
ctx,
|
||||
in1.(*SyncWantRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.SyncWant, true
|
||||
case 3:
|
||||
return "/coder.agentsocket.v1.AgentSocket/SyncComplete", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
SyncComplete(
|
||||
ctx,
|
||||
in1.(*SyncCompleteRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.SyncComplete, true
|
||||
case 4:
|
||||
return "/coder.agentsocket.v1.AgentSocket/SyncReady", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
SyncReady(
|
||||
ctx,
|
||||
in1.(*SyncReadyRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.SyncReady, true
|
||||
case 5:
|
||||
return "/coder.agentsocket.v1.AgentSocket/SyncStatus", drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentSocketServer).
|
||||
SyncStatus(
|
||||
ctx,
|
||||
in1.(*SyncStatusRequest),
|
||||
)
|
||||
}, DRPCAgentSocketServer.SyncStatus, true
|
||||
default:
|
||||
return "", nil, nil, nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func DRPCRegisterAgentSocket(mux drpc.Mux, impl DRPCAgentSocketServer) error {
|
||||
return mux.Register(impl, DRPCAgentSocketDescription{})
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_PingStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*PingResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_PingStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_PingStream) SendAndClose(m *PingResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_SyncStartStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*SyncStartResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_SyncStartStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_SyncStartStream) SendAndClose(m *SyncStartResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_SyncWantStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*SyncWantResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_SyncWantStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_SyncWantStream) SendAndClose(m *SyncWantResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_SyncCompleteStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*SyncCompleteResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_SyncCompleteStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_SyncCompleteStream) SendAndClose(m *SyncCompleteResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_SyncReadyStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*SyncReadyResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_SyncReadyStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_SyncReadyStream) SendAndClose(m *SyncReadyResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgentSocket_SyncStatusStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*SyncStatusResponse) error
|
||||
}
|
||||
|
||||
type drpcAgentSocket_SyncStatusStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgentSocket_SyncStatusStream) SendAndClose(m *SyncStatusResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_agentsocket_proto_agentsocket_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package proto
|
||||
|
||||
import "github.com/coder/coder/v2/apiversion"
|
||||
|
||||
// Version history:
|
||||
//
|
||||
// API v1.0:
|
||||
// - Initial release
|
||||
// - Ping
|
||||
// - Sync operations: SyncStart, SyncWant, SyncComplete, SyncWait, SyncStatus
|
||||
|
||||
const (
|
||||
CurrentMajor = 1
|
||||
CurrentMinor = 0
|
||||
)
|
||||
|
||||
var CurrentVersion = apiversion.New(CurrentMajor, CurrentMinor)
|
||||
@@ -1,138 +0,0 @@
|
||||
package agentsocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"storj.io/drpc/drpcmux"
|
||||
"storj.io/drpc/drpcserver"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Server provides access to the DRPCAgentSocketService via a Unix domain socket.
|
||||
// Do not invoke Server{} directly. Use NewServer() instead.
|
||||
type Server struct {
|
||||
logger slog.Logger
|
||||
path string
|
||||
drpcServer *drpcserver.Server
|
||||
service *DRPCAgentSocketService
|
||||
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewServer creates a new agent socket server.
|
||||
func NewServer(logger slog.Logger, opts ...Option) (*Server, error) {
|
||||
options := &options{}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
logger = logger.Named("agentsocket-server")
|
||||
server := &Server{
|
||||
logger: logger,
|
||||
path: options.path,
|
||||
service: &DRPCAgentSocketService{
|
||||
logger: logger,
|
||||
unitManager: unit.NewManager(),
|
||||
},
|
||||
}
|
||||
|
||||
mux := drpcmux.New()
|
||||
err := proto.DRPCRegisterAgentSocket(mux, server.service)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("failed to register drpc service: %w", err)
|
||||
}
|
||||
|
||||
server.drpcServer = drpcserver.NewWithOptions(mux, drpcserver.Options{
|
||||
Manager: drpcsdk.DefaultDRPCOptions(nil),
|
||||
Log: func(err error) {
|
||||
if errors.Is(err, context.Canceled) ||
|
||||
errors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
}
|
||||
logger.Debug(context.Background(), "drpc server error", slog.Error(err))
|
||||
},
|
||||
})
|
||||
|
||||
listener, err := createSocket(server.path)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create socket: %w", err)
|
||||
}
|
||||
|
||||
server.listener = listener
|
||||
|
||||
// This context is canceled by server.Close().
|
||||
// canceling it will close all connections.
|
||||
server.ctx, server.cancel = context.WithCancel(context.Background())
|
||||
|
||||
server.logger.Info(server.ctx, "agent socket server started", slog.F("path", server.path))
|
||||
|
||||
server.wg.Add(1)
|
||||
go func() {
|
||||
defer server.wg.Done()
|
||||
server.acceptConnections()
|
||||
}()
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// Close stops the server and cleans up resources.
|
||||
func (s *Server) Close() error {
|
||||
s.mu.Lock()
|
||||
|
||||
if s.listener == nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info(s.ctx, "stopping agent socket server")
|
||||
|
||||
s.cancel()
|
||||
|
||||
if err := s.listener.Close(); err != nil {
|
||||
s.logger.Warn(s.ctx, "error closing socket listener", slog.Error(err))
|
||||
}
|
||||
|
||||
s.listener = nil
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
// Wait for all connections to finish
|
||||
s.wg.Wait()
|
||||
|
||||
if err := cleanupSocket(s.path); err != nil {
|
||||
s.logger.Warn(s.ctx, "error cleaning up socket file", slog.Error(err))
|
||||
}
|
||||
|
||||
s.logger.Info(s.ctx, "agent socket server stopped")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) acceptConnections() {
|
||||
// In an edge case, Close() might race with acceptConnections() and set s.listener to nil.
|
||||
// Therefore, we grab a copy of the listener under a lock. We might still get a nil listener,
|
||||
// but then we know close has already run and we can return early.
|
||||
s.mu.Lock()
|
||||
listener := s.listener
|
||||
s.mu.Unlock()
|
||||
if listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err := s.drpcServer.Serve(s.ctx, listener)
|
||||
if err != nil {
|
||||
s.logger.Warn(s.ctx, "error serving drpc server", slog.Error(err))
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package agentsocket_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
agentproto "github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/tailnet/tailnettest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("agentsocket is not supported on Windows")
|
||||
}
|
||||
|
||||
t.Run("StartStop", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(t.TempDir(), "test.sock")
|
||||
logger := slog.Make().Leveled(slog.LevelDebug)
|
||||
server, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, server.Close())
|
||||
})
|
||||
|
||||
t.Run("AlreadyStarted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(t.TempDir(), "test.sock")
|
||||
logger := slog.Make().Leveled(slog.LevelDebug)
|
||||
server1, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
|
||||
require.NoError(t, err)
|
||||
defer server1.Close()
|
||||
_, err = agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
|
||||
require.ErrorContains(t, err, "create socket")
|
||||
})
|
||||
|
||||
t.Run("AutoSocketPath", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(t.TempDir(), "test.sock")
|
||||
logger := slog.Make().Leveled(slog.LevelDebug)
|
||||
server, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, server.Close())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerWindowsNotSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("this test only runs on Windows")
|
||||
}
|
||||
|
||||
t.Run("NewServer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(t.TempDir(), "test.sock")
|
||||
logger := slog.Make().Leveled(slog.LevelDebug)
|
||||
_, err := agentsocket.NewServer(logger, agentsocket.WithPath(socketPath))
|
||||
require.ErrorContains(t, err, "agentsocket is not supported on Windows")
|
||||
})
|
||||
|
||||
t.Run("NewClient", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := agentsocket.NewClient(context.Background(), agentsocket.WithPath("test.sock"))
|
||||
require.ErrorContains(t, err, "agentsocket is not supported on Windows")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentInitializesOnWindowsWithoutSocketServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("this test only runs on Windows")
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
logger := testutil.Logger(t).Named("agent")
|
||||
|
||||
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
|
||||
|
||||
coordinator := tailnet.NewCoordinator(logger)
|
||||
t.Cleanup(func() {
|
||||
_ = coordinator.Close()
|
||||
})
|
||||
|
||||
statsCh := make(chan *agentproto.Stats, 50)
|
||||
agentID := uuid.New()
|
||||
manifest := agentsdk.Manifest{
|
||||
AgentID: agentID,
|
||||
AgentName: "test-agent",
|
||||
WorkspaceName: "test-workspace",
|
||||
OwnerName: "test-user",
|
||||
WorkspaceID: uuid.New(),
|
||||
DERPMap: derpMap,
|
||||
}
|
||||
|
||||
client := agenttest.NewClient(t, logger.Named("agenttest"), agentID, manifest, statsCh, coordinator)
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
options := agent.Options{
|
||||
Client: client,
|
||||
Filesystem: afero.NewMemMapFs(),
|
||||
Logger: logger.Named("agent"),
|
||||
ReconnectingPTYTimeout: testutil.WaitShort,
|
||||
EnvironmentVariables: map[string]string{},
|
||||
SocketPath: "",
|
||||
}
|
||||
|
||||
agnt := agent.New(options)
|
||||
t.Cleanup(func() {
|
||||
_ = agnt.Close()
|
||||
})
|
||||
|
||||
startup := testutil.TryReceive(ctx, t, client.GetStartup())
|
||||
require.NotNil(t, startup, "agent should send startup message")
|
||||
|
||||
err := agnt.Close()
|
||||
require.NoError(t, err, "agent should close cleanly")
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package agentsocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentsocket/proto"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
)
|
||||
|
||||
var _ proto.DRPCAgentSocketServer = (*DRPCAgentSocketService)(nil)
|
||||
|
||||
var ErrUnitManagerNotAvailable = xerrors.New("unit manager not available")
|
||||
|
||||
// DRPCAgentSocketService implements the DRPC agent socket service.
|
||||
type DRPCAgentSocketService struct {
|
||||
unitManager *unit.Manager
|
||||
logger slog.Logger
|
||||
}
|
||||
|
||||
// Ping responds to a ping request to check if the service is alive.
|
||||
func (*DRPCAgentSocketService) Ping(_ context.Context, _ *proto.PingRequest) (*proto.PingResponse, error) {
|
||||
return &proto.PingResponse{}, nil
|
||||
}
|
||||
|
||||
// SyncStart starts a unit in the dependency graph.
|
||||
func (s *DRPCAgentSocketService) SyncStart(_ context.Context, req *proto.SyncStartRequest) (*proto.SyncStartResponse, error) {
|
||||
if s.unitManager == nil {
|
||||
return nil, xerrors.Errorf("SyncStart: %w", ErrUnitManagerNotAvailable)
|
||||
}
|
||||
|
||||
unitID := unit.ID(req.Unit)
|
||||
|
||||
if err := s.unitManager.Register(unitID); err != nil {
|
||||
if !errors.Is(err, unit.ErrUnitAlreadyRegistered) {
|
||||
return nil, xerrors.Errorf("SyncStart: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
isReady, err := s.unitManager.IsReady(unitID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("cannot check readiness: %w", err)
|
||||
}
|
||||
if !isReady {
|
||||
return nil, xerrors.Errorf("cannot start unit %q: unit not ready", req.Unit)
|
||||
}
|
||||
|
||||
err = s.unitManager.UpdateStatus(unitID, unit.StatusStarted)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("cannot start unit %q: %w", req.Unit, err)
|
||||
}
|
||||
|
||||
return &proto.SyncStartResponse{}, nil
|
||||
}
|
||||
|
||||
// SyncWant declares a dependency between units.
|
||||
func (s *DRPCAgentSocketService) SyncWant(_ context.Context, req *proto.SyncWantRequest) (*proto.SyncWantResponse, error) {
|
||||
if s.unitManager == nil {
|
||||
return nil, xerrors.Errorf("cannot add dependency: %w", ErrUnitManagerNotAvailable)
|
||||
}
|
||||
|
||||
unitID := unit.ID(req.Unit)
|
||||
dependsOnID := unit.ID(req.DependsOn)
|
||||
|
||||
if err := s.unitManager.Register(unitID); err != nil && !errors.Is(err, unit.ErrUnitAlreadyRegistered) {
|
||||
return nil, xerrors.Errorf("cannot add dependency: %w", err)
|
||||
}
|
||||
|
||||
if err := s.unitManager.AddDependency(unitID, dependsOnID, unit.StatusComplete); err != nil {
|
||||
return nil, xerrors.Errorf("cannot add dependency: %w", err)
|
||||
}
|
||||
|
||||
return &proto.SyncWantResponse{}, nil
|
||||
}
|
||||
|
||||
// SyncComplete marks a unit as complete in the dependency graph.
|
||||
func (s *DRPCAgentSocketService) SyncComplete(_ context.Context, req *proto.SyncCompleteRequest) (*proto.SyncCompleteResponse, error) {
|
||||
if s.unitManager == nil {
|
||||
return nil, xerrors.Errorf("cannot complete unit: %w", ErrUnitManagerNotAvailable)
|
||||
}
|
||||
|
||||
unitID := unit.ID(req.Unit)
|
||||
|
||||
if err := s.unitManager.UpdateStatus(unitID, unit.StatusComplete); err != nil {
|
||||
return nil, xerrors.Errorf("cannot complete unit %q: %w", req.Unit, err)
|
||||
}
|
||||
|
||||
return &proto.SyncCompleteResponse{}, nil
|
||||
}
|
||||
|
||||
// SyncReady checks whether a unit is ready to be started. That is, all dependencies are satisfied.
|
||||
func (s *DRPCAgentSocketService) SyncReady(_ context.Context, req *proto.SyncReadyRequest) (*proto.SyncReadyResponse, error) {
|
||||
if s.unitManager == nil {
|
||||
return nil, xerrors.Errorf("cannot check readiness: %w", ErrUnitManagerNotAvailable)
|
||||
}
|
||||
|
||||
unitID := unit.ID(req.Unit)
|
||||
isReady, err := s.unitManager.IsReady(unitID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("cannot check readiness: %w", err)
|
||||
}
|
||||
|
||||
return &proto.SyncReadyResponse{
|
||||
Ready: isReady,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SyncStatus gets the status of a unit and lists its dependencies.
|
||||
func (s *DRPCAgentSocketService) SyncStatus(_ context.Context, req *proto.SyncStatusRequest) (*proto.SyncStatusResponse, error) {
|
||||
if s.unitManager == nil {
|
||||
return nil, xerrors.Errorf("cannot get status for unit %q: %w", req.Unit, ErrUnitManagerNotAvailable)
|
||||
}
|
||||
|
||||
unitID := unit.ID(req.Unit)
|
||||
|
||||
isReady, err := s.unitManager.IsReady(unitID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("cannot check readiness: %w", err)
|
||||
}
|
||||
|
||||
dependencies, err := s.unitManager.GetAllDependencies(unitID)
|
||||
switch {
|
||||
case errors.Is(err, unit.ErrUnitNotFound):
|
||||
dependencies = []unit.Dependency{}
|
||||
case err != nil:
|
||||
return nil, xerrors.Errorf("cannot get dependencies: %w", err)
|
||||
}
|
||||
|
||||
var depInfos []*proto.DependencyInfo
|
||||
for _, dep := range dependencies {
|
||||
depInfos = append(depInfos, &proto.DependencyInfo{
|
||||
Unit: string(dep.Unit),
|
||||
DependsOn: string(dep.DependsOn),
|
||||
RequiredStatus: string(dep.RequiredStatus),
|
||||
CurrentStatus: string(dep.CurrentStatus),
|
||||
IsSatisfied: dep.IsSatisfied,
|
||||
})
|
||||
}
|
||||
|
||||
u, err := s.unitManager.Unit(unitID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("cannot get status for unit %q: %w", req.Unit, err)
|
||||
}
|
||||
return &proto.SyncStatusResponse{
|
||||
Status: string(u.Status()),
|
||||
IsReady: isReady,
|
||||
Dependencies: depInfos,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
package agentsocket_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentsocket"
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// tempDirUnixSocket returns a temporary directory that can safely hold unix
|
||||
// sockets (probably).
|
||||
//
|
||||
// During tests on darwin we hit the max path length limit for unix sockets
|
||||
// pretty easily in the default location, so this function uses /tmp instead to
|
||||
// get shorter paths. To keep paths short, we use a hash of the test name
|
||||
// instead of the full test name.
|
||||
func tempDirUnixSocket(t *testing.T) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "darwin" {
|
||||
// Use a short hash of the test name to keep the path under 104 chars
|
||||
hash := sha256.Sum256([]byte(t.Name()))
|
||||
hashStr := hex.EncodeToString(hash[:])[:8] // Use first 8 chars of hash
|
||||
dir, err := os.MkdirTemp("/tmp", fmt.Sprintf("c-%s-", hashStr))
|
||||
require.NoError(t, err, "create temp dir for unix socket test")
|
||||
t.Cleanup(func() {
|
||||
err := os.RemoveAll(dir)
|
||||
assert.NoError(t, err, "remove temp dir", dir)
|
||||
})
|
||||
return dir
|
||||
}
|
||||
return t.TempDir()
|
||||
}
|
||||
|
||||
// newSocketClient creates a DRPC client connected to the Unix socket at the given path.
|
||||
func newSocketClient(ctx context.Context, t *testing.T, socketPath string) *agentsocket.Client {
|
||||
t.Helper()
|
||||
|
||||
client, err := agentsocket.NewClient(ctx, agentsocket.WithPath(socketPath))
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func TestDRPCAgentSocketService(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("agentsocket is not supported on Windows")
|
||||
}
|
||||
|
||||
t.Run("Ping", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
err = client.Ping(ctx)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("SyncStart", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NewUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
})
|
||||
|
||||
t.Run("UnitAlreadyStarted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// First Start
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
|
||||
// Second Start
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.ErrorContains(t, err, unit.ErrSameStatusAlreadySet.Error())
|
||||
|
||||
status, err = client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
})
|
||||
|
||||
t.Run("UnitAlreadyCompleted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// First start
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
|
||||
// Complete the unit
|
||||
err = client.SyncComplete(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err = client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusComplete, status.Status)
|
||||
|
||||
// Second start
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err = client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
})
|
||||
|
||||
t.Run("UnitNotReady", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.ErrorContains(t, err, "unit not ready")
|
||||
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusPending, status.Status)
|
||||
require.False(t, status.IsReady)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("SyncWant", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("NewUnits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// If dependency units are not registered, they are registered automatically
|
||||
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, status.Dependencies, 1)
|
||||
require.Equal(t, unit.ID("dependency-unit"), status.Dependencies[0].DependsOn)
|
||||
require.Equal(t, unit.StatusComplete, status.Dependencies[0].RequiredStatus)
|
||||
})
|
||||
|
||||
t.Run("DependencyAlreadyRegistered", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// Start the dependency unit
|
||||
err = client.SyncStart(ctx, "dependency-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err := client.SyncStatus(ctx, "dependency-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
|
||||
// Add the dependency after the dependency unit has already started
|
||||
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
|
||||
|
||||
// Dependencies can be added even if the dependency unit has already started
|
||||
require.NoError(t, err)
|
||||
|
||||
// The dependency is now reflected in the test unit's status
|
||||
status, err = client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.ID("dependency-unit"), status.Dependencies[0].DependsOn)
|
||||
require.Equal(t, unit.StatusComplete, status.Dependencies[0].RequiredStatus)
|
||||
})
|
||||
|
||||
t.Run("DependencyAddedAfterDependentStarted", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// Start the dependent unit
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
status, err := client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.StatusStarted, status.Status)
|
||||
|
||||
// Add the dependency after the dependency unit has already started
|
||||
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
|
||||
|
||||
// Dependencies can be added even if the dependent unit has already started.
|
||||
// The dependency applies the next time a unit is started. The current status is not updated.
|
||||
// This is to allow flexible dependency management. It does mean that users of this API should
|
||||
// take care to add dependencies before they start their dependent units.
|
||||
require.NoError(t, err)
|
||||
|
||||
// The dependency is now reflected in the test unit's status
|
||||
status, err = client.SyncStatus(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unit.ID("dependency-unit"), status.Dependencies[0].DependsOn)
|
||||
require.Equal(t, unit.StatusComplete, status.Dependencies[0].RequiredStatus)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("SyncReady", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("UnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
ready, err := client.SyncReady(ctx, "unregistered-unit")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ready)
|
||||
})
|
||||
|
||||
t.Run("UnitNotReady", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// Register a unit with an unsatisfied dependency
|
||||
err = client.SyncWant(ctx, "test-unit", "dependency-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check readiness - should be false because dependency is not satisfied
|
||||
ready, err := client.SyncReady(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.False(t, ready)
|
||||
})
|
||||
|
||||
t.Run("UnitReady", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
socketPath := filepath.Join(tempDirUnixSocket(t), "test.sock")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
server, err := agentsocket.NewServer(
|
||||
slog.Make().Leveled(slog.LevelDebug),
|
||||
agentsocket.WithPath(socketPath),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer server.Close()
|
||||
|
||||
client := newSocketClient(ctx, t, socketPath)
|
||||
|
||||
// Register a unit with no dependencies - should be ready immediately
|
||||
err = client.SyncStart(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check readiness - should be true
|
||||
ready, err := client.SyncReady(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ready)
|
||||
|
||||
// Also test a unit with satisfied dependencies
|
||||
err = client.SyncWant(ctx, "dependent-unit", "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Complete the dependency
|
||||
err = client.SyncComplete(ctx, "test-unit")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now dependent-unit should be ready
|
||||
ready, err = client.SyncReady(ctx, "dependent-unit")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ready)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package agentsocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const defaultSocketPath = "/tmp/coder-agent.sock"
|
||||
|
||||
func createSocket(path string) (net.Listener, error) {
|
||||
if path == "" {
|
||||
path = defaultSocketPath
|
||||
}
|
||||
|
||||
if !isSocketAvailable(path) {
|
||||
return nil, xerrors.Errorf("socket path %s is not available", path)
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return nil, xerrors.Errorf("remove existing socket: %w", err)
|
||||
}
|
||||
|
||||
parentDir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(parentDir, 0o700); err != nil {
|
||||
return nil, xerrors.Errorf("create socket directory: %w", err)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen on unix socket: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
_ = listener.Close()
|
||||
return nil, xerrors.Errorf("set socket permissions: %w", err)
|
||||
}
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
func cleanupSocket(path string) error {
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func isSocketAvailable(path string) bool {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Try to connect to see if it's actually listening.
|
||||
dialer := net.Dialer{Timeout: 10 * time.Second}
|
||||
conn, err := dialer.Dial("unix", path)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
_ = conn.Close()
|
||||
return false
|
||||
}
|
||||
|
||||
func dialSocket(ctx context.Context, path string) (net.Conn, error) {
|
||||
if path == "" {
|
||||
path = defaultSocketPath
|
||||
}
|
||||
|
||||
dialer := net.Dialer{}
|
||||
return dialer.DialContext(ctx, "unix", path)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package agentsocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func createSocket(_ string) (net.Listener, error) {
|
||||
return nil, xerrors.New("agentsocket is not supported on Windows")
|
||||
}
|
||||
|
||||
func cleanupSocket(_ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func dialSocket(_ context.Context, _ string) (net.Conn, error) {
|
||||
return nil, xerrors.New("agentsocket is not supported on Windows")
|
||||
}
|
||||
@@ -391,19 +391,10 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
env := session.Environ()
|
||||
magicType, magicTypeRaw, env := extractMagicSessionType(env)
|
||||
|
||||
// It's not safe to assume RemoteAddr() returns a non-nil value. slog.F usage is fine because it correctly
|
||||
// handles nil.
|
||||
// c.f. https://github.com/coder/internal/issues/1143
|
||||
remoteAddr := session.RemoteAddr()
|
||||
remoteAddrString := ""
|
||||
if remoteAddr != nil {
|
||||
remoteAddrString = remoteAddr.String()
|
||||
}
|
||||
|
||||
if !s.trackSession(session, true) {
|
||||
reason := "unable to accept new session, server is closing"
|
||||
// Report connection attempt even if we couldn't accept it.
|
||||
disconnected := s.config.ReportConnection(id, magicType, remoteAddrString)
|
||||
disconnected := s.config.ReportConnection(id, magicType, session.RemoteAddr().String())
|
||||
defer disconnected(1, reason)
|
||||
|
||||
logger.Info(ctx, reason)
|
||||
@@ -438,7 +429,7 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
scr := &sessionCloseTracker{Session: session}
|
||||
session = scr
|
||||
|
||||
disconnected := s.config.ReportConnection(id, magicType, remoteAddrString)
|
||||
disconnected := s.config.ReportConnection(id, magicType, session.RemoteAddr().String())
|
||||
defer func() {
|
||||
disconnected(scr.exitCode(), reason)
|
||||
}()
|
||||
|
||||
@@ -176,7 +176,7 @@ func (x *x11Forwarder) listenForConnections(
|
||||
var originPort uint32
|
||||
|
||||
if tcpConn, ok := conn.(*net.TCPConn); ok {
|
||||
if tcpAddr, ok := tcpConn.LocalAddr().(*net.TCPAddr); ok && tcpAddr != nil {
|
||||
if tcpAddr, ok := tcpConn.LocalAddr().(*net.TCPAddr); ok {
|
||||
originAddr = tcpAddr.IP.String()
|
||||
// #nosec G115 - Safe conversion as TCP port numbers are within uint32 range (0-65535)
|
||||
originPort = uint32(tcpAddr.Port)
|
||||
|
||||
+31
-33
@@ -2,31 +2,41 @@ package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw/loggermw"
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/httpmw"
|
||||
)
|
||||
|
||||
func (a *agent) apiHandler() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(
|
||||
httpmw.Recover(a.logger),
|
||||
tracing.StatusWriterMiddleware,
|
||||
loggermw.Logger(a.logger),
|
||||
)
|
||||
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
||||
Message: "Hello from the agent!",
|
||||
})
|
||||
})
|
||||
|
||||
// Make a copy to ensure the map is not modified after the handler is
|
||||
// created.
|
||||
cpy := make(map[int]string)
|
||||
for k, b := range a.ignorePorts {
|
||||
cpy[k] = b
|
||||
}
|
||||
|
||||
cacheDuration := 1 * time.Second
|
||||
if a.portCacheDuration > 0 {
|
||||
cacheDuration = a.portCacheDuration
|
||||
}
|
||||
|
||||
lp := &listeningPortsHandler{
|
||||
ignorePorts: cpy,
|
||||
cacheDuration: cacheDuration,
|
||||
}
|
||||
|
||||
if a.devcontainers {
|
||||
r.Mount("/api/v0/containers", a.containerAPI.Routes())
|
||||
} else if manifest := a.manifest.Load(); manifest != nil && manifest.ParentID != uuid.Nil {
|
||||
@@ -47,7 +57,7 @@ func (a *agent) apiHandler() http.Handler {
|
||||
|
||||
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
|
||||
|
||||
r.Get("/api/v0/listening-ports", a.listeningPortsHandler.handler)
|
||||
r.Get("/api/v0/listening-ports", lp.handler)
|
||||
r.Get("/api/v0/netcheck", a.HandleNetcheck)
|
||||
r.Post("/api/v0/list-directory", a.HandleLS)
|
||||
r.Get("/api/v0/read-file", a.HandleReadFile)
|
||||
@@ -62,21 +72,22 @@ func (a *agent) apiHandler() http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
type ListeningPortsGetter interface {
|
||||
GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error)
|
||||
}
|
||||
|
||||
type listeningPortsHandler struct {
|
||||
// In production code, this is set to an osListeningPortsGetter, but it can be overridden for
|
||||
// testing.
|
||||
getter ListeningPortsGetter
|
||||
ignorePorts map[int]string
|
||||
ignorePorts map[int]string
|
||||
cacheDuration time.Duration
|
||||
|
||||
//nolint: unused // used on some but not all platforms
|
||||
mut sync.Mutex
|
||||
//nolint: unused // used on some but not all platforms
|
||||
ports []codersdk.WorkspaceAgentListeningPort
|
||||
//nolint: unused // used on some but not all platforms
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
// handler returns a list of listening ports. This is tested by coderd's
|
||||
// TestWorkspaceAgentListeningPorts test.
|
||||
func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request) {
|
||||
ports, err := lp.getter.GetListeningPorts()
|
||||
ports, err := lp.getListeningPorts()
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Could not scan for listening ports.",
|
||||
@@ -85,20 +96,7 @@ func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
filteredPorts := make([]codersdk.WorkspaceAgentListeningPort, 0, len(ports))
|
||||
for _, port := range ports {
|
||||
if port.Port < workspacesdk.AgentMinimumListeningPort {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore ports that we've been told to ignore.
|
||||
if _, ok := lp.ignorePorts[int(port.Port)]; ok {
|
||||
continue
|
||||
}
|
||||
filteredPorts = append(filteredPorts, port)
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceAgentListeningPortsResponse{
|
||||
Ports: filteredPorts,
|
||||
Ports: ports,
|
||||
})
|
||||
}
|
||||
|
||||
+1
-2
@@ -63,7 +63,6 @@ func NewAppHealthReporterWithClock(
|
||||
// run a ticker for each app health check.
|
||||
var mu sync.RWMutex
|
||||
failures := make(map[uuid.UUID]int, 0)
|
||||
client := &http.Client{}
|
||||
for _, nextApp := range apps {
|
||||
if !shouldStartTicker(nextApp) {
|
||||
continue
|
||||
@@ -92,7 +91,7 @@ func NewAppHealthReporterWithClock(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+1
-3
@@ -250,9 +250,7 @@ func (a *agent) editFile(ctx context.Context, path string, edits []workspacesdk.
|
||||
transforms[i] = replace.String(edit.Search, edit.Replace)
|
||||
}
|
||||
|
||||
// Create an adjacent file to ensure it will be on the same device and can be
|
||||
// moved atomically.
|
||||
tmpfile, err := afero.TempFile(a.filesystem, filepath.Dir(path), filepath.Base(path))
|
||||
tmpfile, err := afero.TempFile(a.filesystem, "", filepath.Base(path))
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
@@ -3,23 +3,16 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cakturk/go-netstat/netstat"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
type osListeningPortsGetter struct {
|
||||
cacheDuration time.Duration
|
||||
mut sync.Mutex
|
||||
ports []codersdk.WorkspaceAgentListeningPort
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
func (lp *osListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
lp.mut.Lock()
|
||||
defer lp.mut.Unlock()
|
||||
|
||||
@@ -40,7 +33,12 @@ func (lp *osListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgent
|
||||
seen := make(map[uint16]struct{}, len(tabs))
|
||||
ports := []codersdk.WorkspaceAgentListeningPort{}
|
||||
for _, tab := range tabs {
|
||||
if tab.LocalAddr == nil {
|
||||
if tab.LocalAddr == nil || tab.LocalAddr.Port < workspacesdk.AgentMinimumListeningPort {
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore ports that we've been told to ignore.
|
||||
if _, ok := lp.ignorePorts[int(tab.LocalAddr.Port)]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
//go:build linux || (windows && amd64)
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOSListeningPortsGetter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
uut := &osListeningPortsGetter{
|
||||
cacheDuration: 1 * time.Hour,
|
||||
}
|
||||
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
|
||||
ports, err := uut.GetListeningPorts()
|
||||
require.NoError(t, err)
|
||||
found := false
|
||||
for _, port := range ports {
|
||||
// #nosec G115 - Safe conversion as TCP port numbers are within uint16 range (0-65535)
|
||||
if port.Port == uint16(l.Addr().(*net.TCPAddr).Port) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found)
|
||||
|
||||
// check that we cache the ports
|
||||
err = l.Close()
|
||||
require.NoError(t, err)
|
||||
portsNew, err := uut.GetListeningPorts()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ports, portsNew)
|
||||
|
||||
// note that it's unsafe to try to assert that a port does not exist in the response
|
||||
// because the OS may reallocate the port very quickly.
|
||||
}
|
||||
@@ -2,17 +2,9 @@
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"time"
|
||||
import "github.com/coder/coder/v2/codersdk"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
type osListeningPortsGetter struct {
|
||||
cacheDuration time.Duration
|
||||
}
|
||||
|
||||
func (*osListeningPortsGetter) GetListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
func (*listeningPortsHandler) getListeningPorts() ([]codersdk.WorkspaceAgentListeningPort, error) {
|
||||
// Can't scan for ports on non-linux or non-windows_amd64 systems at the
|
||||
// moment. The UI will not show any "no ports found" message to the user, so
|
||||
// the user won't suspect a thing.
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
|
||||
// screenReconnectingPTY provides a reconnectable PTY via `screen`.
|
||||
type screenReconnectingPTY struct {
|
||||
logger slog.Logger
|
||||
execer agentexec.Execer
|
||||
command *pty.Cmd
|
||||
|
||||
@@ -63,7 +62,6 @@ type screenReconnectingPTY struct {
|
||||
// own which causes it to spawn with the specified size.
|
||||
func newScreen(ctx context.Context, logger slog.Logger, execer agentexec.Execer, cmd *pty.Cmd, options *Options) *screenReconnectingPTY {
|
||||
rpty := &screenReconnectingPTY{
|
||||
logger: logger,
|
||||
execer: execer,
|
||||
command: cmd,
|
||||
metrics: options.Metrics,
|
||||
@@ -175,7 +173,6 @@ func (rpty *screenReconnectingPTY) Attach(ctx context.Context, _ string, conn ne
|
||||
|
||||
ptty, process, err := rpty.doAttach(ctx, conn, height, width, logger)
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "unable to attach to screen reconnecting pty", slog.Error(err))
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// Likely the process was too short-lived and canceled the version command.
|
||||
// TODO: Is it worth distinguishing between that and a cancel from the
|
||||
@@ -185,7 +182,6 @@ func (rpty *screenReconnectingPTY) Attach(ctx context.Context, _ string, conn ne
|
||||
}
|
||||
return err
|
||||
}
|
||||
logger.Debug(ctx, "attached to screen reconnecting pty")
|
||||
|
||||
defer func() {
|
||||
// Log only for debugging since the process might have already exited on its
|
||||
@@ -407,7 +403,6 @@ func (rpty *screenReconnectingPTY) Wait() {
|
||||
}
|
||||
|
||||
func (rpty *screenReconnectingPTY) Close(err error) {
|
||||
rpty.logger.Debug(context.Background(), "closing screen reconnecting pty", slog.Error(err))
|
||||
// The closing state change will be handled by the lifecycle.
|
||||
rpty.state.setState(StateClosing, err)
|
||||
}
|
||||
|
||||
@@ -74,21 +74,11 @@ func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr err
|
||||
break
|
||||
}
|
||||
clog := s.logger.With(
|
||||
slog.F("remote", conn.RemoteAddr()),
|
||||
slog.F("local", conn.LocalAddr()))
|
||||
slog.F("remote", conn.RemoteAddr().String()),
|
||||
slog.F("local", conn.LocalAddr().String()))
|
||||
clog.Info(ctx, "accepted conn")
|
||||
|
||||
// It's not safe to assume RemoteAddr() returns a non-nil value. slog.F usage is fine because it correctly
|
||||
// handles nil.
|
||||
// c.f. https://github.com/coder/internal/issues/1143
|
||||
remoteAddr := conn.RemoteAddr()
|
||||
remoteAddrString := ""
|
||||
if remoteAddr != nil {
|
||||
remoteAddrString = remoteAddr.String()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
disconnected := s.reportConnection(uuid.New(), remoteAddrString)
|
||||
disconnected := s.reportConnection(uuid.New(), conn.RemoteAddr().String())
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"gonum.org/v1/gonum/graph/encoding/dot"
|
||||
"gonum.org/v1/gonum/graph/simple"
|
||||
"gonum.org/v1/gonum/graph/topo"
|
||||
)
|
||||
|
||||
// Graph provides a bidirectional interface over gonum's directed graph implementation.
|
||||
// While the underlying gonum graph is directed, we overlay bidirectional semantics
|
||||
// by distinguishing between forward and reverse edges. Wanting and being wanted by
|
||||
// other units are related but different concepts that have different graph traversal
|
||||
// implications when Units update their status.
|
||||
//
|
||||
// The graph stores edge types to represent different relationships between units,
|
||||
// allowing for domain-specific semantics beyond simple connectivity.
|
||||
type Graph[EdgeType, VertexType comparable] struct {
|
||||
mu sync.RWMutex
|
||||
// The underlying gonum graph. It stores vertices and edges without knowing about the types of the vertices and edges.
|
||||
gonumGraph *simple.DirectedGraph
|
||||
// Maps vertices to their IDs so that a gonum vertex ID can be used to lookup the vertex type.
|
||||
vertexToID map[VertexType]int64
|
||||
// Maps vertex IDs to their types so that a vertex type can be used to lookup the gonum vertex ID.
|
||||
idToVertex map[int64]VertexType
|
||||
// The next ID to assign to a vertex.
|
||||
nextID int64
|
||||
// Store edge types by "fromID->toID" key. This is used to lookup the edge type for a given edge.
|
||||
edgeTypes map[string]EdgeType
|
||||
}
|
||||
|
||||
// Edge is a convenience type for representing an edge in the graph.
|
||||
// It encapsulates the from and to vertices and the edge type itself.
|
||||
type Edge[EdgeType, VertexType comparable] struct {
|
||||
From VertexType
|
||||
To VertexType
|
||||
Edge EdgeType
|
||||
}
|
||||
|
||||
// AddEdge adds an edge to the graph. It initializes the graph and metadata on first use,
|
||||
// checks for cycles, and adds the edge to the gonum graph.
|
||||
func (g *Graph[EdgeType, VertexType]) AddEdge(from, to VertexType, edge EdgeType) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.gonumGraph == nil {
|
||||
g.gonumGraph = simple.NewDirectedGraph()
|
||||
g.vertexToID = make(map[VertexType]int64)
|
||||
g.idToVertex = make(map[int64]VertexType)
|
||||
g.edgeTypes = make(map[string]EdgeType)
|
||||
g.nextID = 1
|
||||
}
|
||||
|
||||
fromID := g.getOrCreateVertexID(from)
|
||||
toID := g.getOrCreateVertexID(to)
|
||||
|
||||
if g.canReach(to, from) {
|
||||
return xerrors.Errorf("adding edge (%v -> %v): %w", from, to, ErrCycleDetected)
|
||||
}
|
||||
|
||||
g.gonumGraph.SetEdge(simple.Edge{F: simple.Node(fromID), T: simple.Node(toID)})
|
||||
|
||||
edgeKey := fmt.Sprintf("%d->%d", fromID, toID)
|
||||
g.edgeTypes[edgeKey] = edge
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetForwardAdjacentVertices returns all the edges that originate from the given vertex.
|
||||
func (g *Graph[EdgeType, VertexType]) GetForwardAdjacentVertices(from VertexType) []Edge[EdgeType, VertexType] {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
fromID, exists := g.vertexToID[from]
|
||||
if !exists {
|
||||
return []Edge[EdgeType, VertexType]{}
|
||||
}
|
||||
|
||||
edges := []Edge[EdgeType, VertexType]{}
|
||||
toNodes := g.gonumGraph.From(fromID)
|
||||
for toNodes.Next() {
|
||||
toID := toNodes.Node().ID()
|
||||
to := g.idToVertex[toID]
|
||||
|
||||
// Get the edge type
|
||||
edgeKey := fmt.Sprintf("%d->%d", fromID, toID)
|
||||
edgeType := g.edgeTypes[edgeKey]
|
||||
|
||||
edges = append(edges, Edge[EdgeType, VertexType]{From: from, To: to, Edge: edgeType})
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
// GetReverseAdjacentVertices returns all the edges that terminate at the given vertex.
|
||||
func (g *Graph[EdgeType, VertexType]) GetReverseAdjacentVertices(to VertexType) []Edge[EdgeType, VertexType] {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
toID, exists := g.vertexToID[to]
|
||||
if !exists {
|
||||
return []Edge[EdgeType, VertexType]{}
|
||||
}
|
||||
|
||||
edges := []Edge[EdgeType, VertexType]{}
|
||||
fromNodes := g.gonumGraph.To(toID)
|
||||
for fromNodes.Next() {
|
||||
fromID := fromNodes.Node().ID()
|
||||
from := g.idToVertex[fromID]
|
||||
|
||||
// Get the edge type
|
||||
edgeKey := fmt.Sprintf("%d->%d", fromID, toID)
|
||||
edgeType := g.edgeTypes[edgeKey]
|
||||
|
||||
edges = append(edges, Edge[EdgeType, VertexType]{From: from, To: to, Edge: edgeType})
|
||||
}
|
||||
|
||||
return edges
|
||||
}
|
||||
|
||||
// getOrCreateVertexID returns the ID for a vertex, creating it if it doesn't exist.
|
||||
func (g *Graph[EdgeType, VertexType]) getOrCreateVertexID(vertex VertexType) int64 {
|
||||
if id, exists := g.vertexToID[vertex]; exists {
|
||||
return id
|
||||
}
|
||||
|
||||
id := g.nextID
|
||||
g.nextID++
|
||||
g.vertexToID[vertex] = id
|
||||
g.idToVertex[id] = vertex
|
||||
|
||||
// Add the node to the gonum graph
|
||||
g.gonumGraph.AddNode(simple.Node(id))
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// canReach checks if there is a path from the start vertex to the end vertex.
|
||||
func (g *Graph[EdgeType, VertexType]) canReach(start, end VertexType) bool {
|
||||
if start == end {
|
||||
return true
|
||||
}
|
||||
|
||||
startID, startExists := g.vertexToID[start]
|
||||
endID, endExists := g.vertexToID[end]
|
||||
|
||||
if !startExists || !endExists {
|
||||
return false
|
||||
}
|
||||
|
||||
// Use gonum's built-in path existence check
|
||||
return topo.PathExistsIn(g.gonumGraph, simple.Node(startID), simple.Node(endID))
|
||||
}
|
||||
|
||||
// ToDOT exports the graph to DOT format for visualization
|
||||
func (g *Graph[EdgeType, VertexType]) ToDOT(name string) (string, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
if g.gonumGraph == nil {
|
||||
return "", xerrors.New("graph is not initialized")
|
||||
}
|
||||
|
||||
// Marshal the graph to DOT format
|
||||
dotBytes, err := dot.Marshal(g.gonumGraph, name, "", " ")
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("failed to marshal graph to DOT: %w", err)
|
||||
}
|
||||
|
||||
return string(dotBytes), nil
|
||||
}
|
||||
@@ -1,452 +0,0 @@
|
||||
// Package unit_test provides tests for the unit package.
|
||||
//
|
||||
// DOT Graph Testing:
|
||||
// The graph tests use golden files for DOT representation verification.
|
||||
// To update the golden files:
|
||||
// make gen/golden-files
|
||||
//
|
||||
// The golden files contain the expected DOT representation and can be easily
|
||||
// inspected, version controlled, and updated when the graph structure changes.
|
||||
package unit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
type testGraphEdge string
|
||||
|
||||
const (
|
||||
testEdgeStarted testGraphEdge = "started"
|
||||
testEdgeCompleted testGraphEdge = "completed"
|
||||
)
|
||||
|
||||
type testGraphVertex struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type (
|
||||
testGraph = unit.Graph[testGraphEdge, *testGraphVertex]
|
||||
testEdge = unit.Edge[testGraphEdge, *testGraphVertex]
|
||||
)
|
||||
|
||||
// randInt generates a random integer in the range [0, limit).
|
||||
func randInt(limit int) int {
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
n, err := cryptorand.Int63n(int64(limit))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
|
||||
// UpdateGoldenFiles indicates golden files should be updated.
|
||||
// To update the golden files:
|
||||
// make gen/golden-files
|
||||
var UpdateGoldenFiles = flag.Bool("update", false, "update .golden files")
|
||||
|
||||
// assertDOTGraph requires that the graph's DOT representation matches the golden file
|
||||
func assertDOTGraph(t *testing.T, graph *testGraph, goldenName string) {
|
||||
t.Helper()
|
||||
|
||||
dot, err := graph.ToDOT(goldenName)
|
||||
require.NoError(t, err)
|
||||
|
||||
goldenFile := filepath.Join("testdata", goldenName+".golden")
|
||||
if *UpdateGoldenFiles {
|
||||
t.Logf("update golden file for: %q: %s", goldenName, goldenFile)
|
||||
err := os.MkdirAll(filepath.Dir(goldenFile), 0o755)
|
||||
require.NoError(t, err, "want no error creating golden file directory")
|
||||
err = os.WriteFile(goldenFile, []byte(dot), 0o600)
|
||||
require.NoError(t, err, "update golden file")
|
||||
}
|
||||
|
||||
expected, err := os.ReadFile(goldenFile)
|
||||
require.NoError(t, err, "read golden file, run \"make gen/golden-files\" and commit the changes")
|
||||
|
||||
// Normalize line endings for cross-platform compatibility
|
||||
expected = normalizeLineEndings(expected)
|
||||
normalizedDot := normalizeLineEndings([]byte(dot))
|
||||
|
||||
assert.Empty(t, cmp.Diff(string(expected), string(normalizedDot)), "golden file mismatch (-want +got): %s, run \"make gen/golden-files\", verify and commit the changes", goldenFile)
|
||||
}
|
||||
|
||||
// normalizeLineEndings ensures that all line endings are normalized to \n.
|
||||
// Required for Windows compatibility.
|
||||
func normalizeLineEndings(content []byte) []byte {
|
||||
content = bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n"))
|
||||
content = bytes.ReplaceAll(content, []byte("\r"), []byte("\n"))
|
||||
return content
|
||||
}
|
||||
|
||||
func TestGraph(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testFuncs := map[string]func(t *testing.T) *unit.Graph[testGraphEdge, *testGraphVertex]{
|
||||
"ForwardAndReverseEdges": func(t *testing.T) *unit.Graph[testGraphEdge, *testGraphVertex] {
|
||||
graph := &unit.Graph[testGraphEdge, *testGraphVertex]{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
unit2 := &testGraphVertex{Name: "unit2"}
|
||||
unit3 := &testGraphVertex{Name: "unit3"}
|
||||
err := graph.AddEdge(unit1, unit2, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unit1, unit3, testEdgeStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check for forward edge
|
||||
vertices := graph.GetForwardAdjacentVertices(unit1)
|
||||
require.Len(t, vertices, 2)
|
||||
// Unit 1 depends on the completion of Unit2
|
||||
require.Contains(t, vertices, testEdge{
|
||||
From: unit1,
|
||||
To: unit2,
|
||||
Edge: testEdgeCompleted,
|
||||
})
|
||||
// Unit 1 depends on the start of Unit3
|
||||
require.Contains(t, vertices, testEdge{
|
||||
From: unit1,
|
||||
To: unit3,
|
||||
Edge: testEdgeStarted,
|
||||
})
|
||||
|
||||
// Check for reverse edges
|
||||
unit2ReverseEdges := graph.GetReverseAdjacentVertices(unit2)
|
||||
require.Len(t, unit2ReverseEdges, 1)
|
||||
// Unit 2 must be completed before Unit 1 can start
|
||||
require.Contains(t, unit2ReverseEdges, testEdge{
|
||||
From: unit1,
|
||||
To: unit2,
|
||||
Edge: testEdgeCompleted,
|
||||
})
|
||||
|
||||
unit3ReverseEdges := graph.GetReverseAdjacentVertices(unit3)
|
||||
require.Len(t, unit3ReverseEdges, 1)
|
||||
// Unit 3 must be started before Unit 1 can complete
|
||||
require.Contains(t, unit3ReverseEdges, testEdge{
|
||||
From: unit1,
|
||||
To: unit3,
|
||||
Edge: testEdgeStarted,
|
||||
})
|
||||
|
||||
return graph
|
||||
},
|
||||
"SelfReference": func(t *testing.T) *testGraph {
|
||||
graph := &testGraph{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
err := graph.AddEdge(unit1, unit1, testEdgeCompleted)
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
|
||||
return graph
|
||||
},
|
||||
"Cycle": func(t *testing.T) *testGraph {
|
||||
graph := &testGraph{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
unit2 := &testGraphVertex{Name: "unit2"}
|
||||
err := graph.AddEdge(unit1, unit2, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unit2, unit1, testEdgeStarted)
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
|
||||
return graph
|
||||
},
|
||||
"MultipleDependenciesSameStatus": func(t *testing.T) *testGraph {
|
||||
graph := &testGraph{}
|
||||
unit1 := &testGraphVertex{Name: "unit1"}
|
||||
unit2 := &testGraphVertex{Name: "unit2"}
|
||||
unit3 := &testGraphVertex{Name: "unit3"}
|
||||
unit4 := &testGraphVertex{Name: "unit4"}
|
||||
|
||||
// Unit1 depends on completion of both unit2 and unit3 (same status type)
|
||||
err := graph.AddEdge(unit1, unit2, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unit1, unit3, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unit1 also depends on starting of unit4 (different status type)
|
||||
err = graph.AddEdge(unit1, unit4, testEdgeStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that unit1 has 3 forward dependencies
|
||||
forwardEdges := graph.GetForwardAdjacentVertices(unit1)
|
||||
require.Len(t, forwardEdges, 3)
|
||||
|
||||
// Verify all expected dependencies exist
|
||||
expectedDependencies := []testEdge{
|
||||
{From: unit1, To: unit2, Edge: testEdgeCompleted},
|
||||
{From: unit1, To: unit3, Edge: testEdgeCompleted},
|
||||
{From: unit1, To: unit4, Edge: testEdgeStarted},
|
||||
}
|
||||
|
||||
for _, expected := range expectedDependencies {
|
||||
require.Contains(t, forwardEdges, expected)
|
||||
}
|
||||
|
||||
// Check reverse dependencies
|
||||
unit2ReverseEdges := graph.GetReverseAdjacentVertices(unit2)
|
||||
require.Len(t, unit2ReverseEdges, 1)
|
||||
require.Contains(t, unit2ReverseEdges, testEdge{
|
||||
From: unit1, To: unit2, Edge: testEdgeCompleted,
|
||||
})
|
||||
|
||||
unit3ReverseEdges := graph.GetReverseAdjacentVertices(unit3)
|
||||
require.Len(t, unit3ReverseEdges, 1)
|
||||
require.Contains(t, unit3ReverseEdges, testEdge{
|
||||
From: unit1, To: unit3, Edge: testEdgeCompleted,
|
||||
})
|
||||
|
||||
unit4ReverseEdges := graph.GetReverseAdjacentVertices(unit4)
|
||||
require.Len(t, unit4ReverseEdges, 1)
|
||||
require.Contains(t, unit4ReverseEdges, testEdge{
|
||||
From: unit1, To: unit4, Edge: testEdgeStarted,
|
||||
})
|
||||
|
||||
return graph
|
||||
},
|
||||
}
|
||||
|
||||
for testName, testFunc := range testFuncs {
|
||||
var graph *testGraph
|
||||
t.Run(testName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
graph = testFunc(t)
|
||||
assertDOTGraph(t, graph, testName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphThreadSafety(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ConcurrentReadWrite", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
graph := &testGraph{}
|
||||
var wg sync.WaitGroup
|
||||
const numWriters = 50
|
||||
const numReaders = 100
|
||||
const operationsPerWriter = 1000
|
||||
const operationsPerReader = 2000
|
||||
|
||||
barrier := make(chan struct{})
|
||||
// Launch writers
|
||||
for i := 0; i < numWriters; i++ {
|
||||
wg.Add(1)
|
||||
go func(writerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
for j := 0; j < operationsPerWriter; j++ {
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", writerID, j)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("writer-%d-%d", writerID, j+1)}
|
||||
graph.AddEdge(from, to, testEdgeCompleted)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Launch readers
|
||||
readerResults := make([]struct {
|
||||
panicked bool
|
||||
readCount int
|
||||
}, numReaders)
|
||||
|
||||
for i := 0; i < numReaders; i++ {
|
||||
wg.Add(1)
|
||||
go func(readerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
readerResults[readerID].panicked = true
|
||||
}
|
||||
}()
|
||||
|
||||
readCount := 0
|
||||
for j := 0; j < operationsPerReader; j++ {
|
||||
// Create a test vertex and read
|
||||
testUnit := &testGraphVertex{Name: fmt.Sprintf("test-reader-%d-%d", readerID, j)}
|
||||
forwardEdges := graph.GetForwardAdjacentVertices(testUnit)
|
||||
reverseEdges := graph.GetReverseAdjacentVertices(testUnit)
|
||||
|
||||
// Just verify no panics (results may be nil for non-existent vertices)
|
||||
_ = forwardEdges
|
||||
_ = reverseEdges
|
||||
readCount++
|
||||
}
|
||||
readerResults[readerID].readCount = readCount
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(barrier)
|
||||
wg.Wait()
|
||||
|
||||
// Verify no panics occurred in readers
|
||||
for i, result := range readerResults {
|
||||
require.False(t, result.panicked, "reader %d panicked", i)
|
||||
require.Equal(t, operationsPerReader, result.readCount, "reader %d should have performed expected reads", i)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ConcurrentCycleDetection", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
graph := &testGraph{}
|
||||
|
||||
// Pre-create chain: A→B→C→D
|
||||
unitA := &testGraphVertex{Name: "A"}
|
||||
unitB := &testGraphVertex{Name: "B"}
|
||||
unitC := &testGraphVertex{Name: "C"}
|
||||
unitD := &testGraphVertex{Name: "D"}
|
||||
|
||||
err := graph.AddEdge(unitA, unitB, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unitB, unitC, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
err = graph.AddEdge(unitC, unitD, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
|
||||
barrier := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
const numGoroutines = 50
|
||||
cycleErrors := make([]error, numGoroutines)
|
||||
|
||||
// Launch goroutines trying to add D→A (creates cycle)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
err := graph.AddEdge(unitD, unitA, testEdgeCompleted)
|
||||
cycleErrors[goroutineID] = err
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(barrier)
|
||||
wg.Wait()
|
||||
|
||||
// Verify all attempts correctly returned cycle error
|
||||
for i, err := range cycleErrors {
|
||||
require.Error(t, err, "goroutine %d should have detected cycle", i)
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
}
|
||||
|
||||
// Verify graph remains valid (original chain intact)
|
||||
dot, err := graph.ToDOT("test")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, dot)
|
||||
})
|
||||
|
||||
t.Run("ConcurrentToDOT", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
graph := &testGraph{}
|
||||
|
||||
// Pre-populate graph
|
||||
for i := 0; i < 20; i++ {
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("dot-unit-%d", i)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("dot-unit-%d", i+1)}
|
||||
err := graph.AddEdge(from, to, testEdgeCompleted)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
barrier := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
const numReaders = 100
|
||||
const numWriters = 20
|
||||
dotResults := make([]string, numReaders)
|
||||
|
||||
// Launch readers calling ToDOT
|
||||
dotErrors := make([]error, numReaders)
|
||||
for i := 0; i < numReaders; i++ {
|
||||
wg.Add(1)
|
||||
go func(readerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
dot, err := graph.ToDOT(fmt.Sprintf("test-%d", readerID))
|
||||
dotErrors[readerID] = err
|
||||
if err == nil {
|
||||
dotResults[readerID] = dot
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Launch writers adding edges
|
||||
for i := 0; i < numWriters; i++ {
|
||||
wg.Add(1)
|
||||
go func(writerID int) {
|
||||
defer wg.Done()
|
||||
<-barrier
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("writer-dot-%d", writerID)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("writer-dot-target-%d", writerID)}
|
||||
graph.AddEdge(from, to, testEdgeCompleted)
|
||||
}(i)
|
||||
}
|
||||
|
||||
close(barrier)
|
||||
wg.Wait()
|
||||
|
||||
// Verify no errors occurred during DOT generation
|
||||
for i, err := range dotErrors {
|
||||
require.NoError(t, err, "DOT generation error at index %d", i)
|
||||
}
|
||||
|
||||
// Verify all DOT results are valid
|
||||
for i, dot := range dotResults {
|
||||
require.NotEmpty(t, dot, "DOT result %d should not be empty", i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkGraph_ConcurrentMixedOperations(b *testing.B) {
|
||||
graph := &testGraph{}
|
||||
var wg sync.WaitGroup
|
||||
const numGoroutines = 200
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Launch goroutines performing random operations
|
||||
for j := 0; j < numGoroutines; j++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
operationCount := 0
|
||||
|
||||
for operationCount < 50 {
|
||||
operation := float32(randInt(100)) / 100.0
|
||||
|
||||
if operation < 0.6 { // 60% reads
|
||||
// Read operation
|
||||
testUnit := &testGraphVertex{Name: fmt.Sprintf("bench-read-%d-%d", goroutineID, operationCount)}
|
||||
forwardEdges := graph.GetForwardAdjacentVertices(testUnit)
|
||||
reverseEdges := graph.GetReverseAdjacentVertices(testUnit)
|
||||
|
||||
// Just verify no panics (results may be nil for non-existent vertices)
|
||||
_ = forwardEdges
|
||||
_ = reverseEdges
|
||||
} else { // 40% writes
|
||||
// Write operation
|
||||
from := &testGraphVertex{Name: fmt.Sprintf("bench-write-%d-%d", goroutineID, operationCount)}
|
||||
to := &testGraphVertex{Name: fmt.Sprintf("bench-write-target-%d-%d", goroutineID, operationCount)}
|
||||
graph.AddEdge(from, to, testEdgeCompleted)
|
||||
}
|
||||
|
||||
operationCount++
|
||||
}
|
||||
}(j)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
package unit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnitIDRequired = xerrors.New("unit name is required")
|
||||
ErrUnitNotFound = xerrors.New("unit not found")
|
||||
ErrUnitAlreadyRegistered = xerrors.New("unit already registered")
|
||||
ErrCannotUpdateOtherUnit = xerrors.New("cannot update other unit's status")
|
||||
ErrDependenciesNotSatisfied = xerrors.New("unit dependencies not satisfied")
|
||||
ErrSameStatusAlreadySet = xerrors.New("same status already set")
|
||||
ErrCycleDetected = xerrors.New("cycle detected")
|
||||
ErrFailedToAddDependency = xerrors.New("failed to add dependency")
|
||||
)
|
||||
|
||||
// Status represents the status of a unit.
|
||||
type Status string
|
||||
|
||||
var _ fmt.Stringer = Status("")
|
||||
|
||||
func (s Status) String() string {
|
||||
if s == StatusNotRegistered {
|
||||
return "not registered"
|
||||
}
|
||||
return string(s)
|
||||
}
|
||||
|
||||
// Status constants for dependency tracking.
|
||||
const (
|
||||
StatusNotRegistered Status = ""
|
||||
StatusPending Status = "pending"
|
||||
StatusStarted Status = "started"
|
||||
StatusComplete Status = "completed"
|
||||
)
|
||||
|
||||
// ID provides a type narrowed representation of the unique identifier of a unit.
|
||||
type ID string
|
||||
|
||||
// Unit represents a point-in-time snapshot of a vertex in the dependency graph.
|
||||
// Units may depend on other units, or be depended on by other units. The unit struct
|
||||
// is not aware of updates made to the dependency graph after it is initialized and should
|
||||
// not be cached.
|
||||
type Unit struct {
|
||||
id ID
|
||||
status Status
|
||||
// ready is true if all dependencies are satisfied.
|
||||
// It does not have an accessor method on Unit, because a unit cannot know whether it is ready.
|
||||
// Only the Manager can calculate whether a unit is ready based on knowledge of the dependency graph.
|
||||
// To discourage use of an outdated readiness value, only the Manager should set and return this field.
|
||||
ready bool
|
||||
}
|
||||
|
||||
func (u Unit) ID() ID {
|
||||
return u.id
|
||||
}
|
||||
|
||||
func (u Unit) Status() Status {
|
||||
return u.status
|
||||
}
|
||||
|
||||
// Dependency represents a dependency relationship between units.
|
||||
type Dependency struct {
|
||||
Unit ID
|
||||
DependsOn ID
|
||||
RequiredStatus Status
|
||||
CurrentStatus Status
|
||||
IsSatisfied bool
|
||||
}
|
||||
|
||||
// Manager provides reactive dependency tracking over a Graph.
|
||||
// It manages Unit registration, dependency relationships, and status updates
|
||||
// with automatic recalculation of readiness when dependencies are satisfied.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// The underlying graph that stores dependency relationships
|
||||
graph *Graph[Status, ID]
|
||||
|
||||
// Store vertex instances for each unit to ensure consistent references
|
||||
units map[ID]Unit
|
||||
}
|
||||
|
||||
// NewManager creates a new Manager instance.
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
graph: &Graph[Status, ID]{},
|
||||
units: make(map[ID]Unit),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a unit to the manager if it is not already registered.
|
||||
// If a Unit is already registered (per the ID field), it is not updated.
|
||||
func (m *Manager) Register(id ID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if id == "" {
|
||||
return xerrors.Errorf("registering unit %q: %w", id, ErrUnitIDRequired)
|
||||
}
|
||||
|
||||
if m.registered(id) {
|
||||
return xerrors.Errorf("registering unit %q: %w", id, ErrUnitAlreadyRegistered)
|
||||
}
|
||||
|
||||
m.units[id] = Unit{
|
||||
id: id,
|
||||
status: StatusPending,
|
||||
ready: true,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// registered checks if a unit is registered in the manager.
|
||||
func (m *Manager) registered(id ID) bool {
|
||||
return m.units[id].status != StatusNotRegistered
|
||||
}
|
||||
|
||||
// Unit fetches a unit from the manager. If the unit does not exist,
|
||||
// it returns the Unit zero-value as a placeholder unit, because
|
||||
// units may depend on other units that have not yet been created.
|
||||
func (m *Manager) Unit(id ID) (Unit, error) {
|
||||
if id == "" {
|
||||
return Unit{}, xerrors.Errorf("unit ID cannot be empty: %w", ErrUnitIDRequired)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return m.units[id], nil
|
||||
}
|
||||
|
||||
func (m *Manager) IsReady(id ID) (bool, error) {
|
||||
if id == "" {
|
||||
return false, xerrors.Errorf("unit ID cannot be empty: %w", ErrUnitIDRequired)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if !m.registered(id) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return m.units[id].ready, nil
|
||||
}
|
||||
|
||||
// AddDependency adds a dependency relationship between units.
|
||||
// The unit depends on the dependsOn unit reaching the requiredStatus.
|
||||
func (m *Manager) AddDependency(unit ID, dependsOn ID, requiredStatus Status) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case unit == "":
|
||||
return xerrors.Errorf("dependent name cannot be empty: %w", ErrUnitIDRequired)
|
||||
case dependsOn == "":
|
||||
return xerrors.Errorf("dependency name cannot be empty: %w", ErrUnitIDRequired)
|
||||
case !m.registered(unit):
|
||||
return xerrors.Errorf("dependent unit %q must be registered first: %w", unit, ErrUnitNotFound)
|
||||
}
|
||||
|
||||
// Add the dependency edge to the graph
|
||||
// The edge goes from unit to dependsOn, representing the dependency
|
||||
err := m.graph.AddEdge(unit, dependsOn, requiredStatus)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("adding edge for unit %q: %w", unit, errors.Join(ErrFailedToAddDependency, err))
|
||||
}
|
||||
|
||||
// Recalculate readiness for the unit since it now has a new dependency
|
||||
m.recalculateReadinessUnsafe(unit)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStatus updates a unit's status and recalculates readiness for affected dependents.
|
||||
func (m *Manager) UpdateStatus(unit ID, newStatus Status) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case unit == "":
|
||||
return xerrors.Errorf("updating status for unit %q: %w", unit, ErrUnitIDRequired)
|
||||
case !m.registered(unit):
|
||||
return xerrors.Errorf("unit %q must be registered first: %w", unit, ErrUnitNotFound)
|
||||
}
|
||||
|
||||
u := m.units[unit]
|
||||
if u.status == newStatus {
|
||||
return xerrors.Errorf("checking status for unit %q: %w", unit, ErrSameStatusAlreadySet)
|
||||
}
|
||||
|
||||
u.status = newStatus
|
||||
m.units[unit] = u
|
||||
|
||||
// Get all units that depend on this one (reverse adjacent vertices)
|
||||
dependents := m.graph.GetReverseAdjacentVertices(unit)
|
||||
|
||||
// Recalculate readiness for all dependents
|
||||
for _, dependent := range dependents {
|
||||
m.recalculateReadinessUnsafe(dependent.From)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// recalculateReadinessUnsafe recalculates the readiness state for a unit.
|
||||
// This method assumes the caller holds the write lock.
|
||||
func (m *Manager) recalculateReadinessUnsafe(unit ID) {
|
||||
u := m.units[unit]
|
||||
dependencies := m.graph.GetForwardAdjacentVertices(unit)
|
||||
|
||||
allSatisfied := true
|
||||
for _, dependency := range dependencies {
|
||||
requiredStatus := dependency.Edge
|
||||
dependsOnUnit := m.units[dependency.To]
|
||||
if dependsOnUnit.status != requiredStatus {
|
||||
allSatisfied = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
u.ready = allSatisfied
|
||||
m.units[unit] = u
|
||||
}
|
||||
|
||||
// GetGraph returns the underlying graph for visualization and debugging.
|
||||
// This should be used carefully as it exposes the internal graph structure.
|
||||
func (m *Manager) GetGraph() *Graph[Status, ID] {
|
||||
return m.graph
|
||||
}
|
||||
|
||||
// GetAllDependencies returns all dependencies for a unit, both satisfied and unsatisfied.
|
||||
func (m *Manager) GetAllDependencies(unit ID) ([]Dependency, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if unit == "" {
|
||||
return nil, xerrors.Errorf("unit ID cannot be empty: %w", ErrUnitIDRequired)
|
||||
}
|
||||
|
||||
if !m.registered(unit) {
|
||||
return nil, xerrors.Errorf("checking registration for unit %q: %w", unit, ErrUnitNotFound)
|
||||
}
|
||||
|
||||
dependencies := m.graph.GetForwardAdjacentVertices(unit)
|
||||
|
||||
var allDependencies []Dependency
|
||||
|
||||
for _, dependency := range dependencies {
|
||||
dependsOnUnit := m.units[dependency.To]
|
||||
requiredStatus := dependency.Edge
|
||||
allDependencies = append(allDependencies, Dependency{
|
||||
Unit: unit,
|
||||
DependsOn: dependency.To,
|
||||
RequiredStatus: requiredStatus,
|
||||
CurrentStatus: dependsOnUnit.status,
|
||||
IsSatisfied: dependsOnUnit.status == requiredStatus,
|
||||
})
|
||||
}
|
||||
|
||||
return allDependencies, nil
|
||||
}
|
||||
|
||||
// GetUnmetDependencies returns a list of unsatisfied dependencies for a unit.
|
||||
func (m *Manager) GetUnmetDependencies(unit ID) ([]Dependency, error) {
|
||||
allDependencies, err := m.GetAllDependencies(unit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var unmetDependencies []Dependency = slice.Filter(allDependencies, func(dependency Dependency) bool {
|
||||
return !dependency.IsSatisfied
|
||||
})
|
||||
|
||||
return unmetDependencies, nil
|
||||
}
|
||||
|
||||
// ExportDOT exports the dependency graph to DOT format for visualization.
|
||||
func (m *Manager) ExportDOT(name string) (string, error) {
|
||||
return m.graph.ToDOT(name)
|
||||
}
|
||||
@@ -1,743 +0,0 @@
|
||||
package unit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/unit"
|
||||
)
|
||||
|
||||
const (
|
||||
unitA unit.ID = "serviceA"
|
||||
unitB unit.ID = "serviceB"
|
||||
unitC unit.ID = "serviceC"
|
||||
unitD unit.ID = "serviceD"
|
||||
)
|
||||
|
||||
func TestManager_UnitValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Empty Unit Name", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
err := manager.Register("")
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
err = manager.AddDependency("", unitA, unit.StatusStarted)
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
err = manager.AddDependency(unitA, "", unit.StatusStarted)
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
dependencies, err := manager.GetAllDependencies("")
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
require.Len(t, dependencies, 0)
|
||||
unmetDependencies, err := manager.GetUnmetDependencies("")
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
require.Len(t, unmetDependencies, 0)
|
||||
err = manager.UpdateStatus("", unit.StatusStarted)
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
isReady, err := manager.IsReady("")
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
require.False(t, isReady)
|
||||
u, err := manager.Unit("")
|
||||
require.ErrorIs(t, err, unit.ErrUnitIDRequired)
|
||||
assert.Equal(t, unit.Unit{}, u)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_Register(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("RegisterNewUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: a unit is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: the unit should be ready (no dependencies)
|
||||
u, err := manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unitA, u.ID())
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("RegisterDuplicateUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: a unit is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Newly registered units have StatusPending. We update the unit status to StatusStarted,
|
||||
// so we can later assert that it is not overwritten back to StatusPending by the second
|
||||
// register call
|
||||
manager.UpdateStatus(unitA, unit.StatusStarted)
|
||||
|
||||
// When: the unit is registered again
|
||||
err = manager.Register(unitA)
|
||||
|
||||
// Then: a descriptive error should be returned
|
||||
require.ErrorIs(t, err, unit.ErrUnitAlreadyRegistered)
|
||||
|
||||
// Then: the unit status should not be overwritten
|
||||
u, err := manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusStarted, u.Status())
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("RegisterMultipleUnits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: multiple units are registered
|
||||
unitIDs := []unit.ID{unitA, unitB, unitC}
|
||||
for _, unit := range unitIDs {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Then: all units should be ready initially
|
||||
for _, unitID := range unitIDs {
|
||||
u, err := manager.Unit(unitID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err := manager.IsReady(unitID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_AddDependency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("AddDependencyBetweenRegisteredUnits", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: units A and B are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should not be ready (depends on B)
|
||||
u, err := manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Then: Unit B should still be ready (no dependencies)
|
||||
u, err = manager.Unit(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
|
||||
// When: Unit B is stopped
|
||||
err = manager.UpdateStatus(unitB, unit.StatusPending)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should no longer be ready, because its dependency is not in the desired state.
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("AddDependencyByAnUnregisteredDependentUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given Unit B is registered
|
||||
err := manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given Unit A depends on Unit B being started
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
|
||||
// Then: a descriptive error communicates that the dependency cannot be added
|
||||
// because the dependent unit must be registered first.
|
||||
require.ErrorIs(t, err, unit.ErrUnitNotFound)
|
||||
})
|
||||
|
||||
t.Run("AddDependencyOnAnUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given unit A is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given Unit B is not yet registered
|
||||
// And Unit A depends on Unit B being started
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: The dependency should be visible in Unit A's status
|
||||
dependencies, err := manager.GetAllDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, dependencies, 1)
|
||||
assert.Equal(t, unitB, dependencies[0].DependsOn)
|
||||
assert.Equal(t, unit.StatusStarted, dependencies[0].RequiredStatus)
|
||||
assert.False(t, dependencies[0].IsSatisfied)
|
||||
|
||||
u, err := manager.Unit(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusNotRegistered, u.Status())
|
||||
|
||||
// Then: Unit A should not be ready, because it depends on Unit B
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// When: Unit B is registered
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should still not be ready.
|
||||
// Unit B is not registered, but it has not been started as required by the dependency.
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("AddDependencyCreatesACyclicDependency", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register units
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitC)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitD)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A depends on B
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
// B depends on C
|
||||
err = manager.AddDependency(unitB, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// C depends on D
|
||||
err = manager.AddDependency(unitC, unitD, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to make D depend on A (creates indirect cycle)
|
||||
err = manager.AddDependency(unitD, unitA, unit.StatusStarted)
|
||||
require.ErrorIs(t, err, unit.ErrCycleDetected)
|
||||
})
|
||||
|
||||
t.Run("UpdatingADependency", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given units A and B are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: The dependency is updated to unit.StatusComplete
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should only have one dependency, and it should be unit.StatusComplete
|
||||
dependencies, err := manager.GetAllDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, dependencies, 1)
|
||||
assert.Equal(t, unit.StatusComplete, dependencies[0].RequiredStatus)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_UpdateStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("UpdateStatusTriggersReadinessRecalculation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given units A and B are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should not be ready (depends on B)
|
||||
u, err := manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
u, err = manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("UpdateStatusWithUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given Unit A is not registered
|
||||
// When: Unit A is updated to unit.StatusStarted
|
||||
err := manager.UpdateStatus(unitA, unit.StatusStarted)
|
||||
|
||||
// Then: a descriptive error communicates that the unit must be registered first.
|
||||
require.ErrorIs(t, err, unit.ErrUnitNotFound)
|
||||
})
|
||||
|
||||
t.Run("LinearChainDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given units A, B, and C are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitC)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create chain: A depends on B being "started", B depends on C being "completed"
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitB, unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: only Unit C should be ready (no dependencies)
|
||||
u, err := manager.Unit(unitC)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err := manager.IsReady(unitC)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
|
||||
u, err = manager.Unit(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
u, err = manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// When: Unit C is completed
|
||||
err = manager.UpdateStatus(unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit B should be ready, because its dependency is now in the desired state.
|
||||
u, err = manager.Unit(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
|
||||
u, err = manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
u, err = manager.Unit(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because its dependency is now in the desired state.
|
||||
u, err = manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusPending, u.Status())
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_GetUnmetDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnitWithNoDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: Unit A is registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A has no dependencies
|
||||
// Then: Unit A should have no unmet dependencies
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, unmet)
|
||||
})
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnitWithUnsatisfiedDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, unmet, 1)
|
||||
|
||||
assert.Equal(t, unitA, unmet[0].Unit)
|
||||
assert.Equal(t, unitB, unmet[0].DependsOn)
|
||||
assert.Equal(t, unit.StatusStarted, unmet[0].RequiredStatus)
|
||||
assert.False(t, unmet[0].IsSatisfied)
|
||||
})
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnitWithSatisfiedDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: Unit A and Unit B are registered
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should have no unmet dependencies
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, unmet)
|
||||
})
|
||||
|
||||
t.Run("GetUnmetDependenciesForUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// When: Unit A is requested
|
||||
unmet, err := manager.GetUnmetDependencies(unitA)
|
||||
|
||||
// Then: a descriptive error communicates that the unit must be registered first.
|
||||
require.ErrorIs(t, err, unit.ErrUnitNotFound)
|
||||
assert.Nil(t, unmet)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_MultipleDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("UnitWithMultipleDependencies", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register all units
|
||||
units := []unit.ID{unitA, unitB, unitC, unitD}
|
||||
for _, unit := range units {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// A depends on B being unit.StatusStarted AND C being "started"
|
||||
err := manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A should not be ready (depends on both B and C)
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Update B to unit.StatusStarted - A should still not be ready (needs C too)
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Update C to "started" - A should now be ready
|
||||
err = manager.UpdateStatus(unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("ComplexDependencyChain", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register all units
|
||||
units := []unit.ID{unitA, unitB, unitC, unitD}
|
||||
for _, unit := range units {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create complex dependency graph:
|
||||
// A depends on B being unit.StatusStarted AND C being "started"
|
||||
err := manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
// B depends on D being "completed"
|
||||
err = manager.AddDependency(unitB, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
// C depends on D being "completed"
|
||||
err = manager.AddDependency(unitC, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initially only D is ready
|
||||
isReady, err := manager.IsReady(unitD)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
isReady, err = manager.IsReady(unitC)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Update D to "completed" - B and C should become ready
|
||||
err = manager.UpdateStatus(unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
isReady, err = manager.IsReady(unitB)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
isReady, err = manager.IsReady(unitC)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Update B to unit.StatusStarted - A should still not be ready (needs C)
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// Update C to "started" - A should now be ready
|
||||
err = manager.UpdateStatus(unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
|
||||
t.Run("DifferentStatusTypes", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register units
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitC)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Given: Unit A depends on Unit B being unit.StatusStarted
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
// Given: Unit A depends on Unit C being "completed"
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When: Unit B is started
|
||||
err = manager.UpdateStatus(unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should not be ready, because only one of its dependencies is in the desired state.
|
||||
// It still requires Unit C to be completed.
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, isReady)
|
||||
|
||||
// When: Unit C is completed
|
||||
err = manager.UpdateStatus(unitC, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then: Unit A should be ready, because both of its dependencies are in the desired state.
|
||||
isReady, err = manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_IsReady(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("IsReadyWithUnregisteredUnit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Given: a unit is not registered
|
||||
u, err := manager.Unit(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, unit.StatusNotRegistered, u.Status())
|
||||
// Then: the unit is not ready
|
||||
isReady, err := manager.IsReady(unitA)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, isReady)
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager_ToDOT(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("ExportSimpleGraph", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register units
|
||||
err := manager.Register(unitA)
|
||||
require.NoError(t, err)
|
||||
err = manager.Register(unitB)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add dependency
|
||||
err = manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
|
||||
dot, err := manager.ExportDOT("test")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, dot)
|
||||
assert.Contains(t, dot, "digraph")
|
||||
})
|
||||
|
||||
t.Run("ExportComplexGraph", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
manager := unit.NewManager()
|
||||
|
||||
// Register all units
|
||||
units := []unit.ID{unitA, unitB, unitC, unitD}
|
||||
for _, unit := range units {
|
||||
err := manager.Register(unit)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create complex dependency graph
|
||||
// A depends on B and C, B depends on D, C depends on D
|
||||
err := manager.AddDependency(unitA, unitB, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitA, unitC, unit.StatusStarted)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitB, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
err = manager.AddDependency(unitC, unitD, unit.StatusComplete)
|
||||
require.NoError(t, err)
|
||||
|
||||
dot, err := manager.ExportDOT("complex")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, dot)
|
||||
assert.Contains(t, dot, "digraph")
|
||||
})
|
||||
}
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
strict digraph Cycle {
|
||||
// Node definitions.
|
||||
1;
|
||||
2;
|
||||
|
||||
// Edge definitions.
|
||||
1 -> 2;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
strict digraph ForwardAndReverseEdges {
|
||||
// Node definitions.
|
||||
1;
|
||||
2;
|
||||
3;
|
||||
|
||||
// Edge definitions.
|
||||
1 -> 2;
|
||||
1 -> 3;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
strict digraph MultipleDependenciesSameStatus {
|
||||
// Node definitions.
|
||||
1;
|
||||
2;
|
||||
3;
|
||||
4;
|
||||
|
||||
// Edge definitions.
|
||||
1 -> 2;
|
||||
1 -> 3;
|
||||
1 -> 4;
|
||||
}
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
strict digraph SelfReference {
|
||||
// Node definitions.
|
||||
1;
|
||||
}
|
||||
+11
-5
@@ -6,7 +6,10 @@
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!**/pnpm-lock.yaml"],
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/pnpm-lock.yaml"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
},
|
||||
"linter": {
|
||||
@@ -45,14 +48,13 @@
|
||||
"options": {
|
||||
"paths": {
|
||||
"@mui/material": "Use @mui/material/<name> instead. See: https://material-ui.com/guides/minimizing-bundle-size/.",
|
||||
"@mui/icons-material": "Use @mui/icons-material/<name> instead. See: https://material-ui.com/guides/minimizing-bundle-size/.",
|
||||
"@mui/material/Avatar": "Use components/Avatar/Avatar instead.",
|
||||
"@mui/material/Alert": "Use components/Alert/Alert instead.",
|
||||
"@mui/material/Popover": "Use components/Popover/Popover instead.",
|
||||
"@mui/material/Typography": "Use native HTML elements instead. Eg: <span>, <p>, <h1>, etc.",
|
||||
"@mui/material/Box": "Use a <div> instead.",
|
||||
"@mui/material/Button": "Use a components/Button/Button instead.",
|
||||
"@mui/material/styles": "Import from @emotion/react instead.",
|
||||
"@mui/material/Table*": "Import from components/Table/Table instead.",
|
||||
"lodash": "Use lodash/<name> instead."
|
||||
}
|
||||
}
|
||||
@@ -67,7 +69,11 @@
|
||||
"noConsole": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"allow": ["error", "info", "warn"]
|
||||
"allow": [
|
||||
"error",
|
||||
"info",
|
||||
"warn"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -76,5 +82,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json"
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json"
|
||||
}
|
||||
|
||||
+17
-62
@@ -11,7 +11,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -57,8 +56,6 @@ func workspaceAgent() *serpent.Command {
|
||||
devcontainers bool
|
||||
devcontainerProjectDiscovery bool
|
||||
devcontainerDiscoveryAutostart bool
|
||||
socketServerEnabled bool
|
||||
socketPath string
|
||||
)
|
||||
agentAuth := &AgentAuth{}
|
||||
cmd := &serpent.Command{
|
||||
@@ -204,15 +201,18 @@ func workspaceAgent() *serpent.Command {
|
||||
// Enable pprof handler
|
||||
// This prevents the pprof import from being accidentally deleted.
|
||||
_ = pprof.Handler
|
||||
if pprofAddress != "" {
|
||||
pprofSrvClose := ServeHandler(ctx, logger, nil, pprofAddress, "pprof")
|
||||
defer pprofSrvClose()
|
||||
pprofSrvClose := ServeHandler(ctx, logger, nil, pprofAddress, "pprof")
|
||||
defer pprofSrvClose()
|
||||
if port, err := extractPort(pprofAddress); err == nil {
|
||||
ignorePorts[port] = "pprof"
|
||||
}
|
||||
|
||||
if port, err := extractPort(pprofAddress); err == nil {
|
||||
ignorePorts[port] = "pprof"
|
||||
}
|
||||
} else {
|
||||
logger.Debug(ctx, "pprof address is empty, disabling pprof server")
|
||||
if port, err := extractPort(prometheusAddress); err == nil {
|
||||
ignorePorts[port] = "prometheus"
|
||||
}
|
||||
|
||||
if port, err := extractPort(debugAddress); err == nil {
|
||||
ignorePorts[port] = "debug"
|
||||
}
|
||||
|
||||
executablePath, err := os.Executable()
|
||||
@@ -276,28 +276,6 @@ func workspaceAgent() *serpent.Command {
|
||||
for {
|
||||
prometheusRegistry := prometheus.NewRegistry()
|
||||
|
||||
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
|
||||
var serverClose []func()
|
||||
if prometheusAddress != "" {
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promHandler, prometheusAddress, "prometheus")
|
||||
serverClose = append(serverClose, prometheusSrvClose)
|
||||
|
||||
if port, err := extractPort(prometheusAddress); err == nil {
|
||||
ignorePorts[port] = "prometheus"
|
||||
}
|
||||
} else {
|
||||
logger.Debug(ctx, "prometheus address is empty, disabling prometheus server")
|
||||
}
|
||||
|
||||
if debugAddress != "" {
|
||||
// ServerHandle depends on `agnt.HTTPDebug()`, but `agnt`
|
||||
// depends on `ignorePorts`. Keep this if statement in sync
|
||||
// with below.
|
||||
if port, err := extractPort(debugAddress); err == nil {
|
||||
ignorePorts[port] = "debug"
|
||||
}
|
||||
}
|
||||
|
||||
agnt := agent.New(agent.Options{
|
||||
Client: client,
|
||||
Logger: logger,
|
||||
@@ -319,19 +297,12 @@ func workspaceAgent() *serpent.Command {
|
||||
agentcontainers.WithProjectDiscovery(devcontainerProjectDiscovery),
|
||||
agentcontainers.WithDiscoveryAutostart(devcontainerDiscoveryAutostart),
|
||||
},
|
||||
SocketPath: socketPath,
|
||||
SocketServerEnabled: socketServerEnabled,
|
||||
})
|
||||
|
||||
if debugAddress != "" {
|
||||
// ServerHandle depends on `agnt.HTTPDebug()`, but `agnt`
|
||||
// depends on `ignorePorts`. Keep this if statement in sync
|
||||
// with above.
|
||||
debugSrvClose := ServeHandler(ctx, logger, agnt.HTTPDebug(), debugAddress, "debug")
|
||||
serverClose = append(serverClose, debugSrvClose)
|
||||
} else {
|
||||
logger.Debug(ctx, "debug address is empty, disabling debug server")
|
||||
}
|
||||
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promHandler, prometheusAddress, "prometheus")
|
||||
|
||||
debugSrvClose := ServeHandler(ctx, logger, agnt.HTTPDebug(), debugAddress, "debug")
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -343,11 +314,8 @@ func workspaceAgent() *serpent.Command {
|
||||
}
|
||||
|
||||
lastErr = agnt.Close()
|
||||
|
||||
slices.Reverse(serverClose)
|
||||
for _, closeFunc := range serverClose {
|
||||
closeFunc()
|
||||
}
|
||||
debugSrvClose()
|
||||
prometheusSrvClose()
|
||||
|
||||
if mustExit {
|
||||
break
|
||||
@@ -481,19 +449,6 @@ func workspaceAgent() *serpent.Command {
|
||||
Description: "Allow the agent to autostart devcontainer projects it discovers based on their configuration.",
|
||||
Value: serpent.BoolOf(&devcontainerDiscoveryAutostart),
|
||||
},
|
||||
{
|
||||
Flag: "socket-server-enabled",
|
||||
Default: "false",
|
||||
Env: "CODER_AGENT_SOCKET_SERVER_ENABLED",
|
||||
Description: "Enable the agent socket server.",
|
||||
Value: serpent.BoolOf(&socketServerEnabled),
|
||||
},
|
||||
{
|
||||
Flag: "socket-path",
|
||||
Env: "CODER_AGENT_SOCKET_PATH",
|
||||
Description: "Specify the path for the agent socket.",
|
||||
Value: serpent.StringOf(&socketPath),
|
||||
},
|
||||
}
|
||||
agentAuth.AttachOptions(cmd, false)
|
||||
return cmd
|
||||
|
||||
@@ -178,51 +178,6 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
require.Greater(t, atomic.LoadInt64(&called), int64(0), "expected coderd to be reached with custom headers")
|
||||
require.Greater(t, atomic.LoadInt64(&derpCalled), int64(0), "expected /derp to be called with custom headers")
|
||||
})
|
||||
|
||||
t.Run("DisabledServers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
|
||||
logDir := t.TempDir()
|
||||
inv, _ := clitest.New(t,
|
||||
"agent",
|
||||
"--auth", "token",
|
||||
"--agent-token", r.AgentToken,
|
||||
"--agent-url", client.URL.String(),
|
||||
"--log-dir", logDir,
|
||||
"--pprof-address", "",
|
||||
"--prometheus-address", "",
|
||||
"--debug-address", "",
|
||||
)
|
||||
|
||||
clitest.Start(t, inv)
|
||||
|
||||
// Verify the agent is connected and working.
|
||||
resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).
|
||||
MatchResources(matchAgentWithVersion).Wait()
|
||||
require.Len(t, resources, 1)
|
||||
require.Len(t, resources[0].Agents, 1)
|
||||
require.NotEmpty(t, resources[0].Agents[0].Version)
|
||||
|
||||
// Verify the servers are not listening by checking the log for disabled
|
||||
// messages.
|
||||
require.Eventually(t, func() bool {
|
||||
logContent, err := os.ReadFile(filepath.Join(logDir, "coder-agent.log"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
logStr := string(logContent)
|
||||
return strings.Contains(logStr, "pprof address is empty, disabling pprof server") &&
|
||||
strings.Contains(logStr, "prometheus address is empty, disabling prometheus server") &&
|
||||
strings.Contains(logStr, "debug address is empty, disabling debug server")
|
||||
}, testutil.WaitLong, testutil.IntervalMedium)
|
||||
})
|
||||
}
|
||||
|
||||
func matchAgentWithVersion(rs []codersdk.WorkspaceResource) bool {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
var (
|
||||
_ pflag.SliceValue = &AllowListFlag{}
|
||||
_ pflag.Value = &AllowListFlag{}
|
||||
)
|
||||
|
||||
// AllowListFlag implements pflag.SliceValue for codersdk.APIAllowListTarget entries.
|
||||
type AllowListFlag []codersdk.APIAllowListTarget
|
||||
|
||||
func AllowListFlagOf(al *[]codersdk.APIAllowListTarget) *AllowListFlag {
|
||||
return (*AllowListFlag)(al)
|
||||
}
|
||||
|
||||
func (a AllowListFlag) String() string {
|
||||
return strings.Join(a.GetSlice(), ",")
|
||||
}
|
||||
|
||||
func (a AllowListFlag) Value() []codersdk.APIAllowListTarget {
|
||||
return []codersdk.APIAllowListTarget(a)
|
||||
}
|
||||
|
||||
func (AllowListFlag) Type() string { return "allow-list" }
|
||||
|
||||
func (a *AllowListFlag) Set(set string) error {
|
||||
values, err := csv.NewReader(strings.NewReader(set)).Read()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse allow list entries as csv: %w", err)
|
||||
}
|
||||
for _, v := range values {
|
||||
if err := a.Append(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AllowListFlag) Append(value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return xerrors.New("allow list entry cannot be empty")
|
||||
}
|
||||
var target codersdk.APIAllowListTarget
|
||||
if err := target.UnmarshalText([]byte(value)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*a = append(*a, target)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AllowListFlag) Replace(items []string) error {
|
||||
*a = []codersdk.APIAllowListTarget{}
|
||||
for _, item := range items {
|
||||
if err := a.Append(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AllowListFlag) GetSlice() []string {
|
||||
out := make([]string, len(*a))
|
||||
for i, entry := range *a {
|
||||
out[i] = entry.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
+4
-20
@@ -28,9 +28,7 @@ import (
|
||||
)
|
||||
|
||||
// New creates a CLI instance with a configuration pointed to a
|
||||
// temporary testing directory. The invocation is set up to use a
|
||||
// global config directory for the given testing.TB, and keyring
|
||||
// usage disabled.
|
||||
// temporary testing directory.
|
||||
func New(t testing.TB, args ...string) (*serpent.Invocation, config.Root) {
|
||||
var root cli.RootCmd
|
||||
|
||||
@@ -61,15 +59,6 @@ func NewWithCommand(
|
||||
t testing.TB, cmd *serpent.Command, args ...string,
|
||||
) (*serpent.Invocation, config.Root) {
|
||||
configDir := config.Root(t.TempDir())
|
||||
// Keyring usage is disabled here when --global-config is set because many existing
|
||||
// tests expect the session token to be stored on disk and is not properly instrumented
|
||||
// for parallel testing against the actual operating system keyring.
|
||||
invArgs := append([]string{"--global-config", string(configDir)}, args...)
|
||||
return setupInvocation(t, cmd, invArgs...), configDir
|
||||
}
|
||||
|
||||
func setupInvocation(t testing.TB, cmd *serpent.Command, args ...string,
|
||||
) *serpent.Invocation {
|
||||
// I really would like to fail test on error logs, but realistically, turning on by default
|
||||
// in all our CLI tests is going to create a lot of flaky noise.
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
|
||||
@@ -77,21 +66,16 @@ func setupInvocation(t testing.TB, cmd *serpent.Command, args ...string,
|
||||
Named("cli")
|
||||
i := &serpent.Invocation{
|
||||
Command: cmd,
|
||||
Args: args,
|
||||
Args: append([]string{"--global-config", string(configDir)}, args...),
|
||||
Stdin: io.LimitReader(nil, 0),
|
||||
Stdout: (&logWriter{prefix: "stdout", log: logger}),
|
||||
Stderr: (&logWriter{prefix: "stderr", log: logger}),
|
||||
Logger: logger,
|
||||
}
|
||||
t.Logf("invoking command: %s %s", cmd.Name(), strings.Join(i.Args, " "))
|
||||
return i
|
||||
}
|
||||
|
||||
func NewWithDefaultKeyringCommand(t testing.TB, cmd *serpent.Command, args ...string,
|
||||
) (*serpent.Invocation, config.Root) {
|
||||
configDir := config.Root(t.TempDir())
|
||||
invArgs := append([]string{"--global-config", string(configDir)}, args...)
|
||||
return setupInvocation(t, cmd, invArgs...), configDir
|
||||
// These can be overridden by the test.
|
||||
return i, configDir
|
||||
}
|
||||
|
||||
// SetupConfig applies the URL and SessionToken of the client to the config.
|
||||
|
||||
+1
-26
@@ -53,9 +53,6 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
t := time.NewTimer(0)
|
||||
defer t.Stop()
|
||||
|
||||
startTime := time.Now()
|
||||
baseInterval := opts.FetchInterval
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -71,11 +68,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
return
|
||||
}
|
||||
fetchedAgent <- fetchAgent{agent: agent}
|
||||
|
||||
// Adjust the interval based on how long we've been waiting.
|
||||
elapsed := time.Since(startTime)
|
||||
currentInterval := GetProgressiveInterval(baseInterval, elapsed)
|
||||
t.Reset(currentInterval)
|
||||
t.Reset(opts.FetchInterval)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -300,24 +293,6 @@ func safeDuration(sw *stageWriter, a, b *time.Time) time.Duration {
|
||||
return a.Sub(*b)
|
||||
}
|
||||
|
||||
// GetProgressiveInterval returns an interval that increases over time.
|
||||
// The interval starts at baseInterval and increases to
|
||||
// a maximum of baseInterval * 16 over time.
|
||||
func GetProgressiveInterval(baseInterval time.Duration, elapsed time.Duration) time.Duration {
|
||||
switch {
|
||||
case elapsed < 60*time.Second:
|
||||
return baseInterval // 500ms for first 60 seconds
|
||||
case elapsed < 2*time.Minute:
|
||||
return baseInterval * 2 // 1s for next 1 minute
|
||||
case elapsed < 5*time.Minute:
|
||||
return baseInterval * 4 // 2s for next 3 minutes
|
||||
case elapsed < 10*time.Minute:
|
||||
return baseInterval * 8 // 4s for next 5 minutes
|
||||
default:
|
||||
return baseInterval * 16 // 8s after 10 minutes
|
||||
}
|
||||
}
|
||||
|
||||
type closeFunc func() error
|
||||
|
||||
func (c closeFunc) Close() error {
|
||||
|
||||
@@ -866,31 +866,3 @@ func TestConnDiagnostics(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProgressiveInterval(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseInterval := 500 * time.Millisecond
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
elapsed time.Duration
|
||||
expected time.Duration
|
||||
}{
|
||||
{"first_minute", 30 * time.Second, baseInterval},
|
||||
{"second_minute", 90 * time.Second, baseInterval * 2},
|
||||
{"third_to_fifth_minute", 3 * time.Minute, baseInterval * 4},
|
||||
{"sixth_to_tenth_minute", 7 * time.Minute, baseInterval * 8},
|
||||
{"after_ten_minutes", 15 * time.Minute, baseInterval * 16},
|
||||
{"boundary_first_minute", 59 * time.Second, baseInterval},
|
||||
{"boundary_second_minute", 61 * time.Second, baseInterval * 2},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := cliui.GetProgressiveInterval(baseInterval, tc.elapsed)
|
||||
require.Equal(t, tc.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +106,6 @@ var _ OutputFormat = &tableFormat{}
|
||||
//
|
||||
// defaultColumns is optional and specifies the default columns to display. If
|
||||
// not specified, all columns are displayed by default.
|
||||
//
|
||||
// If the data is empty, an empty string is returned. Callers should check for
|
||||
// this and provide an appropriate message to the user.
|
||||
func TableFormat(out any, defaultColumns []string) OutputFormat {
|
||||
v := reflect.Indirect(reflect.ValueOf(out))
|
||||
if v.Kind() != reflect.Slice {
|
||||
|
||||
+9
-24
@@ -180,12 +180,6 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
func renderTable(out any, sort string, headers table.Row, filterColumns []string) (string, error) {
|
||||
v := reflect.Indirect(reflect.ValueOf(out))
|
||||
|
||||
// Return empty string for empty data. Callers should check for this
|
||||
// and provide an appropriate message to the user.
|
||||
if v.Kind() == reflect.Slice && v.Len() == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
headers = filterHeaders(headers, filterColumns)
|
||||
columnConfigs := createColumnConfigs(headers, filterColumns)
|
||||
|
||||
@@ -302,23 +296,22 @@ func renderTable(out any, sort string, headers table.Row, filterColumns []string
|
||||
// returned. If the table tag is malformed, an error is returned.
|
||||
//
|
||||
// The returned name is transformed from "snake_case" to "normal text".
|
||||
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName, emptyNil bool, err error) {
|
||||
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName bool, err error) {
|
||||
tags, err := structtag.Parse(string(field.Tag))
|
||||
if err != nil {
|
||||
return "", false, false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
return "", false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
}
|
||||
|
||||
tag, err := tags.Get("table")
|
||||
if err != nil || tag.Name == "-" {
|
||||
// tags.Get only returns an error if the tag is not found.
|
||||
return "", false, false, false, false, false, nil
|
||||
return "", false, false, false, false, nil
|
||||
}
|
||||
|
||||
defaultSortOpt := false
|
||||
noSortOpt = false
|
||||
recursiveOpt := false
|
||||
skipParentNameOpt := false
|
||||
emptyNilOpt := false
|
||||
for _, opt := range tag.Options {
|
||||
switch opt {
|
||||
case "default_sort":
|
||||
@@ -333,14 +326,12 @@ func parseTableStructTag(field reflect.StructField) (name string, defaultSort, n
|
||||
// make sure the child name is unique across all nested structs in the parent.
|
||||
recursiveOpt = true
|
||||
skipParentNameOpt = true
|
||||
case "empty_nil":
|
||||
emptyNilOpt = true
|
||||
default:
|
||||
return "", false, false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
return "", false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, emptyNilOpt, nil
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, nil
|
||||
}
|
||||
|
||||
func isStructOrStructPointer(t reflect.Type) bool {
|
||||
@@ -367,7 +358,7 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
|
||||
noSortOpt := false
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
name, defaultSort, noSort, recursive, skip, _, err := parseTableStructTag(field)
|
||||
name, defaultSort, noSort, recursive, skip, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
|
||||
}
|
||||
@@ -444,7 +435,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
fieldVal := val.Field(i)
|
||||
name, _, _, recursive, skip, emptyNil, err := parseTableStructTag(field)
|
||||
name, _, _, recursive, skip, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
|
||||
}
|
||||
@@ -452,14 +443,8 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldType := field.Type
|
||||
|
||||
// If empty_nil is set and this is a nil pointer, use a zero value.
|
||||
if emptyNil && fieldVal.Kind() == reflect.Pointer && fieldVal.IsNil() {
|
||||
fieldVal = reflect.New(fieldType.Elem())
|
||||
}
|
||||
|
||||
// Recurse if it's a struct.
|
||||
fieldType := field.Type
|
||||
if recursive {
|
||||
if !isStructOrStructPointer(fieldType) {
|
||||
return nil, xerrors.Errorf("field %q in type %q is marked as recursive but does not contain a struct or a pointer to a struct", field.Name, fieldType.String())
|
||||
@@ -482,7 +467,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
}
|
||||
|
||||
// Otherwise, we just use the field value.
|
||||
row[name] = fieldVal.Interface()
|
||||
row[name] = val.Field(i).Interface()
|
||||
}
|
||||
|
||||
return row, nil
|
||||
|
||||
@@ -400,87 +400,6 @@ foo <nil> 10 [a, b, c] foo1 11 foo2 12 fo
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("EmptyNil", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type emptyNilTest struct {
|
||||
Name string `table:"name,default_sort"`
|
||||
EmptyOnNil *string `table:"empty_on_nil,empty_nil"`
|
||||
NormalBehavior *string `table:"normal_behavior"`
|
||||
}
|
||||
|
||||
value := "value"
|
||||
in := []emptyNilTest{
|
||||
{
|
||||
Name: "has_value",
|
||||
EmptyOnNil: &value,
|
||||
NormalBehavior: &value,
|
||||
},
|
||||
{
|
||||
Name: "has_nil",
|
||||
EmptyOnNil: nil,
|
||||
NormalBehavior: nil,
|
||||
},
|
||||
}
|
||||
|
||||
expected := `
|
||||
NAME EMPTY ON NIL NORMAL BEHAVIOR
|
||||
has_nil <nil>
|
||||
has_value value value
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "", nil)
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
t.Run("EmptyNilWithRecursiveInline", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type nestedData struct {
|
||||
Name string `table:"name"`
|
||||
}
|
||||
|
||||
type inlineTest struct {
|
||||
Nested *nestedData `table:"ignored,recursive_inline,empty_nil"`
|
||||
Count int `table:"count,default_sort"`
|
||||
}
|
||||
|
||||
in := []inlineTest{
|
||||
{
|
||||
Nested: &nestedData{
|
||||
Name: "alice",
|
||||
},
|
||||
Count: 1,
|
||||
},
|
||||
{
|
||||
Nested: nil,
|
||||
Count: 2,
|
||||
},
|
||||
}
|
||||
|
||||
expected := `
|
||||
NAME COUNT
|
||||
alice 1
|
||||
2
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "", nil)
|
||||
log.Println("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var in []tableTest4
|
||||
out, err := cliui.DisplayTable(in, "", nil)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, out)
|
||||
})
|
||||
}
|
||||
|
||||
// compareTables normalizes the incoming table lines
|
||||
|
||||
+44
-48
@@ -577,57 +577,53 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
|
||||
return nil, xerrors.Errorf("template version git auth: %w", err)
|
||||
}
|
||||
|
||||
// Only perform dry-run for workspace creation and updates
|
||||
// Skip for start and restart to avoid unnecessary delays
|
||||
if args.Action == WorkspaceCreate || args.Action == WorkspaceUpdate {
|
||||
// Run a dry-run with the given parameters to check correctness
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(inv.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
RichParameterValues: buildParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("begin workspace dry-run: %w", err)
|
||||
}
|
||||
// Run a dry-run with the given parameters to check correctness
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(inv.Context(), templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
RichParameterValues: buildParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("begin workspace dry-run: %w", err)
|
||||
}
|
||||
|
||||
matchedProvisioners, err := client.TemplateVersionDryRunMatchedProvisioners(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get matched provisioners: %w", err)
|
||||
}
|
||||
cliutil.WarnMatchedProvisioners(inv.Stdout, &matchedProvisioners, dryRun)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
return client.TemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelTemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return client.TemplateVersionDryRunLogsAfter(inv.Context(), templateVersion.ID, dryRun.ID, 0)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
})
|
||||
if err != nil {
|
||||
// TODO (Dean): reprompt for parameter values if we deem it to
|
||||
// be a validation error
|
||||
return nil, xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
matchedProvisioners, err := client.TemplateVersionDryRunMatchedProvisioners(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get matched provisioners: %w", err)
|
||||
}
|
||||
cliutil.WarnMatchedProvisioners(inv.Stdout, &matchedProvisioners, dryRun)
|
||||
_, _ = fmt.Fprintln(inv.Stdout, "Planning workspace...")
|
||||
err = cliui.ProvisionerJob(inv.Context(), inv.Stdout, cliui.ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
return client.TemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Cancel: func() error {
|
||||
return client.CancelTemplateVersionDryRun(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return client.TemplateVersionDryRunLogsAfter(inv.Context(), templateVersion.ID, dryRun.ID, 0)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
})
|
||||
if err != nil {
|
||||
// TODO (Dean): reprompt for parameter values if we deem it to
|
||||
// be a validation error
|
||||
return nil, xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
|
||||
resources, err := client.TemplateVersionDryRunResources(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace dry-run resources: %w", err)
|
||||
}
|
||||
resources, err := client.TemplateVersionDryRunResources(inv.Context(), templateVersion.ID, dryRun.ID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get workspace dry-run resources: %w", err)
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceResources(inv.Stdout, resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
// Since agents haven't connected yet, hiding this makes more sense.
|
||||
HideAgentState: true,
|
||||
Title: "Workspace Preview",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get resources: %w", err)
|
||||
}
|
||||
err = cliui.WorkspaceResources(inv.Stdout, resources, cliui.WorkspaceResourcesOptions{
|
||||
WorkspaceName: args.NewWorkspaceName,
|
||||
// Since agents haven't connected yet, hiding this makes more sense.
|
||||
HideAgentState: true,
|
||||
Title: "Workspace Preview",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get resources: %w", err)
|
||||
}
|
||||
|
||||
return buildParameters, nil
|
||||
|
||||
@@ -185,6 +185,9 @@ func TestDelete(t *testing.T) {
|
||||
|
||||
t.Run("WarnNoProvisioners", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test requires postgres")
|
||||
}
|
||||
|
||||
store, ps, db := dbtestutil.NewDBWithSQLDB(t)
|
||||
client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{
|
||||
@@ -225,6 +228,9 @@ func TestDelete(t *testing.T) {
|
||||
|
||||
t.Run("Prebuilt workspace delete permissions", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !dbtestutil.WillUsePostgres() {
|
||||
t.Skip("this test requires postgres")
|
||||
}
|
||||
|
||||
// Setup
|
||||
db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
|
||||
|
||||
@@ -90,6 +90,7 @@ func TestExpRpty(t *testing.T) {
|
||||
wantLabel := "coder.devcontainers.TestExpRpty.Container"
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
pool, err := dockertest.NewPool("")
|
||||
require.NoError(t, err, "Could not connect to docker")
|
||||
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
|
||||
@@ -127,15 +128,14 @@ func TestExpRpty(t *testing.T) {
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
cmdDone := tGo(t, func() {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
pty.ExpectMatchContext(ctx, " #")
|
||||
pty.ExpectMatch(" #")
|
||||
pty.WriteLine("hostname")
|
||||
pty.ExpectMatchContext(ctx, ct.Container.Config.Hostname)
|
||||
pty.ExpectMatch(ct.Container.Config.Hostname)
|
||||
pty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
|
||||
+69
-347
@@ -33,7 +33,6 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/scaletest/agentconn"
|
||||
"github.com/coder/coder/v2/scaletest/autostart"
|
||||
"github.com/coder/coder/v2/scaletest/createusers"
|
||||
"github.com/coder/coder/v2/scaletest/createworkspaces"
|
||||
"github.com/coder/coder/v2/scaletest/dashboard"
|
||||
@@ -58,15 +57,9 @@ func (r *RootCmd) scaletestCmd() *serpent.Command {
|
||||
Children: []*serpent.Command{
|
||||
r.scaletestCleanup(),
|
||||
r.scaletestDashboard(),
|
||||
r.scaletestDynamicParameters(),
|
||||
r.scaletestCreateWorkspaces(),
|
||||
r.scaletestWorkspaceUpdates(),
|
||||
r.scaletestWorkspaceTraffic(),
|
||||
r.scaletestAutostart(),
|
||||
r.scaletestNotifications(),
|
||||
r.scaletestTaskStatus(),
|
||||
r.scaletestSMTP(),
|
||||
r.scaletestPrebuilds(),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -386,88 +379,6 @@ func (s *scaletestPrometheusFlags) attach(opts *serpent.OptionSet) {
|
||||
)
|
||||
}
|
||||
|
||||
// workspaceTargetFlags holds common flags for targeting specific workspaces in scale tests.
|
||||
type workspaceTargetFlags struct {
|
||||
template string
|
||||
targetWorkspaces string
|
||||
useHostLogin bool
|
||||
}
|
||||
|
||||
// attach adds the workspace target flags to the given options set.
|
||||
func (f *workspaceTargetFlags) attach(opts *serpent.OptionSet) {
|
||||
*opts = append(*opts,
|
||||
serpent.Option{
|
||||
Flag: "template",
|
||||
FlagShorthand: "t",
|
||||
Env: "CODER_SCALETEST_TEMPLATE",
|
||||
Description: "Name or ID of the template. Traffic generation will be limited to workspaces created from this template.",
|
||||
Value: serpent.StringOf(&f.template),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "target-workspaces",
|
||||
Env: "CODER_SCALETEST_TARGET_WORKSPACES",
|
||||
Description: "Target a specific range of workspaces in the format [START]:[END] (exclusive). Example: 0:10 will target the 10 first alphabetically sorted workspaces (0-9).",
|
||||
Value: serpent.StringOf(&f.targetWorkspaces),
|
||||
},
|
||||
serpent.Option{
|
||||
Flag: "use-host-login",
|
||||
Env: "CODER_SCALETEST_USE_HOST_LOGIN",
|
||||
Default: "false",
|
||||
Description: "Connect as the currently logged in user.",
|
||||
Value: serpent.BoolOf(&f.useHostLogin),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// getTargetedWorkspaces retrieves the workspaces based on the template filter and target range. warnWriter is where to
|
||||
// write a warning message if any workspaces were skipped due to ownership mismatch.
|
||||
func (f *workspaceTargetFlags) getTargetedWorkspaces(ctx context.Context, client *codersdk.Client, organizationIDs []uuid.UUID, warnWriter io.Writer) ([]codersdk.Workspace, error) {
|
||||
// Validate template if provided
|
||||
if f.template != "" {
|
||||
_, err := parseTemplate(ctx, client, organizationIDs, f.template)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse target range
|
||||
targetStart, targetEnd, err := parseTargetRange("workspaces", f.targetWorkspaces)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse target workspaces: %w", err)
|
||||
}
|
||||
|
||||
// Determine owner based on useHostLogin
|
||||
var owner string
|
||||
if f.useHostLogin {
|
||||
owner = codersdk.Me
|
||||
}
|
||||
|
||||
// Get workspaces
|
||||
workspaces, numSkipped, err := getScaletestWorkspaces(ctx, client, owner, f.template)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if numSkipped > 0 {
|
||||
cliui.Warnf(warnWriter, "CODER_DISABLE_OWNER_WORKSPACE_ACCESS is set on the deployment.\n\t%d workspace(s) were skipped due to ownership mismatch.\n\tSet --use-host-login to only target workspaces you own.", numSkipped)
|
||||
}
|
||||
|
||||
// Adjust targetEnd if not specified
|
||||
if targetEnd == 0 {
|
||||
targetEnd = len(workspaces)
|
||||
}
|
||||
|
||||
// Validate range
|
||||
if len(workspaces) == 0 {
|
||||
return nil, xerrors.Errorf("no scaletest workspaces exist")
|
||||
}
|
||||
if targetEnd > len(workspaces) {
|
||||
return nil, xerrors.Errorf("target workspace end %d is greater than the number of workspaces %d", targetEnd, len(workspaces))
|
||||
}
|
||||
|
||||
// Return the sliced workspaces
|
||||
return workspaces[targetStart:targetEnd], nil
|
||||
}
|
||||
|
||||
func requireAdmin(ctx context.Context, client *codersdk.Client) (codersdk.User, error) {
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
@@ -1277,10 +1188,12 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
bytesPerTick int64
|
||||
ssh bool
|
||||
disableDirect bool
|
||||
useHostLogin bool
|
||||
app string
|
||||
template string
|
||||
targetWorkspaces string
|
||||
workspaceProxyURL string
|
||||
|
||||
targetFlags = &workspaceTargetFlags{}
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
strategy = &scaletestStrategyFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
@@ -1325,9 +1238,15 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
},
|
||||
}
|
||||
|
||||
workspaces, err := targetFlags.getTargetedWorkspaces(ctx, client, me.OrganizationIDs, inv.Stdout)
|
||||
if template != "" {
|
||||
_, err := parseTemplate(ctx, client, me.OrganizationIDs, template)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse template: %w", err)
|
||||
}
|
||||
}
|
||||
targetWorkspaceStart, targetWorkspaceEnd, err := parseTargetRange("workspaces", targetWorkspaces)
|
||||
if err != nil {
|
||||
return err
|
||||
return xerrors.Errorf("parse target workspaces: %w", err)
|
||||
}
|
||||
|
||||
appHost, err := client.AppHost(ctx)
|
||||
@@ -1335,6 +1254,30 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
return xerrors.Errorf("get app host: %w", err)
|
||||
}
|
||||
|
||||
var owner string
|
||||
if useHostLogin {
|
||||
owner = codersdk.Me
|
||||
}
|
||||
|
||||
workspaces, numSkipped, err := getScaletestWorkspaces(inv.Context(), client, owner, template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if numSkipped > 0 {
|
||||
cliui.Warnf(inv.Stdout, "CODER_DISABLE_OWNER_WORKSPACE_ACCESS is set on the deployment.\n\t%d workspace(s) were skipped due to ownership mismatch.\n\tSet --use-host-login to only target workspaces you own.", numSkipped)
|
||||
}
|
||||
|
||||
if targetWorkspaceEnd == 0 {
|
||||
targetWorkspaceEnd = len(workspaces)
|
||||
}
|
||||
|
||||
if len(workspaces) == 0 {
|
||||
return xerrors.Errorf("no scaletest workspaces exist")
|
||||
}
|
||||
if targetWorkspaceEnd > len(workspaces) {
|
||||
return xerrors.Errorf("target workspace end %d is greater than the number of workspaces %d", targetWorkspaceEnd, len(workspaces))
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
@@ -1359,6 +1302,10 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
|
||||
th := harness.NewTestHarness(strategy.toStrategy(), cleanupStrategy.toStrategy())
|
||||
for idx, ws := range workspaces {
|
||||
if idx < targetWorkspaceStart || idx >= targetWorkspaceEnd {
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
agent codersdk.WorkspaceAgent
|
||||
name = "workspace-traffic"
|
||||
@@ -1463,6 +1410,19 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
}
|
||||
|
||||
cmd.Options = []serpent.Option{
|
||||
{
|
||||
Flag: "template",
|
||||
FlagShorthand: "t",
|
||||
Env: "CODER_SCALETEST_TEMPLATE",
|
||||
Description: "Name or ID of the template. Traffic generation will be limited to workspaces created from this template.",
|
||||
Value: serpent.StringOf(&template),
|
||||
},
|
||||
{
|
||||
Flag: "target-workspaces",
|
||||
Env: "CODER_SCALETEST_TARGET_WORKSPACES",
|
||||
Description: "Target a specific range of workspaces in the format [START]:[END] (exclusive). Example: 0:10 will target the 10 first alphabetically sorted workspaces (0-9).",
|
||||
Value: serpent.StringOf(&targetWorkspaces),
|
||||
},
|
||||
{
|
||||
Flag: "bytes-per-tick",
|
||||
Env: "CODER_SCALETEST_WORKSPACE_TRAFFIC_BYTES_PER_TICK",
|
||||
@@ -1498,6 +1458,13 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
Description: "Send WebSocket traffic to a workspace app (proxied via coderd), cannot be used with --ssh.",
|
||||
Value: serpent.StringOf(&app),
|
||||
},
|
||||
{
|
||||
Flag: "use-host-login",
|
||||
Env: "CODER_SCALETEST_USE_HOST_LOGIN",
|
||||
Default: "false",
|
||||
Description: "Connect as the currently logged in user.",
|
||||
Value: serpent.BoolOf(&useHostLogin),
|
||||
},
|
||||
{
|
||||
Flag: "workspace-proxy-url",
|
||||
Env: "CODER_SCALETEST_WORKSPACE_PROXY_URL",
|
||||
@@ -1507,7 +1474,6 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
},
|
||||
}
|
||||
|
||||
targetFlags.attach(&cmd.Options)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
strategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
@@ -1559,15 +1525,6 @@ func (r *RootCmd) scaletestDashboard() *serpent.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
reg := prometheus.NewRegistry()
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
defer func() {
|
||||
// Allow time for traces to flush even if command context is
|
||||
// canceled. This is a no-op if tracing is not enabled.
|
||||
@@ -1579,7 +1536,14 @@ func (r *RootCmd) scaletestDashboard() *serpent.Command {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
reg := prometheus.NewRegistry()
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
metrics := dashboard.NewMetrics(reg)
|
||||
|
||||
th := harness.NewTestHarness(strategy.toStrategy(), cleanupStrategy.toStrategy())
|
||||
@@ -1718,239 +1682,6 @@ func (r *RootCmd) scaletestDashboard() *serpent.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
const (
|
||||
autostartTestName = "autostart"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestAutostart() *serpent.Command {
|
||||
var (
|
||||
workspaceCount int64
|
||||
workspaceJobTimeout time.Duration
|
||||
autostartDelay time.Duration
|
||||
autostartTimeout time.Duration
|
||||
template string
|
||||
noCleanup bool
|
||||
|
||||
parameterFlags workspaceParameterFlags
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "autostart",
|
||||
Short: "Replicate a thundering herd of autostarting workspaces",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...) // Checked later.
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if workspaceCount <= 0 {
|
||||
return xerrors.Errorf("--workspace-count must be greater than zero")
|
||||
}
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
|
||||
tpl, err := parseTemplate(ctx, client, me.OrganizationIDs, template)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("can't parse given parameter values: %w", err)
|
||||
}
|
||||
|
||||
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
|
||||
Action: WorkspaceCreate,
|
||||
TemplateVersionID: tpl.ActiveVersionID,
|
||||
|
||||
RichParameterFile: parameterFlags.richParameterFile,
|
||||
RichParameters: cliRichParameters,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("prepare build: %w", err)
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := autostart.NewMetrics(reg)
|
||||
|
||||
setupBarrier := new(sync.WaitGroup)
|
||||
setupBarrier.Add(int(workspaceCount))
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
for i := range workspaceCount {
|
||||
id := strconv.Itoa(int(i))
|
||||
config := autostart.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
},
|
||||
Workspace: workspacebuild.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
Request: codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: tpl.ID,
|
||||
RichParameterValues: richParameters,
|
||||
},
|
||||
},
|
||||
WorkspaceJobTimeout: workspaceJobTimeout,
|
||||
AutostartDelay: autostartDelay,
|
||||
AutostartTimeout: autostartTimeout,
|
||||
Metrics: metrics,
|
||||
SetupBarrier: setupBarrier,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
var runner harness.Runnable = autostart.NewRunner(client, config)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("%s/%s", autostartTestName, id),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
th.AddRun(autostartTestName, id, runner)
|
||||
}
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Running autostart load test...")
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip stats.
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !noCleanup {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "workspace-count",
|
||||
FlagShorthand: "c",
|
||||
Env: "CODER_SCALETEST_WORKSPACE_COUNT",
|
||||
Description: "Required: Total number of workspaces to create.",
|
||||
Value: serpent.Int64Of(&workspaceCount),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Flag: "workspace-job-timeout",
|
||||
Env: "CODER_SCALETEST_WORKSPACE_JOB_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "Timeout for workspace jobs (e.g. build, start).",
|
||||
Value: serpent.DurationOf(&workspaceJobTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "autostart-delay",
|
||||
Env: "CODER_SCALETEST_AUTOSTART_DELAY",
|
||||
Default: "2m",
|
||||
Description: "How long after all the workspaces have been stopped to schedule them to be started again.",
|
||||
Value: serpent.DurationOf(&autostartDelay),
|
||||
},
|
||||
{
|
||||
Flag: "autostart-timeout",
|
||||
Env: "CODER_SCALETEST_AUTOSTART_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "Timeout for the autostart build to be initiated after the scheduled start time.",
|
||||
Value: serpent.DurationOf(&autostartTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "template",
|
||||
FlagShorthand: "t",
|
||||
Env: "CODER_SCALETEST_TEMPLATE",
|
||||
Description: "Required: Name or ID of the template to use for workspaces.",
|
||||
Value: serpent.StringOf(&template),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Flag: "no-cleanup",
|
||||
Env: "CODER_SCALETEST_NO_CLEANUP",
|
||||
Description: "Do not clean up resources after the test completes.",
|
||||
Value: serpent.BoolOf(&noCleanup),
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
type runnableTraceWrapper struct {
|
||||
tracer trace.Tracer
|
||||
spanName string
|
||||
@@ -1960,9 +1691,8 @@ type runnableTraceWrapper struct {
|
||||
}
|
||||
|
||||
var (
|
||||
_ harness.Runnable = &runnableTraceWrapper{}
|
||||
_ harness.Cleanable = &runnableTraceWrapper{}
|
||||
_ harness.Collectable = &runnableTraceWrapper{}
|
||||
_ harness.Runnable = &runnableTraceWrapper{}
|
||||
_ harness.Cleanable = &runnableTraceWrapper{}
|
||||
)
|
||||
|
||||
func (r *runnableTraceWrapper) Run(ctx context.Context, id string, logs io.Writer) error {
|
||||
@@ -2004,14 +1734,6 @@ func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string, logs io.W
|
||||
return c.Cleanup(ctx, id, logs)
|
||||
}
|
||||
|
||||
func (r *runnableTraceWrapper) GetMetrics() map[string]any {
|
||||
c, ok := r.runner.(harness.Collectable)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return c.GetMetrics()
|
||||
}
|
||||
|
||||
func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, owner, template string) ([]codersdk.Workspace, int, error) {
|
||||
var (
|
||||
pageNumber = 0
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/serpent"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scaletest/dynamicparameters"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
)
|
||||
|
||||
const (
|
||||
dynamicParametersTestName = "dynamic-parameters"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestDynamicParameters() *serpent.Command {
|
||||
var (
|
||||
templateName string
|
||||
provisionerTags []string
|
||||
numEvals int64
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
// This test requires unlimited concurrency
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
)
|
||||
orgContext := NewOrganizationContext()
|
||||
output := &scaletestOutputFlags{}
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "dynamic-parameters",
|
||||
Short: "Generates load on the Coder server evaluating dynamic parameters",
|
||||
Long: `It is recommended that all rate limits are disabled on the server before running this scaletest. This test generates many login events which will be rate limited against the (most likely single) IP.`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if templateName == "" {
|
||||
return xerrors.Errorf("template cannot be empty")
|
||||
}
|
||||
|
||||
tags, err := ParseProvisionerTags(provisionerTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := dynamicparameters.NewMetrics(reg, "concurrent_evaluations")
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Allow time for traces to flush even if command context is
|
||||
// canceled. This is a no-op if tracing is not enabled.
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
partitions, err := dynamicparameters.SetupPartitions(ctx, client, org.ID, templateName, tags, numEvals, logger)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("setup dynamic parameters partitions: %w", err)
|
||||
}
|
||||
|
||||
th := harness.NewTestHarness(
|
||||
timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}),
|
||||
// there is no cleanup since it's just a connection that we sever.
|
||||
nil)
|
||||
|
||||
for i, part := range partitions {
|
||||
for j := range part.ConcurrentEvaluations {
|
||||
cfg := dynamicparameters.Config{
|
||||
TemplateVersion: part.TemplateVersion.ID,
|
||||
Metrics: metrics,
|
||||
MetricLabelValues: []string{fmt.Sprintf("%d", part.ConcurrentEvaluations)},
|
||||
}
|
||||
var runner harness.Runnable = dynamicparameters.NewRunner(client, cfg)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("%s/%d/%d", dynamicParametersTestName, i, j),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
th.AddRun(dynamicParametersTestName, fmt.Sprintf("%d/%d", j, i), runner)
|
||||
}
|
||||
}
|
||||
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness: %w", err)
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "template",
|
||||
Description: "Name of the template to use. If it does not exist, it will be created.",
|
||||
Default: "scaletest-dynamic-parameters",
|
||||
Value: serpent.StringOf(&templateName),
|
||||
},
|
||||
{
|
||||
Flag: "concurrent-evaluations",
|
||||
Description: "Number of concurrent dynamic parameter evaluations to perform.",
|
||||
Default: "100",
|
||||
Value: serpent.Int64Of(&numEvals),
|
||||
},
|
||||
{
|
||||
Flag: "provisioner-tag",
|
||||
Description: "Specify a set of tags to target provisioner daemons.",
|
||||
Value: serpent.StringArrayOf(&provisionerTags),
|
||||
},
|
||||
}
|
||||
orgContext.AttachOptions(cmd)
|
||||
output.attach(&cmd.Options)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"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/notifications"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestNotifications() *serpent.Command {
|
||||
var (
|
||||
userCount int64
|
||||
templateAdminPercentage float64
|
||||
notificationTimeout time.Duration
|
||||
smtpRequestTimeout time.Duration
|
||||
dialTimeout time.Duration
|
||||
noCleanup bool
|
||||
smtpAPIURL string
|
||||
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
|
||||
// This test requires unlimited concurrency.
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "notifications",
|
||||
Short: "Simulate notification delivery by creating many users listening to notifications.",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if userCount <= 0 {
|
||||
return xerrors.Errorf("--user-count must be greater than 0")
|
||||
}
|
||||
|
||||
if templateAdminPercentage < 0 || templateAdminPercentage > 100 {
|
||||
return xerrors.Errorf("--template-admin-percentage must be between 0 and 100")
|
||||
}
|
||||
|
||||
if smtpAPIURL != "" && !strings.HasPrefix(smtpAPIURL, "http://") && !strings.HasPrefix(smtpAPIURL, "https://") {
|
||||
return xerrors.Errorf("--smtp-api-url must start with http:// or https://")
|
||||
}
|
||||
|
||||
templateAdminCount := int64(float64(userCount) * templateAdminPercentage / 100)
|
||||
if templateAdminCount == 0 && templateAdminPercentage > 0 {
|
||||
templateAdminCount = 1
|
||||
}
|
||||
regularUserCount := userCount - templateAdminCount
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Distribution plan:\n")
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Total users: %d\n", userCount)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Template admins: %d (%.1f%%)\n", templateAdminCount, templateAdminPercentage)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, " Regular users: %d (%.1f%%)\n", regularUserCount, 100.0-templateAdminPercentage)
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := notifications.NewMetrics(reg)
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Creating users...")
|
||||
|
||||
dialBarrier := &sync.WaitGroup{}
|
||||
templateAdminWatchBarrier := &sync.WaitGroup{}
|
||||
dialBarrier.Add(int(userCount))
|
||||
templateAdminWatchBarrier.Add(int(templateAdminCount))
|
||||
|
||||
expectedNotificationIDs := map[uuid.UUID]struct{}{
|
||||
notificationsLib.TemplateTemplateDeleted: {},
|
||||
}
|
||||
|
||||
triggerTimes := make(map[uuid.UUID]chan time.Time, len(expectedNotificationIDs))
|
||||
for id := range expectedNotificationIDs {
|
||||
triggerTimes[id] = make(chan time.Time, 1)
|
||||
}
|
||||
|
||||
smtpHTTPTransport := &http.Transport{
|
||||
MaxConnsPerHost: 512,
|
||||
MaxIdleConnsPerHost: 512,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
}
|
||||
smtpHTTPClient := &http.Client{
|
||||
Transport: smtpHTTPTransport,
|
||||
}
|
||||
|
||||
configs := make([]notifications.Config, 0, userCount)
|
||||
for range templateAdminCount {
|
||||
config := notifications.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
},
|
||||
Roles: []string{codersdk.RoleTemplateAdmin},
|
||||
NotificationTimeout: notificationTimeout,
|
||||
DialTimeout: dialTimeout,
|
||||
DialBarrier: dialBarrier,
|
||||
ReceivingWatchBarrier: templateAdminWatchBarrier,
|
||||
ExpectedNotificationsIDs: expectedNotificationIDs,
|
||||
Metrics: metrics,
|
||||
SMTPApiURL: smtpAPIURL,
|
||||
SMTPRequestTimeout: smtpRequestTimeout,
|
||||
SMTPHttpClient: smtpHTTPClient,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
for range regularUserCount {
|
||||
config := notifications.Config{
|
||||
User: createusers.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
},
|
||||
Roles: []string{},
|
||||
NotificationTimeout: notificationTimeout,
|
||||
DialTimeout: dialTimeout,
|
||||
DialBarrier: dialBarrier,
|
||||
ReceivingWatchBarrier: templateAdminWatchBarrier,
|
||||
Metrics: metrics,
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
configs = append(configs, config)
|
||||
}
|
||||
|
||||
go triggerNotifications(
|
||||
ctx,
|
||||
logger,
|
||||
client,
|
||||
me.OrganizationIDs[0],
|
||||
dialBarrier,
|
||||
dialTimeout,
|
||||
triggerTimes,
|
||||
)
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
|
||||
for i, config := range configs {
|
||||
id := strconv.Itoa(i)
|
||||
name := fmt.Sprintf("notifications-%s", id)
|
||||
var runner harness.Runnable = notifications.NewRunner(client, config)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: name,
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
th.AddRun(name, id, runner)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Running notification delivery scaletest...")
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip stats.
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
|
||||
if err := computeNotificationLatencies(ctx, logger, triggerTimes, res, metrics); err != nil {
|
||||
return xerrors.Errorf("compute notification latencies: %w", err)
|
||||
}
|
||||
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !noCleanup {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nCleaning up...")
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "user-count",
|
||||
FlagShorthand: "c",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_USER_COUNT",
|
||||
Description: "Required: Total number of users to create.",
|
||||
Value: serpent.Int64Of(&userCount),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Flag: "template-admin-percentage",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_TEMPLATE_ADMIN_PERCENTAGE",
|
||||
Default: "20.0",
|
||||
Description: "Percentage of users to assign Template Admin role to (0-100).",
|
||||
Value: serpent.Float64Of(&templateAdminPercentage),
|
||||
},
|
||||
{
|
||||
Flag: "notification-timeout",
|
||||
Env: "CODER_SCALETEST_NOTIFICATION_TIMEOUT",
|
||||
Default: "10m",
|
||||
Description: "How long to wait for notifications after triggering.",
|
||||
Value: serpent.DurationOf(¬ificationTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "smtp-request-timeout",
|
||||
Env: "CODER_SCALETEST_SMTP_REQUEST_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "Timeout for SMTP requests.",
|
||||
Value: serpent.DurationOf(&smtpRequestTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "dial-timeout",
|
||||
Env: "CODER_SCALETEST_DIAL_TIMEOUT",
|
||||
Default: "10m",
|
||||
Description: "Timeout for dialing the notification websocket endpoint.",
|
||||
Value: serpent.DurationOf(&dialTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "no-cleanup",
|
||||
Env: "CODER_SCALETEST_NO_CLEANUP",
|
||||
Description: "Do not clean up resources after the test completes.",
|
||||
Value: serpent.BoolOf(&noCleanup),
|
||||
},
|
||||
{
|
||||
Flag: "smtp-api-url",
|
||||
Env: "CODER_SCALETEST_SMTP_API_URL",
|
||||
Description: "SMTP mock HTTP API address.",
|
||||
Value: serpent.StringOf(&smtpAPIURL),
|
||||
},
|
||||
}
|
||||
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func computeNotificationLatencies(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
expectedNotifications map[uuid.UUID]chan time.Time,
|
||||
results harness.Results,
|
||||
metrics *notifications.Metrics,
|
||||
) error {
|
||||
triggerTimes := make(map[uuid.UUID]time.Time)
|
||||
for notificationID, triggerTimeChan := range expectedNotifications {
|
||||
select {
|
||||
case triggerTime := <-triggerTimeChan:
|
||||
triggerTimes[notificationID] = triggerTime
|
||||
logger.Info(ctx, "received trigger time",
|
||||
slog.F("notification_id", notificationID),
|
||||
slog.F("trigger_time", triggerTime))
|
||||
default:
|
||||
logger.Warn(ctx, "no trigger time received for notification",
|
||||
slog.F("notification_id", notificationID))
|
||||
}
|
||||
}
|
||||
|
||||
if len(triggerTimes) == 0 {
|
||||
logger.Warn(ctx, "no trigger times available, skipping latency computation")
|
||||
return nil
|
||||
}
|
||||
|
||||
var totalLatencies int
|
||||
for runID, runResult := range results.Runs {
|
||||
if runResult.Error != nil {
|
||||
logger.Debug(ctx, "skipping failed run for latency computation",
|
||||
slog.F("run_id", runID))
|
||||
continue
|
||||
}
|
||||
|
||||
if runResult.Metrics == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process websocket notifications.
|
||||
if wsReceiptTimes, ok := runResult.Metrics[notifications.WebsocketNotificationReceiptTimeMetric].(map[uuid.UUID]time.Time); ok {
|
||||
for notificationID, receiptTime := range wsReceiptTimes {
|
||||
if triggerTime, ok := triggerTimes[notificationID]; ok {
|
||||
latency := receiptTime.Sub(triggerTime)
|
||||
metrics.RecordLatency(latency, notificationID.String(), notifications.NotificationTypeWebsocket)
|
||||
totalLatencies++
|
||||
logger.Debug(ctx, "computed websocket latency",
|
||||
slog.F("run_id", runID),
|
||||
slog.F("notification_id", notificationID),
|
||||
slog.F("latency", latency))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process SMTP notifications
|
||||
if smtpReceiptTimes, ok := runResult.Metrics[notifications.SMTPNotificationReceiptTimeMetric].(map[uuid.UUID]time.Time); ok {
|
||||
for notificationID, receiptTime := range smtpReceiptTimes {
|
||||
if triggerTime, ok := triggerTimes[notificationID]; ok {
|
||||
latency := receiptTime.Sub(triggerTime)
|
||||
metrics.RecordLatency(latency, notificationID.String(), notifications.NotificationTypeSMTP)
|
||||
totalLatencies++
|
||||
logger.Debug(ctx, "computed SMTP latency",
|
||||
slog.F("run_id", runID),
|
||||
slog.F("notification_id", notificationID),
|
||||
slog.F("latency", latency))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info(ctx, "finished computing notification latencies",
|
||||
slog.F("total_runs", results.TotalRuns),
|
||||
slog.F("total_latencies_computed", totalLatencies))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// triggerNotifications waits for all test users to connect,
|
||||
// then creates and deletes a test template to trigger notification events for testing.
|
||||
func triggerNotifications(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
client *codersdk.Client,
|
||||
orgID uuid.UUID,
|
||||
dialBarrier *sync.WaitGroup,
|
||||
dialTimeout time.Duration,
|
||||
expectedNotifications map[uuid.UUID]chan time.Time,
|
||||
) {
|
||||
logger.Info(ctx, "waiting for all users to connect")
|
||||
|
||||
// Wait for all users to connect
|
||||
waitCtx, cancel := context.WithTimeout(ctx, dialTimeout+30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
dialBarrier.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
logger.Info(ctx, "all users connected")
|
||||
case <-waitCtx.Done():
|
||||
if waitCtx.Err() == context.DeadlineExceeded {
|
||||
logger.Error(ctx, "timeout waiting for users to connect")
|
||||
} else {
|
||||
logger.Info(ctx, "context canceled while waiting for users")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info(ctx, "creating test template to test notifications")
|
||||
|
||||
// Upload empty template file.
|
||||
file, err := client.Upload(ctx, codersdk.ContentTypeTar, bytes.NewReader([]byte{}))
|
||||
if err != nil {
|
||||
logger.Error(ctx, "upload test template", slog.Error(err))
|
||||
return
|
||||
}
|
||||
logger.Info(ctx, "test template uploaded", slog.F("file_id", file.ID))
|
||||
|
||||
// Create template version.
|
||||
version, err := client.CreateTemplateVersion(ctx, orgID, codersdk.CreateTemplateVersionRequest{
|
||||
StorageMethod: codersdk.ProvisionerStorageMethodFile,
|
||||
FileID: file.ID,
|
||||
Provisioner: codersdk.ProvisionerTypeEcho,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "create test template version", slog.Error(err))
|
||||
return
|
||||
}
|
||||
logger.Info(ctx, "test template version created", slog.F("template_version_id", version.ID))
|
||||
|
||||
// Create template.
|
||||
testTemplate, err := client.CreateTemplate(ctx, orgID, codersdk.CreateTemplateRequest{
|
||||
Name: "scaletest-test-template",
|
||||
Description: "scaletest-test-template",
|
||||
VersionID: version.ID,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "create test template", slog.Error(err))
|
||||
return
|
||||
}
|
||||
logger.Info(ctx, "test template created", slog.F("template_id", testTemplate.ID))
|
||||
|
||||
// Delete template to trigger notification.
|
||||
err = client.DeleteTemplate(ctx, testTemplate.ID)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "delete test template", slog.Error(err))
|
||||
return
|
||||
}
|
||||
logger.Info(ctx, "test template deleted", slog.F("template_id", testTemplate.ID))
|
||||
|
||||
// Record expected notification.
|
||||
expectedNotifications[notificationsLib.TemplateTemplateDeleted] <- time.Now()
|
||||
close(expectedNotifications[notificationsLib.TemplateTemplateDeleted])
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/scaletest/harness"
|
||||
"github.com/coder/coder/v2/scaletest/prebuilds"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestPrebuilds() *serpent.Command {
|
||||
var (
|
||||
numTemplates int64
|
||||
numPresets int64
|
||||
numPresetPrebuilds int64
|
||||
templateVersionJobTimeout time.Duration
|
||||
prebuildWorkspaceTimeout time.Duration
|
||||
noCleanup bool
|
||||
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "prebuilds",
|
||||
Short: "Creates prebuild workspaces on the Coder server.",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if numTemplates <= 0 {
|
||||
return xerrors.Errorf("--num-templates must be greater than 0")
|
||||
}
|
||||
if numPresets <= 0 {
|
||||
return xerrors.Errorf("--num-presets must be greater than 0")
|
||||
}
|
||||
if numPresetPrebuilds <= 0 {
|
||||
return xerrors.Errorf("--num-preset-prebuilds must be greater than 0")
|
||||
}
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse output flags: %w", err)
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := prebuilds.NewMetrics(reg)
|
||||
|
||||
logger := inv.Logger
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
defer func() {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("pause prebuilds: %w", err)
|
||||
}
|
||||
|
||||
setupBarrier := new(sync.WaitGroup)
|
||||
setupBarrier.Add(int(numTemplates))
|
||||
creationBarrier := new(sync.WaitGroup)
|
||||
creationBarrier.Add(int(numTemplates))
|
||||
deletionSetupBarrier := new(sync.WaitGroup)
|
||||
deletionSetupBarrier.Add(1)
|
||||
deletionBarrier := new(sync.WaitGroup)
|
||||
deletionBarrier.Add(int(numTemplates))
|
||||
|
||||
th := harness.NewTestHarness(timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}), cleanupStrategy.toStrategy())
|
||||
|
||||
for i := range numTemplates {
|
||||
id := strconv.Itoa(int(i))
|
||||
cfg := prebuilds.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
NumPresets: int(numPresets),
|
||||
NumPresetPrebuilds: int(numPresetPrebuilds),
|
||||
TemplateVersionJobTimeout: templateVersionJobTimeout,
|
||||
PrebuildWorkspaceTimeout: prebuildWorkspaceTimeout,
|
||||
Metrics: metrics,
|
||||
SetupBarrier: setupBarrier,
|
||||
CreationBarrier: creationBarrier,
|
||||
DeletionSetupBarrier: deletionSetupBarrier,
|
||||
DeletionBarrier: deletionBarrier,
|
||||
Clock: quartz.NewReal(),
|
||||
}
|
||||
err := cfg.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
|
||||
var runner harness.Runnable = prebuilds.NewRunner(client, cfg)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("prebuilds/%s", id),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
th.AddRun("prebuilds", id, runner)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Creating %d templates with %d presets and %d prebuilds per preset...\n",
|
||||
numTemplates, numPresets, numPresetPrebuilds)
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Total expected prebuilds: %d\n", numTemplates*numPresets*numPresetPrebuilds)
|
||||
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
|
||||
runErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
runErrCh <- th.Run(testCtx)
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all templates to be created...")
|
||||
setupBarrier.Wait()
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "All templates created")
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: false,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resume prebuilds: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all prebuilds to be created...")
|
||||
creationBarrier.Wait()
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "All prebuilds created")
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("pause prebuilds before deletion: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Prebuilds paused, signaling runners to prepare for deletion")
|
||||
deletionSetupBarrier.Done()
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all templates to be updated with 0 prebuilds...")
|
||||
deletionBarrier.Wait()
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "All templates updated")
|
||||
|
||||
err = client.PutPrebuildsSettings(ctx, codersdk.PrebuildsSettings{
|
||||
ReconciliationPaused: false,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resume prebuilds for deletion: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Waiting for all prebuilds to be deleted...")
|
||||
err = <-runErrCh
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
// If the command was interrupted, skip cleanup & stats
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !noCleanup {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nStarting cleanup (deleting templates)...")
|
||||
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
|
||||
// If the cleanup was interrupted, skip stats
|
||||
if notifyCtx.Err() != nil {
|
||||
return notifyCtx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("prebuild creation test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "num-templates",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_NUM_TEMPLATES",
|
||||
Default: "1",
|
||||
Description: "Number of templates to create for the test.",
|
||||
Value: serpent.Int64Of(&numTemplates),
|
||||
},
|
||||
{
|
||||
Flag: "num-presets",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_NUM_PRESETS",
|
||||
Default: "1",
|
||||
Description: "Number of presets per template.",
|
||||
Value: serpent.Int64Of(&numPresets),
|
||||
},
|
||||
{
|
||||
Flag: "num-preset-prebuilds",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_NUM_PRESET_PREBUILDS",
|
||||
Default: "1",
|
||||
Description: "Number of prebuilds per preset.",
|
||||
Value: serpent.Int64Of(&numPresetPrebuilds),
|
||||
},
|
||||
{
|
||||
Flag: "template-version-job-timeout",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_TEMPLATE_VERSION_JOB_TIMEOUT",
|
||||
Default: "5m",
|
||||
Description: "Timeout for template version provisioning jobs.",
|
||||
Value: serpent.DurationOf(&templateVersionJobTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "prebuild-workspace-timeout",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_WORKSPACE_TIMEOUT",
|
||||
Default: "10m",
|
||||
Description: "Timeout for all prebuild workspaces to be created/deleted.",
|
||||
Value: serpent.DurationOf(&prebuildWorkspaceTimeout),
|
||||
},
|
||||
{
|
||||
Flag: "skip-cleanup",
|
||||
Env: "CODER_SCALETEST_PREBUILDS_SKIP_CLEANUP",
|
||||
Description: "Skip cleanup (deletion test) and leave resources intact.",
|
||||
Value: serpent.BoolOf(&noCleanup),
|
||||
},
|
||||
}
|
||||
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
output.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
"github.com/coder/coder/v2/scaletest/smtpmock"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (*RootCmd) scaletestSMTP() *serpent.Command {
|
||||
var (
|
||||
hostAddress string
|
||||
smtpPort int64
|
||||
apiPort int64
|
||||
purgeAtCount int64
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "smtp",
|
||||
Short: "Start a mock SMTP server for testing",
|
||||
Long: `Start a mock SMTP server with an HTTP API server that can be used to purge
|
||||
messages and get messages by email.`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
notifyCtx, stop := signal.NotifyContext(ctx, StopSignals...)
|
||||
defer stop()
|
||||
ctx = notifyCtx
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stderr)).Leveled(slog.LevelInfo)
|
||||
config := smtpmock.Config{
|
||||
HostAddress: hostAddress,
|
||||
SMTPPort: int(smtpPort),
|
||||
APIPort: int(apiPort),
|
||||
Logger: logger,
|
||||
}
|
||||
srv := new(smtpmock.Server)
|
||||
|
||||
if err := srv.Start(ctx, config); err != nil {
|
||||
return xerrors.Errorf("start mock SMTP server: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = srv.Stop()
|
||||
}()
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Mock SMTP server started on %s\n", srv.SMTPAddress())
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "HTTP API server started on %s\n", srv.APIAddress())
|
||||
if purgeAtCount > 0 {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, " Auto-purge when message count reaches %d\n", purgeAtCount)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "\nTotal messages received since last purge: %d\n", srv.MessageCount())
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
count := srv.MessageCount()
|
||||
if count > 0 {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Messages received: %d\n", count)
|
||||
}
|
||||
|
||||
if purgeAtCount > 0 && int64(count) >= purgeAtCount {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Message count (%d) reached threshold (%d). Purging...\n", count, purgeAtCount)
|
||||
srv.Purge()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = []serpent.Option{
|
||||
{
|
||||
Flag: "host-address",
|
||||
Env: "CODER_SCALETEST_SMTP_HOST_ADDRESS",
|
||||
Default: "localhost",
|
||||
Description: "Host address to bind the mock SMTP and API servers.",
|
||||
Value: serpent.StringOf(&hostAddress),
|
||||
},
|
||||
{
|
||||
Flag: "smtp-port",
|
||||
Env: "CODER_SCALETEST_SMTP_PORT",
|
||||
Description: "Port for the mock SMTP server. Uses a random port if not specified.",
|
||||
Value: serpent.Int64Of(&smtpPort),
|
||||
},
|
||||
{
|
||||
Flag: "api-port",
|
||||
Env: "CODER_SCALETEST_SMTP_API_PORT",
|
||||
Description: "Port for the HTTP API server. Uses a random port if not specified.",
|
||||
Value: serpent.Int64Of(&apiPort),
|
||||
},
|
||||
{
|
||||
Flag: "purge-at-count",
|
||||
Env: "CODER_SCALETEST_SMTP_PURGE_AT_COUNT",
|
||||
Default: "100000",
|
||||
Description: "Maximum number of messages to keep before auto-purging. Set to 0 to disable.",
|
||||
Value: serpent.Int64Of(&purgeAtCount),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
//go:build !slim
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"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/taskstatus"
|
||||
)
|
||||
|
||||
const (
|
||||
taskStatusTestName = "task-status"
|
||||
)
|
||||
|
||||
func (r *RootCmd) scaletestTaskStatus() *serpent.Command {
|
||||
var (
|
||||
count int64
|
||||
template string
|
||||
workspaceNamePrefix string
|
||||
appSlug string
|
||||
reportStatusPeriod time.Duration
|
||||
reportStatusDuration time.Duration
|
||||
baselineDuration time.Duration
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
prometheusFlags = &scaletestPrometheusFlags{}
|
||||
timeoutStrategy = &timeoutFlags{}
|
||||
cleanupStrategy = newScaletestCleanupStrategy()
|
||||
output = &scaletestOutputFlags{}
|
||||
)
|
||||
orgContext := NewOrganizationContext()
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "task-status",
|
||||
Short: "Generates load on the Coder server by simulating task status reporting",
|
||||
Long: `This test creates external workspaces and simulates AI agents reporting task status.
|
||||
After all runners connect, it waits for the baseline duration before triggering status reporting.`,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags: %w", err)
|
||||
}
|
||||
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Disable rate limits for this test
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &codersdk.HeaderTransport{
|
||||
Transport: http.DefaultTransport,
|
||||
Header: map[string][]string{
|
||||
codersdk.BypassRatelimitHeader: {"true"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Find the template
|
||||
tpl, err := parseTemplate(ctx, client, []uuid.UUID{org.ID}, template)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse template %q: %w", template, err)
|
||||
}
|
||||
templateID := tpl.ID
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
metrics := taskstatus.NewMetrics(reg)
|
||||
|
||||
logger := slog.Make(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
|
||||
prometheusSrvClose := ServeHandler(ctx, logger, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), prometheusFlags.Address, "prometheus")
|
||||
defer prometheusSrvClose()
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Allow time for traces to flush even if command context is
|
||||
// canceled. This is a no-op if tracing is not enabled.
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "\nUploading traces...")
|
||||
if err := closeTracing(ctx); err != nil {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
// Wait for prometheus metrics to be scraped
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "Waiting %s for prometheus metrics to be scraped\n", prometheusFlags.Wait)
|
||||
<-time.After(prometheusFlags.Wait)
|
||||
}()
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
// Setup shared resources for coordination
|
||||
connectedWaitGroup := &sync.WaitGroup{}
|
||||
connectedWaitGroup.Add(int(count))
|
||||
startReporting := make(chan struct{})
|
||||
|
||||
// Create the test harness
|
||||
th := harness.NewTestHarness(
|
||||
timeoutStrategy.wrapStrategy(harness.ConcurrentExecutionStrategy{}),
|
||||
cleanupStrategy.toStrategy(),
|
||||
)
|
||||
|
||||
// Create runners
|
||||
for i := range count {
|
||||
workspaceName := fmt.Sprintf("%s-%d", workspaceNamePrefix, i)
|
||||
cfg := taskstatus.Config{
|
||||
TemplateID: templateID,
|
||||
WorkspaceName: workspaceName,
|
||||
AppSlug: appSlug,
|
||||
ConnectedWaitGroup: connectedWaitGroup,
|
||||
StartReporting: startReporting,
|
||||
ReportStatusPeriod: reportStatusPeriod,
|
||||
ReportStatusDuration: reportStatusDuration,
|
||||
Metrics: metrics,
|
||||
MetricLabelValues: []string{},
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return xerrors.Errorf("validate config for runner %d: %w", i, err)
|
||||
}
|
||||
|
||||
var runner harness.Runnable = taskstatus.NewRunner(client, cfg)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("%s/%d", taskStatusTestName, i),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
th.AddRun(taskStatusTestName, workspaceName, runner)
|
||||
}
|
||||
|
||||
// Start the test in a separate goroutine so we can coordinate timing
|
||||
testCtx, testCancel := timeoutStrategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
testDone := make(chan error)
|
||||
go func() {
|
||||
testDone <- th.Run(testCtx)
|
||||
}()
|
||||
|
||||
// Wait for all runners to connect
|
||||
logger.Info(ctx, "waiting for all runners to connect")
|
||||
waitCtx, waitCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer waitCancel()
|
||||
|
||||
connectDone := make(chan struct{})
|
||||
go func() {
|
||||
connectedWaitGroup.Wait()
|
||||
close(connectDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
return xerrors.Errorf("timeout waiting for runners to connect")
|
||||
case <-connectDone:
|
||||
logger.Info(ctx, "all runners connected")
|
||||
}
|
||||
|
||||
// Wait for baseline duration
|
||||
logger.Info(ctx, "waiting for baseline duration", slog.F("duration", baselineDuration))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(baselineDuration):
|
||||
}
|
||||
|
||||
// Trigger all runners to start reporting
|
||||
logger.Info(ctx, "triggering runners to start reporting task status")
|
||||
close(startReporting)
|
||||
|
||||
// Wait for the test to complete
|
||||
err = <-testDone
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness: %w", err)
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, inv.Stdout)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{
|
||||
{
|
||||
Flag: "count",
|
||||
Description: "Number of concurrent runners to create.",
|
||||
Default: "10",
|
||||
Value: serpent.Int64Of(&count),
|
||||
},
|
||||
{
|
||||
Flag: "template",
|
||||
Description: "Name or UUID of the template to use for the scale test. The template MUST include a coder_external_agent and a coder_app.",
|
||||
Default: "scaletest-task-status",
|
||||
Value: serpent.StringOf(&template),
|
||||
},
|
||||
{
|
||||
Flag: "workspace-name-prefix",
|
||||
Description: "Prefix for workspace names (will be suffixed with index).",
|
||||
Default: "scaletest-task-status",
|
||||
Value: serpent.StringOf(&workspaceNamePrefix),
|
||||
},
|
||||
{
|
||||
Flag: "app-slug",
|
||||
Description: "Slug of the app designated as the AI Agent.",
|
||||
Default: "ai-agent",
|
||||
Value: serpent.StringOf(&appSlug),
|
||||
},
|
||||
{
|
||||
Flag: "report-status-period",
|
||||
Description: "Time between reporting task statuses.",
|
||||
Default: "10s",
|
||||
Value: serpent.DurationOf(&reportStatusPeriod),
|
||||
},
|
||||
{
|
||||
Flag: "report-status-duration",
|
||||
Description: "Total time to report task statuses after baseline.",
|
||||
Default: "15m",
|
||||
Value: serpent.DurationOf(&reportStatusDuration),
|
||||
},
|
||||
{
|
||||
Flag: "baseline-duration",
|
||||
Description: "Duration to wait after all runners connect before starting to report status.",
|
||||
Default: "10m",
|
||||
Value: serpent.DurationOf(&baselineDuration),
|
||||
},
|
||||
}
|
||||
orgContext.AttachOptions(cmd)
|
||||
output.attach(&cmd.Options)
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
prometheusFlags.attach(&cmd.Options)
|
||||
timeoutStrategy.attach(&cmd.Options)
|
||||
cleanupStrategy.attach(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
@@ -8,7 +8,7 @@ func (r *RootCmd) tasksCommand() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "task",
|
||||
Aliases: []string{"tasks"},
|
||||
Short: "Manage tasks",
|
||||
Short: "Experimental task commands.",
|
||||
Handler: func(i *serpent.Invocation) error {
|
||||
return i.Command.HelpHandler(i)
|
||||
},
|
||||
@@ -28,29 +28,7 @@ func (r *RootCmd) taskCreate() *serpent.Command {
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "create [input]",
|
||||
Short: "Create a task",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Create a task with direct input",
|
||||
Command: "coder task create \"Add authentication to the user service\"",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task with stdin input",
|
||||
Command: "echo \"Add authentication to the user service\" | coder task create",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task with a specific name",
|
||||
Command: "coder task create --name task1 \"Add authentication to the user service\"",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task from a specific template / preset",
|
||||
Command: "coder task create --template backend-dev --preset \"My Preset\" \"Add authentication to the user service\"",
|
||||
},
|
||||
Example{
|
||||
Description: "Create a task for another user (requires appropriate permissions)",
|
||||
Command: "coder task create --owner user@example.com \"Add authentication to the user service\"",
|
||||
},
|
||||
),
|
||||
Short: "Create an experimental task",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireRangeArgs(0, 1),
|
||||
),
|
||||
@@ -111,7 +89,8 @@ func (r *RootCmd) taskCreate() *serpent.Command {
|
||||
}
|
||||
|
||||
var (
|
||||
ctx = inv.Context()
|
||||
ctx = inv.Context()
|
||||
expClient = codersdk.NewExperimentalClient(client)
|
||||
|
||||
taskInput string
|
||||
templateVersionID uuid.UUID
|
||||
@@ -207,7 +186,7 @@ func (r *RootCmd) taskCreate() *serpent.Command {
|
||||
templateVersionPresetID = preset.ID
|
||||
}
|
||||
|
||||
task, err := client.CreateTask(ctx, ownerArg, codersdk.CreateTaskRequest{
|
||||
task, err := expClient.CreateTask(ctx, ownerArg, codersdk.CreateTaskRequest{
|
||||
Name: taskName,
|
||||
TemplateVersionID: templateVersionID,
|
||||
TemplateVersionPresetID: templateVersionPresetID,
|
||||
@@ -69,7 +69,7 @@ func TestTaskCreate(t *testing.T) {
|
||||
ActiveVersionID: templateVersionID,
|
||||
},
|
||||
})
|
||||
case fmt.Sprintf("/api/v2/tasks/%s", username):
|
||||
case fmt.Sprintf("/api/experimental/tasks/%s", username):
|
||||
var req codersdk.CreateTaskRequest
|
||||
if !httpapi.Read(ctx, w, r, &req) {
|
||||
return
|
||||
@@ -329,7 +329,7 @@ func TestTaskCreate(t *testing.T) {
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
srv = httptest.NewServer(tt.handler(t, ctx))
|
||||
client = codersdk.New(testutil.MustURL(t, srv.URL))
|
||||
args = []string{"task", "create"}
|
||||
args = []string{"exp", "task", "create"}
|
||||
sb strings.Builder
|
||||
err error
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) taskDelete() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "delete <task> [<task> ...]",
|
||||
Short: "Delete experimental tasks",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireRangeArgs(1, -1),
|
||||
),
|
||||
Options: serpent.OptionSet{
|
||||
cliui.SkipPromptOption(),
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
client, err := r.InitClient(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
|
||||
type toDelete struct {
|
||||
ID uuid.UUID
|
||||
Owner string
|
||||
Display string
|
||||
}
|
||||
|
||||
var items []toDelete
|
||||
for _, identifier := range inv.Args {
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
return xerrors.New("task identifier cannot be empty or whitespace")
|
||||
}
|
||||
|
||||
// Check task identifier, try UUID first.
|
||||
if id, err := uuid.Parse(identifier); err == nil {
|
||||
task, err := exp.TaskByID(ctx, id)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", identifier, err)
|
||||
}
|
||||
display := fmt.Sprintf("%s/%s", task.OwnerName, task.Name)
|
||||
items = append(items, toDelete{ID: id, Display: display, Owner: task.OwnerName})
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-UUID, treat as a workspace identifier (name or owner/name).
|
||||
ws, err := namedWorkspace(ctx, client, identifier)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("resolve task %q: %w", identifier, err)
|
||||
}
|
||||
display := ws.FullName()
|
||||
items = append(items, toDelete{ID: ws.ID, Display: display, Owner: ws.OwnerName})
|
||||
}
|
||||
|
||||
// Confirm deletion of the tasks.
|
||||
var displayList []string
|
||||
for _, it := range items {
|
||||
displayList = append(displayList, it.Display)
|
||||
}
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Delete these tasks: %s?", pretty.Sprint(cliui.DefaultStyles.Code, strings.Join(displayList, ", "))),
|
||||
IsConfirm: true,
|
||||
Default: cliui.ConfirmNo,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if err := exp.DeleteTask(ctx, item.Owner, item.ID); err != nil {
|
||||
return xerrors.Errorf("delete task %q: %w", item.Display, err)
|
||||
}
|
||||
_, _ = fmt.Fprintln(
|
||||
inv.Stdout, "Deleted task "+pretty.Sprint(cliui.DefaultStyles.Keyword, item.Display)+" at "+cliui.Timestamp(time.Now()),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -56,15 +56,14 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
taskID := uuid.MustParse(id1)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/exists":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/exists":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK,
|
||||
codersdk.Task{
|
||||
ID: taskID,
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id1:
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
Name: "exists",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id1:
|
||||
c.deleteCalls.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
@@ -82,13 +81,13 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
buildHandler: func(c *testCounters) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/"+id2:
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id2:
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse(id2),
|
||||
OwnerName: "me",
|
||||
Name: "uuid-task",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id2:
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id2:
|
||||
c.deleteCalls.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
@@ -102,26 +101,26 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
name: "Multiple_YesFlag",
|
||||
args: []string{"--yes", "first", id4},
|
||||
buildHandler: func(c *testCounters) http.HandlerFunc {
|
||||
firstID := uuid.MustParse(id3)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/first":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/first":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse(id3),
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
|
||||
ID: firstID,
|
||||
Name: "first",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/"+id4:
|
||||
c.nameResolves.Add(1)
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/experimental/tasks/me/"+id4:
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
ID: uuid.MustParse(id4),
|
||||
OwnerName: "me",
|
||||
Name: "uuid-task-4",
|
||||
Name: "uuid-task-2",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id3:
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id3:
|
||||
c.deleteCalls.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/"+id4:
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id4:
|
||||
c.deleteCalls.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
default:
|
||||
@@ -130,7 +129,7 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
}
|
||||
},
|
||||
wantDeleteCalls: 2,
|
||||
wantNameResolves: 2,
|
||||
wantNameResolves: 1,
|
||||
wantDeletedMessage: 2,
|
||||
},
|
||||
{
|
||||
@@ -140,14 +139,8 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
buildHandler: func(_ *testCounters) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks" && r.URL.Query().Get("q") == "owner:\"me\"":
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, struct {
|
||||
Tasks []codersdk.Task `json:"tasks"`
|
||||
Count int `json:"count"`
|
||||
}{
|
||||
Tasks: []codersdk.Task{},
|
||||
Count: 0,
|
||||
})
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/doesnotexist":
|
||||
httpapi.ResourceNotFound(w)
|
||||
default:
|
||||
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
|
||||
}
|
||||
@@ -163,14 +156,14 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
taskID := uuid.MustParse(id5)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/me/bad":
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/users/me/workspace/bad":
|
||||
c.nameResolves.Add(1)
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Task{
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, codersdk.Workspace{
|
||||
ID: taskID,
|
||||
Name: "bad",
|
||||
OwnerName: "me",
|
||||
})
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/tasks/me/bad":
|
||||
case r.Method == http.MethodDelete && r.URL.Path == "/api/experimental/tasks/me/"+id5:
|
||||
httpapi.InternalServerError(w, xerrors.New("boom"))
|
||||
default:
|
||||
httpapi.InternalServerError(w, xerrors.New("unwanted path: "+r.Method+" "+r.URL.Path))
|
||||
@@ -193,7 +186,7 @@ func TestExpTaskDelete(t *testing.T) {
|
||||
|
||||
client := codersdk.New(testutil.MustURL(t, srv.URL))
|
||||
|
||||
args := append([]string{"task", "delete"}, tc.args...)
|
||||
args := append([]string{"exp", "task", "delete"}, tc.args...)
|
||||
inv, root := clitest.New(t, args...)
|
||||
inv = inv.WithContext(ctx)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
@@ -68,30 +67,8 @@ func (r *RootCmd) taskList() *serpent.Command {
|
||||
)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "list",
|
||||
Short: "List tasks",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "List tasks for the current user.",
|
||||
Command: "coder task list",
|
||||
},
|
||||
Example{
|
||||
Description: "List tasks for a specific user.",
|
||||
Command: "coder task list --user someone-else",
|
||||
},
|
||||
Example{
|
||||
Description: "List all tasks you can view.",
|
||||
Command: "coder task list --all",
|
||||
},
|
||||
Example{
|
||||
Description: "List all your running tasks.",
|
||||
Command: "coder task list --status running",
|
||||
},
|
||||
Example{
|
||||
Description: "As above, but only show IDs.",
|
||||
Command: "coder task list --status running --quiet",
|
||||
},
|
||||
),
|
||||
Use: "list",
|
||||
Short: "List experimental tasks",
|
||||
Aliases: []string{"ls"},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
@@ -99,10 +76,10 @@ func (r *RootCmd) taskList() *serpent.Command {
|
||||
Options: serpent.OptionSet{
|
||||
{
|
||||
Name: "status",
|
||||
Description: "Filter by task status.",
|
||||
Description: "Filter by task status (e.g. running, failed, etc).",
|
||||
Flag: "status",
|
||||
Default: "",
|
||||
Value: serpent.EnumOf(&statusFilter, slice.ToStrings(codersdk.AllTaskStatuses())...),
|
||||
Value: serpent.StringOf(&statusFilter),
|
||||
},
|
||||
{
|
||||
Name: "all",
|
||||
@@ -135,15 +112,16 @@ func (r *RootCmd) taskList() *serpent.Command {
|
||||
}
|
||||
|
||||
ctx := inv.Context()
|
||||
exp := codersdk.NewExperimentalClient(client)
|
||||
|
||||
targetUser := strings.TrimSpace(user)
|
||||
if targetUser == "" && !all {
|
||||
targetUser = codersdk.Me
|
||||
}
|
||||
|
||||
tasks, err := client.Tasks(ctx, &codersdk.TasksFilter{
|
||||
tasks, err := exp.Tasks(ctx, &codersdk.TasksFilter{
|
||||
Owner: targetUser,
|
||||
Status: codersdk.TaskStatus(statusFilter),
|
||||
Status: statusFilter,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("list tasks: %w", err)
|
||||
@@ -157,6 +135,12 @@ func (r *RootCmd) taskList() *serpent.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If no rows and not JSON, show a friendly message.
|
||||
if len(tasks) == 0 && formatter.FormatID() != cliui.JSONFormat().ID() {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "No tasks found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
rows := make([]taskListRow, len(tasks))
|
||||
now := time.Now()
|
||||
for i := range tasks {
|
||||
@@ -167,10 +151,6 @@ func (r *RootCmd) taskList() *serpent.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("format tasks: %w", err)
|
||||
}
|
||||
if out == "" {
|
||||
cliui.Infof(inv.Stderr, "No tasks found.")
|
||||
return nil
|
||||
}
|
||||
_, _ = fmt.Fprintln(inv.Stdout, out)
|
||||
return nil
|
||||
},
|
||||
@@ -2,6 +2,7 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -18,7 +19,9 @@ import (
|
||||
"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/dbfake"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
@@ -26,7 +29,7 @@ import (
|
||||
)
|
||||
|
||||
// makeAITask creates an AI-task workspace.
|
||||
func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) database.Task {
|
||||
func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UUID, transition database.WorkspaceTransition, prompt string) (workspace database.WorkspaceTable) {
|
||||
t.Helper()
|
||||
|
||||
tv := dbfake.TemplateVersion(t, db).
|
||||
@@ -39,22 +42,56 @@ func makeAITask(t *testing.T, db database.Store, orgID, adminID, ownerID uuid.UU
|
||||
},
|
||||
}).Do()
|
||||
|
||||
build := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
ws := database.WorkspaceTable{
|
||||
OrganizationID: orgID,
|
||||
OwnerID: ownerID,
|
||||
TemplateID: tv.Template.ID,
|
||||
}).
|
||||
}
|
||||
build := dbfake.WorkspaceBuild(t, db, ws).
|
||||
Seed(database.WorkspaceBuild{
|
||||
TemplateVersionID: tv.TemplateVersion.ID,
|
||||
Transition: transition,
|
||||
}).
|
||||
WithAgent().
|
||||
WithTask(database.TaskTable{
|
||||
Prompt: prompt,
|
||||
}, nil).
|
||||
Do()
|
||||
}).WithAgent().Do()
|
||||
dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{
|
||||
{
|
||||
WorkspaceBuildID: build.Build.ID,
|
||||
Name: codersdk.AITaskPromptParameterName,
|
||||
Value: prompt,
|
||||
},
|
||||
})
|
||||
agents, err := db.GetWorkspaceAgentsByWorkspaceAndBuildNumber(
|
||||
dbauthz.AsSystemRestricted(context.Background()),
|
||||
database.GetWorkspaceAgentsByWorkspaceAndBuildNumberParams{
|
||||
WorkspaceID: build.Workspace.ID,
|
||||
BuildNumber: build.Build.BuildNumber,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, agents)
|
||||
agentID := agents[0].ID
|
||||
|
||||
return build.Task
|
||||
// Create a workspace app and set it as the sidebar app.
|
||||
app := dbgen.WorkspaceApp(t, db, database.WorkspaceApp{
|
||||
AgentID: agentID,
|
||||
Slug: "task-sidebar",
|
||||
DisplayName: "Task Sidebar",
|
||||
External: false,
|
||||
})
|
||||
|
||||
// Update build flags to reference the sidebar app and HasAITask=true.
|
||||
err = db.UpdateWorkspaceBuildFlagsByID(
|
||||
dbauthz.AsSystemRestricted(context.Background()),
|
||||
database.UpdateWorkspaceBuildFlagsByIDParams{
|
||||
ID: build.Build.ID,
|
||||
HasAITask: sql.NullBool{Bool: true, Valid: true},
|
||||
HasExternalAgent: sql.NullBool{Bool: false, Valid: false},
|
||||
SidebarAppID: uuid.NullUUID{UUID: app.ID, Valid: true},
|
||||
UpdatedAt: build.Build.UpdatedAt,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return build.Workspace
|
||||
}
|
||||
|
||||
func TestExpTaskList(t *testing.T) {
|
||||
@@ -69,7 +106,7 @@ func TestExpTaskList(t *testing.T) {
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
inv, root := clitest.New(t, "task", "list")
|
||||
inv, root := clitest.New(t, "exp", "task", "list")
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
@@ -91,9 +128,9 @@ func TestExpTaskList(t *testing.T) {
|
||||
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
wantPrompt := "build me a web app"
|
||||
task := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt)
|
||||
ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, wantPrompt)
|
||||
|
||||
inv, root := clitest.New(t, "task", "list", "--column", "id,name,status,initial prompt")
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--column", "id,name,status,initial prompt")
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
@@ -103,8 +140,8 @@ func TestExpTaskList(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate the table includes the task and status.
|
||||
pty.ExpectMatch(task.Name)
|
||||
pty.ExpectMatch("initializing")
|
||||
pty.ExpectMatch(ws.Name)
|
||||
pty.ExpectMatch("running")
|
||||
pty.ExpectMatch(wantPrompt)
|
||||
})
|
||||
|
||||
@@ -117,12 +154,12 @@ func TestExpTaskList(t *testing.T) {
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
// Create two AI tasks: one initializing, one paused.
|
||||
initializingTask := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me initializing")
|
||||
pausedTask := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
|
||||
// Create two AI tasks: one running, one stopped.
|
||||
running := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running")
|
||||
stopped := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
|
||||
|
||||
// Use JSON output to reliably validate filtering.
|
||||
inv, root := clitest.New(t, "task", "list", "--status=paused", "--output=json")
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--status=stopped", "--output=json")
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
@@ -136,10 +173,10 @@ func TestExpTaskList(t *testing.T) {
|
||||
var tasks []codersdk.Task
|
||||
require.NoError(t, json.Unmarshal(stdout.Bytes(), &tasks))
|
||||
|
||||
// Only the paused task is returned.
|
||||
// Only the stopped task is returned.
|
||||
require.Len(t, tasks, 1, "expected one task after filtering")
|
||||
require.Equal(t, pausedTask.ID, tasks[0].ID)
|
||||
require.NotEqual(t, initializingTask.ID, tasks[0].ID)
|
||||
require.Equal(t, stopped.ID, tasks[0].ID)
|
||||
require.NotEqual(t, running.ID, tasks[0].ID)
|
||||
})
|
||||
|
||||
t.Run("UserFlag_Me_Table", func(t *testing.T) {
|
||||
@@ -151,9 +188,9 @@ func TestExpTaskList(t *testing.T) {
|
||||
_, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
_ = makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "other-task")
|
||||
task := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task")
|
||||
ws := makeAITask(t, db, owner.OrganizationID, owner.UserID, owner.UserID, database.WorkspaceTransitionStart, "me-task")
|
||||
|
||||
inv, root := clitest.New(t, "task", "list", "--user", "me")
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--user", "me")
|
||||
//nolint:gocritic // Owner client is intended here smoke test the member task not showing up.
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
@@ -163,7 +200,7 @@ func TestExpTaskList(t *testing.T) {
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
pty.ExpectMatch(task.Name)
|
||||
pty.ExpectMatch(ws.Name)
|
||||
})
|
||||
|
||||
t.Run("Quiet", func(t *testing.T) {
|
||||
@@ -176,11 +213,11 @@ func TestExpTaskList(t *testing.T) {
|
||||
memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
// Given: We have two tasks
|
||||
task1 := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me active")
|
||||
task1 := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStart, "keep me running")
|
||||
task2 := makeAITask(t, db, owner.OrganizationID, owner.UserID, memberUser.ID, database.WorkspaceTransitionStop, "stop me please")
|
||||
|
||||
// Given: We add the `--quiet` flag
|
||||
inv, root := clitest.New(t, "task", "list", "--quiet")
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--quiet")
|
||||
clitest.SetupConfig(t, memberClient, root)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
@@ -224,7 +261,7 @@ func TestExpTaskList_OwnerCanListOthers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// As the owner, list only member A tasks.
|
||||
inv, root := clitest.New(t, "task", "list", "--user", memberAUser.Username, "--output=json")
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--user", memberAUser.Username, "--output=json")
|
||||
//nolint:gocritic // Owner client is intended here to allow member tasks to be listed.
|
||||
clitest.SetupConfig(t, ownerClient, root)
|
||||
|
||||
@@ -252,7 +289,7 @@ func TestExpTaskList_OwnerCanListOthers(t *testing.T) {
|
||||
|
||||
// As the owner, list all tasks to verify both member tasks are present.
|
||||
// Use JSON output to reliably validate filtering.
|
||||
inv, root := clitest.New(t, "task", "list", "--all", "--output=json")
|
||||
inv, root := clitest.New(t, "exp", "task", "list", "--all", "--output=json")
|
||||
//nolint:gocritic // Owner client is intended here to allow all tasks to be listed.
|
||||
clitest.SetupConfig(t, ownerClient, root)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user