Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0ebeebb29 | |||
| dd50c4ecc9 | |||
| bda202f3f1 | |||
| 0f27da0359 | |||
| 7c4c5048bc | |||
| 17dbb517ad | |||
| d31c994018 | |||
| 552c4cd93d | |||
| fb71cb5f96 | |||
| 2f32b11831 | |||
| a9775fa3d5 |
-122
@@ -1,122 +0,0 @@
|
||||
# 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.
|
||||
@@ -9,10 +9,5 @@
|
||||
}
|
||||
},
|
||||
// SYS_PTRACE to enable go debugging
|
||||
"runArgs": ["--cap-add=SYS_PTRACE"],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["biomejs.biome"]
|
||||
}
|
||||
}
|
||||
"runArgs": ["--cap-add=SYS_PTRACE"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
# Generated files
|
||||
agent/agentcontainers/acmock/acmock.go linguist-generated=true
|
||||
agent/agentcontainers/dcspec/dcspec_gen.go linguist-generated=true
|
||||
agent/agentcontainers/testdata/devcontainercli/*/*.log linguist-generated=true
|
||||
coderd/apidoc/docs.go linguist-generated=true
|
||||
docs/reference/api/*.md linguist-generated=true
|
||||
docs/reference/cli/*.md linguist-generated=true
|
||||
|
||||
@@ -20,9 +20,5 @@ ignorePatterns:
|
||||
- pattern: "www.emacswiki.org"
|
||||
- pattern: "linux.die.net/man"
|
||||
- pattern: "www.gnu.org"
|
||||
- pattern: "wiki.ubuntu.com"
|
||||
- pattern: "mutagen.io"
|
||||
- pattern: "docs.github.com"
|
||||
- pattern: "claude.ai"
|
||||
aliveStatusCodes:
|
||||
- 200
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
name: "🐞 Bug"
|
||||
description: "File a bug report."
|
||||
title: "bug: "
|
||||
labels: ["needs-triage"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: existing_issues
|
||||
attributes:
|
||||
label: "Is there an existing issue for this?"
|
||||
description: "Please search to see if an issue already exists for the bug you encountered."
|
||||
options:
|
||||
- label: "I have searched the existing issues"
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: issue
|
||||
attributes:
|
||||
label: "Current Behavior"
|
||||
description: "A concise description of what you're experiencing."
|
||||
placeholder: "Tell us what you see!"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: "Relevant Log Output"
|
||||
description: "Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks."
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: "Expected Behavior"
|
||||
description: "A concise description of what you expected to happen."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: steps_to_reproduce
|
||||
attributes:
|
||||
label: "Steps to Reproduce"
|
||||
description: "Provide step-by-step instructions to reproduce the issue."
|
||||
placeholder: |
|
||||
1. First step
|
||||
2. Second step
|
||||
3. Another step
|
||||
4. Issue occurs
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: "Environment"
|
||||
description: |
|
||||
Provide details about your environment:
|
||||
- **Host OS**: (e.g., Ubuntu 24.04, Debian 12)
|
||||
- **Coder Version**: (e.g., v2.18.4)
|
||||
placeholder: |
|
||||
Run `coder version` to get Coder version
|
||||
value: |
|
||||
- Host OS:
|
||||
- Coder version:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: additional_info
|
||||
attributes:
|
||||
label: "Additional Context"
|
||||
description: "Select any applicable options:"
|
||||
multiple: true
|
||||
options:
|
||||
- "The issue occurs consistently"
|
||||
- "The issue is new (previously worked fine)"
|
||||
- "The issue happens on multiple deployments"
|
||||
- "I have tested this on the latest version"
|
||||
@@ -1,10 +0,0 @@
|
||||
contact_links:
|
||||
- name: Questions, suggestion or feature requests?
|
||||
url: https://github.com/coder/coder/discussions/new/choose
|
||||
about: Our preferred starting point if you have any questions or suggestions about configuration, features or unexpected behavior.
|
||||
- name: Coder Docs
|
||||
url: https://coder.com/docs
|
||||
about: Check our docs.
|
||||
- name: Coder Discord Community
|
||||
url: https://discord.gg/coder
|
||||
about: Get in touch with the Coder developers and community for support.
|
||||
@@ -1,10 +0,0 @@
|
||||
name: "Install cosign"
|
||||
description: |
|
||||
Cosign Github Action.
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1
|
||||
with:
|
||||
cosign-release: "v2.4.3"
|
||||
@@ -1,10 +0,0 @@
|
||||
name: "Install syft"
|
||||
description: |
|
||||
Downloads Syft to the Action tool cache and provides a reference.
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install syft
|
||||
uses: anchore/sbom-action/download-syft@f325610c9f50a54015d37c8d16cb3b0e2c8f4de0 # v0.18.0
|
||||
with:
|
||||
syft-version: "v1.20.0"
|
||||
@@ -1,14 +0,0 @@
|
||||
name: "Setup Go tools"
|
||||
description: |
|
||||
Set up tools for `make gen`, `offlinedocs` and Schmoder CI.
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: go install tools
|
||||
shell: bash
|
||||
run: |
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30
|
||||
go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34
|
||||
go install golang.org/x/tools/cmd/goimports@v0.31.0
|
||||
go install github.com/mikefarah/yq/v4@v4.44.3
|
||||
go install go.uber.org/mock/mockgen@v0.5.0
|
||||
@@ -4,7 +4,7 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.24.2"
|
||||
default: "1.22.8"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
|
||||
@@ -7,5 +7,5 @@ runs:
|
||||
- name: Install Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
with:
|
||||
terraform_version: 1.11.4
|
||||
terraform_version: 1.10.5
|
||||
terraform_wrapper: false
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: "Download Test Cache"
|
||||
description: |
|
||||
Downloads the test cache and outputs today's cache key.
|
||||
A PR job can use a cache if it was created by its base branch, its current
|
||||
branch, or the default branch.
|
||||
https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache
|
||||
outputs:
|
||||
cache-key:
|
||||
description: "Today's cache key"
|
||||
value: ${{ steps.vars.outputs.cache-key }}
|
||||
inputs:
|
||||
key-prefix:
|
||||
description: "Prefix for the cache key"
|
||||
required: true
|
||||
cache-path:
|
||||
description: "Path to the cache directory"
|
||||
required: true
|
||||
# This path is defined in testutil/cache.go
|
||||
default: "~/.cache/coderv2-test"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Get date values and cache key
|
||||
id: vars
|
||||
shell: bash
|
||||
run: |
|
||||
export YEAR_MONTH=$(date +'%Y-%m')
|
||||
export PREV_YEAR_MONTH=$(date -d 'last month' +'%Y-%m')
|
||||
export DAY=$(date +'%d')
|
||||
echo "year-month=$YEAR_MONTH" >> $GITHUB_OUTPUT
|
||||
echo "prev-year-month=$PREV_YEAR_MONTH" >> $GITHUB_OUTPUT
|
||||
echo "cache-key=${{ inputs.key-prefix }}-${YEAR_MONTH}-${DAY}" >> $GITHUB_OUTPUT
|
||||
|
||||
# TODO: As a cost optimization, we could remove caches that are older than
|
||||
# a day or two. By default, depot keeps caches for 14 days, which isn't
|
||||
# necessary for the test cache.
|
||||
# https://depot.dev/docs/github-actions/overview#cache-retention-policy
|
||||
- name: Download test cache
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: ${{ inputs.cache-path }}
|
||||
key: ${{ steps.vars.outputs.cache-key }}
|
||||
# > If there are multiple partial matches for a restore key, the action returns the most recently created cache.
|
||||
# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows#matching-a-cache-key
|
||||
# The second restore key allows non-main branches to use the cache from the previous month.
|
||||
# This prevents PRs from rebuilding the cache on the first day of the month.
|
||||
# It also makes sure that once a month, the cache is fully reset.
|
||||
restore-keys: |
|
||||
${{ inputs.key-prefix }}-${{ steps.vars.outputs.year-month }}-
|
||||
${{ github.ref != 'refs/heads/main' && format('{0}-{1}-', inputs.key-prefix, steps.vars.outputs.prev-year-month) || '' }}
|
||||
@@ -1,20 +0,0 @@
|
||||
name: "Upload Test Cache"
|
||||
description: Uploads the test cache. Only works on the main branch.
|
||||
inputs:
|
||||
cache-key:
|
||||
description: "Cache key"
|
||||
required: true
|
||||
cache-path:
|
||||
description: "Path to the cache directory"
|
||||
required: true
|
||||
# This path is defined in testutil/cache.go
|
||||
default: "~/.cache/coderv2-test"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Upload test cache
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: ${{ inputs.cache-path }}
|
||||
key: ${{ inputs.cache-key }}
|
||||
+16
-2
@@ -9,6 +9,21 @@ updates:
|
||||
labels: []
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
ignore:
|
||||
# These actions deliver the latest versions by updating the major
|
||||
# release tag, so ignore minor and patch versions
|
||||
- dependency-name: "actions/*"
|
||||
update-types:
|
||||
- version-update:semver-minor
|
||||
- version-update:semver-patch
|
||||
- dependency-name: "Apple-Actions/import-codesign-certs"
|
||||
update-types:
|
||||
- version-update:semver-minor
|
||||
- version-update:semver-patch
|
||||
- dependency-name: "marocchino/sticky-pull-request-comment"
|
||||
update-types:
|
||||
- version-update:semver-minor
|
||||
- version-update:semver-patch
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
@@ -37,8 +52,7 @@ updates:
|
||||
# Update our Dockerfile.
|
||||
- package-ecosystem: "docker"
|
||||
directories:
|
||||
- "/dogfood/coder"
|
||||
- "/dogfood/coder-envbuilder"
|
||||
- "/dogfood/contents"
|
||||
- "/scripts"
|
||||
- "/examples/templates/docker/build"
|
||||
- "/examples/parameters/build"
|
||||
|
||||
+83
-348
@@ -34,12 +34,12 @@ jobs:
|
||||
tailnet-integration: ${{ steps.filter.outputs.tailnet-integration }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# For pull requests it's not necessary to checkout the code
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
# runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
# uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
# 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
|
||||
@@ -155,12 +155,12 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -172,13 +172,13 @@ jobs:
|
||||
|
||||
- name: Get golangci-lint cache dir
|
||||
run: |
|
||||
linter_ver=$(egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2)
|
||||
linter_ver=$(egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/contents/Dockerfile | cut -d '=' -f 2)
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v$linter_ver
|
||||
dir=$(golangci-lint cache status | awk '/Dir/ { print $2 }')
|
||||
echo "LINT_CACHE_DIR=$dir" >> $GITHUB_ENV
|
||||
|
||||
- name: golangci-lint cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
uses: actions/cache@2cdf405574d6ef1f33a1d12acccd3ae82f47b3f2 # v4.1.0
|
||||
with:
|
||||
path: |
|
||||
${{ env.LINT_CACHE_DIR }}
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
|
||||
# Check for any typos
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@b1a1ef3893ff35ade0cfa71523852a49bfd05d19 # v1.31.1
|
||||
uses: crate-ci/typos@685eb3d55be2f85191e8c84acb9f44d7756f84ab # v1.29.4
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
|
||||
# Needed for helm chart linting
|
||||
- name: Install helm
|
||||
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
||||
uses: azure/setup-helm@fe7b79cd5ee1e45176fcad797de68ecaf3ca4814 # v4.2.0
|
||||
with:
|
||||
version: v3.9.2
|
||||
|
||||
@@ -227,12 +227,12 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -249,7 +249,12 @@ jobs:
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: go install tools
|
||||
uses: ./.github/actions/setup-go-tools
|
||||
run: |
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30
|
||||
go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
go install github.com/mikefarah/yq/v4@v4.44.3
|
||||
go install go.uber.org/mock/mockgen@v0.5.0
|
||||
|
||||
- name: Install Protoc
|
||||
run: |
|
||||
@@ -262,15 +267,18 @@ jobs:
|
||||
popd
|
||||
|
||||
- name: make gen
|
||||
# no `-j` flag as `make` fails with:
|
||||
# coderd/rbac/object_gen.go:1:1: syntax error: package statement must be first
|
||||
run: "make --output-sync -B gen"
|
||||
|
||||
- name: make update-golden-files
|
||||
run: |
|
||||
# Remove golden files to detect discrepancy in generated files.
|
||||
make clean/golden-files
|
||||
# Notifications require DB, we could start a DB instance here but
|
||||
# let's just restore for now.
|
||||
git checkout -- coderd/notifications/testdata/rendered-templates
|
||||
# no `-j` flag as `make` fails with:
|
||||
# coderd/rbac/object_gen.go:1:1: syntax error: package statement must be first
|
||||
make --output-sync -B gen
|
||||
# As above, skip `-j` flag.
|
||||
make --output-sync -B update-golden-files
|
||||
|
||||
- name: Check for unstaged files
|
||||
run: ./scripts/check_unstaged.sh
|
||||
@@ -282,21 +290,18 @@ jobs:
|
||||
timeout-minutes: 7
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Check Go version
|
||||
run: IGNORE_NIX=true ./scripts/check_go_versions.sh
|
||||
|
||||
# Use default Go version
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
@@ -326,12 +331,12 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -341,12 +346,6 @@ jobs:
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Download Test Cache
|
||||
id: download-cache
|
||||
uses: ./.github/actions/test-cache/download
|
||||
with:
|
||||
key-prefix: test-go-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Test with Mock Database
|
||||
id: test
|
||||
shell: bash
|
||||
@@ -371,11 +370,6 @@ jobs:
|
||||
gotestsum --junitfile="gotests.xml" --jsonfile="gotests.json" \
|
||||
--packages="./..." -- $PARALLEL_FLAG -short -failfast
|
||||
|
||||
- name: Upload Test Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
with:
|
||||
cache-key: ${{ steps.download-cache.outputs.cache-key }}
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
timeout-minutes: 1
|
||||
continue-on-error: true
|
||||
@@ -397,12 +391,12 @@ jobs:
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -453,12 +447,12 @@ jobs:
|
||||
- ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -473,12 +467,6 @@ jobs:
|
||||
if: runner.os == 'Windows'
|
||||
uses: ./.github/actions/setup-imdisk
|
||||
|
||||
- name: Download Test Cache
|
||||
id: download-cache
|
||||
uses: ./.github/actions/test-cache/download
|
||||
with:
|
||||
key-prefix: test-go-pg-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
env:
|
||||
POSTGRES_VERSION: "13"
|
||||
@@ -493,11 +481,6 @@ jobs:
|
||||
|
||||
make test-postgres
|
||||
|
||||
- name: Upload Test Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
with:
|
||||
cache-key: ${{ steps.download-cache.outputs.cache-key }}
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
timeout-minutes: 1
|
||||
continue-on-error: true
|
||||
@@ -521,12 +504,12 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -536,12 +519,6 @@ jobs:
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Download Test Cache
|
||||
id: download-cache
|
||||
uses: ./.github/actions/test-cache/download
|
||||
with:
|
||||
key-prefix: test-go-pg-16-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
env:
|
||||
POSTGRES_VERSION: "16"
|
||||
@@ -549,11 +526,6 @@ jobs:
|
||||
run: |
|
||||
make test-postgres
|
||||
|
||||
- name: Upload Test Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
with:
|
||||
cache-key: ${{ steps.download-cache.outputs.cache-key }}
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
timeout-minutes: 1
|
||||
continue-on-error: true
|
||||
@@ -569,12 +541,12 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -584,12 +556,6 @@ jobs:
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Download Test Cache
|
||||
id: download-cache
|
||||
uses: ./.github/actions/test-cache/download
|
||||
with:
|
||||
key-prefix: test-go-race-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
# We run race tests with reduced parallelism because they use more CPU and we were finding
|
||||
# instances where tests appear to hang for multiple seconds, resulting in flaky tests when
|
||||
# short timeouts are used.
|
||||
@@ -598,11 +564,6 @@ jobs:
|
||||
run: |
|
||||
gotestsum --junitfile="gotests.xml" -- -race -parallel 4 -p 4 ./...
|
||||
|
||||
- name: Upload Test Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
with:
|
||||
cache-key: ${{ steps.download-cache.outputs.cache-key }}
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
timeout-minutes: 1
|
||||
continue-on-error: true
|
||||
@@ -618,12 +579,12 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -633,12 +594,6 @@ jobs:
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Download Test Cache
|
||||
id: download-cache
|
||||
uses: ./.github/actions/test-cache/download
|
||||
with:
|
||||
key-prefix: test-go-race-pg-${{ runner.os }}-${{ runner.arch }}
|
||||
|
||||
# We run race tests with reduced parallelism because they use more CPU and we were finding
|
||||
# instances where tests appear to hang for multiple seconds, resulting in flaky tests when
|
||||
# short timeouts are used.
|
||||
@@ -650,11 +605,6 @@ jobs:
|
||||
make test-postgres-docker
|
||||
DB=ci gotestsum --junitfile="gotests.xml" -- -race -parallel 4 -p 4 ./...
|
||||
|
||||
- name: Upload Test Cache
|
||||
uses: ./.github/actions/test-cache/upload
|
||||
with:
|
||||
cache-key: ${{ steps.download-cache.outputs.cache-key }}
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
timeout-minutes: 1
|
||||
continue-on-error: true
|
||||
@@ -677,12 +627,12 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -703,12 +653,12 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -727,20 +677,20 @@ jobs:
|
||||
variant:
|
||||
- premium: false
|
||||
name: test-e2e
|
||||
#- premium: true
|
||||
# name: test-e2e-premium
|
||||
- premium: true
|
||||
name: test-e2e-premium
|
||||
# Skip test-e2e on forks as they don't have access to CI secrets
|
||||
if: (needs.changes.outputs.go == 'true' || needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main') && !(github.event.pull_request.head.repo.fork)
|
||||
timeout-minutes: 20
|
||||
name: ${{ matrix.variant.name }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -783,7 +733,7 @@ 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
with:
|
||||
name: failed-test-videos${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/*.webm
|
||||
@@ -791,7 +741,7 @@ jobs:
|
||||
|
||||
- name: Upload pprof dumps
|
||||
if: always() && github.actor != 'dependabot[bot]' && runner.os == 'Linux' && !github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
with:
|
||||
name: debug-pprof-dumps${{ matrix.variant.premium && '-premium' || '' }}
|
||||
path: ./site/test-results/**/debug-pprof-*.txt
|
||||
@@ -804,12 +754,12 @@ jobs:
|
||||
if: needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
# Required by Chromatic for build-over-build history, otherwise we
|
||||
# only get 1 commit on shallow checkout.
|
||||
@@ -881,12 +831,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
# 0 is required here for version.sh to work.
|
||||
fetch-depth: 0
|
||||
@@ -910,7 +860,12 @@ jobs:
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Install go tools
|
||||
uses: ./.github/actions/setup-go-tools
|
||||
run: |
|
||||
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30
|
||||
go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34
|
||||
go install golang.org/x/tools/cmd/goimports@latest
|
||||
go install github.com/mikefarah/yq/v4@v4.44.3
|
||||
go install go.uber.org/mock/mockgen@v0.5.0
|
||||
|
||||
- name: Setup sqlc
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
@@ -950,7 +905,7 @@ jobs:
|
||||
if: always()
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -985,9 +940,13 @@ jobs:
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest' }}
|
||||
steps:
|
||||
# Harden Runner doesn't work on macOS
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -998,11 +957,6 @@ jobs:
|
||||
echo "$(brew --prefix gnu-getopt)/bin" >> $GITHUB_PATH
|
||||
echo "$(brew --prefix make)/libexec/gnubin" >> $GITHUB_PATH
|
||||
|
||||
- name: Switch XCode Version
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
|
||||
with:
|
||||
xcode-version: "16.0.0"
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
@@ -1045,7 +999,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: ${{ github.repository_owner == 'coder' && github.ref == 'refs/heads/main' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: dylibs
|
||||
path: |
|
||||
@@ -1066,31 +1020,24 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' && needs.changes.outputs.docs-only == 'false' && !github.event.pull_request.head.repo.fork
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-22.04' }}
|
||||
permissions:
|
||||
# Necessary to push docker images to ghcr.io.
|
||||
packages: write
|
||||
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
|
||||
# Also necessary for keyless cosign (https://docs.sigstore.dev/cosign/signing/overview/)
|
||||
# And for GitHub Actions attestation
|
||||
id-token: write
|
||||
# Required for GitHub Actions attestation
|
||||
attestations: write
|
||||
packages: write # Needed to push images to ghcr.io
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
outputs:
|
||||
IMAGE: ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -1102,52 +1049,14 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
# Necessary for signing Windows binaries.
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "11.0"
|
||||
|
||||
- name: Install go-winres
|
||||
run: go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3
|
||||
|
||||
- name: Install nfpm
|
||||
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.35.1
|
||||
|
||||
- name: Install zstd
|
||||
run: sudo apt-get install -y zstd
|
||||
|
||||
- name: Install cosign
|
||||
uses: ./.github/actions/install-cosign
|
||||
|
||||
- name: Install syft
|
||||
uses: ./.github/actions/install-syft
|
||||
|
||||
- name: Setup Windows EV Signing Certificate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
touch /tmp/ev_cert.pem
|
||||
chmod 600 /tmp/ev_cert.pem
|
||||
echo "$EV_SIGNING_CERT" > /tmp/ev_cert.pem
|
||||
wget https://github.com/ebourg/jsign/releases/download/6.0/jsign-6.0.jar -O /tmp/jsign-6.0.jar
|
||||
env:
|
||||
EV_SIGNING_CERT: ${{ secrets.EV_SIGNING_CERT }}
|
||||
|
||||
# Setup GCloud for signing Windows binaries.
|
||||
- name: Authenticate to Google Cloud
|
||||
id: gcloud_auth
|
||||
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }}
|
||||
token_format: "access_token"
|
||||
|
||||
- name: Setup GCloud SDK
|
||||
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||
with:
|
||||
name: dylibs
|
||||
path: ./build
|
||||
@@ -1172,18 +1081,6 @@ jobs:
|
||||
build/coder_linux_{amd64,arm64,armv7} \
|
||||
build/coder_"$version"_windows_amd64.zip \
|
||||
build/coder_"$version"_linux_amd64.{tar.gz,deb}
|
||||
env:
|
||||
# The Windows slim binary must be signed for Coder Desktop to accept
|
||||
# it. The darwin executables don't need to be signed, but the dylibs
|
||||
# do (see above).
|
||||
CODER_SIGN_WINDOWS: "1"
|
||||
CODER_WINDOWS_RESOURCES: "1"
|
||||
EV_KEY: ${{ secrets.EV_KEY }}
|
||||
EV_KEYSTORE: ${{ secrets.EV_KEYSTORE }}
|
||||
EV_TSA_URL: ${{ secrets.EV_TSA_URL }}
|
||||
EV_CERTIFICATE_PATH: /tmp/ev_cert.pem
|
||||
GCLOUD_ACCESS_TOKEN: ${{ steps.gcloud_auth.outputs.access_token }}
|
||||
JSIGN_PATH: /tmp/jsign-6.0.jar
|
||||
|
||||
- name: Build Linux Docker images
|
||||
id: build-docker
|
||||
@@ -1225,166 +1122,6 @@ jobs:
|
||||
done
|
||||
fi
|
||||
|
||||
- name: SBOM Generation and Attestation
|
||||
if: github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
env:
|
||||
COSIGN_EXPERIMENTAL: 1
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
# Define image base and tags
|
||||
IMAGE_BASE="ghcr.io/coder/coder-preview"
|
||||
TAGS=("${{ steps.build-docker.outputs.tag }}" "main" "latest")
|
||||
|
||||
# Generate and attest SBOM for each tag
|
||||
for tag in "${TAGS[@]}"; do
|
||||
IMAGE="${IMAGE_BASE}:${tag}"
|
||||
SBOM_FILE="coder_sbom_${tag//[:\/]/_}.spdx.json"
|
||||
|
||||
echo "Generating SBOM for image: ${IMAGE}"
|
||||
syft "${IMAGE}" -o spdx-json > "${SBOM_FILE}"
|
||||
|
||||
echo "Attesting SBOM to image: ${IMAGE}"
|
||||
cosign clean --force=true "${IMAGE}"
|
||||
cosign attest --type spdxjson \
|
||||
--predicate "${SBOM_FILE}" \
|
||||
--yes \
|
||||
"${IMAGE}"
|
||||
done
|
||||
|
||||
# GitHub attestation provides SLSA provenance for the Docker images, establishing a verifiable
|
||||
# record that these images were built in GitHub Actions with specific inputs and environment.
|
||||
# This complements our existing cosign attestations which focus on SBOMs.
|
||||
#
|
||||
# We attest each tag separately to ensure all tags have proper provenance records.
|
||||
# TODO: Consider refactoring these steps to use a matrix strategy or composite action to reduce duplication
|
||||
# while maintaining the required functionality for each tag.
|
||||
- name: GitHub Attestation for Docker image
|
||||
id: attest_main
|
||||
if: github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
uses: actions/attest@afd638254319277bb3d7f0a234478733e2e46a73 # v2.3.0
|
||||
with:
|
||||
subject-name: "ghcr.io/coder/coder-preview:main"
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
predicate: |
|
||||
{
|
||||
"buildType": "https://github.com/actions/runner-images/",
|
||||
"builder": {
|
||||
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
},
|
||||
"invocation": {
|
||||
"configSource": {
|
||||
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
|
||||
"digest": {
|
||||
"sha1": "${{ github.sha }}"
|
||||
},
|
||||
"entryPoint": ".github/workflows/ci.yaml"
|
||||
},
|
||||
"environment": {
|
||||
"github_workflow": "${{ github.workflow }}",
|
||||
"github_run_id": "${{ github.run_id }}"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"buildInvocationID": "${{ github.run_id }}",
|
||||
"completeness": {
|
||||
"environment": true,
|
||||
"materials": true
|
||||
}
|
||||
}
|
||||
}
|
||||
push-to-registry: true
|
||||
|
||||
- name: GitHub Attestation for Docker image (latest tag)
|
||||
id: attest_latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
uses: actions/attest@afd638254319277bb3d7f0a234478733e2e46a73 # v2.3.0
|
||||
with:
|
||||
subject-name: "ghcr.io/coder/coder-preview:latest"
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
predicate: |
|
||||
{
|
||||
"buildType": "https://github.com/actions/runner-images/",
|
||||
"builder": {
|
||||
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
},
|
||||
"invocation": {
|
||||
"configSource": {
|
||||
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
|
||||
"digest": {
|
||||
"sha1": "${{ github.sha }}"
|
||||
},
|
||||
"entryPoint": ".github/workflows/ci.yaml"
|
||||
},
|
||||
"environment": {
|
||||
"github_workflow": "${{ github.workflow }}",
|
||||
"github_run_id": "${{ github.run_id }}"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"buildInvocationID": "${{ github.run_id }}",
|
||||
"completeness": {
|
||||
"environment": true,
|
||||
"materials": true
|
||||
}
|
||||
}
|
||||
}
|
||||
push-to-registry: true
|
||||
|
||||
- name: GitHub Attestation for version-specific Docker image
|
||||
id: attest_version
|
||||
if: github.ref == 'refs/heads/main'
|
||||
continue-on-error: true
|
||||
uses: actions/attest@afd638254319277bb3d7f0a234478733e2e46a73 # v2.3.0
|
||||
with:
|
||||
subject-name: "ghcr.io/coder/coder-preview:${{ steps.build-docker.outputs.tag }}"
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
predicate: |
|
||||
{
|
||||
"buildType": "https://github.com/actions/runner-images/",
|
||||
"builder": {
|
||||
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
},
|
||||
"invocation": {
|
||||
"configSource": {
|
||||
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
|
||||
"digest": {
|
||||
"sha1": "${{ github.sha }}"
|
||||
},
|
||||
"entryPoint": ".github/workflows/ci.yaml"
|
||||
},
|
||||
"environment": {
|
||||
"github_workflow": "${{ github.workflow }}",
|
||||
"github_run_id": "${{ github.run_id }}"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"buildInvocationID": "${{ github.run_id }}",
|
||||
"completeness": {
|
||||
"environment": true,
|
||||
"materials": true
|
||||
}
|
||||
}
|
||||
}
|
||||
push-to-registry: true
|
||||
|
||||
# Report attestation failures but don't fail the workflow
|
||||
- name: Check attestation status
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
if [[ "${{ steps.attest_main.outcome }}" == "failure" ]]; then
|
||||
echo "::warning::GitHub attestation for main tag failed"
|
||||
fi
|
||||
if [[ "${{ steps.attest_latest.outcome }}" == "failure" ]]; then
|
||||
echo "::warning::GitHub attestation for latest tag failed"
|
||||
fi
|
||||
if [[ "${{ steps.attest_version.outcome }}" == "failure" ]]; then
|
||||
echo "::warning::GitHub attestation for version-specific tag failed"
|
||||
fi
|
||||
|
||||
- name: Prune old images
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: vlaurin/action-ghcr-prune@0cf7d39f88546edd31965acba78cdcb0be14d641 # v0.6.0
|
||||
@@ -1402,7 +1139,7 @@ jobs:
|
||||
|
||||
- name: Upload build artifacts
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
with:
|
||||
name: coder
|
||||
path: |
|
||||
@@ -1426,32 +1163,32 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
|
||||
uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7
|
||||
with:
|
||||
workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github
|
||||
service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com
|
||||
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
|
||||
uses: google-github-actions/setup-gcloud@6189d56e4096ee891640bb02ac264be376592d6a # v2.1.2
|
||||
|
||||
- name: Set up Flux CLI
|
||||
uses: fluxcd/flux2/action@8d5f40dca5aa5d3c0fc3414457dda15a0ac92fa4 # v2.5.1
|
||||
uses: fluxcd/flux2/action@5350425cdcd5fa015337e09fa502153c0275bd4b # v2.4.0
|
||||
with:
|
||||
# Keep this and the github action up to date with the version of flux installed in dogfood cluster
|
||||
version: "2.5.1"
|
||||
version: "2.2.1"
|
||||
|
||||
- name: Get Cluster Credentials
|
||||
uses: google-github-actions/get-gke-credentials@d0cee45012069b163a631894b98904a9e6723729 # v2.3.3
|
||||
uses: google-github-actions/get-gke-credentials@9025e8f90f2d8e0c3dafc3128cc705a26d992a6a # v2.3.0
|
||||
with:
|
||||
cluster_name: dogfood-v2
|
||||
location: us-central1-a
|
||||
@@ -1481,8 +1218,6 @@ jobs:
|
||||
kubectl --namespace coder rollout status deployment/coder
|
||||
kubectl --namespace coder rollout restart deployment/coder-provisioner
|
||||
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
|
||||
|
||||
deploy-wsproxies:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1490,12 +1225,12 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' && !github.event.pull_request.head.repo.fork
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -1525,12 +1260,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@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# We need golang to run the migration main.go
|
||||
|
||||
@@ -2,14 +2,15 @@ name: contrib
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
pull_request_target:
|
||||
types: [created]
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- closed
|
||||
- synchronize
|
||||
- labeled
|
||||
- unlabeled
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
# For jobs that don't run on draft PRs.
|
||||
@@ -22,13 +23,88 @@ permissions:
|
||||
concurrency: pr-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
# Dependabot is annoying, but this makes it a bit less so.
|
||||
dependabot-automerge:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'coder/coder'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@d7267f607e9d3fb96fc2fbe83e0af444713e90b7 # v2.3.0
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Approve the PR
|
||||
run: gh pr review --approve "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
- name: Enable auto-merge for Dependabot PRs
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
dependabot-automerge-notify:
|
||||
# Send a slack notification when a dependabot PR is merged.
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'coder/coder' && github.event.pull_request.merged
|
||||
steps:
|
||||
- name: Send Slack notification
|
||||
env:
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
PR_TITLE: ${{github.event.pull_request.title}}
|
||||
PR_NUMBER: ${{github.event.pull_request.number}}
|
||||
run: |
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{
|
||||
"username": "dependabot",
|
||||
"icon_url": "https://avatars.githubusercontent.com/u/27347476",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": ":pr-merged: Auto merged Dependabot PR #${{ env.PR_NUMBER }}",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "${{ env.PR_TITLE }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View PR"
|
||||
},
|
||||
"url": "${{ env.PR_URL }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}' ${{ secrets.DEPENDABOT_PRS_SLACK_WEBHOOK }}
|
||||
|
||||
cla:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: cla
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request'
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -46,10 +122,8 @@ jobs:
|
||||
|
||||
release-labels:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
# Skip tagging for draft PRs.
|
||||
if: ${{ github.event_name == 'pull_request_target' && !github.event.pull_request.draft }}
|
||||
if: ${{ github.event_name == 'pull_request' && !github.event.pull_request.draft }}
|
||||
steps:
|
||||
- name: release-labels
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
@@ -84,7 +158,7 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
}
|
||||
|
||||
if (action === "opened" || action === "reopened" || action === "ready_for_review") {
|
||||
if (action === "opened" || action === "reopened") {
|
||||
if (isBreakingTitle && !labels.includes(releaseLabels.breaking)) {
|
||||
console.log('Add "%s" label', releaseLabels.breaking)
|
||||
await github.rest.issues.addLabels({
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
name: dependabot
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependabot-automerge:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened' &&
|
||||
github.event.pull_request.user.login == 'dependabot[bot]' &&
|
||||
github.actor_id == 49699333 &&
|
||||
github.repository == 'coder/coder'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@d7267f607e9d3fb96fc2fbe83e0af444713e90b7 # v2.3.0
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Approve the PR
|
||||
run: |
|
||||
echo "Approving $PR_URL"
|
||||
gh pr review --approve "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
- name: Enable auto-merge
|
||||
run: |
|
||||
echo "Enabling auto-merge for $PR_URL"
|
||||
gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
GH_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
- name: Send Slack notification
|
||||
env:
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
PR_TITLE: ${{github.event.pull_request.title}}
|
||||
PR_NUMBER: ${{github.event.pull_request.number}}
|
||||
run: |
|
||||
curl -X POST -H 'Content-type: application/json' \
|
||||
--data '{
|
||||
"username": "dependabot",
|
||||
"icon_url": "https://avatars.githubusercontent.com/u/27347476",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": ":pr-merged: Auto merge enabled for Dependabot PR #${{ env.PR_NUMBER }}",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": "${{ env.PR_TITLE }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "actions",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": "View PR"
|
||||
},
|
||||
"url": "${{ env.PR_URL }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}' ${{ secrets.DEPENDABOT_PRS_SLACK_WEBHOOK }}
|
||||
@@ -38,15 +38,15 @@ jobs:
|
||||
if: github.repository_owner == 'coder'
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Docker login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -15,20 +15,17 @@ on:
|
||||
- "**.md"
|
||||
- ".github/workflows/docs-ci.yaml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- uses: tj-actions/changed-files@5426ecc3f5c2b10effaefbd374f0abdc6a571b2f # v45.0.7
|
||||
- uses: tj-actions/changed-files@d6e91a2266cdb9d62096cebf1e8546899c6aa18f # v45.0.6
|
||||
id: changed-files
|
||||
with:
|
||||
files: |
|
||||
|
||||
@@ -27,38 +27,22 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@5bb6a3b3abe66fd09bbf250dce8ada94f856a703 # v30
|
||||
uses: DeterminateSystems/nix-installer-action@e50d5f73bfe71c2dd0aa4218de8f4afa59f8f81d # v16
|
||||
|
||||
- uses: nix-community/cache-nix-action@135667ec418502fa5a3598af6fb9eb733888ce6a # v6.1.3
|
||||
with:
|
||||
# restore and save a cache using this key
|
||||
primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
|
||||
# if there's no cache hit, restore a cache by this prefix
|
||||
restore-prefixes-first-match: nix-${{ runner.os }}-
|
||||
# collect garbage until Nix store size (in bytes) is at most this number
|
||||
# before trying to save a new cache
|
||||
# 1G = 1073741824
|
||||
gc-max-store-size-linux: 5G
|
||||
# do purge caches
|
||||
purge: true
|
||||
# purge all versions of the cache
|
||||
purge-prefixes: nix-${{ runner.os }}-
|
||||
# created more than this number of seconds ago relative to the start of the `Post Restore` phase
|
||||
purge-created: 0
|
||||
# except the version with the `primary-key`, if it exists
|
||||
purge-primary-key: never
|
||||
- name: Setup GHA Nix cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@6221693898146dc97e38ad0e013488a16477a4c4 # v9
|
||||
|
||||
- name: Get branch name
|
||||
id: branch-name
|
||||
uses: tj-actions/branch-names@dde14ac574a8b9b1cedc59a1cf312788af43d8d8 # v8.2.1
|
||||
uses: tj-actions/branch-names@6871f53176ad61624f978536bbf089c574dc19a2 # v8.0.1
|
||||
|
||||
- name: "Branch name to Docker tag name"
|
||||
id: docker-tag-name
|
||||
@@ -72,11 +56,11 @@ jobs:
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
@@ -87,7 +71,7 @@ jobs:
|
||||
project: b4q6ltmpzh
|
||||
token: ${{ secrets.DEPOT_TOKEN }}
|
||||
buildx-fallback: true
|
||||
context: "{{defaultContext}}:dogfood/coder"
|
||||
context: "{{defaultContext}}:dogfood/contents"
|
||||
pull: true
|
||||
save: true
|
||||
push: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -114,36 +98,30 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
|
||||
uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7
|
||||
with:
|
||||
workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github
|
||||
service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com
|
||||
|
||||
- name: Terraform init and validate
|
||||
run: |
|
||||
pushd dogfood/
|
||||
terraform init
|
||||
cd dogfood
|
||||
terraform init -upgrade
|
||||
terraform validate
|
||||
popd
|
||||
pushd dogfood/coder
|
||||
terraform init
|
||||
cd contents
|
||||
terraform init -upgrade
|
||||
terraform validate
|
||||
popd
|
||||
pushd dogfood/coder-envbuilder
|
||||
terraform init
|
||||
terraform validate
|
||||
popd
|
||||
|
||||
- name: Get short commit SHA
|
||||
if: github.ref == 'refs/heads/main'
|
||||
@@ -167,6 +145,6 @@ jobs:
|
||||
# Template source & details
|
||||
TF_VAR_CODER_TEMPLATE_NAME: ${{ secrets.CODER_TEMPLATE_NAME }}
|
||||
TF_VAR_CODER_TEMPLATE_VERSION: ${{ steps.vars.outputs.sha_short }}
|
||||
TF_VAR_CODER_TEMPLATE_DIR: ./coder
|
||||
TF_VAR_CODER_TEMPLATE_DIR: ./contents
|
||||
TF_VAR_CODER_TEMPLATE_MESSAGE: ${{ steps.message.outputs.pr_title }}
|
||||
TF_LOG: info
|
||||
|
||||
@@ -20,19 +20,18 @@ jobs:
|
||||
# even if some of the preceding steps are slow.
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- macos-latest
|
||||
- windows-2022
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
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@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Check if PR is open
|
||||
id: check_pr
|
||||
@@ -74,12 +74,12 @@ jobs:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
pull-requests: write # needed for commenting on PRs
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -218,12 +218,12 @@ jobs:
|
||||
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: GHCR Login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -276,7 +276,7 @@ jobs:
|
||||
PR_HOSTNAME: "pr${{ needs.get_info.outputs.PR_NUMBER }}.${{ secrets.PR_DEPLOYMENTS_DOMAIN }}"
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -325,7 +325,7 @@ jobs:
|
||||
kubectl create namespace "pr${{ env.PR_NUMBER }}"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Check and Create Certificate
|
||||
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
+40
-313
@@ -36,9 +36,13 @@ jobs:
|
||||
build-dylib:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest' }}
|
||||
steps:
|
||||
# Harden Runner doesn't work on macOS.
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -57,11 +61,6 @@ jobs:
|
||||
echo "$(brew --prefix gnu-getopt)/bin" >> $GITHUB_PATH
|
||||
echo "$(brew --prefix make)/libexec/gnubin" >> $GITHUB_PATH
|
||||
|
||||
- name: Switch XCode Version
|
||||
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1.6.0
|
||||
with:
|
||||
xcode-version: "16.0.0"
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
@@ -101,7 +100,7 @@ jobs:
|
||||
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
with:
|
||||
name: dylibs
|
||||
path: |
|
||||
@@ -122,11 +121,7 @@ jobs:
|
||||
# Necessary to push docker images to ghcr.io.
|
||||
packages: write
|
||||
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
|
||||
# Also necessary for keyless cosign (https://docs.sigstore.dev/cosign/signing/overview/)
|
||||
# And for GitHub Actions attestation
|
||||
id-token: write
|
||||
# Required for GitHub Actions attestation
|
||||
attestations: write
|
||||
env:
|
||||
# Necessary for Docker manifest
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
@@ -134,12 +129,12 @@ jobs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -208,7 +203,7 @@ jobs:
|
||||
cat "$CODER_RELEASE_NOTES_FILE"
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -222,17 +217,26 @@ jobs:
|
||||
|
||||
# Necessary for signing Windows binaries.
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.4.0
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "11.0"
|
||||
|
||||
- name: Install go-winres
|
||||
run: go install github.com/tc-hib/go-winres@d743268d7ea168077ddd443c4240562d4f5e8c3e # v0.3.3
|
||||
|
||||
- name: Install nsis and zstd
|
||||
run: sudo apt-get install -y nsis zstd
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||
with:
|
||||
name: dylibs
|
||||
path: ./build
|
||||
|
||||
- name: Insert dylibs
|
||||
run: |
|
||||
mv ./build/*amd64.dylib ./site/out/bin/coder-vpn-darwin-amd64.dylib
|
||||
mv ./build/*arm64.dylib ./site/out/bin/coder-vpn-darwin-arm64.dylib
|
||||
mv ./build/*arm64.h ./site/out/bin/coder-vpn-darwin-dylib.h
|
||||
|
||||
- name: Install nfpm
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -250,12 +254,6 @@ jobs:
|
||||
apple-codesign-0.22.0-x86_64-unknown-linux-musl/rcodesign
|
||||
rm /tmp/rcodesign.tar.gz
|
||||
|
||||
- name: Install cosign
|
||||
uses: ./.github/actions/install-cosign
|
||||
|
||||
- name: Install syft
|
||||
uses: ./.github/actions/install-syft
|
||||
|
||||
- name: Setup Apple Developer certificate and API key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -286,26 +284,14 @@ jobs:
|
||||
# Setup GCloud for signing Windows binaries.
|
||||
- name: Authenticate to Google Cloud
|
||||
id: gcloud_auth
|
||||
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
|
||||
uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_CODE_SIGNING_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_CODE_SIGNING_SERVICE_ACCOUNT }}
|
||||
token_format: "access_token"
|
||||
|
||||
- name: Setup GCloud SDK
|
||||
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
|
||||
|
||||
- name: Download dylibs
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: dylibs
|
||||
path: ./build
|
||||
|
||||
- name: Insert dylibs
|
||||
run: |
|
||||
mv ./build/*amd64.dylib ./site/out/bin/coder-vpn-darwin-amd64.dylib
|
||||
mv ./build/*arm64.dylib ./site/out/bin/coder-vpn-darwin-arm64.dylib
|
||||
mv ./build/*arm64.h ./site/out/bin/coder-vpn-darwin-dylib.h
|
||||
uses: google-github-actions/setup-gcloud@6189d56e4096ee891640bb02ac264be376592d6a # v2.1.2
|
||||
|
||||
- name: Build binaries
|
||||
run: |
|
||||
@@ -323,7 +309,6 @@ jobs:
|
||||
env:
|
||||
CODER_SIGN_WINDOWS: "1"
|
||||
CODER_SIGN_DARWIN: "1"
|
||||
CODER_WINDOWS_RESOURCES: "1"
|
||||
AC_CERTIFICATE_FILE: /tmp/apple_cert.p12
|
||||
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
|
||||
AC_APIKEY_ISSUER_ID: ${{ secrets.AC_APIKEY_ISSUER_ID }}
|
||||
@@ -371,7 +356,6 @@ jobs:
|
||||
file: scripts/Dockerfile.base
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
provenance: true
|
||||
sbom: true
|
||||
pull: true
|
||||
no-cache: true
|
||||
push: true
|
||||
@@ -408,52 +392,7 @@ jobs:
|
||||
echo "$manifests" | grep -q linux/arm64
|
||||
echo "$manifests" | grep -q linux/arm/v7
|
||||
|
||||
# GitHub attestation provides SLSA provenance for Docker images, establishing a verifiable
|
||||
# record that these images were built in GitHub Actions with specific inputs and environment.
|
||||
# This complements our existing cosign attestations (which focus on SBOMs) by adding
|
||||
# GitHub-specific build provenance to enhance our supply chain security.
|
||||
#
|
||||
# TODO: Consider refactoring these attestation steps to use a matrix strategy or composite action
|
||||
# to reduce duplication while maintaining the required functionality for each distinct image tag.
|
||||
- name: GitHub Attestation for Base Docker image
|
||||
id: attest_base
|
||||
if: ${{ !inputs.dry_run && steps.image-base-tag.outputs.tag != '' }}
|
||||
continue-on-error: true
|
||||
uses: actions/attest@afd638254319277bb3d7f0a234478733e2e46a73 # v2.3.0
|
||||
with:
|
||||
subject-name: ${{ steps.image-base-tag.outputs.tag }}
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
predicate: |
|
||||
{
|
||||
"buildType": "https://github.com/actions/runner-images/",
|
||||
"builder": {
|
||||
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
},
|
||||
"invocation": {
|
||||
"configSource": {
|
||||
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
|
||||
"digest": {
|
||||
"sha1": "${{ github.sha }}"
|
||||
},
|
||||
"entryPoint": ".github/workflows/release.yaml"
|
||||
},
|
||||
"environment": {
|
||||
"github_workflow": "${{ github.workflow }}",
|
||||
"github_run_id": "${{ github.run_id }}"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"buildInvocationID": "${{ github.run_id }}",
|
||||
"completeness": {
|
||||
"environment": true,
|
||||
"materials": true
|
||||
}
|
||||
}
|
||||
}
|
||||
push-to-registry: true
|
||||
|
||||
- name: Build Linux Docker images
|
||||
id: build_docker
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
@@ -472,158 +411,18 @@ jobs:
|
||||
# being pushed so will automatically push them.
|
||||
make push/build/coder_"$version"_linux.tag
|
||||
|
||||
# Save multiarch image tag for attestation
|
||||
multiarch_image="$(./scripts/image_tag.sh)"
|
||||
echo "multiarch_image=${multiarch_image}" >> $GITHUB_OUTPUT
|
||||
|
||||
# For debugging, print all docker image tags
|
||||
docker images
|
||||
|
||||
# if the current version is equal to the highest (according to semver)
|
||||
# version in the repo, also create a multi-arch image as ":latest" and
|
||||
# push it
|
||||
created_latest_tag=false
|
||||
if [[ "$(git tag | grep '^v' | grep -vE '(rc|dev|-|\+|\/)' | sort -r --version-sort | head -n1)" == "v$(./scripts/version.sh)" ]]; then
|
||||
./scripts/build_docker_multiarch.sh \
|
||||
--push \
|
||||
--target "$(./scripts/image_tag.sh --version latest)" \
|
||||
$(cat build/coder_"$version"_linux_{amd64,arm64,armv7}.tag)
|
||||
created_latest_tag=true
|
||||
echo "created_latest_tag=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "created_latest_tag=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
CODER_BASE_IMAGE_TAG: ${{ steps.image-base-tag.outputs.tag }}
|
||||
|
||||
- name: SBOM Generation and Attestation
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
COSIGN_EXPERIMENTAL: "1"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
# Generate SBOM for multi-arch image with version in filename
|
||||
echo "Generating SBOM for multi-arch image: ${{ steps.build_docker.outputs.multiarch_image }}"
|
||||
syft "${{ steps.build_docker.outputs.multiarch_image }}" -o spdx-json > coder_${{ steps.version.outputs.version }}_sbom.spdx.json
|
||||
|
||||
# Attest SBOM to multi-arch image
|
||||
echo "Attesting SBOM to multi-arch image: ${{ steps.build_docker.outputs.multiarch_image }}"
|
||||
cosign clean --force=true "${{ steps.build_docker.outputs.multiarch_image }}"
|
||||
cosign attest --type spdxjson \
|
||||
--predicate coder_${{ steps.version.outputs.version }}_sbom.spdx.json \
|
||||
--yes \
|
||||
"${{ steps.build_docker.outputs.multiarch_image }}"
|
||||
|
||||
# If latest tag was created, also attest it
|
||||
if [[ "${{ steps.build_docker.outputs.created_latest_tag }}" == "true" ]]; then
|
||||
latest_tag="$(./scripts/image_tag.sh --version latest)"
|
||||
echo "Generating SBOM for latest image: ${latest_tag}"
|
||||
syft "${latest_tag}" -o spdx-json > coder_latest_sbom.spdx.json
|
||||
|
||||
echo "Attesting SBOM to latest image: ${latest_tag}"
|
||||
cosign clean --force=true "${latest_tag}"
|
||||
cosign attest --type spdxjson \
|
||||
--predicate coder_latest_sbom.spdx.json \
|
||||
--yes \
|
||||
"${latest_tag}"
|
||||
fi
|
||||
|
||||
- name: GitHub Attestation for Docker image
|
||||
id: attest_main
|
||||
if: ${{ !inputs.dry_run }}
|
||||
continue-on-error: true
|
||||
uses: actions/attest@afd638254319277bb3d7f0a234478733e2e46a73 # v2.3.0
|
||||
with:
|
||||
subject-name: ${{ steps.build_docker.outputs.multiarch_image }}
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
predicate: |
|
||||
{
|
||||
"buildType": "https://github.com/actions/runner-images/",
|
||||
"builder": {
|
||||
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
},
|
||||
"invocation": {
|
||||
"configSource": {
|
||||
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
|
||||
"digest": {
|
||||
"sha1": "${{ github.sha }}"
|
||||
},
|
||||
"entryPoint": ".github/workflows/release.yaml"
|
||||
},
|
||||
"environment": {
|
||||
"github_workflow": "${{ github.workflow }}",
|
||||
"github_run_id": "${{ github.run_id }}"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"buildInvocationID": "${{ github.run_id }}",
|
||||
"completeness": {
|
||||
"environment": true,
|
||||
"materials": true
|
||||
}
|
||||
}
|
||||
}
|
||||
push-to-registry: true
|
||||
|
||||
# Get the latest tag name for attestation
|
||||
- name: Get latest tag name
|
||||
id: latest_tag
|
||||
if: ${{ !inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' }}
|
||||
run: echo "tag=$(./scripts/image_tag.sh --version latest)" >> $GITHUB_OUTPUT
|
||||
|
||||
# If this is the highest version according to semver, also attest the "latest" tag
|
||||
- name: GitHub Attestation for "latest" Docker image
|
||||
id: attest_latest
|
||||
if: ${{ !inputs.dry_run && steps.build_docker.outputs.created_latest_tag == 'true' }}
|
||||
continue-on-error: true
|
||||
uses: actions/attest@afd638254319277bb3d7f0a234478733e2e46a73 # v2.3.0
|
||||
with:
|
||||
subject-name: ${{ steps.latest_tag.outputs.tag }}
|
||||
predicate-type: "https://slsa.dev/provenance/v1"
|
||||
predicate: |
|
||||
{
|
||||
"buildType": "https://github.com/actions/runner-images/",
|
||||
"builder": {
|
||||
"id": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
},
|
||||
"invocation": {
|
||||
"configSource": {
|
||||
"uri": "git+https://github.com/${{ github.repository }}@${{ github.ref }}",
|
||||
"digest": {
|
||||
"sha1": "${{ github.sha }}"
|
||||
},
|
||||
"entryPoint": ".github/workflows/release.yaml"
|
||||
},
|
||||
"environment": {
|
||||
"github_workflow": "${{ github.workflow }}",
|
||||
"github_run_id": "${{ github.run_id }}"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"buildInvocationID": "${{ github.run_id }}",
|
||||
"completeness": {
|
||||
"environment": true,
|
||||
"materials": true
|
||||
}
|
||||
}
|
||||
}
|
||||
push-to-registry: true
|
||||
|
||||
# Report attestation failures but don't fail the workflow
|
||||
- name: Check attestation status
|
||||
if: ${{ !inputs.dry_run }}
|
||||
run: |
|
||||
if [[ "${{ steps.attest_base.outcome }}" == "failure" && "${{ steps.attest_base.conclusion }}" != "skipped" ]]; then
|
||||
echo "::warning::GitHub attestation for base image failed"
|
||||
fi
|
||||
if [[ "${{ steps.attest_main.outcome }}" == "failure" ]]; then
|
||||
echo "::warning::GitHub attestation for main image failed"
|
||||
fi
|
||||
if [[ "${{ steps.attest_latest.outcome }}" == "failure" && "${{ steps.attest_latest.conclusion }}" != "skipped" ]]; then
|
||||
echo "::warning::GitHub attestation for latest image failed"
|
||||
fi
|
||||
|
||||
- name: Generate offline docs
|
||||
run: |
|
||||
version="$(./scripts/version.sh)"
|
||||
@@ -645,39 +444,28 @@ jobs:
|
||||
fi
|
||||
declare -p publish_args
|
||||
|
||||
# Build the list of files to publish
|
||||
files=(
|
||||
./build/*_installer.exe
|
||||
./build/*.zip
|
||||
./build/*.tar.gz
|
||||
./build/*.tgz
|
||||
./build/*.apk
|
||||
./build/*.deb
|
||||
./build/*.rpm
|
||||
./coder_${{ steps.version.outputs.version }}_sbom.spdx.json
|
||||
)
|
||||
|
||||
# Only include the latest SBOM file if it was created
|
||||
if [[ "${{ steps.build_docker.outputs.created_latest_tag }}" == "true" ]]; then
|
||||
files+=(./coder_latest_sbom.spdx.json)
|
||||
fi
|
||||
|
||||
./scripts/release/publish.sh \
|
||||
"${publish_args[@]}" \
|
||||
--release-notes-file "$CODER_RELEASE_NOTES_FILE" \
|
||||
"${files[@]}"
|
||||
./build/*_installer.exe \
|
||||
./build/*.zip \
|
||||
./build/*.tar.gz \
|
||||
./build/*.tgz \
|
||||
./build/*.apk \
|
||||
./build/*.deb \
|
||||
./build/*.rpm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CODER_GPG_RELEASE_KEY_BASE64: ${{ secrets.GPG_RELEASE_KEY_BASE64 }}
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
|
||||
uses: google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f # v2.1.7
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Setup GCloud SDK
|
||||
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # 2.1.4
|
||||
uses: google-github-actions/setup-gcloud@6189d56e4096ee891640bb02ac264be376592d6a # 2.1.2
|
||||
|
||||
- name: Publish Helm Chart
|
||||
if: ${{ !inputs.dry_run }}
|
||||
@@ -696,7 +484,7 @@ jobs:
|
||||
|
||||
- name: Upload artifacts to actions (if dry-run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
with:
|
||||
name: release-artifacts
|
||||
path: |
|
||||
@@ -707,15 +495,6 @@ jobs:
|
||||
./build/*.apk
|
||||
./build/*.deb
|
||||
./build/*.rpm
|
||||
./coder_${{ steps.version.outputs.version }}_sbom.spdx.json
|
||||
retention-days: 7
|
||||
|
||||
- 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: latest-sbom-artifact
|
||||
path: ./coder_latest_sbom.spdx.json
|
||||
retention-days: 7
|
||||
|
||||
- name: Send repository-dispatch event
|
||||
@@ -737,7 +516,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@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -813,7 +592,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -823,7 +602,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -903,12 +682,12 @@ jobs:
|
||||
if: ${{ !inputs.dry_run }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -924,55 +703,3 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: |
|
||||
make sqlc-push
|
||||
|
||||
update-calendar:
|
||||
name: "Update release calendar in docs"
|
||||
runs-on: "ubuntu-latest"
|
||||
needs: [release, publish-homebrew, publish-winget, publish-sqlc]
|
||||
if: ${{ !inputs.dry_run }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0 # Needed to get all tags for version calculation
|
||||
|
||||
- name: Set up Git
|
||||
run: |
|
||||
git config user.name "Coder CI"
|
||||
git config user.email "cdrci@coder.com"
|
||||
|
||||
- name: Run update script
|
||||
run: |
|
||||
./scripts/update-release-calendar.sh
|
||||
make fmt/markdown
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
if git diff --quiet docs/install/releases/index.md; then
|
||||
echo "No changes detected in release calendar."
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Changes detected in release calendar."
|
||||
echo "changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check_changes.outputs.changes == 'true'
|
||||
uses: peter-evans/create-pull-request@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
|
||||
with:
|
||||
commit-message: "docs: update release calendar"
|
||||
title: "docs: update release calendar"
|
||||
body: |
|
||||
This PR automatically updates the release calendar in the docs.
|
||||
branch: bot/update-release-calendar
|
||||
delete-branch: true
|
||||
labels: docs
|
||||
|
||||
@@ -20,17 +20,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
|
||||
uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0
|
||||
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
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@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
|
||||
uses: github/codeql-action/upload-sarif@f6091c0113d1dcf9b98e269ee48e8a7e51b7bdd4 # v3.28.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -27,18 +27,18 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
|
||||
uses: github/codeql-action/init@f6091c0113d1dcf9b98e269ee48e8a7e51b7bdd4 # v3.28.5
|
||||
with:
|
||||
languages: go, javascript
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
rm Makefile
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
|
||||
uses: github/codeql-action/analyze@f6091c0113d1dcf9b98e269ee48e8a7e51b7bdd4 # v3.28.5
|
||||
|
||||
- name: Send Slack notification on failure
|
||||
if: ${{ failure() }}
|
||||
@@ -67,12 +67,12 @@ jobs:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -85,12 +85,6 @@ jobs:
|
||||
- name: Setup sqlc
|
||||
uses: ./.github/actions/setup-sqlc
|
||||
|
||||
- name: Install cosign
|
||||
uses: ./.github/actions/install-cosign
|
||||
|
||||
- name: Install syft
|
||||
uses: ./.github/actions/install-syft
|
||||
|
||||
- name: Install yq
|
||||
run: go run github.com/mikefarah/yq/v4@v4.44.3
|
||||
- name: Install mockgen
|
||||
@@ -105,7 +99,7 @@ jobs:
|
||||
# version in the comments will differ. This is also defined in
|
||||
# ci.yaml.
|
||||
set -euxo pipefail
|
||||
cd dogfood/coder
|
||||
cd dogfood/contents
|
||||
mkdir -p /usr/local/bin
|
||||
mkdir -p /usr/local/include
|
||||
|
||||
@@ -142,7 +136,7 @@ jobs:
|
||||
echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5
|
||||
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0
|
||||
with:
|
||||
image-ref: ${{ steps.build.outputs.image }}
|
||||
format: sarif
|
||||
@@ -150,13 +144,13 @@ jobs:
|
||||
severity: "CRITICAL,HIGH"
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
|
||||
uses: github/codeql-action/upload-sarif@f6091c0113d1dcf9b98e269ee48e8a7e51b7bdd4 # v3.28.5
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: "Trivy"
|
||||
|
||||
- name: Upload Trivy scan results as an artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1
|
||||
with:
|
||||
name: trivy
|
||||
path: trivy-results.sarif
|
||||
|
||||
@@ -18,12 +18,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: stale
|
||||
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
|
||||
with:
|
||||
stale-issue-label: "stale"
|
||||
stale-pr-label: "stale"
|
||||
@@ -96,14 +96,14 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
- name: Run delete-old-branches-action
|
||||
uses: beatlabs/delete-old-branches-action@4eeeb8740ff8b3cb310296ddd6b43c3387734588 # v0.0.11
|
||||
uses: beatlabs/delete-old-branches-action@6e94df089372a619c01ae2c2f666bf474f890911 # v0.0.10
|
||||
with:
|
||||
repo_token: ${{ github.token }}
|
||||
date: "6 months ago"
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Start Workspace On Issue Creation or Comment
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@coder')) ||
|
||||
(github.event_name == 'issues' && contains(github.event.issue.body, '@coder'))
|
||||
environment: dev.coder.com
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Start Coder workspace
|
||||
uses: coder/start-workspace-action@35a4608cefc7e8cc56573cae7c3b85304575cb72
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github-username: >-
|
||||
${{
|
||||
(github.event_name == 'issue_comment' && github.event.comment.user.login) ||
|
||||
(github.event_name == 'issues' && github.event.issue.user.login)
|
||||
}}
|
||||
coder-url: ${{ secrets.CODER_URL }}
|
||||
coder-token: ${{ secrets.CODER_TOKEN }}
|
||||
template-name: ${{ secrets.CODER_TEMPLATE_NAME }}
|
||||
parameters: |-
|
||||
AI Prompt: "Use the gh CLI tool to read the details of issue https://github.com/${{ github.repository }}/issues/${{ github.event.issue.number }} and then address it."
|
||||
Region: us-pittsburgh
|
||||
@@ -1,6 +1,3 @@
|
||||
[default]
|
||||
extend-ignore-identifiers-re = ["gho_.*"]
|
||||
|
||||
[default.extend-identifiers]
|
||||
alog = "alog"
|
||||
Jetbrains = "JetBrains"
|
||||
@@ -27,7 +24,6 @@ EDE = "EDE"
|
||||
HELO = "HELO"
|
||||
LKE = "LKE"
|
||||
byt = "byt"
|
||||
typ = "typ"
|
||||
|
||||
[files]
|
||||
extend-exclude = [
|
||||
@@ -46,6 +42,5 @@ extend-exclude = [
|
||||
"site/src/pages/SetupPage/countries.tsx",
|
||||
"provisioner/terraform/testdata/**",
|
||||
# notifications' golden files confuse the detector because of quoted-printable encoding
|
||||
"coderd/notifications/testdata/**",
|
||||
"agent/agentcontainers/testdata/devcontainercli/**"
|
||||
"coderd/notifications/testdata/**"
|
||||
]
|
||||
|
||||
@@ -21,15 +21,15 @@ jobs:
|
||||
pull-requests: write # required to post PR review comments by the action
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
|
||||
|
||||
- name: Check Markdown links
|
||||
uses: umbrelladocs/action-linkspector@a0567ce1c7c13de4a2358587492ed43cab5d0102 # v1.3.4
|
||||
uses: umbrelladocs/action-linkspector@de84085e0f51452a470558693d7d308fbb2fa261 # v1.2.5
|
||||
id: markdown-link-check
|
||||
# checks all markdown files from /docs including all subfolders
|
||||
with:
|
||||
|
||||
+1
-2
@@ -32,8 +32,7 @@ site/e2e/.auth.json
|
||||
site/playwright-report/*
|
||||
site/.swc
|
||||
|
||||
# Make target for updating generated/golden files (any dir).
|
||||
.gen
|
||||
# Make target for updating golden files (any dir).
|
||||
.gen-golden
|
||||
|
||||
# Build
|
||||
|
||||
+29
-12
@@ -24,19 +24,30 @@ linters-settings:
|
||||
enabled-checks:
|
||||
# - appendAssign
|
||||
# - appendCombine
|
||||
- argOrder
|
||||
# - assignOp
|
||||
# - badCall
|
||||
- badCond
|
||||
- badLock
|
||||
- badRegexp
|
||||
- boolExprSimplify
|
||||
# - builtinShadow
|
||||
- builtinShadowDecl
|
||||
- captLocal
|
||||
- caseOrder
|
||||
- codegenComment
|
||||
# - commentedOutCode
|
||||
- commentedOutImport
|
||||
- commentFormatting
|
||||
- defaultCaseOrder
|
||||
- deferUnlambda
|
||||
# - deprecatedComment
|
||||
# - docStub
|
||||
- dupArg
|
||||
- dupBranchBody
|
||||
- dupCase
|
||||
- dupImport
|
||||
- dupSubExpr
|
||||
# - elseif
|
||||
- emptyFallthrough
|
||||
# - emptyStringTest
|
||||
@@ -45,6 +56,8 @@ linters-settings:
|
||||
# - exitAfterDefer
|
||||
# - exposedSyncMutex
|
||||
# - filepathJoin
|
||||
- flagDeref
|
||||
- flagName
|
||||
- hexLiteral
|
||||
# - httpNoBody
|
||||
# - hugeParam
|
||||
@@ -52,36 +65,47 @@ linters-settings:
|
||||
# - importShadow
|
||||
- indexAlloc
|
||||
- initClause
|
||||
- mapKey
|
||||
- methodExprCall
|
||||
# - nestingReduce
|
||||
- newDeref
|
||||
- nilValReturn
|
||||
# - octalLiteral
|
||||
- offBy1
|
||||
# - paramTypeCombine
|
||||
# - preferStringWriter
|
||||
# - preferWriteByte
|
||||
# - ptrToRefParam
|
||||
# - rangeExprCopy
|
||||
# - rangeValCopy
|
||||
- regexpMust
|
||||
- regexpPattern
|
||||
# - regexpSimplify
|
||||
- ruleguard
|
||||
- singleCaseSwitch
|
||||
- sloppyLen
|
||||
# - sloppyReassign
|
||||
- sloppyTypeAssert
|
||||
- sortSlice
|
||||
- sprintfQuotedString
|
||||
- sqlQuery
|
||||
# - stringConcatSimplify
|
||||
# - stringXbytes
|
||||
# - suspiciousSorting
|
||||
- switchTrue
|
||||
- truncateCmp
|
||||
- typeAssertChain
|
||||
# - typeDefFirst
|
||||
- typeSwitchVar
|
||||
# - typeUnparen
|
||||
- underef
|
||||
# - unlabelStmt
|
||||
# - unlambda
|
||||
# - unnamedResult
|
||||
# - unnecessaryBlock
|
||||
# - unnecessaryDefer
|
||||
# - unslice
|
||||
- valSwap
|
||||
- weakCond
|
||||
# - whyNoLint
|
||||
# - wrapperFunc
|
||||
@@ -164,7 +188,6 @@ linters-settings:
|
||||
- name: unnecessary-stmt
|
||||
- name: unreachable-code
|
||||
- name: unused-parameter
|
||||
exclude: "**/*_test.go"
|
||||
- name: unused-receiver
|
||||
- name: var-declaration
|
||||
- name: var-naming
|
||||
@@ -180,14 +203,6 @@ linters-settings:
|
||||
- G601
|
||||
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- coderd/database/dbmem
|
||||
- node_modules
|
||||
- .git
|
||||
|
||||
exclude-files:
|
||||
- scripts/rules.go
|
||||
|
||||
# Rules listed here: https://github.com/securego/gosec#available-rules
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
@@ -199,15 +214,17 @@ issues:
|
||||
- path: scripts/*
|
||||
linters:
|
||||
- exhaustruct
|
||||
- path: scripts/rules.go
|
||||
linters:
|
||||
- ALL
|
||||
|
||||
fix: true
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
run:
|
||||
skip-dirs:
|
||||
- node_modules
|
||||
- .git
|
||||
skip-files:
|
||||
- scripts/rules.go
|
||||
timeout: 10m
|
||||
|
||||
# Over time, add more and more linters from
|
||||
|
||||
Vendored
+9
-8
@@ -1,14 +1,14 @@
|
||||
{
|
||||
// For info about snippets, visit https://code.visualstudio.com/docs/editor/userdefinedsnippets
|
||||
// https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
|
||||
|
||||
"alert": {
|
||||
"prefix": "#alert",
|
||||
"admonition": {
|
||||
"prefix": "#callout",
|
||||
"body": [
|
||||
"> [!${1|CAUTION,IMPORTANT,NOTE,TIP,WARNING|}]",
|
||||
"> ${TM_SELECTED_TEXT:${2:add info here}}\n"
|
||||
"<blockquote class=\"admonition ${1|caution,important,note,tip,warning|}\">\n",
|
||||
"${TM_SELECTED_TEXT:${2:add info here}}\n",
|
||||
"</blockquote>\n"
|
||||
],
|
||||
"description": "callout admonition caution important note tip warning"
|
||||
"description": "callout admonition caution info note tip warning"
|
||||
},
|
||||
"fenced code block": {
|
||||
"prefix": "#codeblock",
|
||||
@@ -23,8 +23,9 @@
|
||||
"premium-feature": {
|
||||
"prefix": "#premium-feature",
|
||||
"body": [
|
||||
"> [!NOTE]\n",
|
||||
"> ${1:feature} ${2|is,are|} an Enterprise and Premium feature. [Learn more](https://coder.com/pricing#compare-plans).\n"
|
||||
"<blockquote class=\"info\">\n",
|
||||
"${1:feature} ${2|is,are|} an Enterprise and Premium feature. [Learn more](https://coder.com/pricing#compare-plans).\n",
|
||||
"</blockquote>"
|
||||
]
|
||||
},
|
||||
"tabs": {
|
||||
|
||||
Vendored
+1
-4
@@ -57,8 +57,5 @@
|
||||
"[css][html][markdown][yaml]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"typos.config": ".github/workflows/typos.toml",
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
|
||||
}
|
||||
"typos.config": ".github/workflows/typos.toml"
|
||||
}
|
||||
|
||||
@@ -54,16 +54,6 @@ FIND_EXCLUSIONS= \
|
||||
-not \( \( -path '*/.git/*' -o -path './build/*' -o -path './vendor/*' -o -path './.coderv2/*' -o -path '*/node_modules/*' -o -path '*/out/*' -o -path './coderd/apidoc/*' -o -path '*/.next/*' -o -path '*/.terraform/*' \) -prune \)
|
||||
# Source files used for make targets, evaluated on use.
|
||||
GO_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go' -not -name '*_test.go')
|
||||
# Same as GO_SRC_FILES but excluding certain files that have problematic
|
||||
# Makefile dependencies (e.g. pnpm).
|
||||
MOST_GO_SRC_FILES := $(shell \
|
||||
find . \
|
||||
$(FIND_EXCLUSIONS) \
|
||||
-type f \
|
||||
-name '*.go' \
|
||||
-not -name '*_test.go' \
|
||||
-not -wholename './agent/agentcontainers/dcspec/dcspec_gen.go' \
|
||||
)
|
||||
# All the shell files in the repo, excluding ignored files.
|
||||
SHELL_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
|
||||
|
||||
@@ -126,7 +116,7 @@ endif
|
||||
|
||||
clean:
|
||||
rm -rf build/ site/build/ site/out/
|
||||
mkdir -p build/
|
||||
mkdir -p build/ site/out/bin/
|
||||
git restore site/out/
|
||||
.PHONY: clean
|
||||
|
||||
@@ -253,7 +243,7 @@ $(CODER_ALL_BINARIES): go.mod go.sum \
|
||||
fi
|
||||
|
||||
# This task builds Coder Desktop dylibs
|
||||
$(CODER_DYLIBS): go.mod go.sum $(MOST_GO_SRC_FILES)
|
||||
$(CODER_DYLIBS): go.mod go.sum $(GO_SRC_FILES)
|
||||
@if [ "$(shell uname)" = "Darwin" ]; then
|
||||
$(get-mode-os-arch-ext)
|
||||
./scripts/build_go.sh \
|
||||
@@ -398,21 +388,16 @@ $(foreach chart,$(charts),build/$(chart)_helm_$(VERSION).tgz): build/%_helm_$(VE
|
||||
--chart $* \
|
||||
--output "$@"
|
||||
|
||||
node_modules/.installed: package.json pnpm-lock.yaml
|
||||
node_modules/.installed: package.json
|
||||
./scripts/pnpm_install.sh
|
||||
touch "$@"
|
||||
|
||||
offlinedocs/node_modules/.installed: offlinedocs/package.json offlinedocs/pnpm-lock.yaml
|
||||
(cd offlinedocs/ && ../scripts/pnpm_install.sh)
|
||||
touch "$@"
|
||||
offlinedocs/node_modules/.installed: offlinedocs/package.json
|
||||
cd offlinedocs/
|
||||
../scripts/pnpm_install.sh
|
||||
|
||||
site/node_modules/.installed: site/package.json site/pnpm-lock.yaml
|
||||
(cd site/ && ../scripts/pnpm_install.sh)
|
||||
touch "$@"
|
||||
|
||||
scripts/apidocgen/node_modules/.installed: scripts/apidocgen/package.json scripts/apidocgen/pnpm-lock.yaml
|
||||
(cd scripts/apidocgen && ../../scripts/pnpm_install.sh)
|
||||
touch "$@"
|
||||
site/node_modules/.installed: site/package.json
|
||||
cd site/
|
||||
../scripts/pnpm_install.sh
|
||||
|
||||
SITE_GEN_FILES := \
|
||||
site/src/api/typesGenerated.ts \
|
||||
@@ -520,7 +505,7 @@ lint/ts: site/node_modules/.installed
|
||||
lint/go:
|
||||
./scripts/check_enterprise_imports.sh
|
||||
./scripts/check_codersdk_imports.sh
|
||||
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/coder/Dockerfile | cut -d '=' -f 2)
|
||||
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/contents/Dockerfile | cut -d '=' -f 2)
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
|
||||
.PHONY: lint/go
|
||||
|
||||
@@ -574,35 +559,20 @@ GEN_FILES := \
|
||||
docs/reference/cli/index.md \
|
||||
docs/admin/security/audit-logs.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
docs/manifest.json \
|
||||
provisioner/terraform/testdata/version \
|
||||
site/e2e/provisionerGenerated.ts \
|
||||
examples/examples.gen.json \
|
||||
$(TAILNETTEST_MOCKS) \
|
||||
coderd/database/pubsub/psmock/psmock.go \
|
||||
agent/agentcontainers/acmock/acmock.go \
|
||||
agent/agentcontainers/dcspec/dcspec_gen.go \
|
||||
coderd/httpmw/loggermw/loggermock/loggermock.go
|
||||
|
||||
# all gen targets should be added here and to gen/mark-fresh
|
||||
gen: gen/db gen/golden-files $(GEN_FILES)
|
||||
gen: gen/db $(GEN_FILES)
|
||||
.PHONY: gen
|
||||
|
||||
gen/db: $(DB_GEN_FILES)
|
||||
.PHONY: gen/db
|
||||
|
||||
gen/golden-files: \
|
||||
cli/testdata/.gen-golden \
|
||||
coderd/.gen-golden \
|
||||
coderd/notifications/.gen-golden \
|
||||
enterprise/cli/testdata/.gen-golden \
|
||||
enterprise/tailnet/testdata/.gen-golden \
|
||||
helm/coder/tests/testdata/.gen-golden \
|
||||
helm/provisioner/tests/testdata/.gen-golden \
|
||||
provisioner/terraform/testdata/.gen-golden \
|
||||
tailnet/testdata/.gen-golden
|
||||
.PHONY: gen/golden-files
|
||||
|
||||
# Mark all generated files as fresh so make thinks they're up-to-date. This is
|
||||
# used during releases so we don't run generation scripts.
|
||||
gen/mark-fresh:
|
||||
@@ -623,14 +593,11 @@ gen/mark-fresh:
|
||||
docs/reference/cli/index.md \
|
||||
docs/admin/security/audit-logs.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
docs/manifest.json \
|
||||
site/e2e/provisionerGenerated.ts \
|
||||
site/src/theme/icons.json \
|
||||
examples/examples.gen.json \
|
||||
$(TAILNETTEST_MOCKS) \
|
||||
coderd/database/pubsub/psmock/psmock.go \
|
||||
agent/agentcontainers/acmock/acmock.go \
|
||||
agent/agentcontainers/dcspec/dcspec_gen.go \
|
||||
coderd/httpmw/loggermw/loggermock/loggermock.go \
|
||||
"
|
||||
|
||||
@@ -650,42 +617,24 @@ gen/mark-fresh:
|
||||
# applied.
|
||||
coderd/database/dump.sql: coderd/database/gen/dump/main.go $(wildcard coderd/database/migrations/*.sql)
|
||||
go run ./coderd/database/gen/dump/main.go
|
||||
touch "$@"
|
||||
|
||||
# Generates Go code for querying the database.
|
||||
# coderd/database/queries.sql.go
|
||||
# coderd/database/models.go
|
||||
coderd/database/querier.go: coderd/database/sqlc.yaml coderd/database/dump.sql $(wildcard coderd/database/queries/*.sql)
|
||||
./coderd/database/generate.sh
|
||||
touch "$@"
|
||||
|
||||
coderd/database/dbmock/dbmock.go: coderd/database/db.go coderd/database/querier.go
|
||||
go generate ./coderd/database/dbmock/
|
||||
touch "$@"
|
||||
|
||||
coderd/database/pubsub/psmock/psmock.go: coderd/database/pubsub/pubsub.go
|
||||
go generate ./coderd/database/pubsub/psmock
|
||||
touch "$@"
|
||||
|
||||
agent/agentcontainers/acmock/acmock.go: agent/agentcontainers/containers.go
|
||||
go generate ./agent/agentcontainers/acmock/
|
||||
touch "$@"
|
||||
|
||||
coderd/httpmw/loggermw/loggermock/loggermock.go: coderd/httpmw/loggermw/logger.go
|
||||
go generate ./coderd/httpmw/loggermw/loggermock/
|
||||
touch "$@"
|
||||
|
||||
agent/agentcontainers/dcspec/dcspec_gen.go: \
|
||||
node_modules/.installed \
|
||||
agent/agentcontainers/dcspec/devContainer.base.schema.json \
|
||||
agent/agentcontainers/dcspec/gen.sh \
|
||||
agent/agentcontainers/dcspec/doc.go
|
||||
DCSPEC_QUIET=true go generate ./agent/agentcontainers/dcspec/
|
||||
touch "$@"
|
||||
|
||||
$(TAILNETTEST_MOCKS): tailnet/coordinator.go tailnet/service.go
|
||||
go generate ./tailnet/tailnettest/
|
||||
touch "$@"
|
||||
|
||||
tailnet/proto/tailnet.pb.go: tailnet/proto/tailnet.proto
|
||||
protoc \
|
||||
@@ -728,94 +677,77 @@ vpn/vpn.pb.go: vpn/vpn.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
|
||||
go run -C ./scripts/apitypings main.go > $@
|
||||
(cd site/ && pnpm exec biome format --write src/api/typesGenerated.ts)
|
||||
touch "$@"
|
||||
cd site/
|
||||
pnpm exec biome format --write src/api/typesGenerated.ts
|
||||
|
||||
site/e2e/provisionerGenerated.ts: site/node_modules/.installed provisionerd/proto/provisionerd.pb.go provisionersdk/proto/provisioner.pb.go
|
||||
(cd site/ && pnpm run gen:provisioner)
|
||||
touch "$@"
|
||||
cd site/
|
||||
pnpm run gen:provisioner
|
||||
|
||||
site/src/theme/icons.json: site/node_modules/.installed $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*)
|
||||
go run ./scripts/gensite/ -icons "$@"
|
||||
(cd site/ && pnpm exec biome format --write src/theme/icons.json)
|
||||
touch "$@"
|
||||
cd site/
|
||||
pnpm exec biome format --write src/theme/icons.json
|
||||
|
||||
examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates)
|
||||
go run ./scripts/examplegen/main.go > examples/examples.gen.json
|
||||
touch "$@"
|
||||
|
||||
coderd/rbac/object_gen.go: scripts/typegen/rbacobject.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
|
||||
tempdir=$(shell mktemp -d /tmp/typegen_rbac_object.XXXXXX)
|
||||
go run ./scripts/typegen/main.go rbac object > "$$tempdir/object_gen.go"
|
||||
mv -v "$$tempdir/object_gen.go" coderd/rbac/object_gen.go
|
||||
rmdir -v "$$tempdir"
|
||||
touch "$@"
|
||||
|
||||
codersdk/rbacresources_gen.go: scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
|
||||
# Do no overwrite codersdk/rbacresources_gen.go directly, as it would make the file empty, breaking
|
||||
# the `codersdk` package and any parallel build targets.
|
||||
go run scripts/typegen/main.go rbac codersdk > /tmp/rbacresources_gen.go
|
||||
mv /tmp/rbacresources_gen.go codersdk/rbacresources_gen.go
|
||||
touch "$@"
|
||||
|
||||
site/src/api/rbacresourcesGenerated.ts: site/node_modules/.installed scripts/typegen/codersdk.gotmpl scripts/typegen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
|
||||
go run scripts/typegen/main.go rbac typescript > "$@"
|
||||
(cd site/ && pnpm exec biome format --write src/api/rbacresourcesGenerated.ts)
|
||||
touch "$@"
|
||||
cd site/
|
||||
pnpm exec biome format --write src/api/rbacresourcesGenerated.ts
|
||||
|
||||
site/src/api/countriesGenerated.ts: site/node_modules/.installed scripts/typegen/countries.tstmpl scripts/typegen/main.go codersdk/countries.go
|
||||
go run scripts/typegen/main.go countries > "$@"
|
||||
(cd site/ && pnpm exec biome format --write src/api/countriesGenerated.ts)
|
||||
touch "$@"
|
||||
cd site/
|
||||
pnpm exec biome format --write src/api/countriesGenerated.ts
|
||||
|
||||
docs/admin/integrations/prometheus.md: node_modules/.installed scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
|
||||
go run scripts/metricsdocgen/main.go
|
||||
pnpm exec markdownlint-cli2 --fix ./docs/admin/integrations/prometheus.md
|
||||
pnpm exec markdown-table-formatter ./docs/admin/integrations/prometheus.md
|
||||
touch "$@"
|
||||
|
||||
docs/reference/cli/index.md: node_modules/.installed scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
|
||||
docs/reference/cli/index.md: node_modules/.installed site/node_modules/.installed scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
|
||||
CI=true BASE_PATH="." go run ./scripts/clidocgen
|
||||
pnpm exec markdownlint-cli2 --fix ./docs/reference/cli/*.md
|
||||
pnpm exec markdown-table-formatter ./docs/reference/cli/*.md
|
||||
touch "$@"
|
||||
cd site/
|
||||
pnpm exec biome format --write ../docs/manifest.json
|
||||
|
||||
docs/admin/security/audit-logs.md: node_modules/.installed coderd/database/querier.go scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go
|
||||
go run scripts/auditdocgen/main.go
|
||||
pnpm exec markdownlint-cli2 --fix ./docs/admin/security/audit-logs.md
|
||||
pnpm exec markdown-table-formatter ./docs/admin/security/audit-logs.md
|
||||
touch "$@"
|
||||
|
||||
coderd/apidoc/.gen: \
|
||||
node_modules/.installed \
|
||||
scripts/apidocgen/node_modules/.installed \
|
||||
$(wildcard coderd/*.go) \
|
||||
$(wildcard enterprise/coderd/*.go) \
|
||||
$(wildcard codersdk/*.go) \
|
||||
$(wildcard enterprise/wsproxy/wsproxysdk/*.go) \
|
||||
$(DB_GEN_FILES) \
|
||||
coderd/rbac/object_gen.go \
|
||||
.swaggo \
|
||||
scripts/apidocgen/generate.sh \
|
||||
$(wildcard scripts/apidocgen/postprocess/*) \
|
||||
$(wildcard scripts/apidocgen/markdown-template/*)
|
||||
coderd/apidoc/swagger.json: node_modules/.installed site/node_modules/.installed $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS) -type f) $(wildcard coderd/*.go) $(wildcard enterprise/coderd/*.go) $(wildcard codersdk/*.go) $(wildcard enterprise/wsproxy/wsproxysdk/*.go) $(DB_GEN_FILES) .swaggo docs/manifest.json coderd/rbac/object_gen.go
|
||||
./scripts/apidocgen/generate.sh
|
||||
pnpm exec markdownlint-cli2 --fix ./docs/reference/api/*.md
|
||||
pnpm exec markdown-table-formatter ./docs/reference/api/*.md
|
||||
touch "$@"
|
||||
cd site/
|
||||
pnpm exec biome format --write ../docs/manifest.json ../coderd/apidoc/swagger.json
|
||||
|
||||
docs/manifest.json: site/node_modules/.installed coderd/apidoc/.gen docs/reference/cli/index.md
|
||||
(cd site/ && pnpm exec biome format --write ../docs/manifest.json)
|
||||
touch "$@"
|
||||
|
||||
coderd/apidoc/swagger.json: site/node_modules/.installed coderd/apidoc/.gen
|
||||
(cd site/ && pnpm exec biome format --write ../coderd/apidoc/swagger.json)
|
||||
touch "$@"
|
||||
|
||||
update-golden-files:
|
||||
echo 'WARNING: This target is deprecated. Use "make gen/golden-files" instead.' >&2
|
||||
echo 'Running "make gen/golden-files"' >&2
|
||||
make gen/golden-files
|
||||
update-golden-files: \
|
||||
cli/testdata/.gen-golden \
|
||||
coderd/.gen-golden \
|
||||
coderd/notifications/.gen-golden \
|
||||
enterprise/cli/testdata/.gen-golden \
|
||||
enterprise/tailnet/testdata/.gen-golden \
|
||||
helm/coder/tests/testdata/.gen-golden \
|
||||
helm/provisioner/tests/testdata/.gen-golden \
|
||||
provisioner/terraform/testdata/.gen-golden \
|
||||
tailnet/testdata/.gen-golden
|
||||
.PHONY: update-golden-files
|
||||
|
||||
clean/golden-files:
|
||||
@@ -834,39 +766,39 @@ clean/golden-files:
|
||||
.PHONY: clean/golden-files
|
||||
|
||||
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
|
||||
go test ./cli -run="Test(CommandHelp|ServerYAML|ErrorExamples|.*Golden)" -update
|
||||
touch "$@"
|
||||
|
||||
enterprise/cli/testdata/.gen-golden: $(wildcard enterprise/cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard enterprise/cli/*_test.go)
|
||||
TZ=UTC go test ./enterprise/cli -run="TestEnterpriseCommandHelp" -update
|
||||
go test ./enterprise/cli -run="TestEnterpriseCommandHelp" -update
|
||||
touch "$@"
|
||||
|
||||
tailnet/testdata/.gen-golden: $(wildcard tailnet/testdata/*.golden.html) $(GO_SRC_FILES) $(wildcard tailnet/*_test.go)
|
||||
TZ=UTC go test ./tailnet -run="TestDebugTemplate" -update
|
||||
go test ./tailnet -run="TestDebugTemplate" -update
|
||||
touch "$@"
|
||||
|
||||
enterprise/tailnet/testdata/.gen-golden: $(wildcard enterprise/tailnet/testdata/*.golden.html) $(GO_SRC_FILES) $(wildcard enterprise/tailnet/*_test.go)
|
||||
TZ=UTC go test ./enterprise/tailnet -run="TestDebugTemplate" -update
|
||||
go test ./enterprise/tailnet -run="TestDebugTemplate" -update
|
||||
touch "$@"
|
||||
|
||||
helm/coder/tests/testdata/.gen-golden: $(wildcard helm/coder/tests/testdata/*.yaml) $(wildcard helm/coder/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/coder/tests/*_test.go)
|
||||
TZ=UTC go test ./helm/coder/tests -run=TestUpdateGoldenFiles -update
|
||||
go test ./helm/coder/tests -run=TestUpdateGoldenFiles -update
|
||||
touch "$@"
|
||||
|
||||
helm/provisioner/tests/testdata/.gen-golden: $(wildcard helm/provisioner/tests/testdata/*.yaml) $(wildcard helm/provisioner/tests/testdata/*.golden) $(GO_SRC_FILES) $(wildcard helm/provisioner/tests/*_test.go)
|
||||
TZ=UTC go test ./helm/provisioner/tests -run=TestUpdateGoldenFiles -update
|
||||
go test ./helm/provisioner/tests -run=TestUpdateGoldenFiles -update
|
||||
touch "$@"
|
||||
|
||||
coderd/.gen-golden: $(wildcard coderd/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard coderd/*_test.go)
|
||||
TZ=UTC go test ./coderd -run="Test.*Golden$$" -update
|
||||
go test ./coderd -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
|
||||
coderd/notifications/.gen-golden: $(wildcard coderd/notifications/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard coderd/notifications/*_test.go)
|
||||
TZ=UTC go test ./coderd/notifications -run="Test.*Golden$$" -update
|
||||
go test ./coderd/notifications -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
|
||||
provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard provisioner/terraform/*_test.go)
|
||||
TZ=UTC go test ./provisioner/terraform -run="Test.*Golden$$" -update
|
||||
go test ./provisioner/terraform -run="Test.*Golden$$" -update
|
||||
touch "$@"
|
||||
|
||||
provisioner/terraform/testdata/version:
|
||||
@@ -876,7 +808,7 @@ provisioner/terraform/testdata/version:
|
||||
.PHONY: provisioner/terraform/testdata/version
|
||||
|
||||
test:
|
||||
$(GIT_FLAGS) gotestsum --format standard-quiet -- -v -short -count=1 ./... $(if $(RUN),-run $(RUN))
|
||||
$(GIT_FLAGS) gotestsum --format standard-quiet -- -v -short -count=1 ./...
|
||||
.PHONY: test
|
||||
|
||||
test-cli:
|
||||
@@ -1030,5 +962,5 @@ else
|
||||
endif
|
||||
.PHONY: test-e2e
|
||||
|
||||
dogfood/coder/nix.hash: flake.nix flake.lock
|
||||
sha256sum flake.nix flake.lock >./dogfood/coder/nix.hash
|
||||
dogfood/contents/nix.hash: flake.nix flake.lock
|
||||
sha256sum flake.nix flake.lock >./dogfood/contents/nix.hash
|
||||
|
||||
+97
-415
@@ -6,15 +6,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -27,22 +24,19 @@ import (
|
||||
"github.com/prometheus/common/expfmt"
|
||||
"github.com/spf13/afero"
|
||||
"go.uber.org/atomic"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
"tailscale.com/net/speedtest"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/netlogtype"
|
||||
"tailscale.com/util/clientmetric"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/clistat"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentscripts"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/agent/proto/resourcesmonitor"
|
||||
"github.com/coder/coder/v2/agent/reconnectingpty"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/cli/gitauth"
|
||||
@@ -52,7 +46,6 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
@@ -89,14 +82,11 @@ type Options struct {
|
||||
ServiceBannerRefreshInterval time.Duration
|
||||
BlockFileTransfer bool
|
||||
Execer agentexec.Execer
|
||||
|
||||
ExperimentalDevcontainersEnabled bool
|
||||
ContainerAPIOptions []agentcontainers.Option // Enable ExperimentalDevcontainersEnabled for these to be effective.
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
ConnectRPC24(ctx context.Context) (
|
||||
proto.DRPCAgentClient24, tailnetproto.DRPCTailnetClient24, error,
|
||||
ConnectRPC23(ctx context.Context) (
|
||||
proto.DRPCAgentClient23, tailnetproto.DRPCTailnetClient23, error,
|
||||
)
|
||||
RewriteDERPMap(derpMap *tailcfg.DERPMap)
|
||||
}
|
||||
@@ -132,7 +122,7 @@ func New(options Options) Agent {
|
||||
options.ScriptDataDir = options.TempDir
|
||||
}
|
||||
if options.ExchangeToken == nil {
|
||||
options.ExchangeToken = func(_ context.Context) (string, error) {
|
||||
options.ExchangeToken = func(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
@@ -176,7 +166,6 @@ func New(options Options) Agent {
|
||||
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,
|
||||
@@ -189,9 +178,6 @@ func New(options Options) Agent {
|
||||
prometheusRegistry: prometheusRegistry,
|
||||
metrics: newAgentMetrics(prometheusRegistry),
|
||||
execer: options.Execer,
|
||||
|
||||
experimentalDevcontainersEnabled: options.ExperimentalDevcontainersEnabled,
|
||||
containerAPIOptions: options.ContainerAPIOptions,
|
||||
}
|
||||
// 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
|
||||
@@ -226,21 +212,13 @@ type agent struct {
|
||||
// we track 2 contexts and associated cancel functions: "graceful" which is Done when it is time
|
||||
// to start gracefully shutting down and "hard" which is Done when it is time to close
|
||||
// everything down (regardless of whether graceful shutdown completed).
|
||||
gracefulCtx context.Context
|
||||
gracefulCancel context.CancelFunc
|
||||
hardCtx context.Context
|
||||
hardCancel context.CancelFunc
|
||||
|
||||
// closeMutex protects the following:
|
||||
closeMutex sync.Mutex
|
||||
gracefulCtx context.Context
|
||||
gracefulCancel context.CancelFunc
|
||||
hardCtx context.Context
|
||||
hardCancel context.CancelFunc
|
||||
closeWaitGroup sync.WaitGroup
|
||||
closeMutex sync.Mutex
|
||||
coordDisconnected chan struct{}
|
||||
closing bool
|
||||
// note that once the network is set to non-nil, it is never modified, as with the statsReporter. So, routines
|
||||
// that run after createOrUpdateNetwork and check the networkOK checkpoint do not need to hold the lock to use them.
|
||||
network *tailnet.Conn
|
||||
statsReporter *statsReporter
|
||||
// end fields protected by closeMutex
|
||||
|
||||
environmentVariables map[string]string
|
||||
|
||||
@@ -260,26 +238,18 @@ type agent struct {
|
||||
lifecycleStates []agentsdk.PostLifecycleRequest
|
||||
lifecycleLastReportedIndex int // Keeps track of the last lifecycle state we successfully reported.
|
||||
|
||||
reportConnectionsUpdate chan struct{}
|
||||
reportConnectionsMu sync.Mutex
|
||||
reportConnections []*proto.ReportConnectionRequest
|
||||
|
||||
logSender *agentsdk.LogSender
|
||||
network *tailnet.Conn
|
||||
statsReporter *statsReporter
|
||||
logSender *agentsdk.LogSender
|
||||
|
||||
prometheusRegistry *prometheus.Registry
|
||||
// metrics are prometheus registered metrics that will be collected and
|
||||
// labeled in Coder with the agent + workspace.
|
||||
metrics *agentMetrics
|
||||
execer agentexec.Execer
|
||||
|
||||
experimentalDevcontainersEnabled bool
|
||||
containerAPIOptions []agentcontainers.Option
|
||||
containerAPI atomic.Pointer[agentcontainers.API] // Set by apiHandler.
|
||||
}
|
||||
|
||||
func (a *agent) TailnetConn() *tailnet.Conn {
|
||||
a.closeMutex.Lock()
|
||||
defer a.closeMutex.Unlock()
|
||||
return a.network
|
||||
}
|
||||
|
||||
@@ -292,26 +262,6 @@ func (a *agent) init() {
|
||||
UpdateEnv: a.updateCommandEnv,
|
||||
WorkingDirectory: func() string { return a.manifest.Load().Directory },
|
||||
BlockFileTransfer: a.blockFileTransfer,
|
||||
ReportConnection: func(id uuid.UUID, magicType agentssh.MagicSessionType, ip string) func(code int, reason string) {
|
||||
var connectionType proto.Connection_Type
|
||||
switch magicType {
|
||||
case agentssh.MagicSessionTypeSSH:
|
||||
connectionType = proto.Connection_SSH
|
||||
case agentssh.MagicSessionTypeVSCode:
|
||||
connectionType = proto.Connection_VSCODE
|
||||
case agentssh.MagicSessionTypeJetBrains:
|
||||
connectionType = proto.Connection_JETBRAINS
|
||||
case agentssh.MagicSessionTypeUnknown:
|
||||
connectionType = proto.Connection_TYPE_UNSPECIFIED
|
||||
default:
|
||||
a.logger.Error(a.hardCtx, "unhandled magic session type when reporting connection", slog.F("magic_type", magicType))
|
||||
connectionType = proto.Connection_TYPE_UNSPECIFIED
|
||||
}
|
||||
|
||||
return a.reportConnection(id, connectionType, ip)
|
||||
},
|
||||
|
||||
ExperimentalDevContainersEnabled: a.experimentalDevcontainersEnabled,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -334,14 +284,8 @@ func (a *agent) init() {
|
||||
a.reconnectingPTYServer = reconnectingpty.NewServer(
|
||||
a.logger.Named("reconnecting-pty"),
|
||||
a.sshServer,
|
||||
func(id uuid.UUID, ip string) func(code int, reason string) {
|
||||
return a.reportConnection(id, proto.Connection_RECONNECTING_PTY, ip)
|
||||
},
|
||||
a.metrics.connectionsTotal, a.metrics.reconnectingPTYErrors,
|
||||
a.reconnectingPTYTimeout,
|
||||
func(s *reconnectingpty.Server) {
|
||||
s.ExperimentalDevcontainersEnabled = a.experimentalDevcontainersEnabled
|
||||
},
|
||||
)
|
||||
go a.runLoop()
|
||||
}
|
||||
@@ -363,11 +307,9 @@ func (a *agent) runLoop() {
|
||||
if ctx.Err() != nil {
|
||||
// Context canceled errors may come from websocket pings, so we
|
||||
// don't want to use `errors.Is(err, context.Canceled)` here.
|
||||
a.logger.Warn(ctx, "runLoop exited with error", slog.Error(ctx.Err()))
|
||||
return
|
||||
}
|
||||
if a.isClosed() {
|
||||
a.logger.Warn(ctx, "runLoop exited because agent is closed")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
@@ -388,7 +330,7 @@ func (a *agent) collectMetadata(ctx context.Context, md codersdk.WorkspaceAgentM
|
||||
// if it can guarantee the clocks are synchronized.
|
||||
CollectedAt: now,
|
||||
}
|
||||
cmdPty, err := a.sshServer.CreateCommand(ctx, md.Script, nil, nil)
|
||||
cmdPty, err := a.sshServer.CreateCommand(ctx, md.Script, nil)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("create cmd: %+v", err)
|
||||
return result
|
||||
@@ -420,6 +362,7 @@ func (a *agent) collectMetadata(ctx context.Context, md codersdk.WorkspaceAgentM
|
||||
// Important: if the command times out, we may see a misleading error like
|
||||
// "exit status 1", so it's important to include the context error.
|
||||
err = errors.Join(err, ctx.Err())
|
||||
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("run cmd: %+v", err)
|
||||
}
|
||||
@@ -456,7 +399,7 @@ func (t *trySingleflight) Do(key string, fn func()) {
|
||||
fn()
|
||||
}
|
||||
|
||||
func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient24) error {
|
||||
func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient23) error {
|
||||
tickerDone := make(chan struct{})
|
||||
collectDone := make(chan struct{})
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
@@ -672,7 +615,7 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient24
|
||||
|
||||
// reportLifecycle reports the current lifecycle state once. All state
|
||||
// changes are reported in order.
|
||||
func (a *agent) reportLifecycle(ctx context.Context, aAPI proto.DRPCAgentClient24) error {
|
||||
func (a *agent) reportLifecycle(ctx context.Context, aAPI proto.DRPCAgentClient23) error {
|
||||
for {
|
||||
select {
|
||||
case <-a.lifecycleUpdate:
|
||||
@@ -751,128 +694,10 @@ func (a *agent) setLifecycle(state codersdk.WorkspaceAgentLifecycle) {
|
||||
}
|
||||
}
|
||||
|
||||
// reportConnectionsLoop reports connections to the agent for auditing.
|
||||
func (a *agent) reportConnectionsLoop(ctx context.Context, aAPI proto.DRPCAgentClient24) error {
|
||||
for {
|
||||
select {
|
||||
case <-a.reportConnectionsUpdate:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
for {
|
||||
a.reportConnectionsMu.Lock()
|
||||
if len(a.reportConnections) == 0 {
|
||||
a.reportConnectionsMu.Unlock()
|
||||
break
|
||||
}
|
||||
payload := a.reportConnections[0]
|
||||
// Release lock while we send the payload, this is safe
|
||||
// since we only append to the slice.
|
||||
a.reportConnectionsMu.Unlock()
|
||||
|
||||
logger := a.logger.With(slog.F("payload", payload))
|
||||
logger.Debug(ctx, "reporting connection")
|
||||
_, err := aAPI.ReportConnection(ctx, payload)
|
||||
if err != nil {
|
||||
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.
|
||||
a.reportConnections = a.reportConnections[1:]
|
||||
a.reportConnectionsMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// reportConnectionBufferLimit limits the number of connection reports we
|
||||
// buffer to avoid growing the buffer indefinitely. This should not happen
|
||||
// unless the agent has lost connection to coderd for a long time or if
|
||||
// the agent is being spammed with connections.
|
||||
//
|
||||
// If we assume ~150 byte per connection report, this would be around 300KB
|
||||
// of memory which seems acceptable. We could reduce this if necessary by
|
||||
// not using the proto struct directly.
|
||||
reportConnectionBufferLimit = 2048
|
||||
)
|
||||
|
||||
func (a *agent) reportConnection(id uuid.UUID, connectionType proto.Connection_Type, ip string) (disconnected func(code int, reason string)) {
|
||||
// Remove the port from the IP because ports are not supported in coderd.
|
||||
if host, _, err := net.SplitHostPort(ip); err != nil {
|
||||
a.logger.Error(a.hardCtx, "split host and port for connection report failed", slog.F("ip", ip), slog.Error(err))
|
||||
} else {
|
||||
// Best effort.
|
||||
ip = host
|
||||
}
|
||||
|
||||
a.reportConnectionsMu.Lock()
|
||||
defer a.reportConnectionsMu.Unlock()
|
||||
|
||||
if len(a.reportConnections) >= reportConnectionBufferLimit {
|
||||
a.logger.Warn(a.hardCtx, "connection report buffer limit reached, dropping connect",
|
||||
slog.F("limit", reportConnectionBufferLimit),
|
||||
slog.F("connection_id", id),
|
||||
slog.F("connection_type", connectionType),
|
||||
slog.F("ip", ip),
|
||||
)
|
||||
} else {
|
||||
a.reportConnections = append(a.reportConnections, &proto.ReportConnectionRequest{
|
||||
Connection: &proto.Connection{
|
||||
Id: id[:],
|
||||
Action: proto.Connection_CONNECT,
|
||||
Type: connectionType,
|
||||
Timestamp: timestamppb.New(time.Now()),
|
||||
Ip: ip,
|
||||
StatusCode: 0,
|
||||
Reason: nil,
|
||||
},
|
||||
})
|
||||
select {
|
||||
case a.reportConnectionsUpdate <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return func(code int, reason string) {
|
||||
a.reportConnectionsMu.Lock()
|
||||
defer a.reportConnectionsMu.Unlock()
|
||||
if len(a.reportConnections) >= reportConnectionBufferLimit {
|
||||
a.logger.Warn(a.hardCtx, "connection report buffer limit reached, dropping disconnect",
|
||||
slog.F("limit", reportConnectionBufferLimit),
|
||||
slog.F("connection_id", id),
|
||||
slog.F("connection_type", connectionType),
|
||||
slog.F("ip", ip),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
a.reportConnections = append(a.reportConnections, &proto.ReportConnectionRequest{
|
||||
Connection: &proto.Connection{
|
||||
Id: id[:],
|
||||
Action: proto.Connection_DISCONNECT,
|
||||
Type: connectionType,
|
||||
Timestamp: timestamppb.New(time.Now()),
|
||||
Ip: ip,
|
||||
StatusCode: int32(code), //nolint:gosec
|
||||
Reason: &reason,
|
||||
},
|
||||
})
|
||||
select {
|
||||
case a.reportConnectionsUpdate <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchServiceBannerLoop fetches the service banner on an interval. It will
|
||||
// not be fetched immediately; the expectation is that it is primed elsewhere
|
||||
// (and must be done before the session actually starts).
|
||||
func (a *agent) fetchServiceBannerLoop(ctx context.Context, aAPI proto.DRPCAgentClient24) error {
|
||||
func (a *agent) fetchServiceBannerLoop(ctx context.Context, aAPI proto.DRPCAgentClient23) error {
|
||||
ticker := time.NewTicker(a.announcementBannersRefreshInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
@@ -908,14 +733,14 @@ func (a *agent) run() (retErr error) {
|
||||
a.sessionToken.Store(&sessionToken)
|
||||
|
||||
// ConnectRPC returns the dRPC connection we use for the Agent and Tailnet v2+ APIs
|
||||
aAPI, tAPI, err := a.client.ConnectRPC24(a.hardCtx)
|
||||
aAPI, tAPI, err := a.client.ConnectRPC23(a.hardCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
cErr := aAPI.DRPCConn().Close()
|
||||
if cErr != nil {
|
||||
a.logger.Debug(a.hardCtx, "error closing drpc connection", slog.Error(cErr))
|
||||
a.logger.Debug(a.hardCtx, "error closing drpc connection", slog.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -925,7 +750,7 @@ func (a *agent) run() (retErr error) {
|
||||
connMan := newAPIConnRoutineManager(a.gracefulCtx, a.hardCtx, a.logger, aAPI, tAPI)
|
||||
|
||||
connMan.startAgentAPI("init notification banners", gracefulShutdownBehaviorStop,
|
||||
func(ctx context.Context, aAPI proto.DRPCAgentClient24) error {
|
||||
func(ctx context.Context, aAPI proto.DRPCAgentClient23) error {
|
||||
bannersProto, err := aAPI.GetAnnouncementBanners(ctx, &proto.GetAnnouncementBannersRequest{})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch service banner: %w", err)
|
||||
@@ -942,9 +767,9 @@ func (a *agent) run() (retErr error) {
|
||||
// sending logs gets gracefulShutdownBehaviorRemain because we want to send logs generated by
|
||||
// shutdown scripts.
|
||||
connMan.startAgentAPI("send logs", gracefulShutdownBehaviorRemain,
|
||||
func(ctx context.Context, aAPI proto.DRPCAgentClient24) error {
|
||||
func(ctx context.Context, aAPI proto.DRPCAgentClient23) error {
|
||||
err := a.logSender.SendLoop(ctx, aAPI)
|
||||
if xerrors.Is(err, agentsdk.ErrLogLimitExceeded) {
|
||||
if xerrors.Is(err, agentsdk.LogLimitExceededError) {
|
||||
// we don't want this error to tear down the API connection and propagate to the
|
||||
// other routines that use the API. The LogSender has already dropped a warning
|
||||
// log, so just return nil here.
|
||||
@@ -960,32 +785,6 @@ func (a *agent) run() (retErr error) {
|
||||
// metadata reporting can cease as soon as we start gracefully shutting down
|
||||
connMan.startAgentAPI("report metadata", gracefulShutdownBehaviorStop, a.reportMetadata)
|
||||
|
||||
// resources monitor can cease as soon as we start gracefully shutting down.
|
||||
connMan.startAgentAPI("resources monitor", gracefulShutdownBehaviorStop, func(ctx context.Context, aAPI proto.DRPCAgentClient24) error {
|
||||
logger := a.logger.Named("resources_monitor")
|
||||
clk := quartz.NewReal()
|
||||
config, err := aAPI.GetResourcesMonitoringConfiguration(ctx, &proto.GetResourcesMonitoringConfigurationRequest{})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to get resources monitoring configuration: %w", err)
|
||||
}
|
||||
|
||||
statfetcher, err := clistat.New()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to create resources fetcher: %w", err)
|
||||
}
|
||||
resourcesFetcher, err := resourcesmonitor.NewFetcher(statfetcher)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("new resource fetcher: %w", err)
|
||||
}
|
||||
|
||||
resourcesmonitor := resourcesmonitor.NewResourcesMonitor(logger, clk, config, resourcesFetcher, aAPI)
|
||||
return resourcesmonitor.Start(ctx)
|
||||
})
|
||||
|
||||
// Connection reports are part of auditing, we should keep sending them via
|
||||
// gracefulShutdownBehaviorRemain.
|
||||
connMan.startAgentAPI("report connections", gracefulShutdownBehaviorRemain, a.reportConnectionsLoop)
|
||||
|
||||
// channels to sync goroutines below
|
||||
// handle manifest
|
||||
// |
|
||||
@@ -1008,7 +807,7 @@ func (a *agent) run() (retErr error) {
|
||||
connMan.startAgentAPI("handle manifest", gracefulShutdownBehaviorStop, a.handleManifest(manifestOK))
|
||||
|
||||
connMan.startAgentAPI("app health reporter", gracefulShutdownBehaviorStop,
|
||||
func(ctx context.Context, aAPI proto.DRPCAgentClient24) error {
|
||||
func(ctx context.Context, aAPI proto.DRPCAgentClient23) error {
|
||||
if err := manifestOK.wait(ctx); err != nil {
|
||||
return xerrors.Errorf("no manifest: %w", err)
|
||||
}
|
||||
@@ -1023,7 +822,7 @@ func (a *agent) run() (retErr error) {
|
||||
a.createOrUpdateNetwork(manifestOK, networkOK))
|
||||
|
||||
connMan.startTailnetAPI("coordination", gracefulShutdownBehaviorStop,
|
||||
func(ctx context.Context, tAPI tailnetproto.DRPCTailnetClient24) error {
|
||||
func(ctx context.Context, tAPI tailnetproto.DRPCTailnetClient23) error {
|
||||
if err := networkOK.wait(ctx); err != nil {
|
||||
return xerrors.Errorf("no network: %w", err)
|
||||
}
|
||||
@@ -1032,7 +831,7 @@ func (a *agent) run() (retErr error) {
|
||||
)
|
||||
|
||||
connMan.startTailnetAPI("derp map subscriber", gracefulShutdownBehaviorStop,
|
||||
func(ctx context.Context, tAPI tailnetproto.DRPCTailnetClient24) error {
|
||||
func(ctx context.Context, tAPI tailnetproto.DRPCTailnetClient23) error {
|
||||
if err := networkOK.wait(ctx); err != nil {
|
||||
return xerrors.Errorf("no network: %w", err)
|
||||
}
|
||||
@@ -1041,23 +840,19 @@ func (a *agent) run() (retErr error) {
|
||||
|
||||
connMan.startAgentAPI("fetch service banner loop", gracefulShutdownBehaviorStop, a.fetchServiceBannerLoop)
|
||||
|
||||
connMan.startAgentAPI("stats report loop", gracefulShutdownBehaviorStop, func(ctx context.Context, aAPI proto.DRPCAgentClient24) error {
|
||||
connMan.startAgentAPI("stats report loop", gracefulShutdownBehaviorStop, func(ctx context.Context, aAPI proto.DRPCAgentClient23) error {
|
||||
if err := networkOK.wait(ctx); err != nil {
|
||||
return xerrors.Errorf("no network: %w", err)
|
||||
}
|
||||
return a.statsReporter.reportLoop(ctx, aAPI)
|
||||
})
|
||||
|
||||
err = connMan.wait()
|
||||
if err != nil {
|
||||
a.logger.Info(context.Background(), "connection manager errored", slog.Error(err))
|
||||
}
|
||||
return err
|
||||
return connMan.wait()
|
||||
}
|
||||
|
||||
// handleManifest returns a function that fetches and processes the manifest
|
||||
func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, aAPI proto.DRPCAgentClient24) error {
|
||||
return func(ctx context.Context, aAPI proto.DRPCAgentClient24) error {
|
||||
func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, aAPI proto.DRPCAgentClient23) error {
|
||||
return func(ctx context.Context, aAPI proto.DRPCAgentClient23) error {
|
||||
var (
|
||||
sentResult = false
|
||||
err error
|
||||
@@ -1087,7 +882,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
//
|
||||
// An example is VS Code Remote, which must know the directory
|
||||
// before initializing a connection.
|
||||
manifest.Directory, err = expandPathToAbs(manifest.Directory)
|
||||
manifest.Directory, err = expandDirectory(manifest.Directory)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("expand directory: %w", err)
|
||||
}
|
||||
@@ -1127,35 +922,16 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
scripts = manifest.Scripts
|
||||
scriptRunnerOpts []agentscripts.InitOption
|
||||
)
|
||||
if a.experimentalDevcontainersEnabled {
|
||||
var dcScripts []codersdk.WorkspaceAgentScript
|
||||
scripts, dcScripts = agentcontainers.ExtractAndInitializeDevcontainerScripts(a.logger, expandPathToAbs, manifest.Devcontainers, scripts)
|
||||
// See ExtractAndInitializeDevcontainerScripts for motivation
|
||||
// behind running dcScripts as post start scripts.
|
||||
scriptRunnerOpts = append(scriptRunnerOpts, agentscripts.WithPostStartScripts(dcScripts...))
|
||||
}
|
||||
err = a.scriptRunner.Init(scripts, aAPI.ScriptCompleted, scriptRunnerOpts...)
|
||||
err = a.scriptRunner.Init(manifest.Scripts, aAPI.ScriptCompleted)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("init script runner: %w", err)
|
||||
}
|
||||
err = a.trackGoroutine(func() {
|
||||
start := time.Now()
|
||||
// Here we use the graceful context because the script runner is
|
||||
// not directly tied to the agent API.
|
||||
//
|
||||
// First we run the start scripts to ensure the workspace has
|
||||
// been initialized and then the post start scripts which may
|
||||
// depend on the workspace start scripts.
|
||||
//
|
||||
// Measure the time immediately after the start scripts have
|
||||
// finished (both start and post start). For instance, an
|
||||
// autostarted devcontainer will be included in this time.
|
||||
// here we use the graceful context because the script runner is not directly tied
|
||||
// to the agent API.
|
||||
err := a.scriptRunner.Execute(a.gracefulCtx, agentscripts.ExecuteStartScripts)
|
||||
err = errors.Join(err, a.scriptRunner.Execute(a.gracefulCtx, agentscripts.ExecutePostStartScripts))
|
||||
// Measure the time immediately after the script has finished
|
||||
dur := time.Since(start).Seconds()
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "startup script(s) failed", slog.Error(err))
|
||||
@@ -1174,12 +950,6 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
}
|
||||
a.metrics.startupScriptSeconds.WithLabelValues(label).Set(dur)
|
||||
a.scriptRunner.StartCron()
|
||||
if containerAPI := a.containerAPI.Load(); containerAPI != nil {
|
||||
// Inform the container API that the agent is ready.
|
||||
// This allows us to start watching for changes to
|
||||
// the devcontainer configuration files.
|
||||
containerAPI.SignalReady()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("track conn goroutine: %w", err)
|
||||
@@ -1191,11 +961,12 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
|
||||
// createOrUpdateNetwork waits for the manifest to be set using manifestOK, then creates or updates
|
||||
// the tailnet using the information in the manifest
|
||||
func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(context.Context, proto.DRPCAgentClient24) error {
|
||||
return func(ctx context.Context, _ proto.DRPCAgentClient24) (retErr error) {
|
||||
func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(context.Context, proto.DRPCAgentClient23) error {
|
||||
return func(ctx context.Context, _ proto.DRPCAgentClient23) (retErr error) {
|
||||
if err := manifestOK.wait(ctx); err != nil {
|
||||
return xerrors.Errorf("no manifest: %w", err)
|
||||
}
|
||||
var err error
|
||||
defer func() {
|
||||
networkOK.complete(retErr)
|
||||
}()
|
||||
@@ -1204,34 +975,23 @@ func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(co
|
||||
network := a.network
|
||||
a.closeMutex.Unlock()
|
||||
if network == nil {
|
||||
keySeed, err := SSHKeySeed(manifest.OwnerName, manifest.WorkspaceName, manifest.AgentName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("generate SSH key seed: %w", err)
|
||||
}
|
||||
// use the graceful context here, because creating the tailnet is not itself tied to the
|
||||
// agent API.
|
||||
network, err = a.createTailnet(
|
||||
a.gracefulCtx,
|
||||
manifest.AgentID,
|
||||
manifest.DERPMap,
|
||||
manifest.DERPForceWebSockets,
|
||||
manifest.DisableDirectConnections,
|
||||
keySeed,
|
||||
)
|
||||
network, err = a.createTailnet(a.gracefulCtx, manifest.AgentID, manifest.DERPMap, manifest.DERPForceWebSockets, manifest.DisableDirectConnections)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tailnet: %w", err)
|
||||
}
|
||||
a.closeMutex.Lock()
|
||||
// Re-check if agent was closed while initializing the network.
|
||||
closing := a.closing
|
||||
if !closing {
|
||||
closed := a.isClosed()
|
||||
if !closed {
|
||||
a.network = network
|
||||
a.statsReporter = newStatsReporter(a.logger, network, a)
|
||||
}
|
||||
a.closeMutex.Unlock()
|
||||
if closing {
|
||||
if closed {
|
||||
_ = network.Close()
|
||||
return xerrors.New("agent is closing")
|
||||
return xerrors.New("agent is closed")
|
||||
}
|
||||
} else {
|
||||
// Update the wireguard IPs if the agent ID changed.
|
||||
@@ -1346,8 +1106,8 @@ func (*agent) wireguardAddresses(agentID uuid.UUID) []netip.Prefix {
|
||||
func (a *agent) trackGoroutine(fn func()) error {
|
||||
a.closeMutex.Lock()
|
||||
defer a.closeMutex.Unlock()
|
||||
if a.closing {
|
||||
return xerrors.New("track conn goroutine: agent is closing")
|
||||
if a.isClosed() {
|
||||
return xerrors.New("track conn goroutine: agent is closed")
|
||||
}
|
||||
a.closeWaitGroup.Add(1)
|
||||
go func() {
|
||||
@@ -1357,13 +1117,7 @@ func (a *agent) trackGoroutine(fn func()) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *agent) createTailnet(
|
||||
ctx context.Context,
|
||||
agentID uuid.UUID,
|
||||
derpMap *tailcfg.DERPMap,
|
||||
derpForceWebSockets, disableDirectConnections bool,
|
||||
keySeed int64,
|
||||
) (_ *tailnet.Conn, err error) {
|
||||
func (a *agent) createTailnet(ctx context.Context, agentID uuid.UUID, derpMap *tailcfg.DERPMap, derpForceWebSockets, disableDirectConnections bool) (_ *tailnet.Conn, err error) {
|
||||
// Inject `CODER_AGENT_HEADER` into the DERP header.
|
||||
var header http.Header
|
||||
if client, ok := a.client.(*agentsdk.Client); ok {
|
||||
@@ -1390,26 +1144,19 @@ func (a *agent) createTailnet(
|
||||
}
|
||||
}()
|
||||
|
||||
if err := a.sshServer.UpdateHostSigner(keySeed); err != nil {
|
||||
return nil, xerrors.Errorf("update host signer: %w", err)
|
||||
sshListener, err := network.Listen("tcp", ":"+strconv.Itoa(workspacesdk.AgentSSHPort))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen on the ssh port: %w", err)
|
||||
}
|
||||
|
||||
for _, port := range []int{workspacesdk.AgentSSHPort, workspacesdk.AgentStandardSSHPort} {
|
||||
sshListener, err := network.Listen("tcp", ":"+strconv.Itoa(port))
|
||||
defer func() {
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen on the ssh port (%v): %w", port, err)
|
||||
}
|
||||
// nolint:revive // We do want to run the deferred functions when createTailnet returns.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = sshListener.Close()
|
||||
}
|
||||
}()
|
||||
if err = a.trackGoroutine(func() {
|
||||
_ = a.sshServer.Serve(sshListener)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
_ = sshListener.Close()
|
||||
}
|
||||
}()
|
||||
if err = a.trackGoroutine(func() {
|
||||
_ = a.sshServer.Serve(sshListener)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reconnectingPTYListener, err := network.Listen("tcp", ":"+strconv.Itoa(workspacesdk.AgentReconnectingPTYPort))
|
||||
@@ -1426,7 +1173,7 @@ func (a *agent) createTailnet(
|
||||
if rPTYServeErr != nil &&
|
||||
a.gracefulCtx.Err() == nil &&
|
||||
!strings.Contains(rPTYServeErr.Error(), "use of closed network connection") {
|
||||
a.logger.Error(ctx, "error serving reconnecting PTY", slog.Error(rPTYServeErr))
|
||||
a.logger.Error(ctx, "error serving reconnecting PTY", slog.Error(err))
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
@@ -1491,13 +1238,8 @@ func (a *agent) createTailnet(
|
||||
}()
|
||||
if err = a.trackGoroutine(func() {
|
||||
defer apiListener.Close()
|
||||
apiHandler, closeAPIHAndler := a.apiHandler()
|
||||
defer func() {
|
||||
_ = closeAPIHAndler()
|
||||
}()
|
||||
server := &http.Server{
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
Handler: apiHandler,
|
||||
Handler: a.apiHandler(),
|
||||
ReadTimeout: 20 * time.Second,
|
||||
ReadHeaderTimeout: 20 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
@@ -1508,7 +1250,6 @@ func (a *agent) createTailnet(
|
||||
case <-ctx.Done():
|
||||
case <-a.hardCtx.Done():
|
||||
}
|
||||
_ = closeAPIHAndler()
|
||||
_ = server.Close()
|
||||
}()
|
||||
|
||||
@@ -1525,7 +1266,7 @@ func (a *agent) createTailnet(
|
||||
|
||||
// runCoordinator runs a coordinator and returns whether a reconnect
|
||||
// should occur.
|
||||
func (a *agent) runCoordinator(ctx context.Context, tClient tailnetproto.DRPCTailnetClient24, network *tailnet.Conn) error {
|
||||
func (a *agent) runCoordinator(ctx context.Context, tClient tailnetproto.DRPCTailnetClient23, network *tailnet.Conn) error {
|
||||
defer a.logger.Debug(ctx, "disconnected from coordination RPC")
|
||||
// we run the RPC on the hardCtx so that we have a chance to send the disconnect message if we
|
||||
// gracefully shut down.
|
||||
@@ -1542,11 +1283,14 @@ func (a *agent) runCoordinator(ctx context.Context, tClient tailnetproto.DRPCTai
|
||||
a.logger.Info(ctx, "connected to coordination RPC")
|
||||
|
||||
// This allows the Close() routine to wait for the coordinator to gracefully disconnect.
|
||||
disconnected := a.setCoordDisconnected()
|
||||
if disconnected == nil {
|
||||
return nil // already closed by something else
|
||||
a.closeMutex.Lock()
|
||||
if a.isClosed() {
|
||||
return nil
|
||||
}
|
||||
disconnected := make(chan struct{})
|
||||
a.coordDisconnected = disconnected
|
||||
defer close(disconnected)
|
||||
a.closeMutex.Unlock()
|
||||
|
||||
ctrl := tailnet.NewAgentCoordinationController(a.logger, network)
|
||||
coordination := ctrl.New(coordinate)
|
||||
@@ -1568,19 +1312,8 @@ func (a *agent) runCoordinator(ctx context.Context, tClient tailnetproto.DRPCTai
|
||||
return <-errCh
|
||||
}
|
||||
|
||||
func (a *agent) setCoordDisconnected() chan struct{} {
|
||||
a.closeMutex.Lock()
|
||||
defer a.closeMutex.Unlock()
|
||||
if a.closing {
|
||||
return nil
|
||||
}
|
||||
disconnected := make(chan struct{})
|
||||
a.coordDisconnected = disconnected
|
||||
return disconnected
|
||||
}
|
||||
|
||||
// runDERPMapSubscriber runs a coordinator and returns if a reconnect should occur.
|
||||
func (a *agent) runDERPMapSubscriber(ctx context.Context, tClient tailnetproto.DRPCTailnetClient24, network *tailnet.Conn) error {
|
||||
func (a *agent) runDERPMapSubscriber(ctx context.Context, tClient tailnetproto.DRPCTailnetClient23, network *tailnet.Conn) error {
|
||||
defer a.logger.Debug(ctx, "disconnected from derp map RPC")
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
@@ -1615,13 +1348,9 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
|
||||
}
|
||||
for conn, counts := range networkStats {
|
||||
stats.ConnectionsByProto[conn.Proto.String()]++
|
||||
// #nosec G115 - Safe conversions for network statistics which we expect to be within int64 range
|
||||
stats.RxBytes += int64(counts.RxBytes)
|
||||
// #nosec G115 - Safe conversions for network statistics which we expect to be within int64 range
|
||||
stats.RxPackets += int64(counts.RxPackets)
|
||||
// #nosec G115 - Safe conversions for network statistics which we expect to be within int64 range
|
||||
stats.TxBytes += int64(counts.TxBytes)
|
||||
// #nosec G115 - Safe conversions for network statistics which we expect to be within int64 range
|
||||
stats.TxPackets += int64(counts.TxPackets)
|
||||
}
|
||||
|
||||
@@ -1674,12 +1403,11 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
|
||||
wg.Wait()
|
||||
sort.Float64s(durations)
|
||||
durationsLength := len(durations)
|
||||
switch {
|
||||
case durationsLength == 0:
|
||||
if durationsLength == 0 {
|
||||
stats.ConnectionMedianLatencyMs = -1
|
||||
case durationsLength%2 == 0:
|
||||
} else if durationsLength%2 == 0 {
|
||||
stats.ConnectionMedianLatencyMs = (durations[durationsLength/2-1] + durations[durationsLength/2]) / 2
|
||||
default:
|
||||
} else {
|
||||
stats.ConnectionMedianLatencyMs = durations[durationsLength/2]
|
||||
}
|
||||
// Convert from microseconds to milliseconds.
|
||||
@@ -1786,7 +1514,7 @@ func (a *agent) HTTPDebug() http.Handler {
|
||||
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
|
||||
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)
|
||||
r.Get("/debug/manifest", a.HandleHTTPDebugManifest)
|
||||
r.NotFound(func(w http.ResponseWriter, _ *http.Request) {
|
||||
r.NotFound(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte("404 not found"))
|
||||
})
|
||||
@@ -1796,10 +1524,7 @@ func (a *agent) HTTPDebug() http.Handler {
|
||||
|
||||
func (a *agent) Close() error {
|
||||
a.closeMutex.Lock()
|
||||
network := a.network
|
||||
coordDisconnected := a.coordDisconnected
|
||||
a.closing = true
|
||||
a.closeMutex.Unlock()
|
||||
defer a.closeMutex.Unlock()
|
||||
if a.isClosed() {
|
||||
return nil
|
||||
}
|
||||
@@ -1808,22 +1533,15 @@ func (a *agent) Close() error {
|
||||
a.setLifecycle(codersdk.WorkspaceAgentLifecycleShuttingDown)
|
||||
|
||||
// Attempt to gracefully shut down all active SSH connections and
|
||||
// stop accepting new ones. If all processes have not exited after 5
|
||||
// seconds, we just log it and move on as it's more important to run
|
||||
// the shutdown scripts. A typical shutdown time for containers is
|
||||
// 10 seconds, so this still leaves a bit of time to run the
|
||||
// shutdown scripts in the worst-case.
|
||||
sshShutdownCtx, sshShutdownCancel := context.WithTimeout(a.hardCtx, 5*time.Second)
|
||||
defer sshShutdownCancel()
|
||||
err := a.sshServer.Shutdown(sshShutdownCtx)
|
||||
// stop accepting new ones.
|
||||
err := a.sshServer.Shutdown(a.hardCtx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
a.logger.Warn(sshShutdownCtx, "ssh server shutdown timeout", slog.Error(err))
|
||||
} else {
|
||||
a.logger.Error(sshShutdownCtx, "ssh server shutdown", slog.Error(err))
|
||||
}
|
||||
a.logger.Error(a.hardCtx, "ssh server shutdown", slog.Error(err))
|
||||
}
|
||||
err = a.sshServer.Close()
|
||||
if err != nil {
|
||||
a.logger.Error(a.hardCtx, "ssh server close", slog.Error(err))
|
||||
}
|
||||
|
||||
// wait for SSH to shut down before the general graceful cancel, because
|
||||
// this triggers a disconnect in the tailnet layer, telling all clients to
|
||||
// shut down their wireguard tunnels to us. If SSH sessions are still up,
|
||||
@@ -1876,7 +1594,7 @@ lifecycleWaitLoop:
|
||||
select {
|
||||
case <-a.hardCtx.Done():
|
||||
a.logger.Warn(context.Background(), "timed out waiting for Coordinator RPC disconnect")
|
||||
case <-coordDisconnected:
|
||||
case <-a.coordDisconnected:
|
||||
a.logger.Debug(context.Background(), "coordinator RPC disconnected")
|
||||
}
|
||||
|
||||
@@ -1887,8 +1605,8 @@ lifecycleWaitLoop:
|
||||
}
|
||||
|
||||
a.hardCancel()
|
||||
if network != nil {
|
||||
_ = network.Close()
|
||||
if a.network != nil {
|
||||
_ = a.network.Close()
|
||||
}
|
||||
a.closeWaitGroup.Wait()
|
||||
|
||||
@@ -1912,29 +1630,30 @@ func userHomeDir() (string, error) {
|
||||
return u.HomeDir, nil
|
||||
}
|
||||
|
||||
// expandPathToAbs converts a path to an absolute path. It primarily resolves
|
||||
// the home directory and any environment variables that may be set.
|
||||
func expandPathToAbs(path string) (string, error) {
|
||||
if path == "" {
|
||||
// expandDirectory converts a directory path to an absolute path.
|
||||
// It primarily resolves the home directory and any environment
|
||||
// variables that may be set
|
||||
func expandDirectory(dir string) (string, error) {
|
||||
if dir == "" {
|
||||
return "", nil
|
||||
}
|
||||
if path[0] == '~' {
|
||||
if dir[0] == '~' {
|
||||
home, err := userHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path = filepath.Join(home, path[1:])
|
||||
dir = filepath.Join(home, dir[1:])
|
||||
}
|
||||
path = os.ExpandEnv(path)
|
||||
dir = os.ExpandEnv(dir)
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
if !filepath.IsAbs(dir) {
|
||||
home, err := userHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path = filepath.Join(home, path)
|
||||
dir = filepath.Join(home, dir)
|
||||
}
|
||||
return path, nil
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// EnvAgentSubsystem is the environment variable used to denote the
|
||||
@@ -1964,8 +1683,8 @@ const (
|
||||
|
||||
type apiConnRoutineManager struct {
|
||||
logger slog.Logger
|
||||
aAPI proto.DRPCAgentClient24
|
||||
tAPI tailnetproto.DRPCTailnetClient24
|
||||
aAPI proto.DRPCAgentClient23
|
||||
tAPI tailnetproto.DRPCTailnetClient23
|
||||
eg *errgroup.Group
|
||||
stopCtx context.Context
|
||||
remainCtx context.Context
|
||||
@@ -1973,7 +1692,7 @@ type apiConnRoutineManager struct {
|
||||
|
||||
func newAPIConnRoutineManager(
|
||||
gracefulCtx, hardCtx context.Context, logger slog.Logger,
|
||||
aAPI proto.DRPCAgentClient24, tAPI tailnetproto.DRPCTailnetClient24,
|
||||
aAPI proto.DRPCAgentClient23, tAPI tailnetproto.DRPCTailnetClient23,
|
||||
) *apiConnRoutineManager {
|
||||
// routines that remain in operation during graceful shutdown use the remainCtx. They'll still
|
||||
// exit if the errgroup hits an error, which usually means a problem with the conn.
|
||||
@@ -2006,7 +1725,7 @@ func newAPIConnRoutineManager(
|
||||
// but for Tailnet.
|
||||
func (a *apiConnRoutineManager) startAgentAPI(
|
||||
name string, behavior gracefulShutdownBehavior,
|
||||
f func(context.Context, proto.DRPCAgentClient24) error,
|
||||
f func(context.Context, proto.DRPCAgentClient23) error,
|
||||
) {
|
||||
logger := a.logger.With(slog.F("name", name))
|
||||
var ctx context.Context
|
||||
@@ -2043,7 +1762,7 @@ func (a *apiConnRoutineManager) startAgentAPI(
|
||||
// but for the Agent API.
|
||||
func (a *apiConnRoutineManager) startTailnetAPI(
|
||||
name string, behavior gracefulShutdownBehavior,
|
||||
f func(context.Context, tailnetproto.DRPCTailnetClient24) error,
|
||||
f func(context.Context, tailnetproto.DRPCTailnetClient23) error,
|
||||
) {
|
||||
logger := a.logger.With(slog.F("name", name))
|
||||
var ctx context.Context
|
||||
@@ -2081,7 +1800,7 @@ func (a *apiConnRoutineManager) wait() error {
|
||||
}
|
||||
|
||||
func PrometheusMetricsHandler(prometheusRegistry *prometheus.Registry, logger slog.Logger) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
|
||||
// Based on: https://github.com/tailscale/tailscale/blob/280255acae604796a1113861f5a84e6fa2dc6121/ipn/localapi/localapi.go#L489
|
||||
@@ -2102,40 +1821,3 @@ func PrometheusMetricsHandler(prometheusRegistry *prometheus.Registry, logger sl
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SSHKeySeed converts an owner userName, workspaceName and agentName to an int64 hash.
|
||||
// This uses the FNV-1a hash algorithm which provides decent distribution and collision
|
||||
// resistance for string inputs.
|
||||
//
|
||||
// Why owner username, workspace name, and agent name? These are the components that are used in hostnames for the
|
||||
// workspace over SSH, and so we want the workspace to have a stable key with respect to these. We don't use the
|
||||
// respective UUIDs. The workspace UUID would be different if you delete and recreate a workspace with the same name.
|
||||
// The agent UUID is regenerated on each build. Since Coder's Tailnet networking is handling the authentication, we
|
||||
// should not be showing users warnings about host SSH keys.
|
||||
func SSHKeySeed(userName, workspaceName, agentName string) (int64, error) {
|
||||
h := fnv.New64a()
|
||||
_, err := h.Write([]byte(userName))
|
||||
if err != nil {
|
||||
return 42, err
|
||||
}
|
||||
// null separators between strings so that (dog, foodstuff) is distinct from (dogfood, stuff)
|
||||
_, err = h.Write([]byte{0})
|
||||
if err != nil {
|
||||
return 42, err
|
||||
}
|
||||
_, err = h.Write([]byte(workspaceName))
|
||||
if err != nil {
|
||||
return 42, err
|
||||
}
|
||||
_, err = h.Write([]byte{0})
|
||||
if err != nil {
|
||||
return 42, err
|
||||
}
|
||||
_, err = h.Write([]byte(agentName))
|
||||
if err != nil {
|
||||
return 42, err
|
||||
}
|
||||
|
||||
// #nosec G115 - Safe conversion to generate int64 hash from Sum64, data loss acceptable
|
||||
return int64(h.Sum64()), nil
|
||||
}
|
||||
|
||||
+107
-508
@@ -19,21 +19,14 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/goleak"
|
||||
"tailscale.com/net/speedtest"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"github.com/bramvdbogaerde/go-scp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/ory/dockertest/v3"
|
||||
"github.com/ory/dockertest/v3/docker"
|
||||
"github.com/pion/udp"
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
@@ -41,17 +34,19 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/net/speedtest"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/agent/usershell"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
@@ -66,96 +61,38 @@ func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
|
||||
}
|
||||
|
||||
var sshPorts = []uint16{workspacesdk.AgentSSHPort, workspacesdk.AgentStandardSSHPort}
|
||||
|
||||
// TestAgent_CloseWhileStarting is a regression test for https://github.com/coder/coder/issues/17328
|
||||
func TestAgent_ImmediateClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{
|
||||
// Agent can drop errors when shutting down, and some, like the
|
||||
// fasthttplistener connection closed error, are unexported.
|
||||
IgnoreErrors: true,
|
||||
}).Leveled(slog.LevelDebug)
|
||||
manifest := agentsdk.Manifest{
|
||||
AgentID: uuid.New(),
|
||||
AgentName: "test-agent",
|
||||
WorkspaceName: "test-workspace",
|
||||
WorkspaceID: uuid.New(),
|
||||
}
|
||||
|
||||
coordinator := tailnet.NewCoordinator(logger)
|
||||
t.Cleanup(func() {
|
||||
_ = coordinator.Close()
|
||||
})
|
||||
statsCh := make(chan *proto.Stats, 50)
|
||||
fs := afero.NewMemMapFs()
|
||||
client := agenttest.NewClient(t, logger.Named("agenttest"), manifest.AgentID, manifest, statsCh, coordinator)
|
||||
t.Cleanup(client.Close)
|
||||
|
||||
options := agent.Options{
|
||||
Client: client,
|
||||
Filesystem: fs,
|
||||
Logger: logger.Named("agent"),
|
||||
ReconnectingPTYTimeout: 0,
|
||||
EnvironmentVariables: map[string]string{},
|
||||
}
|
||||
|
||||
agentUnderTest := agent.New(options)
|
||||
t.Cleanup(func() {
|
||||
_ = agentUnderTest.Close()
|
||||
})
|
||||
|
||||
// wait until the agent has connected and is starting to find races in the startup code
|
||||
_ = testutil.TryReceive(ctx, t, client.GetStartup())
|
||||
t.Log("Closing Agent")
|
||||
err := agentUnderTest.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// NOTE: These tests only work when your default shell is bash for some reason.
|
||||
|
||||
func TestAgent_Stats_SSH(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
for _, port := range sshPorts {
|
||||
port := port
|
||||
t.Run(fmt.Sprintf("(:%d)", port), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
//nolint:dogsled
|
||||
conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
stdin, err := session.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
err = session.Shell()
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:dogsled
|
||||
conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
|
||||
sshClient, err := conn.SSHClientOnPort(ctx, port)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
stdin, err := session.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
err = session.Shell()
|
||||
require.NoError(t, err)
|
||||
|
||||
var s *proto.Stats
|
||||
require.Eventuallyf(t, func() bool {
|
||||
var ok bool
|
||||
s, ok = <-stats
|
||||
return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && s.SessionCountSsh == 1
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats: %+v", s,
|
||||
)
|
||||
_ = stdin.Close()
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
var s *proto.Stats
|
||||
require.Eventuallyf(t, func() bool {
|
||||
var ok bool
|
||||
s, ok = <-stats
|
||||
return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && s.SessionCountSsh == 1
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats: %+v", s,
|
||||
)
|
||||
_ = stdin.Close()
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAgent_Stats_ReconnectingPTY(t *testing.T) {
|
||||
@@ -201,7 +138,7 @@ func TestAgent_Stats_Magic(t *testing.T) {
|
||||
defer sshClient.Close()
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, string(agentssh.MagicSessionTypeVSCode))
|
||||
session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, agentssh.MagicSessionTypeVSCode)
|
||||
defer session.Close()
|
||||
|
||||
command := "sh -c 'echo $" + agentssh.MagicSessionTypeEnvironmentVariable + "'"
|
||||
@@ -222,13 +159,13 @@ func TestAgent_Stats_Magic(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
//nolint:dogsled
|
||||
conn, agentClient, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, string(agentssh.MagicSessionTypeVSCode))
|
||||
session.Setenv(agentssh.MagicSessionTypeEnvironmentVariable, agentssh.MagicSessionTypeVSCode)
|
||||
defer session.Close()
|
||||
stdin, err := session.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
@@ -238,7 +175,7 @@ func TestAgent_Stats_Magic(t *testing.T) {
|
||||
s, ok := <-stats
|
||||
t.Logf("got stats: ok=%t, ConnectionCount=%d, RxBytes=%d, TxBytes=%d, SessionCountVSCode=%d, ConnectionMedianLatencyMS=%f",
|
||||
ok, s.ConnectionCount, s.RxBytes, s.TxBytes, s.SessionCountVscode, s.ConnectionMedianLatencyMs)
|
||||
return ok &&
|
||||
return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 &&
|
||||
// Ensure that the connection didn't count as a "normal" SSH session.
|
||||
// This was a special one, so it should be labeled specially in the stats!
|
||||
s.SessionCountVscode == 1 &&
|
||||
@@ -252,8 +189,6 @@ func TestAgent_Stats_Magic(t *testing.T) {
|
||||
_ = stdin.Close()
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
|
||||
assertConnectionReport(t, agentClient, proto.Connection_VSCODE, 0, "")
|
||||
})
|
||||
|
||||
t.Run("TracksJetBrains", func(t *testing.T) {
|
||||
@@ -290,7 +225,7 @@ func TestAgent_Stats_Magic(t *testing.T) {
|
||||
remotePort := sc.Text()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, agentClient, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
conn, _, stats, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -306,7 +241,8 @@ func TestAgent_Stats_Magic(t *testing.T) {
|
||||
s, ok := <-stats
|
||||
t.Logf("got stats with conn open: ok=%t, ConnectionCount=%d, SessionCountJetBrains=%d",
|
||||
ok, s.ConnectionCount, s.SessionCountJetbrains)
|
||||
return ok && s.SessionCountJetbrains == 1
|
||||
return ok && s.ConnectionCount > 0 &&
|
||||
s.SessionCountJetbrains == 1
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats with conn open",
|
||||
)
|
||||
@@ -325,30 +261,20 @@ func TestAgent_Stats_Magic(t *testing.T) {
|
||||
}, testutil.WaitLong, testutil.IntervalFast,
|
||||
"never saw stats after conn closes",
|
||||
)
|
||||
|
||||
assertConnectionReport(t, agentClient, proto.Connection_JETBRAINS, 0, "")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgent_SessionExec(t *testing.T) {
|
||||
t.Parallel()
|
||||
session := setupSSHSession(t, agentsdk.Manifest{}, codersdk.ServiceBannerConfig{}, nil)
|
||||
|
||||
for _, port := range sshPorts {
|
||||
port := port
|
||||
t.Run(fmt.Sprintf("(:%d)", port), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := setupSSHSessionOnPort(t, agentsdk.Manifest{}, codersdk.ServiceBannerConfig{}, nil, port)
|
||||
|
||||
command := "echo test"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo test"
|
||||
}
|
||||
output, err := session.Output(command)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", strings.TrimSpace(string(output)))
|
||||
})
|
||||
command := "echo test"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo test"
|
||||
}
|
||||
output, err := session.Output(command)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
//nolint:tparallel // Sub tests need to run sequentially.
|
||||
@@ -458,33 +384,25 @@ func TestAgent_SessionTTYShell(t *testing.T) {
|
||||
// it seems like it could be either.
|
||||
t.Skip("ConPTY appears to be inconsistent on Windows.")
|
||||
}
|
||||
|
||||
for _, port := range sshPorts {
|
||||
port := port
|
||||
t.Run(fmt.Sprintf("(%d)", port), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := setupSSHSessionOnPort(t, agentsdk.Manifest{}, codersdk.ServiceBannerConfig{}, nil, port)
|
||||
command := "sh"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe"
|
||||
}
|
||||
err := session.RequestPty("xterm", 128, 128, ssh.TerminalModes{})
|
||||
require.NoError(t, err)
|
||||
ptty := ptytest.New(t)
|
||||
session.Stdout = ptty.Output()
|
||||
session.Stderr = ptty.Output()
|
||||
session.Stdin = ptty.Input()
|
||||
err = session.Start(command)
|
||||
require.NoError(t, err)
|
||||
_ = ptty.Peek(ctx, 1) // wait for the prompt
|
||||
ptty.WriteLine("echo test")
|
||||
ptty.ExpectMatch("test")
|
||||
ptty.WriteLine("exit")
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
session := setupSSHSession(t, agentsdk.Manifest{}, codersdk.ServiceBannerConfig{}, nil)
|
||||
command := "sh"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe"
|
||||
}
|
||||
err := session.RequestPty("xterm", 128, 128, ssh.TerminalModes{})
|
||||
require.NoError(t, err)
|
||||
ptty := ptytest.New(t)
|
||||
session.Stdout = ptty.Output()
|
||||
session.Stderr = ptty.Output()
|
||||
session.Stdin = ptty.Input()
|
||||
err = session.Start(command)
|
||||
require.NoError(t, err)
|
||||
_ = ptty.Peek(ctx, 1) // wait for the prompt
|
||||
ptty.WriteLine("echo test")
|
||||
ptty.ExpectMatch("test")
|
||||
ptty.WriteLine("exit")
|
||||
err = session.Wait()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAgent_SessionTTYExitCode(t *testing.T) {
|
||||
@@ -678,41 +596,37 @@ func TestAgent_Session_TTY_MOTD_Update(t *testing.T) {
|
||||
//nolint:dogsled // Allow the blank identifiers.
|
||||
conn, client, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, setSBInterval)
|
||||
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = sshClient.Close()
|
||||
})
|
||||
|
||||
//nolint:paralleltest // These tests need to swap the banner func.
|
||||
for _, port := range sshPorts {
|
||||
port := port
|
||||
|
||||
sshClient, err := conn.SSHClientOnPort(ctx, port)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = sshClient.Close()
|
||||
})
|
||||
|
||||
for i, test := range tests {
|
||||
test := test
|
||||
t.Run(fmt.Sprintf("(:%d)/%d", port, i), func(t *testing.T) {
|
||||
// Set new banner func and wait for the agent to call it to update the
|
||||
// banner.
|
||||
ready := make(chan struct{}, 2)
|
||||
client.SetAnnouncementBannersFunc(func() ([]codersdk.BannerConfig, error) {
|
||||
select {
|
||||
case ready <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return []codersdk.BannerConfig{test.banner}, nil
|
||||
})
|
||||
<-ready
|
||||
<-ready // Wait for two updates to ensure the value has propagated.
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = session.Close()
|
||||
})
|
||||
|
||||
testSessionOutput(t, session, test.expected, test.unexpected, nil)
|
||||
for i, test := range tests {
|
||||
test := test
|
||||
t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
|
||||
// Set new banner func and wait for the agent to call it to update the
|
||||
// banner.
|
||||
ready := make(chan struct{}, 2)
|
||||
client.SetAnnouncementBannersFunc(func() ([]codersdk.BannerConfig, error) {
|
||||
select {
|
||||
case ready <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return []codersdk.BannerConfig{test.banner}, nil
|
||||
})
|
||||
}
|
||||
<-ready
|
||||
<-ready // Wait for two updates to ensure the value has propagated.
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = session.Close()
|
||||
})
|
||||
|
||||
testSessionOutput(t, session, test.expected, test.unexpected, nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1004,7 +918,7 @@ func TestAgent_SFTP(t *testing.T) {
|
||||
home = "/" + strings.ReplaceAll(home, "\\", "/")
|
||||
}
|
||||
//nolint:dogsled
|
||||
conn, agentClient, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
@@ -1027,10 +941,6 @@ func TestAgent_SFTP(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
_, err = os.Stat(tempFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Close the client to trigger disconnect event.
|
||||
_ = client.Close()
|
||||
assertConnectionReport(t, agentClient, proto.Connection_SSH, 0, "")
|
||||
}
|
||||
|
||||
func TestAgent_SCP(t *testing.T) {
|
||||
@@ -1040,7 +950,7 @@ func TestAgent_SCP(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, agentClient, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
@@ -1053,10 +963,6 @@ func TestAgent_SCP(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
_, err = os.Stat(tempFile)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Close the client to trigger disconnect event.
|
||||
scpClient.Close()
|
||||
assertConnectionReport(t, agentClient, proto.Connection_SSH, 0, "")
|
||||
}
|
||||
|
||||
func TestAgent_FileTransferBlocked(t *testing.T) {
|
||||
@@ -1071,7 +977,7 @@ func TestAgent_FileTransferBlocked(t *testing.T) {
|
||||
isErr := strings.Contains(errorMessage, agentssh.BlockedFileTransferErrorMessage) ||
|
||||
strings.Contains(errorMessage, "EOF") ||
|
||||
strings.Contains(errorMessage, "Process exited with status 65")
|
||||
require.True(t, isErr, "Message: "+errorMessage)
|
||||
require.True(t, isErr, fmt.Sprintf("Message: "+errorMessage))
|
||||
}
|
||||
|
||||
t.Run("SFTP", func(t *testing.T) {
|
||||
@@ -1081,7 +987,7 @@ func TestAgent_FileTransferBlocked(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, agentClient, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.BlockFileTransfer = true
|
||||
})
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
@@ -1090,8 +996,6 @@ func TestAgent_FileTransferBlocked(t *testing.T) {
|
||||
_, err = sftp.NewClient(sshClient)
|
||||
require.Error(t, err)
|
||||
assertFileTransferBlocked(t, err.Error())
|
||||
|
||||
assertConnectionReport(t, agentClient, proto.Connection_SSH, agentssh.BlockedFileTransferErrorCode, "")
|
||||
})
|
||||
|
||||
t.Run("SCP with go-scp package", func(t *testing.T) {
|
||||
@@ -1101,7 +1005,7 @@ func TestAgent_FileTransferBlocked(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, agentClient, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.BlockFileTransfer = true
|
||||
})
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
@@ -1114,8 +1018,6 @@ func TestAgent_FileTransferBlocked(t *testing.T) {
|
||||
err = scpClient.CopyFile(context.Background(), strings.NewReader("hello world"), tempFile, "0755")
|
||||
require.Error(t, err)
|
||||
assertFileTransferBlocked(t, err.Error())
|
||||
|
||||
assertConnectionReport(t, agentClient, proto.Connection_SSH, agentssh.BlockedFileTransferErrorCode, "")
|
||||
})
|
||||
|
||||
t.Run("Forbidden commands", func(t *testing.T) {
|
||||
@@ -1129,7 +1031,7 @@ func TestAgent_FileTransferBlocked(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, agentClient, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.BlockFileTransfer = true
|
||||
})
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
@@ -1151,8 +1053,6 @@ func TestAgent_FileTransferBlocked(t *testing.T) {
|
||||
msg, err := io.ReadAll(stdout)
|
||||
require.NoError(t, err)
|
||||
assertFileTransferBlocked(t, string(msg))
|
||||
|
||||
assertConnectionReport(t, agentClient, proto.Connection_SSH, agentssh.BlockedFileTransferErrorCode, "")
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1241,53 +1141,6 @@ func TestAgent_SSHConnectionEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_SSHConnectionLoginVars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
envInfo := usershell.SystemEnvInfo{}
|
||||
u, err := envInfo.User()
|
||||
require.NoError(t, err, "get current user")
|
||||
shell, err := envInfo.Shell(u.Username)
|
||||
require.NoError(t, err, "get current shell")
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
key: "USER",
|
||||
want: u.Username,
|
||||
},
|
||||
{
|
||||
key: "LOGNAME",
|
||||
want: u.Username,
|
||||
},
|
||||
{
|
||||
key: "HOME",
|
||||
want: u.HomeDir,
|
||||
},
|
||||
{
|
||||
key: "SHELL",
|
||||
want: shell,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := setupSSHSession(t, agentsdk.Manifest{}, codersdk.ServiceBannerConfig{}, nil)
|
||||
command := "sh -c 'echo $" + tt.key + "'"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo %" + tt.key + "%"
|
||||
}
|
||||
output, err := session.Output(command)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, strings.TrimSpace(string(output)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_Metadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1650,10 +1503,8 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
t.Run("ShutdownScriptOnce", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger := testutil.Logger(t)
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
expected := "this-is-shutdown"
|
||||
derpMap, _ := tailnettest.RunDERPAndSTUN(t)
|
||||
statsCh := make(chan *proto.Stats, 50)
|
||||
|
||||
client := agenttest.NewClient(t,
|
||||
logger,
|
||||
@@ -1672,7 +1523,7 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
RunOnStop: true,
|
||||
}},
|
||||
},
|
||||
statsCh,
|
||||
make(chan *proto.Stats, 50),
|
||||
tailnet.NewCoordinator(logger),
|
||||
)
|
||||
defer client.Close()
|
||||
@@ -1697,11 +1548,6 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
return len(content) > 0 // something is in the startup log file
|
||||
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||
|
||||
// In order to avoid shutting down the agent before it is fully started and triggering
|
||||
// errors, we'll wait until the agent is fully up. It's a bit hokey, but among the last things the agent starts
|
||||
// is the stats reporting, so getting a stats report is a good indication the agent is fully up.
|
||||
_ = testutil.TryReceive(ctx, t, statsCh)
|
||||
|
||||
err := agent.Close()
|
||||
require.NoError(t, err, "agent should be closed successfully")
|
||||
|
||||
@@ -1730,7 +1576,7 @@ func TestAgent_Startup(t *testing.T) {
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
Directory: "",
|
||||
}, 0)
|
||||
startup := testutil.TryReceive(ctx, t, client.GetStartup())
|
||||
startup := testutil.RequireRecvCtx(ctx, t, client.GetStartup())
|
||||
require.Equal(t, "", startup.GetExpandedDirectory())
|
||||
})
|
||||
|
||||
@@ -1741,7 +1587,7 @@ func TestAgent_Startup(t *testing.T) {
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
Directory: "~",
|
||||
}, 0)
|
||||
startup := testutil.TryReceive(ctx, t, client.GetStartup())
|
||||
startup := testutil.RequireRecvCtx(ctx, t, client.GetStartup())
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, homeDir, startup.GetExpandedDirectory())
|
||||
@@ -1754,7 +1600,7 @@ func TestAgent_Startup(t *testing.T) {
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
Directory: "coder/coder",
|
||||
}, 0)
|
||||
startup := testutil.TryReceive(ctx, t, client.GetStartup())
|
||||
startup := testutil.RequireRecvCtx(ctx, t, client.GetStartup())
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, filepath.Join(homeDir, "coder/coder"), startup.GetExpandedDirectory())
|
||||
@@ -1767,7 +1613,7 @@ func TestAgent_Startup(t *testing.T) {
|
||||
_, client, _, _, _ := setupAgent(t, agentsdk.Manifest{
|
||||
Directory: "$HOME",
|
||||
}, 0)
|
||||
startup := testutil.TryReceive(ctx, t, client.GetStartup())
|
||||
startup := testutil.RequireRecvCtx(ctx, t, client.GetStartup())
|
||||
homeDir, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, homeDir, startup.GetExpandedDirectory())
|
||||
@@ -1815,16 +1661,8 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, agentClient, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0)
|
||||
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, id, 80, 80, "bash --norc")
|
||||
require.NoError(t, err)
|
||||
_ = netConn0.Close()
|
||||
assertConnectionReport(t, agentClient, proto.Connection_RECONNECTING_PTY, 0, "")
|
||||
|
||||
// --norc disables executing .bashrc, which is often used to customize the bash prompt
|
||||
netConn1, err := conn.ReconnectingPTY(ctx, id, 80, 80, "bash --norc")
|
||||
require.NoError(t, err)
|
||||
@@ -1923,202 +1761,6 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// This tests end-to-end functionality of connecting to a running container
|
||||
// and executing a command. It creates a real Docker container and runs a
|
||||
// command. As such, it does not run by default in CI.
|
||||
// You can run it manually as follows:
|
||||
//
|
||||
// CODER_TEST_USE_DOCKER=1 go test -count=1 ./agent -run TestAgent_ReconnectingPTYContainer
|
||||
func TestAgent_ReconnectingPTYContainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
if os.Getenv("CODER_TEST_USE_DOCKER") != "1" {
|
||||
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
|
||||
}
|
||||
|
||||
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{
|
||||
Repository: "busybox",
|
||||
Tag: "latest",
|
||||
Cmd: []string{"sleep", "infnity"},
|
||||
}, func(config *docker.HostConfig) {
|
||||
config.AutoRemove = true
|
||||
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
|
||||
})
|
||||
require.NoError(t, err, "Could not start container")
|
||||
t.Cleanup(func() {
|
||||
err := pool.Purge(ct)
|
||||
require.NoError(t, err, "Could not stop container")
|
||||
})
|
||||
// Wait for container to start
|
||||
require.Eventually(t, func() bool {
|
||||
ct, ok := pool.ContainerByName(ct.Container.Name)
|
||||
return ok && ct.Container.State.Running
|
||||
}, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time")
|
||||
|
||||
// nolint: dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.ExperimentalDevcontainersEnabled = true
|
||||
})
|
||||
ac, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "/bin/sh", func(arp *workspacesdk.AgentReconnectingPTYInit) {
|
||||
arp.Container = ct.Container.ID
|
||||
})
|
||||
require.NoError(t, err, "failed to create ReconnectingPTY")
|
||||
defer ac.Close()
|
||||
tr := testutil.NewTerminalReader(t, ac)
|
||||
|
||||
require.NoError(t, tr.ReadUntil(ctx, func(line string) bool {
|
||||
return strings.Contains(line, "#") || strings.Contains(line, "$")
|
||||
}), "find prompt")
|
||||
|
||||
require.NoError(t, json.NewEncoder(ac).Encode(workspacesdk.ReconnectingPTYRequest{
|
||||
Data: "hostname\r",
|
||||
}), "write hostname")
|
||||
require.NoError(t, tr.ReadUntil(ctx, func(line string) bool {
|
||||
return strings.Contains(line, "hostname")
|
||||
}), "find hostname command")
|
||||
|
||||
require.NoError(t, tr.ReadUntil(ctx, func(line string) bool {
|
||||
return strings.Contains(line, ct.Container.Config.Hostname)
|
||||
}), "find hostname output")
|
||||
require.NoError(t, json.NewEncoder(ac).Encode(workspacesdk.ReconnectingPTYRequest{
|
||||
Data: "exit\r",
|
||||
}), "write exit command")
|
||||
|
||||
// Wait for the connection to close.
|
||||
require.ErrorIs(t, tr.ReadUntil(ctx, nil), io.EOF)
|
||||
}
|
||||
|
||||
// This tests end-to-end functionality of auto-starting a devcontainer.
|
||||
// It runs "devcontainer up" which creates a real Docker container. As
|
||||
// such, it does not run by default in CI.
|
||||
//
|
||||
// You can run it manually as follows:
|
||||
//
|
||||
// CODER_TEST_USE_DOCKER=1 go test -count=1 ./agent -run TestAgent_DevcontainerAutostart
|
||||
func TestAgent_DevcontainerAutostart(t *testing.T) {
|
||||
t.Parallel()
|
||||
if os.Getenv("CODER_TEST_USE_DOCKER") != "1" {
|
||||
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Connect to Docker
|
||||
pool, err := dockertest.NewPool("")
|
||||
require.NoError(t, err, "Could not connect to docker")
|
||||
|
||||
// Prepare temporary devcontainer for test (mywork).
|
||||
devcontainerID := uuid.New()
|
||||
tempWorkspaceFolder := t.TempDir()
|
||||
tempWorkspaceFolder = filepath.Join(tempWorkspaceFolder, "mywork")
|
||||
t.Logf("Workspace folder: %s", tempWorkspaceFolder)
|
||||
devcontainerPath := filepath.Join(tempWorkspaceFolder, ".devcontainer")
|
||||
err = os.MkdirAll(devcontainerPath, 0o755)
|
||||
require.NoError(t, err, "create devcontainer directory")
|
||||
devcontainerFile := filepath.Join(devcontainerPath, "devcontainer.json")
|
||||
err = os.WriteFile(devcontainerFile, []byte(`{
|
||||
"name": "mywork",
|
||||
"image": "busybox:latest",
|
||||
"cmd": ["sleep", "infinity"]
|
||||
}`), 0o600)
|
||||
require.NoError(t, err, "write devcontainer.json")
|
||||
|
||||
manifest := agentsdk.Manifest{
|
||||
// Set up pre-conditions for auto-starting a devcontainer, the script
|
||||
// is expected to be prepared by the provisioner normally.
|
||||
Devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
ID: devcontainerID,
|
||||
Name: "test",
|
||||
WorkspaceFolder: tempWorkspaceFolder,
|
||||
},
|
||||
},
|
||||
Scripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerID,
|
||||
LogSourceID: agentsdk.ExternalLogSourceID,
|
||||
RunOnStart: true,
|
||||
Script: "echo this-will-be-replaced",
|
||||
DisplayName: "Dev Container (test)",
|
||||
},
|
||||
},
|
||||
}
|
||||
// nolint: dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, manifest, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.ExperimentalDevcontainersEnabled = true
|
||||
})
|
||||
|
||||
t.Logf("Waiting for container with label: devcontainer.local_folder=%s", tempWorkspaceFolder)
|
||||
|
||||
var container docker.APIContainers
|
||||
require.Eventually(t, func() bool {
|
||||
containers, err := pool.Client.ListContainers(docker.ListContainersOptions{All: true})
|
||||
if err != nil {
|
||||
t.Logf("Error listing containers: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
for _, c := range containers {
|
||||
t.Logf("Found container: %s with labels: %v", c.ID[:12], c.Labels)
|
||||
if labelValue, ok := c.Labels["devcontainer.local_folder"]; ok {
|
||||
if labelValue == tempWorkspaceFolder {
|
||||
t.Logf("Found matching container: %s", c.ID[:12])
|
||||
container = c
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}, testutil.WaitSuperLong, testutil.IntervalMedium, "no container with workspace folder label found")
|
||||
|
||||
t.Cleanup(func() {
|
||||
// We can't rely on pool here because the container is not
|
||||
// managed by it (it is managed by @devcontainer/cli).
|
||||
err := pool.Client.RemoveContainer(docker.RemoveContainerOptions{
|
||||
ID: container.ID,
|
||||
RemoveVolumes: true,
|
||||
Force: true,
|
||||
})
|
||||
assert.NoError(t, err, "remove container")
|
||||
})
|
||||
|
||||
containerInfo, err := pool.Client.InspectContainer(container.ID)
|
||||
require.NoError(t, err, "inspect container")
|
||||
t.Logf("Container state: status: %v", containerInfo.State.Status)
|
||||
require.True(t, containerInfo.State.Running, "container should be running")
|
||||
|
||||
ac, err := conn.ReconnectingPTY(ctx, uuid.New(), 80, 80, "", func(opts *workspacesdk.AgentReconnectingPTYInit) {
|
||||
opts.Container = container.ID
|
||||
})
|
||||
require.NoError(t, err, "failed to create ReconnectingPTY")
|
||||
defer ac.Close()
|
||||
|
||||
// Use terminal reader so we can see output in case somethin goes wrong.
|
||||
tr := testutil.NewTerminalReader(t, ac)
|
||||
|
||||
require.NoError(t, tr.ReadUntil(ctx, func(line string) bool {
|
||||
return strings.Contains(line, "#") || strings.Contains(line, "$")
|
||||
}), "find prompt")
|
||||
|
||||
wantFileName := "file-from-devcontainer"
|
||||
wantFile := filepath.Join(tempWorkspaceFolder, wantFileName)
|
||||
|
||||
require.NoError(t, json.NewEncoder(ac).Encode(workspacesdk.ReconnectingPTYRequest{
|
||||
// NOTE(mafredri): We must use absolute path here for some reason.
|
||||
Data: fmt.Sprintf("touch /workspaces/mywork/%s; exit\r", wantFileName),
|
||||
}), "create file inside devcontainer")
|
||||
|
||||
// Wait for the connection to close to ensure the touch was executed.
|
||||
require.ErrorIs(t, tr.ReadUntil(ctx, nil), io.EOF)
|
||||
|
||||
_, err = os.Stat(wantFile)
|
||||
require.NoError(t, err, "file should exist outside devcontainer")
|
||||
}
|
||||
|
||||
func TestAgent_Dial(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2320,7 +1962,7 @@ func TestAgent_UpdatedDERP(t *testing.T) {
|
||||
// Push a new DERP map to the agent.
|
||||
err := client.PushDERPMapUpdate(newDerpMap)
|
||||
require.NoError(t, err)
|
||||
t.Log("pushed DERPMap update to agent")
|
||||
t.Logf("pushed DERPMap update to agent")
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
conn := uut.TailnetConn()
|
||||
@@ -2332,7 +1974,7 @@ func TestAgent_UpdatedDERP(t *testing.T) {
|
||||
t.Logf("agent Conn DERPMap with regionIDs %v, PreferredDERP %d", regionIDs, preferredDERP)
|
||||
return len(regionIDs) == 1 && regionIDs[0] == 2 && preferredDERP == 2
|
||||
}, testutil.WaitLong, testutil.IntervalFast)
|
||||
t.Log("agent got the new DERPMap")
|
||||
t.Logf("agent got the new DERPMap")
|
||||
|
||||
// Connect from a second client and make sure it uses the new DERP map.
|
||||
conn2 := newClientConn(newDerpMap, "client2")
|
||||
@@ -2632,7 +2274,7 @@ done
|
||||
|
||||
n := 1
|
||||
for n <= 5 {
|
||||
logs := testutil.TryReceive(ctx, t, logsCh)
|
||||
logs := testutil.RequireRecvCtx(ctx, t, logsCh)
|
||||
require.NotNil(t, logs)
|
||||
for _, l := range logs.GetLogs() {
|
||||
require.Equal(t, fmt.Sprintf("start %d", n), l.GetOutput())
|
||||
@@ -2645,7 +2287,7 @@ done
|
||||
|
||||
n = 1
|
||||
for n <= 3000 {
|
||||
logs := testutil.TryReceive(ctx, t, logsCh)
|
||||
logs := testutil.RequireRecvCtx(ctx, t, logsCh)
|
||||
require.NotNil(t, logs)
|
||||
for _, l := range logs.GetLogs() {
|
||||
require.Equal(t, fmt.Sprintf("stop %d", n), l.GetOutput())
|
||||
@@ -2671,17 +2313,6 @@ func setupSSHSession(
|
||||
banner codersdk.BannerConfig,
|
||||
prepareFS func(fs afero.Fs),
|
||||
opts ...func(*agenttest.Client, *agent.Options),
|
||||
) *ssh.Session {
|
||||
return setupSSHSessionOnPort(t, manifest, banner, prepareFS, workspacesdk.AgentSSHPort, opts...)
|
||||
}
|
||||
|
||||
func setupSSHSessionOnPort(
|
||||
t *testing.T,
|
||||
manifest agentsdk.Manifest,
|
||||
banner codersdk.BannerConfig,
|
||||
prepareFS func(fs afero.Fs),
|
||||
port uint16,
|
||||
opts ...func(*agenttest.Client, *agent.Options),
|
||||
) *ssh.Session {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
@@ -2695,7 +2326,7 @@ func setupSSHSessionOnPort(
|
||||
if prepareFS != nil {
|
||||
prepareFS(fs)
|
||||
}
|
||||
sshClient, err := conn.SSHClientOnPort(ctx, port)
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = sshClient.Close()
|
||||
@@ -3060,35 +2691,3 @@ func requireEcho(t *testing.T, conn net.Conn) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test", string(b))
|
||||
}
|
||||
|
||||
func assertConnectionReport(t testing.TB, agentClient *agenttest.Client, connectionType proto.Connection_Type, status int, reason string) {
|
||||
t.Helper()
|
||||
|
||||
var reports []*proto.ReportConnectionRequest
|
||||
if !assert.Eventually(t, func() bool {
|
||||
reports = agentClient.GetConnectionReports()
|
||||
return len(reports) >= 2
|
||||
}, testutil.WaitMedium, testutil.IntervalFast, "waiting for 2 connection reports or more; got %d", len(reports)) {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Len(t, reports, 2, "want 2 connection reports")
|
||||
|
||||
assert.Equal(t, proto.Connection_CONNECT, reports[0].GetConnection().GetAction(), "first report should be connect")
|
||||
assert.Equal(t, proto.Connection_DISCONNECT, reports[1].GetConnection().GetAction(), "second report should be disconnect")
|
||||
assert.Equal(t, connectionType, reports[0].GetConnection().GetType(), "connect type should be %s", connectionType)
|
||||
assert.Equal(t, connectionType, reports[1].GetConnection().GetType(), "disconnect type should be %s", connectionType)
|
||||
t1 := reports[0].GetConnection().GetTimestamp().AsTime()
|
||||
t2 := reports[1].GetConnection().GetTimestamp().AsTime()
|
||||
assert.True(t, t1.Before(t2) || t1.Equal(t2), "connect timestamp should be before or equal to disconnect timestamp")
|
||||
assert.NotEmpty(t, reports[0].GetConnection().GetIp(), "connect ip should not be empty")
|
||||
assert.NotEmpty(t, reports[1].GetConnection().GetIp(), "disconnect ip should not be empty")
|
||||
assert.Equal(t, 0, int(reports[0].GetConnection().GetStatusCode()), "connect status code should be 0")
|
||||
assert.Equal(t, status, int(reports[1].GetConnection().GetStatusCode()), "disconnect status code should be %d", status)
|
||||
assert.Equal(t, "", reports[0].GetConnection().GetReason(), "connect reason should be empty")
|
||||
if reason != "" {
|
||||
assert.Contains(t, reports[1].GetConnection().GetReason(), reason, "disconnect reason should contain %s", reason)
|
||||
} else {
|
||||
t.Logf("connection report disconnect reason: %s", reports[1].GetConnection().GetReason())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: .. (interfaces: Lister)
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -destination ./acmock.go -package acmock .. Lister
|
||||
//
|
||||
|
||||
// Package acmock is a generated GoMock package.
|
||||
package acmock
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
codersdk "github.com/coder/coder/v2/codersdk"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockLister is a mock of Lister interface.
|
||||
type MockLister struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockListerMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockListerMockRecorder is the mock recorder for MockLister.
|
||||
type MockListerMockRecorder struct {
|
||||
mock *MockLister
|
||||
}
|
||||
|
||||
// NewMockLister creates a new mock instance.
|
||||
func NewMockLister(ctrl *gomock.Controller) *MockLister {
|
||||
mock := &MockLister{ctrl: ctrl}
|
||||
mock.recorder = &MockListerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockLister) EXPECT() *MockListerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// List mocks base method.
|
||||
func (m *MockLister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "List", ctx)
|
||||
ret0, _ := ret[0].(codersdk.WorkspaceAgentListContainersResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// List indicates an expected call of List.
|
||||
func (mr *MockListerMockRecorder) List(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockLister)(nil).List), ctx)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Package acmock contains a mock implementation of agentcontainers.Lister for use in tests.
|
||||
package acmock
|
||||
|
||||
//go:generate mockgen -destination ./acmock.go -package acmock .. Lister
|
||||
@@ -1,540 +0,0 @@
|
||||
package agentcontainers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultGetContainersCacheDuration = 10 * time.Second
|
||||
dockerCreatedAtTimeFormat = "2006-01-02 15:04:05 -0700 MST"
|
||||
getContainersTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// API is responsible for container-related operations in the agent.
|
||||
// It provides methods to list and manage containers.
|
||||
type API struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
logger slog.Logger
|
||||
watcher watcher.Watcher
|
||||
|
||||
cacheDuration time.Duration
|
||||
execer agentexec.Execer
|
||||
cl Lister
|
||||
dccli DevcontainerCLI
|
||||
clock quartz.Clock
|
||||
|
||||
// lockCh protects the below fields. We use a channel instead of a
|
||||
// mutex so we can handle cancellation properly.
|
||||
lockCh chan struct{}
|
||||
containers codersdk.WorkspaceAgentListContainersResponse
|
||||
mtime time.Time
|
||||
devcontainerNames map[string]struct{} // Track devcontainer names to avoid duplicates.
|
||||
knownDevcontainers []codersdk.WorkspaceAgentDevcontainer // Track predefined and runtime-detected devcontainers.
|
||||
configFileModifiedTimes map[string]time.Time // Track when config files were last modified.
|
||||
}
|
||||
|
||||
// Option is a functional option for API.
|
||||
type Option func(*API)
|
||||
|
||||
// WithClock sets the quartz.Clock implementation to use.
|
||||
// This is primarily used for testing to control time.
|
||||
func WithClock(clock quartz.Clock) Option {
|
||||
return func(api *API) {
|
||||
api.clock = clock
|
||||
}
|
||||
}
|
||||
|
||||
// WithExecer sets the agentexec.Execer implementation to use.
|
||||
func WithExecer(execer agentexec.Execer) Option {
|
||||
return func(api *API) {
|
||||
api.execer = execer
|
||||
}
|
||||
}
|
||||
|
||||
// WithLister sets the agentcontainers.Lister implementation to use.
|
||||
// The default implementation uses the Docker CLI to list containers.
|
||||
func WithLister(cl Lister) Option {
|
||||
return func(api *API) {
|
||||
api.cl = cl
|
||||
}
|
||||
}
|
||||
|
||||
// WithDevcontainerCLI sets the DevcontainerCLI implementation to use.
|
||||
// This can be used in tests to modify @devcontainer/cli behavior.
|
||||
func WithDevcontainerCLI(dccli DevcontainerCLI) Option {
|
||||
return func(api *API) {
|
||||
api.dccli = dccli
|
||||
}
|
||||
}
|
||||
|
||||
// WithDevcontainers sets the known devcontainers for the API. This
|
||||
// allows the API to be aware of devcontainers defined in the workspace
|
||||
// agent manifest.
|
||||
func WithDevcontainers(devcontainers []codersdk.WorkspaceAgentDevcontainer) Option {
|
||||
return func(api *API) {
|
||||
if len(devcontainers) > 0 {
|
||||
api.knownDevcontainers = slices.Clone(devcontainers)
|
||||
api.devcontainerNames = make(map[string]struct{}, len(devcontainers))
|
||||
for _, devcontainer := range devcontainers {
|
||||
api.devcontainerNames[devcontainer.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithWatcher sets the file watcher implementation to use. By default a
|
||||
// noop watcher is used. This can be used in tests to modify the watcher
|
||||
// behavior or to use an actual file watcher (e.g. fsnotify).
|
||||
func WithWatcher(w watcher.Watcher) Option {
|
||||
return func(api *API) {
|
||||
api.watcher = w
|
||||
}
|
||||
}
|
||||
|
||||
// NewAPI returns a new API with the given options applied.
|
||||
func NewAPI(logger slog.Logger, options ...Option) *API {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
api := &API{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
logger: logger,
|
||||
clock: quartz.NewReal(),
|
||||
execer: agentexec.DefaultExecer,
|
||||
cacheDuration: defaultGetContainersCacheDuration,
|
||||
lockCh: make(chan struct{}, 1),
|
||||
devcontainerNames: make(map[string]struct{}),
|
||||
knownDevcontainers: []codersdk.WorkspaceAgentDevcontainer{},
|
||||
configFileModifiedTimes: make(map[string]time.Time),
|
||||
}
|
||||
for _, opt := range options {
|
||||
opt(api)
|
||||
}
|
||||
if api.cl == nil {
|
||||
api.cl = NewDocker(api.execer)
|
||||
}
|
||||
if api.dccli == nil {
|
||||
api.dccli = NewDevcontainerCLI(logger.Named("devcontainer-cli"), api.execer)
|
||||
}
|
||||
if api.watcher == nil {
|
||||
var err error
|
||||
api.watcher, err = watcher.NewFSNotify()
|
||||
if err != nil {
|
||||
logger.Error(ctx, "create file watcher service failed", slog.Error(err))
|
||||
api.watcher = watcher.NewNoop()
|
||||
}
|
||||
}
|
||||
|
||||
go api.loop()
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
// SignalReady signals the API that we are ready to begin watching for
|
||||
// file changes. This is used to prime the cache with the current list
|
||||
// of containers and to start watching the devcontainer config files for
|
||||
// changes. It should be called after the agent ready.
|
||||
func (api *API) SignalReady() {
|
||||
// Prime the cache with the current list of containers.
|
||||
_, _ = api.cl.List(api.ctx)
|
||||
|
||||
// Make sure we watch the devcontainer config files for changes.
|
||||
for _, devcontainer := range api.knownDevcontainers {
|
||||
if devcontainer.ConfigPath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := api.watcher.Add(devcontainer.ConfigPath); err != nil {
|
||||
api.logger.Error(api.ctx, "watch devcontainer config file failed", slog.Error(err), slog.F("file", devcontainer.ConfigPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) loop() {
|
||||
defer close(api.done)
|
||||
|
||||
for {
|
||||
event, err := api.watcher.Next(api.ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, watcher.ErrClosed) {
|
||||
api.logger.Debug(api.ctx, "watcher closed")
|
||||
return
|
||||
}
|
||||
if api.ctx.Err() != nil {
|
||||
api.logger.Debug(api.ctx, "api context canceled")
|
||||
return
|
||||
}
|
||||
api.logger.Error(api.ctx, "watcher error waiting for next event", slog.Error(err))
|
||||
continue
|
||||
}
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
now := api.clock.Now()
|
||||
switch {
|
||||
case event.Has(fsnotify.Create | fsnotify.Write):
|
||||
api.logger.Debug(api.ctx, "devcontainer config file changed", slog.F("file", event.Name))
|
||||
api.markDevcontainerDirty(event.Name, now)
|
||||
case event.Has(fsnotify.Remove):
|
||||
api.logger.Debug(api.ctx, "devcontainer config file removed", slog.F("file", event.Name))
|
||||
api.markDevcontainerDirty(event.Name, now)
|
||||
case event.Has(fsnotify.Rename):
|
||||
api.logger.Debug(api.ctx, "devcontainer config file renamed", slog.F("file", event.Name))
|
||||
api.markDevcontainerDirty(event.Name, now)
|
||||
default:
|
||||
api.logger.Debug(api.ctx, "devcontainer config file event ignored", slog.F("file", event.Name), slog.F("event", event))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Routes returns the HTTP handler for container-related routes.
|
||||
func (api *API) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Get("/", api.handleList)
|
||||
r.Get("/devcontainers", api.handleListDevcontainers)
|
||||
r.Post("/{id}/recreate", api.handleRecreate)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// handleList handles the HTTP request to list containers.
|
||||
func (api *API) handleList(rw http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
// Client went away.
|
||||
return
|
||||
default:
|
||||
ct, err := api.getContainers(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusRequestTimeout, codersdk.Response{
|
||||
Message: "Could not get containers.",
|
||||
Detail: "Took too long to list containers.",
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Could not get containers.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, ct)
|
||||
}
|
||||
}
|
||||
|
||||
func copyListContainersResponse(resp codersdk.WorkspaceAgentListContainersResponse) codersdk.WorkspaceAgentListContainersResponse {
|
||||
return codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: slices.Clone(resp.Containers),
|
||||
Warnings: slices.Clone(resp.Warnings),
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
|
||||
select {
|
||||
case <-api.ctx.Done():
|
||||
return codersdk.WorkspaceAgentListContainersResponse{}, api.ctx.Err()
|
||||
case <-ctx.Done():
|
||||
return codersdk.WorkspaceAgentListContainersResponse{}, ctx.Err()
|
||||
case api.lockCh <- struct{}{}:
|
||||
defer func() { <-api.lockCh }()
|
||||
}
|
||||
|
||||
now := api.clock.Now()
|
||||
if now.Sub(api.mtime) < api.cacheDuration {
|
||||
return copyListContainersResponse(api.containers), nil
|
||||
}
|
||||
|
||||
timeoutCtx, timeoutCancel := context.WithTimeout(ctx, getContainersTimeout)
|
||||
defer timeoutCancel()
|
||||
updated, err := api.cl.List(timeoutCtx)
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("get containers: %w", err)
|
||||
}
|
||||
api.containers = updated
|
||||
api.mtime = now
|
||||
|
||||
dirtyStates := make(map[string]bool)
|
||||
// Reset all known devcontainers to not running.
|
||||
for i := range api.knownDevcontainers {
|
||||
api.knownDevcontainers[i].Running = false
|
||||
api.knownDevcontainers[i].Container = nil
|
||||
|
||||
// Preserve the dirty state and store in map for lookup.
|
||||
dirtyStates[api.knownDevcontainers[i].WorkspaceFolder] = api.knownDevcontainers[i].Dirty
|
||||
}
|
||||
|
||||
// Check if the container is running and update the known devcontainers.
|
||||
for _, container := range updated.Containers {
|
||||
workspaceFolder := container.Labels[DevcontainerLocalFolderLabel]
|
||||
configFile := container.Labels[DevcontainerConfigFileLabel]
|
||||
|
||||
if workspaceFolder == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is already in our known list.
|
||||
if knownIndex := slices.IndexFunc(api.knownDevcontainers, func(dc codersdk.WorkspaceAgentDevcontainer) bool {
|
||||
return dc.WorkspaceFolder == workspaceFolder
|
||||
}); knownIndex != -1 {
|
||||
// Update existing entry with runtime information.
|
||||
if configFile != "" && api.knownDevcontainers[knownIndex].ConfigPath == "" {
|
||||
api.knownDevcontainers[knownIndex].ConfigPath = configFile
|
||||
if err := api.watcher.Add(configFile); err != nil {
|
||||
api.logger.Error(ctx, "watch devcontainer config file failed", slog.Error(err), slog.F("file", configFile))
|
||||
}
|
||||
}
|
||||
api.knownDevcontainers[knownIndex].Running = container.Running
|
||||
api.knownDevcontainers[knownIndex].Container = &container
|
||||
|
||||
// Check if this container was created after the config
|
||||
// file was modified.
|
||||
if configFile != "" && api.knownDevcontainers[knownIndex].Dirty {
|
||||
lastModified, hasModTime := api.configFileModifiedTimes[configFile]
|
||||
if hasModTime && container.CreatedAt.After(lastModified) {
|
||||
api.logger.Info(ctx, "clearing dirty flag for container created after config modification",
|
||||
slog.F("container", container.ID),
|
||||
slog.F("created_at", container.CreatedAt),
|
||||
slog.F("config_modified_at", lastModified),
|
||||
slog.F("file", configFile),
|
||||
)
|
||||
api.knownDevcontainers[knownIndex].Dirty = false
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// NOTE(mafredri): This name impl. may change to accommodate devcontainer agents RFC.
|
||||
// If not in our known list, add as a runtime detected entry.
|
||||
name := path.Base(workspaceFolder)
|
||||
if _, ok := api.devcontainerNames[name]; ok {
|
||||
// Try to find a unique name by appending a number.
|
||||
for i := 2; ; i++ {
|
||||
newName := fmt.Sprintf("%s-%d", name, i)
|
||||
if _, ok := api.devcontainerNames[newName]; !ok {
|
||||
name = newName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
api.devcontainerNames[name] = struct{}{}
|
||||
if configFile != "" {
|
||||
if err := api.watcher.Add(configFile); err != nil {
|
||||
api.logger.Error(ctx, "watch devcontainer config file failed", slog.Error(err), slog.F("file", configFile))
|
||||
}
|
||||
}
|
||||
|
||||
dirty := dirtyStates[workspaceFolder]
|
||||
if dirty {
|
||||
lastModified, hasModTime := api.configFileModifiedTimes[configFile]
|
||||
if hasModTime && container.CreatedAt.After(lastModified) {
|
||||
api.logger.Info(ctx, "new container created after config modification, not marking as dirty",
|
||||
slog.F("container", container.ID),
|
||||
slog.F("created_at", container.CreatedAt),
|
||||
slog.F("config_modified_at", lastModified),
|
||||
slog.F("file", configFile),
|
||||
)
|
||||
dirty = false
|
||||
}
|
||||
}
|
||||
|
||||
api.knownDevcontainers = append(api.knownDevcontainers, codersdk.WorkspaceAgentDevcontainer{
|
||||
ID: uuid.New(),
|
||||
Name: name,
|
||||
WorkspaceFolder: workspaceFolder,
|
||||
ConfigPath: configFile,
|
||||
Running: container.Running,
|
||||
Dirty: dirty,
|
||||
Container: &container,
|
||||
})
|
||||
}
|
||||
|
||||
return copyListContainersResponse(api.containers), nil
|
||||
}
|
||||
|
||||
// handleRecreate handles the HTTP request to recreate a container.
|
||||
func (api *API) handleRecreate(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
if id == "" {
|
||||
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing container ID or name",
|
||||
Detail: "Container ID or name is required to recreate a devcontainer.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
containers, err := api.getContainers(ctx)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Could not list containers",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
containerIdx := slices.IndexFunc(containers.Containers, func(c codersdk.WorkspaceAgentContainer) bool {
|
||||
return c.Match(id)
|
||||
})
|
||||
if containerIdx == -1 {
|
||||
httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Container not found",
|
||||
Detail: "Container ID or name not found in the list of containers.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
container := containers.Containers[containerIdx]
|
||||
workspaceFolder := container.Labels[DevcontainerLocalFolderLabel]
|
||||
configPath := container.Labels[DevcontainerConfigFileLabel]
|
||||
|
||||
// Workspace folder is required to recreate a container, we don't verify
|
||||
// the config path here because it's optional.
|
||||
if workspaceFolder == "" {
|
||||
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Missing workspace folder label",
|
||||
Detail: "The workspace folder label is required to recreate a devcontainer.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = api.dccli.Up(ctx, workspaceFolder, configPath, WithRemoveExistingContainer())
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Could not recreate devcontainer",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO(mafredri): Temporarily handle clearing the dirty state after
|
||||
// recreation, later on this should be handled by a "container watcher".
|
||||
select {
|
||||
case <-api.ctx.Done():
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case api.lockCh <- struct{}{}:
|
||||
defer func() { <-api.lockCh }()
|
||||
}
|
||||
for i := range api.knownDevcontainers {
|
||||
if api.knownDevcontainers[i].WorkspaceFolder == workspaceFolder {
|
||||
if api.knownDevcontainers[i].Dirty {
|
||||
api.logger.Info(ctx, "clearing dirty flag after recreation",
|
||||
slog.F("workspace_folder", workspaceFolder),
|
||||
slog.F("name", api.knownDevcontainers[i].Name),
|
||||
)
|
||||
api.knownDevcontainers[i].Dirty = false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleListDevcontainers handles the HTTP request to list known devcontainers.
|
||||
func (api *API) handleListDevcontainers(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Run getContainers to detect the latest devcontainers and their state.
|
||||
_, err := api.getContainers(ctx)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Could not list containers",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-api.ctx.Done():
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case api.lockCh <- struct{}{}:
|
||||
}
|
||||
devcontainers := slices.Clone(api.knownDevcontainers)
|
||||
<-api.lockCh
|
||||
|
||||
slices.SortFunc(devcontainers, func(a, b codersdk.WorkspaceAgentDevcontainer) int {
|
||||
if cmp := strings.Compare(a.WorkspaceFolder, b.WorkspaceFolder); cmp != 0 {
|
||||
return cmp
|
||||
}
|
||||
return strings.Compare(a.ConfigPath, b.ConfigPath)
|
||||
})
|
||||
|
||||
response := codersdk.WorkspaceAgentDevcontainersResponse{
|
||||
Devcontainers: devcontainers,
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// markDevcontainerDirty finds the devcontainer with the given config file path
|
||||
// and marks it as dirty. It acquires the lock before modifying the state.
|
||||
func (api *API) markDevcontainerDirty(configPath string, modifiedAt time.Time) {
|
||||
select {
|
||||
case <-api.ctx.Done():
|
||||
return
|
||||
case api.lockCh <- struct{}{}:
|
||||
defer func() { <-api.lockCh }()
|
||||
}
|
||||
|
||||
// Record the timestamp of when this configuration file was modified.
|
||||
api.configFileModifiedTimes[configPath] = modifiedAt
|
||||
|
||||
for i := range api.knownDevcontainers {
|
||||
if api.knownDevcontainers[i].ConfigPath != configPath {
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO(mafredri): Simplistic mark for now, we should check if the
|
||||
// container is running and if the config file was modified after
|
||||
// the container was created.
|
||||
if !api.knownDevcontainers[i].Dirty {
|
||||
api.logger.Info(api.ctx, "marking devcontainer as dirty",
|
||||
slog.F("file", configPath),
|
||||
slog.F("name", api.knownDevcontainers[i].Name),
|
||||
slog.F("workspace_folder", api.knownDevcontainers[i].WorkspaceFolder),
|
||||
slog.F("modified_at", modifiedAt),
|
||||
)
|
||||
api.knownDevcontainers[i].Dirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) Close() error {
|
||||
api.cancel()
|
||||
<-api.done
|
||||
err := api.watcher.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package agentcontainers
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/acmock"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
func TestAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// List tests the API.getContainers method using a mock
|
||||
// implementation. It specifically tests caching behavior.
|
||||
t.Run("List", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fakeCt := fakeContainer(t)
|
||||
fakeCt2 := fakeContainer(t)
|
||||
makeResponse := func(cts ...codersdk.WorkspaceAgentContainer) codersdk.WorkspaceAgentListContainersResponse {
|
||||
return codersdk.WorkspaceAgentListContainersResponse{Containers: cts}
|
||||
}
|
||||
|
||||
// Each test case is called multiple times to ensure idempotency
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// data to be stored in the handler
|
||||
cacheData codersdk.WorkspaceAgentListContainersResponse
|
||||
// duration of cache
|
||||
cacheDur time.Duration
|
||||
// relative age of the cached data
|
||||
cacheAge time.Duration
|
||||
// function to set up expectations for the mock
|
||||
setupMock func(*acmock.MockLister)
|
||||
// expected result
|
||||
expected codersdk.WorkspaceAgentListContainersResponse
|
||||
// expected error
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "no cache",
|
||||
setupMock: func(mcl *acmock.MockLister) {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes()
|
||||
},
|
||||
expected: makeResponse(fakeCt),
|
||||
},
|
||||
{
|
||||
name: "no data",
|
||||
cacheData: makeResponse(),
|
||||
cacheAge: 2 * time.Second,
|
||||
cacheDur: time.Second,
|
||||
setupMock: func(mcl *acmock.MockLister) {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes()
|
||||
},
|
||||
expected: makeResponse(fakeCt),
|
||||
},
|
||||
{
|
||||
name: "cached data",
|
||||
cacheAge: time.Second,
|
||||
cacheData: makeResponse(fakeCt),
|
||||
cacheDur: 2 * time.Second,
|
||||
expected: makeResponse(fakeCt),
|
||||
},
|
||||
{
|
||||
name: "lister error",
|
||||
setupMock: func(mcl *acmock.MockLister) {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).AnyTimes()
|
||||
},
|
||||
expectedErr: assert.AnError.Error(),
|
||||
},
|
||||
{
|
||||
name: "stale cache",
|
||||
cacheAge: 2 * time.Second,
|
||||
cacheData: makeResponse(fakeCt),
|
||||
cacheDur: time.Second,
|
||||
setupMock: func(mcl *acmock.MockLister) {
|
||||
mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).AnyTimes()
|
||||
},
|
||||
expected: makeResponse(fakeCt2),
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
ctx = testutil.Context(t, testutil.WaitShort)
|
||||
clk = quartz.NewMock(t)
|
||||
ctrl = gomock.NewController(t)
|
||||
mockLister = acmock.NewMockLister(ctrl)
|
||||
now = time.Now().UTC()
|
||||
logger = slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
api = NewAPI(logger, WithLister(mockLister))
|
||||
)
|
||||
defer api.Close()
|
||||
|
||||
api.cacheDuration = tc.cacheDur
|
||||
api.clock = clk
|
||||
api.containers = tc.cacheData
|
||||
if tc.cacheAge != 0 {
|
||||
api.mtime = now.Add(-tc.cacheAge)
|
||||
}
|
||||
if tc.setupMock != nil {
|
||||
tc.setupMock(mockLister)
|
||||
}
|
||||
|
||||
clk.Set(now).MustWait(ctx)
|
||||
|
||||
// Repeat the test to ensure idempotency
|
||||
for i := 0; i < 2; i++ {
|
||||
actual, err := api.getContainers(ctx)
|
||||
if tc.expectedErr != "" {
|
||||
require.Empty(t, actual, "expected no data (attempt %d)", i)
|
||||
require.ErrorContains(t, err, tc.expectedErr, "expected error (attempt %d)", i)
|
||||
} else {
|
||||
require.NoError(t, err, "expected no error (attempt %d)", i)
|
||||
require.Equal(t, tc.expected, actual, "expected containers to be equal (attempt %d)", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func fakeContainer(t *testing.T, mut ...func(*codersdk.WorkspaceAgentContainer)) codersdk.WorkspaceAgentContainer {
|
||||
t.Helper()
|
||||
ct := codersdk.WorkspaceAgentContainer{
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ID: uuid.New().String(),
|
||||
FriendlyName: testutil.GetRandomName(t),
|
||||
Image: testutil.GetRandomName(t) + ":" + strings.Split(uuid.New().String(), "-")[0],
|
||||
Labels: map[string]string{
|
||||
testutil.GetRandomName(t): testutil.GetRandomName(t),
|
||||
},
|
||||
Running: true,
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{
|
||||
{
|
||||
Network: "tcp",
|
||||
Port: testutil.RandomPortNoListen(t),
|
||||
HostPort: testutil.RandomPortNoListen(t),
|
||||
//nolint:gosec // this is a test
|
||||
HostIP: []string{"127.0.0.1", "[::1]", "localhost", "0.0.0.0", "[::]", testutil.GetRandomName(t)}[rand.Intn(6)],
|
||||
},
|
||||
},
|
||||
Status: testutil.MustRandString(t, 10),
|
||||
Volumes: map[string]string{testutil.GetRandomName(t): testutil.GetRandomName(t)},
|
||||
}
|
||||
for _, m := range mut {
|
||||
m(&ct)
|
||||
}
|
||||
return ct
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
package agentcontainers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
// fakeLister implements the agentcontainers.Lister interface for
|
||||
// testing.
|
||||
type fakeLister struct {
|
||||
containers codersdk.WorkspaceAgentListContainersResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
|
||||
return f.containers, f.err
|
||||
}
|
||||
|
||||
// fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI
|
||||
// interface for testing.
|
||||
type fakeDevcontainerCLI struct {
|
||||
id string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDevcontainerCLI) Up(_ context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) {
|
||||
return f.id, f.err
|
||||
}
|
||||
|
||||
// fakeWatcher implements the watcher.Watcher interface for testing.
|
||||
// It allows controlling what events are sent and when.
|
||||
type fakeWatcher struct {
|
||||
t testing.TB
|
||||
events chan *fsnotify.Event
|
||||
closeNotify chan struct{}
|
||||
addedPaths []string
|
||||
closed bool
|
||||
nextCalled chan struct{}
|
||||
nextErr error
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func newFakeWatcher(t testing.TB) *fakeWatcher {
|
||||
return &fakeWatcher{
|
||||
t: t,
|
||||
events: make(chan *fsnotify.Event, 10), // Buffered to avoid blocking tests.
|
||||
closeNotify: make(chan struct{}),
|
||||
addedPaths: make([]string, 0),
|
||||
nextCalled: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *fakeWatcher) Add(file string) error {
|
||||
w.addedPaths = append(w.addedPaths, file)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *fakeWatcher) Remove(file string) error {
|
||||
for i, path := range w.addedPaths {
|
||||
if path == file {
|
||||
w.addedPaths = append(w.addedPaths[:i], w.addedPaths[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *fakeWatcher) clearNext() {
|
||||
select {
|
||||
case <-w.nextCalled:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (w *fakeWatcher) waitNext(ctx context.Context) bool {
|
||||
select {
|
||||
case <-w.t.Context().Done():
|
||||
return false
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-w.closeNotify:
|
||||
return false
|
||||
case <-w.nextCalled:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (w *fakeWatcher) Next(ctx context.Context) (*fsnotify.Event, error) {
|
||||
select {
|
||||
case w.nextCalled <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
if w.nextErr != nil {
|
||||
err := w.nextErr
|
||||
w.nextErr = nil
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-w.closeNotify:
|
||||
return nil, xerrors.New("watcher closed")
|
||||
case event := <-w.events:
|
||||
return event, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (w *fakeWatcher) Close() error {
|
||||
if w.closed {
|
||||
return nil
|
||||
}
|
||||
|
||||
w.closed = true
|
||||
close(w.closeNotify)
|
||||
return w.closeErr
|
||||
}
|
||||
|
||||
// sendEvent sends a file system event through the fake watcher.
|
||||
func (w *fakeWatcher) sendEventWaitNextCalled(ctx context.Context, event fsnotify.Event) {
|
||||
w.clearNext()
|
||||
w.events <- &event
|
||||
w.waitNext(ctx)
|
||||
}
|
||||
|
||||
func TestAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Recreate", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validContainer := codersdk.WorkspaceAgentContainer{
|
||||
ID: "container-id",
|
||||
FriendlyName: "container-name",
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspace",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json",
|
||||
},
|
||||
}
|
||||
|
||||
missingFolderContainer := codersdk.WorkspaceAgentContainer{
|
||||
ID: "missing-folder-container",
|
||||
FriendlyName: "missing-folder-container",
|
||||
Labels: map[string]string{},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
containerID string
|
||||
lister *fakeLister
|
||||
devcontainerCLI *fakeDevcontainerCLI
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "Missing ID",
|
||||
containerID: "",
|
||||
lister: &fakeLister{},
|
||||
devcontainerCLI: &fakeDevcontainerCLI{},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "Missing container ID or name",
|
||||
},
|
||||
{
|
||||
name: "List error",
|
||||
containerID: "container-id",
|
||||
lister: &fakeLister{
|
||||
err: xerrors.New("list error"),
|
||||
},
|
||||
devcontainerCLI: &fakeDevcontainerCLI{},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: "Could not list containers",
|
||||
},
|
||||
{
|
||||
name: "Container not found",
|
||||
containerID: "nonexistent-container",
|
||||
lister: &fakeLister{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
|
||||
},
|
||||
},
|
||||
devcontainerCLI: &fakeDevcontainerCLI{},
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: "Container not found",
|
||||
},
|
||||
{
|
||||
name: "Missing workspace folder label",
|
||||
containerID: "missing-folder-container",
|
||||
lister: &fakeLister{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{missingFolderContainer},
|
||||
},
|
||||
},
|
||||
devcontainerCLI: &fakeDevcontainerCLI{},
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "Missing workspace folder label",
|
||||
},
|
||||
{
|
||||
name: "Devcontainer CLI error",
|
||||
containerID: "container-id",
|
||||
lister: &fakeLister{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
|
||||
},
|
||||
},
|
||||
devcontainerCLI: &fakeDevcontainerCLI{
|
||||
err: xerrors.New("devcontainer CLI error"),
|
||||
},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantBody: "Could not recreate devcontainer",
|
||||
},
|
||||
{
|
||||
name: "OK",
|
||||
containerID: "container-id",
|
||||
lister: &fakeLister{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{validContainer},
|
||||
},
|
||||
},
|
||||
devcontainerCLI: &fakeDevcontainerCLI{},
|
||||
wantStatus: http.StatusNoContent,
|
||||
wantBody: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
|
||||
// Setup router with the handler under test.
|
||||
r := chi.NewRouter()
|
||||
api := agentcontainers.NewAPI(
|
||||
logger,
|
||||
agentcontainers.WithLister(tt.lister),
|
||||
agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI),
|
||||
agentcontainers.WithWatcher(watcher.NewNoop()),
|
||||
)
|
||||
defer api.Close()
|
||||
r.Mount("/", api.Routes())
|
||||
|
||||
// Simulate HTTP request to the recreate endpoint.
|
||||
req := httptest.NewRequest(http.MethodPost, "/"+tt.containerID+"/recreate", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
// Check the response status code and body.
|
||||
require.Equal(t, tt.wantStatus, rec.Code, "status code mismatch")
|
||||
if tt.wantBody != "" {
|
||||
assert.Contains(t, rec.Body.String(), tt.wantBody, "response body mismatch")
|
||||
} else if tt.wantStatus == http.StatusNoContent {
|
||||
assert.Empty(t, rec.Body.String(), "expected empty response body")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("List devcontainers", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
knownDevcontainerID1 := uuid.New()
|
||||
knownDevcontainerID2 := uuid.New()
|
||||
|
||||
knownDevcontainers := []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
ID: knownDevcontainerID1,
|
||||
Name: "known-devcontainer-1",
|
||||
WorkspaceFolder: "/workspace/known1",
|
||||
ConfigPath: "/workspace/known1/.devcontainer/devcontainer.json",
|
||||
},
|
||||
{
|
||||
ID: knownDevcontainerID2,
|
||||
Name: "known-devcontainer-2",
|
||||
WorkspaceFolder: "/workspace/known2",
|
||||
// No config path intentionally.
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lister *fakeLister
|
||||
knownDevcontainers []codersdk.WorkspaceAgentDevcontainer
|
||||
wantStatus int
|
||||
wantCount int
|
||||
verify func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer)
|
||||
}{
|
||||
{
|
||||
name: "List error",
|
||||
lister: &fakeLister{
|
||||
err: xerrors.New("list error"),
|
||||
},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "Empty containers",
|
||||
lister: &fakeLister{},
|
||||
wantStatus: http.StatusOK,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "Only known devcontainers, no containers",
|
||||
lister: &fakeLister{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{},
|
||||
},
|
||||
},
|
||||
knownDevcontainers: knownDevcontainers,
|
||||
wantStatus: http.StatusOK,
|
||||
wantCount: 2,
|
||||
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
|
||||
for _, dc := range devcontainers {
|
||||
assert.False(t, dc.Running, "devcontainer should not be running")
|
||||
assert.Nil(t, dc.Container, "devcontainer should not have container reference")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Runtime-detected devcontainer",
|
||||
lister: &fakeLister{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
ID: "runtime-container-1",
|
||||
FriendlyName: "runtime-container-1",
|
||||
Running: true,
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/runtime1",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/workspace/runtime1/.devcontainer/devcontainer.json",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "not-a-devcontainer",
|
||||
FriendlyName: "not-a-devcontainer",
|
||||
Running: true,
|
||||
Labels: map[string]string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
wantCount: 1,
|
||||
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
|
||||
dc := devcontainers[0]
|
||||
assert.Equal(t, "/workspace/runtime1", dc.WorkspaceFolder)
|
||||
assert.True(t, dc.Running)
|
||||
require.NotNil(t, dc.Container)
|
||||
assert.Equal(t, "runtime-container-1", dc.Container.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Mixed known and runtime-detected devcontainers",
|
||||
lister: &fakeLister{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
ID: "known-container-1",
|
||||
FriendlyName: "known-container-1",
|
||||
Running: true,
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/known1",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/workspace/known1/.devcontainer/devcontainer.json",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "runtime-container-1",
|
||||
FriendlyName: "runtime-container-1",
|
||||
Running: true,
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/runtime1",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/workspace/runtime1/.devcontainer/devcontainer.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
knownDevcontainers: knownDevcontainers,
|
||||
wantStatus: http.StatusOK,
|
||||
wantCount: 3, // 2 known + 1 runtime
|
||||
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
|
||||
known1 := mustFindDevcontainerByPath(t, devcontainers, "/workspace/known1")
|
||||
known2 := mustFindDevcontainerByPath(t, devcontainers, "/workspace/known2")
|
||||
runtime1 := mustFindDevcontainerByPath(t, devcontainers, "/workspace/runtime1")
|
||||
|
||||
assert.True(t, known1.Running)
|
||||
assert.False(t, known2.Running)
|
||||
assert.True(t, runtime1.Running)
|
||||
|
||||
require.NotNil(t, known1.Container)
|
||||
assert.Nil(t, known2.Container)
|
||||
require.NotNil(t, runtime1.Container)
|
||||
|
||||
assert.Equal(t, "known-container-1", known1.Container.ID)
|
||||
assert.Equal(t, "runtime-container-1", runtime1.Container.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Both running and non-running containers have container references",
|
||||
lister: &fakeLister{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
ID: "running-container",
|
||||
FriendlyName: "running-container",
|
||||
Running: true,
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/running",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/workspace/running/.devcontainer/devcontainer.json",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "non-running-container",
|
||||
FriendlyName: "non-running-container",
|
||||
Running: false,
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/non-running",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/workspace/non-running/.devcontainer/devcontainer.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
wantCount: 2,
|
||||
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
|
||||
running := mustFindDevcontainerByPath(t, devcontainers, "/workspace/running")
|
||||
nonRunning := mustFindDevcontainerByPath(t, devcontainers, "/workspace/non-running")
|
||||
|
||||
assert.True(t, running.Running)
|
||||
assert.False(t, nonRunning.Running)
|
||||
|
||||
require.NotNil(t, running.Container, "running container should have container reference")
|
||||
require.NotNil(t, nonRunning.Container, "non-running container should have container reference")
|
||||
|
||||
assert.Equal(t, "running-container", running.Container.ID)
|
||||
assert.Equal(t, "non-running-container", nonRunning.Container.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Config path update",
|
||||
lister: &fakeLister{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
ID: "known-container-2",
|
||||
FriendlyName: "known-container-2",
|
||||
Running: true,
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/known2",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/workspace/known2/.devcontainer/devcontainer.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
knownDevcontainers: knownDevcontainers,
|
||||
wantStatus: http.StatusOK,
|
||||
wantCount: 2,
|
||||
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
|
||||
var dc2 *codersdk.WorkspaceAgentDevcontainer
|
||||
for i := range devcontainers {
|
||||
if devcontainers[i].ID == knownDevcontainerID2 {
|
||||
dc2 = &devcontainers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, dc2, "missing devcontainer with ID %s", knownDevcontainerID2)
|
||||
assert.True(t, dc2.Running)
|
||||
assert.NotEmpty(t, dc2.ConfigPath)
|
||||
require.NotNil(t, dc2.Container)
|
||||
assert.Equal(t, "known-container-2", dc2.Container.ID)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Name generation and uniqueness",
|
||||
lister: &fakeLister{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
ID: "project1-container",
|
||||
FriendlyName: "project1-container",
|
||||
Running: true,
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/workspace/project/.devcontainer/devcontainer.json",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "project2-container",
|
||||
FriendlyName: "project2-container",
|
||||
Running: true,
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/home/user/project",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/home/user/project/.devcontainer/devcontainer.json",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "project3-container",
|
||||
FriendlyName: "project3-container",
|
||||
Running: true,
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/var/lib/project",
|
||||
agentcontainers.DevcontainerConfigFileLabel: "/var/lib/project/.devcontainer/devcontainer.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
knownDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
ID: uuid.New(),
|
||||
Name: "project", // This will cause uniqueness conflicts.
|
||||
WorkspaceFolder: "/usr/local/project",
|
||||
ConfigPath: "/usr/local/project/.devcontainer/devcontainer.json",
|
||||
},
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
wantCount: 4, // 1 known + 3 runtime
|
||||
verify: func(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer) {
|
||||
names := make(map[string]int)
|
||||
for _, dc := range devcontainers {
|
||||
names[dc.Name]++
|
||||
assert.NotEmpty(t, dc.Name, "devcontainer name should not be empty")
|
||||
}
|
||||
|
||||
for name, count := range names {
|
||||
assert.Equal(t, 1, count, "name '%s' appears %d times, should be unique", name, count)
|
||||
}
|
||||
assert.Len(t, names, 4, "should have four unique devcontainer names")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
|
||||
// Setup router with the handler under test.
|
||||
r := chi.NewRouter()
|
||||
apiOptions := []agentcontainers.Option{
|
||||
agentcontainers.WithLister(tt.lister),
|
||||
agentcontainers.WithWatcher(watcher.NewNoop()),
|
||||
}
|
||||
|
||||
if len(tt.knownDevcontainers) > 0 {
|
||||
apiOptions = append(apiOptions, agentcontainers.WithDevcontainers(tt.knownDevcontainers))
|
||||
}
|
||||
|
||||
api := agentcontainers.NewAPI(logger, apiOptions...)
|
||||
defer api.Close()
|
||||
r.Mount("/", api.Routes())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/devcontainers", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
// Check the response status code.
|
||||
require.Equal(t, tt.wantStatus, rec.Code, "status code mismatch")
|
||||
if tt.wantStatus != http.StatusOK {
|
||||
return
|
||||
}
|
||||
|
||||
var response codersdk.WorkspaceAgentDevcontainersResponse
|
||||
err := json.NewDecoder(rec.Body).Decode(&response)
|
||||
require.NoError(t, err, "unmarshal response failed")
|
||||
|
||||
// Verify the number of devcontainers in the response.
|
||||
assert.Len(t, response.Devcontainers, tt.wantCount, "wrong number of devcontainers")
|
||||
|
||||
// Run custom verification if provided.
|
||||
if tt.verify != nil && len(response.Devcontainers) > 0 {
|
||||
tt.verify(t, response.Devcontainers)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FileWatcher", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
startTime := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
mClock := quartz.NewMock(t)
|
||||
mClock.Set(startTime)
|
||||
fWatcher := newFakeWatcher(t)
|
||||
|
||||
// Create a fake container with a config file.
|
||||
configPath := "/workspace/project/.devcontainer/devcontainer.json"
|
||||
container := codersdk.WorkspaceAgentContainer{
|
||||
ID: "container-id",
|
||||
FriendlyName: "container-name",
|
||||
Running: true,
|
||||
CreatedAt: startTime.Add(-1 * time.Hour), // Created 1 hour before test start.
|
||||
Labels: map[string]string{
|
||||
agentcontainers.DevcontainerLocalFolderLabel: "/workspace/project",
|
||||
agentcontainers.DevcontainerConfigFileLabel: configPath,
|
||||
},
|
||||
}
|
||||
|
||||
fLister := &fakeLister{
|
||||
containers: codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: []codersdk.WorkspaceAgentContainer{container},
|
||||
},
|
||||
}
|
||||
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
api := agentcontainers.NewAPI(
|
||||
logger,
|
||||
agentcontainers.WithLister(fLister),
|
||||
agentcontainers.WithWatcher(fWatcher),
|
||||
agentcontainers.WithClock(mClock),
|
||||
)
|
||||
defer api.Close()
|
||||
|
||||
api.SignalReady()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Mount("/", api.Routes())
|
||||
|
||||
// Call the list endpoint first to ensure config files are
|
||||
// detected and watched.
|
||||
req := httptest.NewRequest(http.MethodGet, "/devcontainers", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var response codersdk.WorkspaceAgentDevcontainersResponse
|
||||
err := json.NewDecoder(rec.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, response.Devcontainers, 1)
|
||||
assert.False(t, response.Devcontainers[0].Dirty,
|
||||
"container should not be marked as dirty initially")
|
||||
|
||||
// Verify the watcher is watching the config file.
|
||||
assert.Contains(t, fWatcher.addedPaths, configPath,
|
||||
"watcher should be watching the container's config file")
|
||||
|
||||
// Make sure the start loop has been called.
|
||||
fWatcher.waitNext(ctx)
|
||||
|
||||
// Send a file modification event and check if the container is
|
||||
// marked dirty.
|
||||
fWatcher.sendEventWaitNextCalled(ctx, fsnotify.Event{
|
||||
Name: configPath,
|
||||
Op: fsnotify.Write,
|
||||
})
|
||||
|
||||
mClock.Advance(time.Minute).MustWait(ctx)
|
||||
|
||||
// Check if the container is marked as dirty.
|
||||
req = httptest.NewRequest(http.MethodGet, "/devcontainers", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
err = json.NewDecoder(rec.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, response.Devcontainers, 1)
|
||||
assert.True(t, response.Devcontainers[0].Dirty,
|
||||
"container should be marked as dirty after config file was modified")
|
||||
|
||||
mClock.Advance(time.Minute).MustWait(ctx)
|
||||
|
||||
container.ID = "new-container-id" // Simulate a new container ID after recreation.
|
||||
container.FriendlyName = "new-container-name"
|
||||
container.CreatedAt = mClock.Now() // Update the creation time.
|
||||
fLister.containers.Containers = []codersdk.WorkspaceAgentContainer{container}
|
||||
|
||||
// Check if dirty flag is cleared.
|
||||
req = httptest.NewRequest(http.MethodGet, "/devcontainers", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
err = json.NewDecoder(rec.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, response.Devcontainers, 1)
|
||||
assert.False(t, response.Devcontainers[0].Dirty,
|
||||
"dirty flag should be cleared after container recreation")
|
||||
})
|
||||
}
|
||||
|
||||
// mustFindDevcontainerByPath returns the devcontainer with the given workspace
|
||||
// folder path. It fails the test if no matching devcontainer is found.
|
||||
func mustFindDevcontainerByPath(t *testing.T, devcontainers []codersdk.WorkspaceAgentDevcontainer, path string) codersdk.WorkspaceAgentDevcontainer {
|
||||
t.Helper()
|
||||
|
||||
for i := range devcontainers {
|
||||
if devcontainers[i].WorkspaceFolder == path {
|
||||
return devcontainers[i]
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "no devcontainer found with workspace folder %q", path)
|
||||
return codersdk.WorkspaceAgentDevcontainer{} // Unreachable, but required for compilation
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package agentcontainers
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// Lister is an interface for listing containers visible to the
|
||||
// workspace agent.
|
||||
type Lister interface {
|
||||
// List returns a list of containers visible to the workspace agent.
|
||||
// This should include running and stopped containers.
|
||||
List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error)
|
||||
}
|
||||
|
||||
// NoopLister is a Lister interface that never returns any containers.
|
||||
type NoopLister struct{}
|
||||
|
||||
var _ Lister = NoopLister{}
|
||||
|
||||
func (NoopLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
|
||||
return codersdk.WorkspaceAgentListContainersResponse{}, nil
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
package agentcontainers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/user"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/dcspec"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/usershell"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// DockerEnvInfoer is an implementation of agentssh.EnvInfoer that returns
|
||||
// information about a container.
|
||||
type DockerEnvInfoer struct {
|
||||
usershell.SystemEnvInfo
|
||||
container string
|
||||
user *user.User
|
||||
userShell string
|
||||
env []string
|
||||
}
|
||||
|
||||
// EnvInfo returns information about the environment of a container.
|
||||
func EnvInfo(ctx context.Context, execer agentexec.Execer, container, containerUser string) (*DockerEnvInfoer, error) {
|
||||
var dei DockerEnvInfoer
|
||||
dei.container = container
|
||||
|
||||
if containerUser == "" {
|
||||
// Get the "default" user of the container if no user is specified.
|
||||
// TODO: handle different container runtimes.
|
||||
cmd, args := wrapDockerExec(container, "", "whoami")
|
||||
stdout, stderr, err := run(ctx, execer, cmd, args...)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get container user: run whoami: %w: %s", err, stderr)
|
||||
}
|
||||
if len(stdout) == 0 {
|
||||
return nil, xerrors.Errorf("get container user: run whoami: empty output")
|
||||
}
|
||||
containerUser = stdout
|
||||
}
|
||||
// Now that we know the username, get the required info from the container.
|
||||
// We can't assume the presence of `getent` so we'll just have to sniff /etc/passwd.
|
||||
cmd, args := wrapDockerExec(container, containerUser, "cat", "/etc/passwd")
|
||||
stdout, stderr, err := run(ctx, execer, cmd, args...)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get container user: read /etc/passwd: %w: %q", err, stderr)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(stdout))
|
||||
var foundLine string
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, containerUser+":") {
|
||||
continue
|
||||
}
|
||||
foundLine = line
|
||||
break
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, xerrors.Errorf("get container user: scan /etc/passwd: %w", err)
|
||||
}
|
||||
if foundLine == "" {
|
||||
return nil, xerrors.Errorf("get container user: no matching entry for %q found in /etc/passwd", containerUser)
|
||||
}
|
||||
|
||||
// Parse the output of /etc/passwd. It looks like this:
|
||||
// postgres:x:999:999::/var/lib/postgresql:/bin/bash
|
||||
passwdFields := strings.Split(foundLine, ":")
|
||||
if len(passwdFields) != 7 {
|
||||
return nil, xerrors.Errorf("get container user: invalid line in /etc/passwd: %q", foundLine)
|
||||
}
|
||||
|
||||
// The fifth entry in /etc/passwd contains GECOS information, which is a
|
||||
// comma-separated list of fields. The first field is the user's full name.
|
||||
gecos := strings.Split(passwdFields[4], ",")
|
||||
fullName := ""
|
||||
if len(gecos) > 1 {
|
||||
fullName = gecos[0]
|
||||
}
|
||||
|
||||
dei.user = &user.User{
|
||||
Gid: passwdFields[3],
|
||||
HomeDir: passwdFields[5],
|
||||
Name: fullName,
|
||||
Uid: passwdFields[2],
|
||||
Username: containerUser,
|
||||
}
|
||||
dei.userShell = passwdFields[6]
|
||||
|
||||
// We need to inspect the container labels for remoteEnv and append these to
|
||||
// the resulting docker exec command.
|
||||
// ref: https://code.visualstudio.com/docs/devcontainers/attach-container
|
||||
env, err := devcontainerEnv(ctx, execer, container)
|
||||
if err != nil { // best effort.
|
||||
return nil, xerrors.Errorf("read devcontainer remoteEnv: %w", err)
|
||||
}
|
||||
dei.env = env
|
||||
|
||||
return &dei, nil
|
||||
}
|
||||
|
||||
func (dei *DockerEnvInfoer) User() (*user.User, error) {
|
||||
// Clone the user so that the caller can't modify it
|
||||
u := *dei.user
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (dei *DockerEnvInfoer) Shell(string) (string, error) {
|
||||
return dei.userShell, nil
|
||||
}
|
||||
|
||||
func (dei *DockerEnvInfoer) ModifyCommand(cmd string, args ...string) (string, []string) {
|
||||
// Wrap the command with `docker exec` and run it as the container user.
|
||||
// There is some additional munging here regarding the container user and environment.
|
||||
dockerArgs := []string{
|
||||
"exec",
|
||||
// The assumption is that this command will be a shell command, so allocate a PTY.
|
||||
"--interactive",
|
||||
"--tty",
|
||||
// Run the command as the user in the container.
|
||||
"--user",
|
||||
dei.user.Username,
|
||||
// Set the working directory to the user's home directory as a sane default.
|
||||
"--workdir",
|
||||
dei.user.HomeDir,
|
||||
}
|
||||
|
||||
// Append the environment variables from the container.
|
||||
for _, e := range dei.env {
|
||||
dockerArgs = append(dockerArgs, "--env", e)
|
||||
}
|
||||
|
||||
// Append the container name and the command.
|
||||
dockerArgs = append(dockerArgs, dei.container, cmd)
|
||||
return "docker", append(dockerArgs, args...)
|
||||
}
|
||||
|
||||
// devcontainerEnv is a helper function that inspects the container labels to
|
||||
// find the required environment variables for running a command in the container.
|
||||
func devcontainerEnv(ctx context.Context, execer agentexec.Execer, container string) ([]string, error) {
|
||||
stdout, stderr, err := runDockerInspect(ctx, execer, container)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("inspect container: %w: %q", err, stderr)
|
||||
}
|
||||
|
||||
ins, _, err := convertDockerInspect(stdout)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("inspect container: %w", err)
|
||||
}
|
||||
|
||||
if len(ins) != 1 {
|
||||
return nil, xerrors.Errorf("inspect container: expected 1 container, got %d", len(ins))
|
||||
}
|
||||
|
||||
in := ins[0]
|
||||
if in.Labels == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// We want to look for the devcontainer metadata, which is in the
|
||||
// value of the label `devcontainer.metadata`.
|
||||
rawMeta, ok := in.Labels["devcontainer.metadata"]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
meta := make([]dcspec.DevContainer, 0)
|
||||
if err := json.Unmarshal([]byte(rawMeta), &meta); err != nil {
|
||||
return nil, xerrors.Errorf("unmarshal devcontainer.metadata: %w", err)
|
||||
}
|
||||
|
||||
// The environment variables are stored in the `remoteEnv` key.
|
||||
env := make([]string, 0)
|
||||
for _, m := range meta {
|
||||
for k, v := range m.RemoteEnv {
|
||||
if v == nil { // *string per spec
|
||||
// devcontainer-cli will set this to the string "null" if the value is
|
||||
// not set. Explicitly setting to an empty string here as this would be
|
||||
// more expected here.
|
||||
v = ptr.Ref("")
|
||||
}
|
||||
env = append(env, fmt.Sprintf("%s=%s", k, *v))
|
||||
}
|
||||
}
|
||||
slices.Sort(env)
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// wrapDockerExec is a helper function that wraps the given command and arguments
|
||||
// with a docker exec command that runs as the given user in the given
|
||||
// container. This is used to fetch information about a container prior to
|
||||
// running the actual command.
|
||||
func wrapDockerExec(containerName, userName, cmd string, args ...string) (string, []string) {
|
||||
dockerArgs := []string{"exec", "--interactive"}
|
||||
if userName != "" {
|
||||
dockerArgs = append(dockerArgs, "--user", userName)
|
||||
}
|
||||
dockerArgs = append(dockerArgs, containerName, cmd)
|
||||
return "docker", append(dockerArgs, args...)
|
||||
}
|
||||
|
||||
// Helper function to run a command and return its stdout and stderr.
|
||||
// We want to differentiate stdout and stderr instead of using CombinedOutput.
|
||||
// We also want to differentiate between a command running successfully with
|
||||
// output to stderr and a non-zero exit code.
|
||||
func run(ctx context.Context, execer agentexec.Execer, cmd string, args ...string) (stdout, stderr string, err error) {
|
||||
var stdoutBuf, stderrBuf strings.Builder
|
||||
execCmd := execer.CommandContext(ctx, cmd, args...)
|
||||
execCmd.Stdout = &stdoutBuf
|
||||
execCmd.Stderr = &stderrBuf
|
||||
err = execCmd.Run()
|
||||
stdout = strings.TrimSpace(stdoutBuf.String())
|
||||
stderr = strings.TrimSpace(stderrBuf.String())
|
||||
return stdout, stderr, err
|
||||
}
|
||||
|
||||
// DockerCLILister is a ContainerLister that lists containers using the docker CLI
|
||||
type DockerCLILister struct {
|
||||
execer agentexec.Execer
|
||||
}
|
||||
|
||||
var _ Lister = &DockerCLILister{}
|
||||
|
||||
func NewDocker(execer agentexec.Execer) Lister {
|
||||
return &DockerCLILister{
|
||||
execer: agentexec.DefaultExecer,
|
||||
}
|
||||
}
|
||||
|
||||
func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
// List all container IDs, one per line, with no truncation
|
||||
cmd := dcl.execer.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc")
|
||||
cmd.Stdout = &stdoutBuf
|
||||
cmd.Stderr = &stderrBuf
|
||||
if err := cmd.Run(); err != nil {
|
||||
// TODO(Cian): detect specific errors:
|
||||
// - docker not installed
|
||||
// - docker not running
|
||||
// - no permissions to talk to docker
|
||||
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker ps: %w: %q", err, strings.TrimSpace(stderrBuf.String()))
|
||||
}
|
||||
|
||||
ids := make([]string, 0)
|
||||
scanner := bufio.NewScanner(&stdoutBuf)
|
||||
for scanner.Scan() {
|
||||
tmp := strings.TrimSpace(scanner.Text())
|
||||
if tmp == "" {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, tmp)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("scan docker ps output: %w", err)
|
||||
}
|
||||
|
||||
res := codersdk.WorkspaceAgentListContainersResponse{
|
||||
Containers: make([]codersdk.WorkspaceAgentContainer, 0, len(ids)),
|
||||
Warnings: make([]string, 0),
|
||||
}
|
||||
dockerPsStderr := strings.TrimSpace(stderrBuf.String())
|
||||
if dockerPsStderr != "" {
|
||||
res.Warnings = append(res.Warnings, dockerPsStderr)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// now we can get the detailed information for each container
|
||||
// Run `docker inspect` on each container ID.
|
||||
// NOTE: There is an unavoidable potential race condition where a
|
||||
// container is removed between `docker ps` and `docker inspect`.
|
||||
// In this case, stderr will contain an error message but stdout
|
||||
// will still contain valid JSON. We will just end up missing
|
||||
// information about the removed container. We could potentially
|
||||
// log this error, but I'm not sure it's worth it.
|
||||
dockerInspectStdout, dockerInspectStderr, err := runDockerInspect(ctx, dcl.execer, ids...)
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w: %s", err, dockerInspectStderr)
|
||||
}
|
||||
|
||||
if len(dockerInspectStderr) > 0 {
|
||||
res.Warnings = append(res.Warnings, string(dockerInspectStderr))
|
||||
}
|
||||
|
||||
outs, warns, err := convertDockerInspect(dockerInspectStdout)
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("convert docker inspect output: %w", err)
|
||||
}
|
||||
res.Warnings = append(res.Warnings, warns...)
|
||||
res.Containers = append(res.Containers, outs...)
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// runDockerInspect is a helper function that runs `docker inspect` on the given
|
||||
// container IDs and returns the parsed output.
|
||||
// The stderr output is also returned for logging purposes.
|
||||
func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...string) (stdout, stderr []byte, err error) {
|
||||
var stdoutBuf, stderrBuf bytes.Buffer
|
||||
cmd := execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...)
|
||||
cmd.Stdout = &stdoutBuf
|
||||
cmd.Stderr = &stderrBuf
|
||||
err = cmd.Run()
|
||||
stdout = bytes.TrimSpace(stdoutBuf.Bytes())
|
||||
stderr = bytes.TrimSpace(stderrBuf.Bytes())
|
||||
if err != nil {
|
||||
if bytes.Contains(stderr, []byte("No such object:")) {
|
||||
// This can happen if a container is deleted between the time we check for its existence and the time we inspect it.
|
||||
return stdout, stderr, nil
|
||||
}
|
||||
return stdout, stderr, err
|
||||
}
|
||||
return stdout, stderr, nil
|
||||
}
|
||||
|
||||
// To avoid a direct dependency on the Docker API, we use the docker CLI
|
||||
// to fetch information about containers.
|
||||
type dockerInspect struct {
|
||||
ID string `json:"Id"`
|
||||
Created time.Time `json:"Created"`
|
||||
Config dockerInspectConfig `json:"Config"`
|
||||
Name string `json:"Name"`
|
||||
Mounts []dockerInspectMount `json:"Mounts"`
|
||||
State dockerInspectState `json:"State"`
|
||||
NetworkSettings dockerInspectNetworkSettings `json:"NetworkSettings"`
|
||||
}
|
||||
|
||||
type dockerInspectConfig struct {
|
||||
Image string `json:"Image"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
|
||||
type dockerInspectPort struct {
|
||||
HostIP string `json:"HostIp"`
|
||||
HostPort string `json:"HostPort"`
|
||||
}
|
||||
|
||||
type dockerInspectMount struct {
|
||||
Source string `json:"Source"`
|
||||
Destination string `json:"Destination"`
|
||||
Type string `json:"Type"`
|
||||
}
|
||||
|
||||
type dockerInspectState struct {
|
||||
Running bool `json:"Running"`
|
||||
ExitCode int `json:"ExitCode"`
|
||||
Error string `json:"Error"`
|
||||
}
|
||||
|
||||
type dockerInspectNetworkSettings struct {
|
||||
Ports map[string][]dockerInspectPort `json:"Ports"`
|
||||
}
|
||||
|
||||
func (dis dockerInspectState) String() string {
|
||||
if dis.Running {
|
||||
return "running"
|
||||
}
|
||||
var sb strings.Builder
|
||||
_, _ = sb.WriteString("exited")
|
||||
if dis.ExitCode != 0 {
|
||||
_, _ = sb.WriteString(fmt.Sprintf(" with code %d", dis.ExitCode))
|
||||
} else {
|
||||
_, _ = sb.WriteString(" successfully")
|
||||
}
|
||||
if dis.Error != "" {
|
||||
_, _ = sb.WriteString(fmt.Sprintf(": %s", dis.Error))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func convertDockerInspect(raw []byte) ([]codersdk.WorkspaceAgentContainer, []string, error) {
|
||||
var warns []string
|
||||
var ins []dockerInspect
|
||||
if err := json.NewDecoder(bytes.NewReader(raw)).Decode(&ins); err != nil {
|
||||
return nil, nil, xerrors.Errorf("decode docker inspect output: %w", err)
|
||||
}
|
||||
outs := make([]codersdk.WorkspaceAgentContainer, 0, len(ins))
|
||||
|
||||
// Say you have two containers:
|
||||
// - Container A with Host IP 127.0.0.1:8000 mapped to container port 8001
|
||||
// - Container B with Host IP [::1]:8000 mapped to container port 8001
|
||||
// A request to localhost:8000 may be routed to either container.
|
||||
// We don't know which one for sure, so we need to surface this to the user.
|
||||
// Keep track of all host ports we see. If we see the same host port
|
||||
// mapped to multiple containers on different host IPs, we need to
|
||||
// warn the user about this.
|
||||
// Note that we only do this for loopback or unspecified IPs.
|
||||
// We'll assume that the user knows what they're doing if they bind to
|
||||
// a specific IP address.
|
||||
hostPortContainers := make(map[int][]string)
|
||||
|
||||
for _, in := range ins {
|
||||
out := codersdk.WorkspaceAgentContainer{
|
||||
CreatedAt: in.Created,
|
||||
// Remove the leading slash from the container name
|
||||
FriendlyName: strings.TrimPrefix(in.Name, "/"),
|
||||
ID: in.ID,
|
||||
Image: in.Config.Image,
|
||||
Labels: in.Config.Labels,
|
||||
Ports: make([]codersdk.WorkspaceAgentContainerPort, 0),
|
||||
Running: in.State.Running,
|
||||
Status: in.State.String(),
|
||||
Volumes: make(map[string]string, len(in.Mounts)),
|
||||
}
|
||||
|
||||
if in.NetworkSettings.Ports == nil {
|
||||
in.NetworkSettings.Ports = make(map[string][]dockerInspectPort)
|
||||
}
|
||||
portKeys := maps.Keys(in.NetworkSettings.Ports)
|
||||
// Sort the ports for deterministic output.
|
||||
sort.Strings(portKeys)
|
||||
// If we see the same port bound to both ipv4 and ipv6 loopback or unspecified
|
||||
// interfaces to the same container port, there is no point in adding it multiple times.
|
||||
loopbackHostPortContainerPorts := make(map[int]uint16, 0)
|
||||
for _, pk := range portKeys {
|
||||
for _, p := range in.NetworkSettings.Ports[pk] {
|
||||
cp, network, err := convertDockerPort(pk)
|
||||
if err != nil {
|
||||
warns = append(warns, fmt.Sprintf("convert docker port: %s", err.Error()))
|
||||
// Default network to "tcp" if we can't parse it.
|
||||
network = "tcp"
|
||||
}
|
||||
hp, err := strconv.Atoi(p.HostPort)
|
||||
if err != nil {
|
||||
warns = append(warns, fmt.Sprintf("convert docker host port: %s", err.Error()))
|
||||
continue
|
||||
}
|
||||
if hp > 65535 || hp < 1 { // invalid port
|
||||
warns = append(warns, fmt.Sprintf("convert docker host port: invalid host port %d", hp))
|
||||
continue
|
||||
}
|
||||
|
||||
// Deduplicate host ports for loopback and unspecified IPs.
|
||||
if isLoopbackOrUnspecified(p.HostIP) {
|
||||
if found, ok := loopbackHostPortContainerPorts[hp]; ok && found == cp {
|
||||
// We've already seen this port, so skip it.
|
||||
continue
|
||||
}
|
||||
loopbackHostPortContainerPorts[hp] = cp
|
||||
// Also keep track of the host port and the container ID.
|
||||
hostPortContainers[hp] = append(hostPortContainers[hp], in.ID)
|
||||
}
|
||||
out.Ports = append(out.Ports, codersdk.WorkspaceAgentContainerPort{
|
||||
Network: network,
|
||||
Port: cp,
|
||||
// #nosec G115 - Safe conversion since Docker ports are limited to uint16 range
|
||||
HostPort: uint16(hp),
|
||||
HostIP: p.HostIP,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if in.Mounts == nil {
|
||||
in.Mounts = []dockerInspectMount{}
|
||||
}
|
||||
// Sort the mounts for deterministic output.
|
||||
sort.Slice(in.Mounts, func(i, j int) bool {
|
||||
return in.Mounts[i].Source < in.Mounts[j].Source
|
||||
})
|
||||
for _, k := range in.Mounts {
|
||||
out.Volumes[k.Source] = k.Destination
|
||||
}
|
||||
outs = append(outs, out)
|
||||
}
|
||||
|
||||
// Check if any host ports are mapped to multiple containers.
|
||||
for hp, ids := range hostPortContainers {
|
||||
if len(ids) > 1 {
|
||||
warns = append(warns, fmt.Sprintf("host port %d is mapped to multiple containers on different interfaces: %s", hp, strings.Join(ids, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
return outs, warns, nil
|
||||
}
|
||||
|
||||
// convertDockerPort converts a Docker port string to a port number and network
|
||||
// example: "8080/tcp" -> 8080, "tcp"
|
||||
//
|
||||
// "8080" -> 8080, "tcp"
|
||||
func convertDockerPort(in string) (uint16, string, error) {
|
||||
parts := strings.Split(in, "/")
|
||||
p, err := strconv.ParseUint(parts[0], 10, 16)
|
||||
if err != nil {
|
||||
return 0, "", xerrors.Errorf("invalid port format: %s", in)
|
||||
}
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
// assume it's a TCP port
|
||||
return uint16(p), "tcp", nil
|
||||
case 2:
|
||||
return uint16(p), parts[1], nil
|
||||
default:
|
||||
return 0, "", xerrors.Errorf("invalid port format: %s", in)
|
||||
}
|
||||
}
|
||||
|
||||
// convenience function to check if an IP address is loopback or unspecified
|
||||
func isLoopbackOrUnspecified(ips string) bool {
|
||||
nip := net.ParseIP(ips)
|
||||
if nip == nil {
|
||||
return false // technically correct, I suppose
|
||||
}
|
||||
return nip.IsLoopback() || nip.IsUnspecified()
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
package agentcontainers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestWrapDockerExec(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
containerUser string
|
||||
cmdArgs []string
|
||||
wantCmd []string
|
||||
}{
|
||||
{
|
||||
name: "cmd with no args",
|
||||
containerUser: "my-user",
|
||||
cmdArgs: []string{"my-cmd"},
|
||||
wantCmd: []string{"docker", "exec", "--interactive", "--user", "my-user", "my-container", "my-cmd"},
|
||||
},
|
||||
{
|
||||
name: "cmd with args",
|
||||
containerUser: "my-user",
|
||||
cmdArgs: []string{"my-cmd", "arg1", "--arg2", "arg3", "--arg4"},
|
||||
wantCmd: []string{"docker", "exec", "--interactive", "--user", "my-user", "my-container", "my-cmd", "arg1", "--arg2", "arg3", "--arg4"},
|
||||
},
|
||||
{
|
||||
name: "no user specified",
|
||||
containerUser: "",
|
||||
cmdArgs: []string{"my-cmd"},
|
||||
wantCmd: []string{"docker", "exec", "--interactive", "my-container", "my-cmd"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt // appease the linter even though this isn't needed anymore
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
actualCmd, actualArgs := wrapDockerExec("my-container", tt.containerUser, tt.cmdArgs[0], tt.cmdArgs[1:]...)
|
||||
assert.Equal(t, tt.wantCmd[0], actualCmd)
|
||||
assert.Equal(t, tt.wantCmd[1:], actualArgs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertDockerPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
//nolint:paralleltest // variable recapture no longer required
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in string
|
||||
expectPort uint16
|
||||
expectNetwork string
|
||||
expectError string
|
||||
}{
|
||||
{
|
||||
name: "empty port",
|
||||
in: "",
|
||||
expectError: "invalid port",
|
||||
},
|
||||
{
|
||||
name: "valid tcp port",
|
||||
in: "8080/tcp",
|
||||
expectPort: 8080,
|
||||
expectNetwork: "tcp",
|
||||
},
|
||||
{
|
||||
name: "valid udp port",
|
||||
in: "8080/udp",
|
||||
expectPort: 8080,
|
||||
expectNetwork: "udp",
|
||||
},
|
||||
{
|
||||
name: "valid port no network",
|
||||
in: "8080",
|
||||
expectPort: 8080,
|
||||
expectNetwork: "tcp",
|
||||
},
|
||||
{
|
||||
name: "invalid port",
|
||||
in: "invalid/tcp",
|
||||
expectError: "invalid port",
|
||||
},
|
||||
{
|
||||
name: "invalid port no network",
|
||||
in: "invalid",
|
||||
expectError: "invalid port",
|
||||
},
|
||||
{
|
||||
name: "multiple network",
|
||||
in: "8080/tcp/udp",
|
||||
expectError: "invalid port",
|
||||
},
|
||||
} {
|
||||
//nolint: paralleltest // variable recapture no longer required
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
actualPort, actualNetwork, actualErr := convertDockerPort(tc.in)
|
||||
if tc.expectError != "" {
|
||||
assert.Zero(t, actualPort, "expected no port")
|
||||
assert.Empty(t, actualNetwork, "expected no network")
|
||||
assert.ErrorContains(t, actualErr, tc.expectError)
|
||||
} else {
|
||||
assert.NoError(t, actualErr, "expected no error")
|
||||
assert.Equal(t, tc.expectPort, actualPort, "expected port to match")
|
||||
assert.Equal(t, tc.expectNetwork, actualNetwork, "expected network to match")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertDockerVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in string
|
||||
expectHostPath string
|
||||
expectContainerPath string
|
||||
expectError string
|
||||
}{
|
||||
{
|
||||
name: "empty volume",
|
||||
in: "",
|
||||
expectError: "invalid volume",
|
||||
},
|
||||
{
|
||||
name: "length 1 volume",
|
||||
in: "/path/to/something",
|
||||
expectHostPath: "/path/to/something",
|
||||
expectContainerPath: "/path/to/something",
|
||||
},
|
||||
{
|
||||
name: "length 2 volume",
|
||||
in: "/path/to/something=/path/to/something/else",
|
||||
expectHostPath: "/path/to/something",
|
||||
expectContainerPath: "/path/to/something/else",
|
||||
},
|
||||
{
|
||||
name: "invalid length volume",
|
||||
in: "/path/to/something=/path/to/something/else=/path/to/something/else/else",
|
||||
expectError: "invalid volume",
|
||||
},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertDockerInspect tests the convertDockerInspect function using
|
||||
// fixtures from ./testdata.
|
||||
func TestConvertDockerInspect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
//nolint:paralleltest // variable recapture no longer required
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
expect []codersdk.WorkspaceAgentContainer
|
||||
expectWarns []string
|
||||
expectError string
|
||||
}{
|
||||
{
|
||||
name: "container_simple",
|
||||
expect: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
CreatedAt: time.Date(2025, 3, 11, 17, 55, 58, 91280203, time.UTC),
|
||||
ID: "6b539b8c60f5230b8b0fde2502cd2332d31c0d526a3e6eb6eef1cc39439b3286",
|
||||
FriendlyName: "eloquent_kowalevski",
|
||||
Image: "debian:bookworm",
|
||||
Labels: map[string]string{},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{},
|
||||
Volumes: map[string]string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "container_labels",
|
||||
expect: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
CreatedAt: time.Date(2025, 3, 11, 20, 3, 28, 71706536, time.UTC),
|
||||
ID: "bd8818e670230fc6f36145b21cf8d6d35580355662aa4d9fe5ae1b188a4c905f",
|
||||
FriendlyName: "fervent_bardeen",
|
||||
Image: "debian:bookworm",
|
||||
Labels: map[string]string{"baz": "zap", "foo": "bar"},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{},
|
||||
Volumes: map[string]string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "container_binds",
|
||||
expect: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
CreatedAt: time.Date(2025, 3, 11, 17, 58, 43, 522505027, time.UTC),
|
||||
ID: "fdc75ebefdc0243c0fce959e7685931691ac7aede278664a0e2c23af8a1e8d6a",
|
||||
FriendlyName: "silly_beaver",
|
||||
Image: "debian:bookworm",
|
||||
Labels: map[string]string{},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{},
|
||||
Volumes: map[string]string{
|
||||
"/tmp/test/a": "/var/coder/a",
|
||||
"/tmp/test/b": "/var/coder/b",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "container_sameport",
|
||||
expect: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
CreatedAt: time.Date(2025, 3, 11, 17, 56, 34, 842164541, time.UTC),
|
||||
ID: "4eac5ce199d27b2329d0ff0ce1a6fc595612ced48eba3669aadb6c57ebef3fa2",
|
||||
FriendlyName: "modest_varahamihira",
|
||||
Image: "debian:bookworm",
|
||||
Labels: map[string]string{},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{
|
||||
{
|
||||
Network: "tcp",
|
||||
Port: 12345,
|
||||
HostPort: 12345,
|
||||
HostIP: "0.0.0.0",
|
||||
},
|
||||
},
|
||||
Volumes: map[string]string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "container_differentport",
|
||||
expect: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
CreatedAt: time.Date(2025, 3, 11, 17, 57, 8, 862545133, time.UTC),
|
||||
ID: "3090de8b72b1224758a94a11b827c82ba2b09c45524f1263dc4a2d83e19625ea",
|
||||
FriendlyName: "boring_ellis",
|
||||
Image: "debian:bookworm",
|
||||
Labels: map[string]string{},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{
|
||||
{
|
||||
Network: "tcp",
|
||||
Port: 23456,
|
||||
HostPort: 12345,
|
||||
HostIP: "0.0.0.0",
|
||||
},
|
||||
},
|
||||
Volumes: map[string]string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "container_sameportdiffip",
|
||||
expect: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
CreatedAt: time.Date(2025, 3, 11, 17, 56, 34, 842164541, time.UTC),
|
||||
ID: "a",
|
||||
FriendlyName: "a",
|
||||
Image: "debian:bookworm",
|
||||
Labels: map[string]string{},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{
|
||||
{
|
||||
Network: "tcp",
|
||||
Port: 8001,
|
||||
HostPort: 8000,
|
||||
HostIP: "0.0.0.0",
|
||||
},
|
||||
},
|
||||
Volumes: map[string]string{},
|
||||
},
|
||||
{
|
||||
CreatedAt: time.Date(2025, 3, 11, 17, 56, 34, 842164541, time.UTC),
|
||||
ID: "b",
|
||||
FriendlyName: "b",
|
||||
Image: "debian:bookworm",
|
||||
Labels: map[string]string{},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{
|
||||
{
|
||||
Network: "tcp",
|
||||
Port: 8001,
|
||||
HostPort: 8000,
|
||||
HostIP: "::",
|
||||
},
|
||||
},
|
||||
Volumes: map[string]string{},
|
||||
},
|
||||
},
|
||||
expectWarns: []string{"host port 8000 is mapped to multiple containers on different interfaces: a, b"},
|
||||
},
|
||||
{
|
||||
name: "container_volume",
|
||||
expect: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
CreatedAt: time.Date(2025, 3, 11, 17, 59, 42, 39484134, time.UTC),
|
||||
ID: "b3688d98c007f53402a55e46d803f2f3ba9181d8e3f71a2eb19b392cf0377b4e",
|
||||
FriendlyName: "upbeat_carver",
|
||||
Image: "debian:bookworm",
|
||||
Labels: map[string]string{},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{},
|
||||
Volumes: map[string]string{
|
||||
"/var/lib/docker/volumes/testvol/_data": "/testvol",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "devcontainer_simple",
|
||||
expect: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
CreatedAt: time.Date(2025, 3, 11, 17, 1, 5, 751972661, time.UTC),
|
||||
ID: "0b2a9fcf5727d9562943ce47d445019f4520e37a2aa7c6d9346d01af4f4f9aed",
|
||||
FriendlyName: "optimistic_hopper",
|
||||
Image: "debian:bookworm",
|
||||
Labels: map[string]string{
|
||||
"devcontainer.config_file": "/home/coder/src/coder/coder/agent/agentcontainers/testdata/devcontainer_simple.json",
|
||||
"devcontainer.metadata": "[]",
|
||||
},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{},
|
||||
Volumes: map[string]string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "devcontainer_forwardport",
|
||||
expect: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
CreatedAt: time.Date(2025, 3, 11, 17, 3, 55, 22053072, time.UTC),
|
||||
ID: "4a16af2293fb75dc827a6949a3905dd57ea28cc008823218ce24fab1cb66c067",
|
||||
FriendlyName: "serene_khayyam",
|
||||
Image: "debian:bookworm",
|
||||
Labels: map[string]string{
|
||||
"devcontainer.config_file": "/home/coder/src/coder/coder/agent/agentcontainers/testdata/devcontainer_forwardport.json",
|
||||
"devcontainer.metadata": "[]",
|
||||
},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{},
|
||||
Volumes: map[string]string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "devcontainer_appport",
|
||||
expect: []codersdk.WorkspaceAgentContainer{
|
||||
{
|
||||
CreatedAt: time.Date(2025, 3, 11, 17, 2, 42, 613747761, time.UTC),
|
||||
ID: "52d23691f4b954d083f117358ea763e20f69af584e1c08f479c5752629ee0be3",
|
||||
FriendlyName: "suspicious_margulis",
|
||||
Image: "debian:bookworm",
|
||||
Labels: map[string]string{
|
||||
"devcontainer.config_file": "/home/coder/src/coder/coder/agent/agentcontainers/testdata/devcontainer_appport.json",
|
||||
"devcontainer.metadata": "[]",
|
||||
},
|
||||
Running: true,
|
||||
Status: "running",
|
||||
Ports: []codersdk.WorkspaceAgentContainerPort{
|
||||
{
|
||||
Network: "tcp",
|
||||
Port: 8080,
|
||||
HostPort: 32768,
|
||||
HostIP: "0.0.0.0",
|
||||
},
|
||||
},
|
||||
Volumes: map[string]string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
// nolint:paralleltest // variable recapture no longer required
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
bs, err := os.ReadFile(filepath.Join("testdata", tt.name, "docker_inspect.json"))
|
||||
require.NoError(t, err, "failed to read testdata file")
|
||||
actual, warns, err := convertDockerInspect(bs)
|
||||
if len(tt.expectWarns) > 0 {
|
||||
assert.Len(t, warns, len(tt.expectWarns), "expected warnings")
|
||||
for _, warn := range tt.expectWarns {
|
||||
assert.Contains(t, warns, warn)
|
||||
}
|
||||
}
|
||||
if tt.expectError != "" {
|
||||
assert.Empty(t, actual, "expected no data")
|
||||
assert.ErrorContains(t, err, tt.expectError)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err, "expected no error")
|
||||
if diff := cmp.Diff(tt.expect, actual); diff != "" {
|
||||
t.Errorf("unexpected diff (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
package agentcontainers_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ory/dockertest/v3"
|
||||
"github.com/ory/dockertest/v3/docker"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// TestIntegrationDocker tests agentcontainers functionality using a real
|
||||
// Docker container. It starts a container with a known
|
||||
// label, lists the containers, and verifies that the expected container is
|
||||
// returned. It also executes a sample command inside the container.
|
||||
// The container is deleted after the test is complete.
|
||||
// As this test creates containers, it is skipped by default.
|
||||
// It can be run manually as follows:
|
||||
//
|
||||
// CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestDockerCLIContainerLister
|
||||
//
|
||||
//nolint:paralleltest // This test tends to flake when lots of containers start and stop in parallel.
|
||||
func TestIntegrationDocker(t *testing.T) {
|
||||
if ctud, ok := os.LookupEnv("CODER_TEST_USE_DOCKER"); !ok || ctud != "1" {
|
||||
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
|
||||
}
|
||||
|
||||
pool, err := dockertest.NewPool("")
|
||||
require.NoError(t, err, "Could not connect to docker")
|
||||
testLabelValue := uuid.New().String()
|
||||
// Create a temporary directory to validate that we surface mounts correctly.
|
||||
testTempDir := t.TempDir()
|
||||
// Pick a random port to expose for testing port bindings.
|
||||
testRandPort := testutil.RandomPortNoListen(t)
|
||||
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
|
||||
Repository: "busybox",
|
||||
Tag: "latest",
|
||||
Cmd: []string{"sleep", "infnity"},
|
||||
Labels: map[string]string{
|
||||
"com.coder.test": testLabelValue,
|
||||
"devcontainer.metadata": `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`,
|
||||
},
|
||||
Mounts: []string{testTempDir + ":" + testTempDir},
|
||||
ExposedPorts: []string{fmt.Sprintf("%d/tcp", testRandPort)},
|
||||
PortBindings: map[docker.Port][]docker.PortBinding{
|
||||
docker.Port(fmt.Sprintf("%d/tcp", testRandPort)): {
|
||||
{
|
||||
HostIP: "0.0.0.0",
|
||||
HostPort: strconv.FormatInt(int64(testRandPort), 10),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, func(config *docker.HostConfig) {
|
||||
config.AutoRemove = true
|
||||
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
|
||||
})
|
||||
require.NoError(t, err, "Could not start test docker container")
|
||||
t.Logf("Created container %q", ct.Container.Name)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name)
|
||||
t.Logf("Purged container %q", ct.Container.Name)
|
||||
})
|
||||
// Wait for container to start
|
||||
require.Eventually(t, func() bool {
|
||||
ct, ok := pool.ContainerByName(ct.Container.Name)
|
||||
return ok && ct.Container.State.Running
|
||||
}, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time")
|
||||
|
||||
dcl := agentcontainers.NewDocker(agentexec.DefaultExecer)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
actual, err := dcl.List(ctx)
|
||||
require.NoError(t, err, "Could not list containers")
|
||||
require.Empty(t, actual.Warnings, "Expected no warnings")
|
||||
var found bool
|
||||
for _, foundContainer := range actual.Containers {
|
||||
if foundContainer.ID == ct.Container.ID {
|
||||
found = true
|
||||
assert.Equal(t, ct.Container.Created, foundContainer.CreatedAt)
|
||||
// ory/dockertest pre-pends a forward slash to the container name.
|
||||
assert.Equal(t, strings.TrimPrefix(ct.Container.Name, "/"), foundContainer.FriendlyName)
|
||||
// ory/dockertest returns the sha256 digest of the image.
|
||||
assert.Equal(t, "busybox:latest", foundContainer.Image)
|
||||
assert.Equal(t, ct.Container.Config.Labels, foundContainer.Labels)
|
||||
assert.True(t, foundContainer.Running)
|
||||
assert.Equal(t, "running", foundContainer.Status)
|
||||
if assert.Len(t, foundContainer.Ports, 1) {
|
||||
assert.Equal(t, testRandPort, foundContainer.Ports[0].Port)
|
||||
assert.Equal(t, "tcp", foundContainer.Ports[0].Network)
|
||||
}
|
||||
if assert.Len(t, foundContainer.Volumes, 1) {
|
||||
assert.Equal(t, testTempDir, foundContainer.Volumes[testTempDir])
|
||||
}
|
||||
// Test that EnvInfo is able to correctly modify a command to be
|
||||
// executed inside the container.
|
||||
dei, err := agentcontainers.EnvInfo(ctx, agentexec.DefaultExecer, ct.Container.ID, "")
|
||||
require.NoError(t, err, "Expected no error from DockerEnvInfo()")
|
||||
ptyWrappedCmd, ptyWrappedArgs := dei.ModifyCommand("/bin/sh", "--norc")
|
||||
ptyCmd, ptyPs, err := pty.Start(agentexec.DefaultExecer.PTYCommandContext(ctx, ptyWrappedCmd, ptyWrappedArgs...))
|
||||
require.NoError(t, err, "failed to start pty command")
|
||||
t.Cleanup(func() {
|
||||
_ = ptyPs.Kill()
|
||||
_ = ptyCmd.Close()
|
||||
})
|
||||
tr := testutil.NewTerminalReader(t, ptyCmd.OutputReader())
|
||||
matchPrompt := func(line string) bool {
|
||||
return strings.Contains(line, "#")
|
||||
}
|
||||
matchHostnameCmd := func(line string) bool {
|
||||
return strings.Contains(strings.TrimSpace(line), "hostname")
|
||||
}
|
||||
matchHostnameOuput := func(line string) bool {
|
||||
return strings.Contains(strings.TrimSpace(line), ct.Container.Config.Hostname)
|
||||
}
|
||||
matchEnvCmd := func(line string) bool {
|
||||
return strings.Contains(strings.TrimSpace(line), "env")
|
||||
}
|
||||
matchEnvOutput := func(line string) bool {
|
||||
return strings.Contains(line, "FOO=bar") || strings.Contains(line, "MULTILINE=foo")
|
||||
}
|
||||
require.NoError(t, tr.ReadUntil(ctx, matchPrompt), "failed to match prompt")
|
||||
t.Logf("Matched prompt")
|
||||
_, err = ptyCmd.InputWriter().Write([]byte("hostname\r\n"))
|
||||
require.NoError(t, err, "failed to write to pty")
|
||||
t.Logf("Wrote hostname command")
|
||||
require.NoError(t, tr.ReadUntil(ctx, matchHostnameCmd), "failed to match hostname command")
|
||||
t.Logf("Matched hostname command")
|
||||
require.NoError(t, tr.ReadUntil(ctx, matchHostnameOuput), "failed to match hostname output")
|
||||
t.Logf("Matched hostname output")
|
||||
_, err = ptyCmd.InputWriter().Write([]byte("env\r\n"))
|
||||
require.NoError(t, err, "failed to write to pty")
|
||||
t.Logf("Wrote env command")
|
||||
require.NoError(t, tr.ReadUntil(ctx, matchEnvCmd), "failed to match env command")
|
||||
t.Logf("Matched env command")
|
||||
require.NoError(t, tr.ReadUntil(ctx, matchEnvOutput), "failed to match env output")
|
||||
t.Logf("Matched env output")
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Expected to find container with label 'com.coder.test=%s'", testLabelValue)
|
||||
}
|
||||
|
||||
// TestDockerEnvInfoer tests the ability of EnvInfo to extract information from
|
||||
// running containers. Containers are deleted after the test is complete.
|
||||
// As this test creates containers, it is skipped by default.
|
||||
// It can be run manually as follows:
|
||||
//
|
||||
// CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestDockerEnvInfoer
|
||||
//
|
||||
//nolint:paralleltest // This test tends to flake when lots of containers start and stop in parallel.
|
||||
func TestDockerEnvInfoer(t *testing.T) {
|
||||
if ctud, ok := os.LookupEnv("CODER_TEST_USE_DOCKER"); !ok || ctud != "1" {
|
||||
t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test")
|
||||
}
|
||||
|
||||
pool, err := dockertest.NewPool("")
|
||||
require.NoError(t, err, "Could not connect to docker")
|
||||
// nolint:paralleltest // variable recapture no longer required
|
||||
for idx, tt := range []struct {
|
||||
image string
|
||||
labels map[string]string
|
||||
expectedEnv []string
|
||||
containerUser string
|
||||
expectedUsername string
|
||||
expectedUserShell string
|
||||
}{
|
||||
{
|
||||
image: "busybox:latest",
|
||||
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`},
|
||||
|
||||
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
||||
expectedUsername: "root",
|
||||
expectedUserShell: "/bin/sh",
|
||||
},
|
||||
{
|
||||
image: "busybox:latest",
|
||||
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`},
|
||||
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
||||
containerUser: "root",
|
||||
expectedUsername: "root",
|
||||
expectedUserShell: "/bin/sh",
|
||||
},
|
||||
{
|
||||
image: "codercom/enterprise-minimal:ubuntu",
|
||||
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`},
|
||||
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
||||
expectedUsername: "coder",
|
||||
expectedUserShell: "/bin/bash",
|
||||
},
|
||||
{
|
||||
image: "codercom/enterprise-minimal:ubuntu",
|
||||
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`},
|
||||
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
||||
containerUser: "coder",
|
||||
expectedUsername: "coder",
|
||||
expectedUserShell: "/bin/bash",
|
||||
},
|
||||
{
|
||||
image: "codercom/enterprise-minimal:ubuntu",
|
||||
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar", "MULTILINE": "foo\nbar\nbaz"}}]`},
|
||||
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
||||
containerUser: "root",
|
||||
expectedUsername: "root",
|
||||
expectedUserShell: "/bin/bash",
|
||||
},
|
||||
{
|
||||
image: "codercom/enterprise-minimal:ubuntu",
|
||||
labels: map[string]string{`devcontainer.metadata`: `[{"remoteEnv": {"FOO": "bar"}},{"remoteEnv": {"MULTILINE": "foo\nbar\nbaz"}}]`},
|
||||
expectedEnv: []string{"FOO=bar", "MULTILINE=foo\nbar\nbaz"},
|
||||
containerUser: "root",
|
||||
expectedUsername: "root",
|
||||
expectedUserShell: "/bin/bash",
|
||||
},
|
||||
} {
|
||||
//nolint:paralleltest // variable recapture no longer required
|
||||
t.Run(fmt.Sprintf("#%d", idx), func(t *testing.T) {
|
||||
// Start a container with the given image
|
||||
// and environment variables
|
||||
image := strings.Split(tt.image, ":")[0]
|
||||
tag := strings.Split(tt.image, ":")[1]
|
||||
ct, err := pool.RunWithOptions(&dockertest.RunOptions{
|
||||
Repository: image,
|
||||
Tag: tag,
|
||||
Cmd: []string{"sleep", "infinity"},
|
||||
Labels: tt.labels,
|
||||
}, func(config *docker.HostConfig) {
|
||||
config.AutoRemove = true
|
||||
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
|
||||
})
|
||||
require.NoError(t, err, "Could not start test docker container")
|
||||
t.Logf("Created container %q", ct.Container.Name)
|
||||
t.Cleanup(func() {
|
||||
assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name)
|
||||
t.Logf("Purged container %q", ct.Container.Name)
|
||||
})
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
dei, err := agentcontainers.EnvInfo(ctx, agentexec.DefaultExecer, ct.Container.ID, tt.containerUser)
|
||||
require.NoError(t, err, "Expected no error from DockerEnvInfo()")
|
||||
|
||||
u, err := dei.User()
|
||||
require.NoError(t, err, "Expected no error from CurrentUser()")
|
||||
require.Equal(t, tt.expectedUsername, u.Username, "Expected username to match")
|
||||
|
||||
hd, err := dei.HomeDir()
|
||||
require.NoError(t, err, "Expected no error from UserHomeDir()")
|
||||
require.NotEmpty(t, hd, "Expected user homedir to be non-empty")
|
||||
|
||||
sh, err := dei.Shell(tt.containerUser)
|
||||
require.NoError(t, err, "Expected no error from UserShell()")
|
||||
require.Equal(t, tt.expectedUserShell, sh, "Expected user shell to match")
|
||||
|
||||
// We don't need to test the actual environment variables here.
|
||||
environ := dei.Environ()
|
||||
require.NotEmpty(t, environ, "Expected environ to be non-empty")
|
||||
|
||||
// Test that the environment variables are present in modified command
|
||||
// output.
|
||||
envCmd, envArgs := dei.ModifyCommand("env")
|
||||
for _, env := range tt.expectedEnv {
|
||||
require.Subset(t, envArgs, []string{"--env", env})
|
||||
}
|
||||
// Run the command in the container and check the output
|
||||
// HACK: we remove the --tty argument because we're not running in a tty
|
||||
envArgs = slices.DeleteFunc(envArgs, func(s string) bool { return s == "--tty" })
|
||||
stdout, stderr, err := run(ctx, agentexec.DefaultExecer, envCmd, envArgs...)
|
||||
require.Empty(t, stderr, "Expected no stderr output")
|
||||
require.NoError(t, err, "Expected no error from running command")
|
||||
for _, env := range tt.expectedEnv {
|
||||
require.Contains(t, stdout, env)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, execer agentexec.Execer, cmd string, args ...string) (stdout, stderr string, err error) {
|
||||
var stdoutBuf, stderrBuf strings.Builder
|
||||
execCmd := execer.CommandContext(ctx, cmd, args...)
|
||||
execCmd.Stdout = &stdoutBuf
|
||||
execCmd.Stderr = &stderrBuf
|
||||
err = execCmd.Run()
|
||||
stdout = strings.TrimSpace(stdoutBuf.String())
|
||||
stderr = strings.TrimSpace(stderrBuf.String())
|
||||
return stdout, stderr, err
|
||||
}
|
||||
@@ -1,601 +0,0 @@
|
||||
// Code generated by dcspec/gen.sh. DO NOT EDIT.
|
||||
//
|
||||
// This file was generated from JSON Schema using quicktype, do not modify it directly.
|
||||
// To parse and unparse this JSON data, add this code to your project and do:
|
||||
//
|
||||
// devContainer, err := UnmarshalDevContainer(bytes)
|
||||
// bytes, err = devContainer.Marshal()
|
||||
|
||||
package dcspec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
)
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func UnmarshalDevContainer(data []byte) (DevContainer, error) {
|
||||
var r DevContainer
|
||||
err := json.Unmarshal(data, &r)
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (r *DevContainer) Marshal() ([]byte, error) {
|
||||
return json.Marshal(r)
|
||||
}
|
||||
|
||||
// Defines a dev container
|
||||
type DevContainer struct {
|
||||
// Docker build-related options.
|
||||
Build *BuildOptions `json:"build,omitempty"`
|
||||
// The location of the context folder for building the Docker image. The path is relative to
|
||||
// the folder containing the `devcontainer.json` file.
|
||||
Context *string `json:"context,omitempty"`
|
||||
// The location of the Dockerfile that defines the contents of the container. The path is
|
||||
// relative to the folder containing the `devcontainer.json` file.
|
||||
DockerFile *string `json:"dockerFile,omitempty"`
|
||||
// The docker image that will be used to create the container.
|
||||
Image *string `json:"image,omitempty"`
|
||||
// Application ports that are exposed by the container. This can be a single port or an
|
||||
// array of ports. Each port can be a number or a string. A number is mapped to the same
|
||||
// port on the host. A string is passed to Docker unchanged and can be used to map ports
|
||||
// differently, e.g. "8000:8010".
|
||||
AppPort *DevContainerAppPort `json:"appPort"`
|
||||
// Whether to overwrite the command specified in the image. The default is true.
|
||||
//
|
||||
// Whether to overwrite the command specified in the image. The default is false.
|
||||
OverrideCommand *bool `json:"overrideCommand,omitempty"`
|
||||
// The arguments required when starting in the container.
|
||||
RunArgs []string `json:"runArgs,omitempty"`
|
||||
// Action to take when the user disconnects from the container in their editor. The default
|
||||
// is to stop the container.
|
||||
//
|
||||
// Action to take when the user disconnects from the primary container in their editor. The
|
||||
// default is to stop all of the compose containers.
|
||||
ShutdownAction *ShutdownAction `json:"shutdownAction,omitempty"`
|
||||
// The path of the workspace folder inside the container.
|
||||
//
|
||||
// The path of the workspace folder inside the container. This is typically the target path
|
||||
// of a volume mount in the docker-compose.yml.
|
||||
WorkspaceFolder *string `json:"workspaceFolder,omitempty"`
|
||||
// The --mount parameter for docker run. The default is to mount the project folder at
|
||||
// /workspaces/$project.
|
||||
WorkspaceMount *string `json:"workspaceMount,omitempty"`
|
||||
// The name of the docker-compose file(s) used to start the services.
|
||||
DockerComposeFile *CacheFrom `json:"dockerComposeFile"`
|
||||
// An array of services that should be started and stopped.
|
||||
RunServices []string `json:"runServices,omitempty"`
|
||||
// The service you want to work on. This is considered the primary container for your dev
|
||||
// environment which your editor will connect to.
|
||||
Service *string `json:"service,omitempty"`
|
||||
// The JSON schema of the `devcontainer.json` file.
|
||||
Schema *string `json:"$schema,omitempty"`
|
||||
AdditionalProperties map[string]interface{} `json:"additionalProperties,omitempty"`
|
||||
// Passes docker capabilities to include when creating the dev container.
|
||||
CapAdd []string `json:"capAdd,omitempty"`
|
||||
// Container environment variables.
|
||||
ContainerEnv map[string]string `json:"containerEnv,omitempty"`
|
||||
// The user the container will be started with. The default is the user on the Docker image.
|
||||
ContainerUser *string `json:"containerUser,omitempty"`
|
||||
// Tool-specific configuration. Each tool should use a JSON object subproperty with a unique
|
||||
// name to group its customizations.
|
||||
Customizations map[string]interface{} `json:"customizations,omitempty"`
|
||||
// Features to add to the dev container.
|
||||
Features *Features `json:"features,omitempty"`
|
||||
// Ports that are forwarded from the container to the local machine. Can be an integer port
|
||||
// number, or a string of the format "host:port_number".
|
||||
ForwardPorts []ForwardPort `json:"forwardPorts,omitempty"`
|
||||
// Host hardware requirements.
|
||||
HostRequirements *HostRequirements `json:"hostRequirements,omitempty"`
|
||||
// Passes the --init flag when creating the dev container.
|
||||
Init *bool `json:"init,omitempty"`
|
||||
// A command to run locally (i.e Your host machine, cloud VM) before anything else. This
|
||||
// command is run before "onCreateCommand". If this is a single string, it will be run in a
|
||||
// shell. If this is an array of strings, it will be run as a single command without shell.
|
||||
// If this is an object, each provided command will be run in parallel.
|
||||
InitializeCommand *Command `json:"initializeCommand"`
|
||||
// Mount points to set up when creating the container. See Docker's documentation for the
|
||||
// --mount option for the supported syntax.
|
||||
Mounts []MountElement `json:"mounts,omitempty"`
|
||||
// A name for the dev container which can be displayed to the user.
|
||||
Name *string `json:"name,omitempty"`
|
||||
// A command to run when creating the container. This command is run after
|
||||
// "initializeCommand" and before "updateContentCommand". If this is a single string, it
|
||||
// will be run in a shell. If this is an array of strings, it will be run as a single
|
||||
// command without shell. If this is an object, each provided command will be run in
|
||||
// parallel.
|
||||
OnCreateCommand *Command `json:"onCreateCommand"`
|
||||
OtherPortsAttributes *OtherPortsAttributes `json:"otherPortsAttributes,omitempty"`
|
||||
// Array consisting of the Feature id (without the semantic version) of Features in the
|
||||
// order the user wants them to be installed.
|
||||
OverrideFeatureInstallOrder []string `json:"overrideFeatureInstallOrder,omitempty"`
|
||||
PortsAttributes *PortsAttributes `json:"portsAttributes,omitempty"`
|
||||
// A command to run when attaching to the container. This command is run after
|
||||
// "postStartCommand". If this is a single string, it will be run in a shell. If this is an
|
||||
// array of strings, it will be run as a single command without shell. If this is an object,
|
||||
// each provided command will be run in parallel.
|
||||
PostAttachCommand *Command `json:"postAttachCommand"`
|
||||
// A command to run after creating the container. This command is run after
|
||||
// "updateContentCommand" and before "postStartCommand". If this is a single string, it will
|
||||
// be run in a shell. If this is an array of strings, it will be run as a single command
|
||||
// without shell. If this is an object, each provided command will be run in parallel.
|
||||
PostCreateCommand *Command `json:"postCreateCommand"`
|
||||
// A command to run after starting the container. This command is run after
|
||||
// "postCreateCommand" and before "postAttachCommand". If this is a single string, it will
|
||||
// be run in a shell. If this is an array of strings, it will be run as a single command
|
||||
// without shell. If this is an object, each provided command will be run in parallel.
|
||||
PostStartCommand *Command `json:"postStartCommand"`
|
||||
// Passes the --privileged flag when creating the dev container.
|
||||
Privileged *bool `json:"privileged,omitempty"`
|
||||
// Remote environment variables to set for processes spawned in the container including
|
||||
// lifecycle scripts and any remote editor/IDE server process.
|
||||
RemoteEnv map[string]*string `json:"remoteEnv,omitempty"`
|
||||
// The username to use for spawning processes in the container including lifecycle scripts
|
||||
// and any remote editor/IDE server process. The default is the same user as the container.
|
||||
RemoteUser *string `json:"remoteUser,omitempty"`
|
||||
// Recommended secrets for this dev container. Recommendations are provided as environment
|
||||
// variable keys with optional metadata.
|
||||
Secrets *Secrets `json:"secrets,omitempty"`
|
||||
// Passes docker security options to include when creating the dev container.
|
||||
SecurityOpt []string `json:"securityOpt,omitempty"`
|
||||
// A command to run when creating the container and rerun when the workspace content was
|
||||
// updated while creating the container. This command is run after "onCreateCommand" and
|
||||
// before "postCreateCommand". If this is a single string, it will be run in a shell. If
|
||||
// this is an array of strings, it will be run as a single command without shell. If this is
|
||||
// an object, each provided command will be run in parallel.
|
||||
UpdateContentCommand *Command `json:"updateContentCommand"`
|
||||
// Controls whether on Linux the container's user should be updated with the local user's
|
||||
// UID and GID. On by default when opening from a local folder.
|
||||
UpdateRemoteUserUID *bool `json:"updateRemoteUserUID,omitempty"`
|
||||
// User environment probe to run. The default is "loginInteractiveShell".
|
||||
UserEnvProbe *UserEnvProbe `json:"userEnvProbe,omitempty"`
|
||||
// The user command to wait for before continuing execution in the background while the UI
|
||||
// is starting up. The default is "updateContentCommand".
|
||||
WaitFor *WaitFor `json:"waitFor,omitempty"`
|
||||
}
|
||||
|
||||
// Docker build-related options.
|
||||
type BuildOptions struct {
|
||||
// The location of the context folder for building the Docker image. The path is relative to
|
||||
// the folder containing the `devcontainer.json` file.
|
||||
Context *string `json:"context,omitempty"`
|
||||
// The location of the Dockerfile that defines the contents of the container. The path is
|
||||
// relative to the folder containing the `devcontainer.json` file.
|
||||
Dockerfile *string `json:"dockerfile,omitempty"`
|
||||
// Build arguments.
|
||||
Args map[string]string `json:"args,omitempty"`
|
||||
// The image to consider as a cache. Use an array to specify multiple images.
|
||||
CacheFrom *CacheFrom `json:"cacheFrom"`
|
||||
// Additional arguments passed to the build command.
|
||||
Options []string `json:"options,omitempty"`
|
||||
// Target stage in a multi-stage build.
|
||||
Target *string `json:"target,omitempty"`
|
||||
}
|
||||
|
||||
// Features to add to the dev container.
|
||||
type Features struct {
|
||||
Fish interface{} `json:"fish"`
|
||||
Gradle interface{} `json:"gradle"`
|
||||
Homebrew interface{} `json:"homebrew"`
|
||||
Jupyterlab interface{} `json:"jupyterlab"`
|
||||
Maven interface{} `json:"maven"`
|
||||
}
|
||||
|
||||
// Host hardware requirements.
|
||||
type HostRequirements struct {
|
||||
// Number of required CPUs.
|
||||
Cpus *int64 `json:"cpus,omitempty"`
|
||||
GPU *GPUUnion `json:"gpu"`
|
||||
// Amount of required RAM in bytes. Supports units tb, gb, mb and kb.
|
||||
Memory *string `json:"memory,omitempty"`
|
||||
// Amount of required disk space in bytes. Supports units tb, gb, mb and kb.
|
||||
Storage *string `json:"storage,omitempty"`
|
||||
}
|
||||
|
||||
// Indicates whether a GPU is required. The string "optional" indicates that a GPU is
|
||||
// optional. An object value can be used to configure more detailed requirements.
|
||||
type GPUClass struct {
|
||||
// Number of required cores.
|
||||
Cores *int64 `json:"cores,omitempty"`
|
||||
// Amount of required RAM in bytes. Supports units tb, gb, mb and kb.
|
||||
Memory *string `json:"memory,omitempty"`
|
||||
}
|
||||
|
||||
type Mount struct {
|
||||
// Mount source.
|
||||
Source *string `json:"source,omitempty"`
|
||||
// Mount target.
|
||||
Target string `json:"target"`
|
||||
// Mount type.
|
||||
Type Type `json:"type"`
|
||||
}
|
||||
|
||||
type OtherPortsAttributes struct {
|
||||
// Automatically prompt for elevation (if needed) when this port is forwarded. Elevate is
|
||||
// required if the local port is a privileged port.
|
||||
ElevateIfNeeded *bool `json:"elevateIfNeeded,omitempty"`
|
||||
// Label that will be shown in the UI for this port.
|
||||
Label *string `json:"label,omitempty"`
|
||||
// Defines the action that occurs when the port is discovered for automatic forwarding
|
||||
OnAutoForward *OnAutoForward `json:"onAutoForward,omitempty"`
|
||||
// The protocol to use when forwarding this port.
|
||||
Protocol *Protocol `json:"protocol,omitempty"`
|
||||
RequireLocalPort *bool `json:"requireLocalPort,omitempty"`
|
||||
}
|
||||
|
||||
type PortsAttributes struct{}
|
||||
|
||||
// Recommended secrets for this dev container. Recommendations are provided as environment
|
||||
// variable keys with optional metadata.
|
||||
type Secrets struct{}
|
||||
|
||||
type GPUEnum string
|
||||
|
||||
const (
|
||||
Optional GPUEnum = "optional"
|
||||
)
|
||||
|
||||
// Mount type.
|
||||
type Type string
|
||||
|
||||
const (
|
||||
Bind Type = "bind"
|
||||
Volume Type = "volume"
|
||||
)
|
||||
|
||||
// Defines the action that occurs when the port is discovered for automatic forwarding
|
||||
type OnAutoForward string
|
||||
|
||||
const (
|
||||
Ignore OnAutoForward = "ignore"
|
||||
Notify OnAutoForward = "notify"
|
||||
OpenBrowser OnAutoForward = "openBrowser"
|
||||
OpenPreview OnAutoForward = "openPreview"
|
||||
Silent OnAutoForward = "silent"
|
||||
)
|
||||
|
||||
// The protocol to use when forwarding this port.
|
||||
type Protocol string
|
||||
|
||||
const (
|
||||
HTTP Protocol = "http"
|
||||
HTTPS Protocol = "https"
|
||||
)
|
||||
|
||||
// Action to take when the user disconnects from the container in their editor. The default
|
||||
// is to stop the container.
|
||||
//
|
||||
// Action to take when the user disconnects from the primary container in their editor. The
|
||||
// default is to stop all of the compose containers.
|
||||
type ShutdownAction string
|
||||
|
||||
const (
|
||||
ShutdownActionNone ShutdownAction = "none"
|
||||
StopCompose ShutdownAction = "stopCompose"
|
||||
StopContainer ShutdownAction = "stopContainer"
|
||||
)
|
||||
|
||||
// User environment probe to run. The default is "loginInteractiveShell".
|
||||
type UserEnvProbe string
|
||||
|
||||
const (
|
||||
InteractiveShell UserEnvProbe = "interactiveShell"
|
||||
LoginInteractiveShell UserEnvProbe = "loginInteractiveShell"
|
||||
LoginShell UserEnvProbe = "loginShell"
|
||||
UserEnvProbeNone UserEnvProbe = "none"
|
||||
)
|
||||
|
||||
// The user command to wait for before continuing execution in the background while the UI
|
||||
// is starting up. The default is "updateContentCommand".
|
||||
type WaitFor string
|
||||
|
||||
const (
|
||||
InitializeCommand WaitFor = "initializeCommand"
|
||||
OnCreateCommand WaitFor = "onCreateCommand"
|
||||
PostCreateCommand WaitFor = "postCreateCommand"
|
||||
PostStartCommand WaitFor = "postStartCommand"
|
||||
UpdateContentCommand WaitFor = "updateContentCommand"
|
||||
)
|
||||
|
||||
// Application ports that are exposed by the container. This can be a single port or an
|
||||
// array of ports. Each port can be a number or a string. A number is mapped to the same
|
||||
// port on the host. A string is passed to Docker unchanged and can be used to map ports
|
||||
// differently, e.g. "8000:8010".
|
||||
type DevContainerAppPort struct {
|
||||
Integer *int64
|
||||
String *string
|
||||
UnionArray []AppPortElement
|
||||
}
|
||||
|
||||
func (x *DevContainerAppPort) UnmarshalJSON(data []byte) error {
|
||||
x.UnionArray = nil
|
||||
object, err := unmarshalUnion(data, &x.Integer, nil, nil, &x.String, true, &x.UnionArray, false, nil, false, nil, false, nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *DevContainerAppPort) MarshalJSON() ([]byte, error) {
|
||||
return marshalUnion(x.Integer, nil, nil, x.String, x.UnionArray != nil, x.UnionArray, false, nil, false, nil, false, nil, false)
|
||||
}
|
||||
|
||||
// Application ports that are exposed by the container. This can be a single port or an
|
||||
// array of ports. Each port can be a number or a string. A number is mapped to the same
|
||||
// port on the host. A string is passed to Docker unchanged and can be used to map ports
|
||||
// differently, e.g. "8000:8010".
|
||||
type AppPortElement struct {
|
||||
Integer *int64
|
||||
String *string
|
||||
}
|
||||
|
||||
func (x *AppPortElement) UnmarshalJSON(data []byte) error {
|
||||
object, err := unmarshalUnion(data, &x.Integer, nil, nil, &x.String, false, nil, false, nil, false, nil, false, nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *AppPortElement) MarshalJSON() ([]byte, error) {
|
||||
return marshalUnion(x.Integer, nil, nil, x.String, false, nil, false, nil, false, nil, false, nil, false)
|
||||
}
|
||||
|
||||
// The image to consider as a cache. Use an array to specify multiple images.
|
||||
//
|
||||
// The name of the docker-compose file(s) used to start the services.
|
||||
type CacheFrom struct {
|
||||
String *string
|
||||
StringArray []string
|
||||
}
|
||||
|
||||
func (x *CacheFrom) UnmarshalJSON(data []byte) error {
|
||||
x.StringArray = nil
|
||||
object, err := unmarshalUnion(data, nil, nil, nil, &x.String, true, &x.StringArray, false, nil, false, nil, false, nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *CacheFrom) MarshalJSON() ([]byte, error) {
|
||||
return marshalUnion(nil, nil, nil, x.String, x.StringArray != nil, x.StringArray, false, nil, false, nil, false, nil, false)
|
||||
}
|
||||
|
||||
type ForwardPort struct {
|
||||
Integer *int64
|
||||
String *string
|
||||
}
|
||||
|
||||
func (x *ForwardPort) UnmarshalJSON(data []byte) error {
|
||||
object, err := unmarshalUnion(data, &x.Integer, nil, nil, &x.String, false, nil, false, nil, false, nil, false, nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *ForwardPort) MarshalJSON() ([]byte, error) {
|
||||
return marshalUnion(x.Integer, nil, nil, x.String, false, nil, false, nil, false, nil, false, nil, false)
|
||||
}
|
||||
|
||||
type GPUUnion struct {
|
||||
Bool *bool
|
||||
Enum *GPUEnum
|
||||
GPUClass *GPUClass
|
||||
}
|
||||
|
||||
func (x *GPUUnion) UnmarshalJSON(data []byte) error {
|
||||
x.GPUClass = nil
|
||||
x.Enum = nil
|
||||
var c GPUClass
|
||||
object, err := unmarshalUnion(data, nil, nil, &x.Bool, nil, false, nil, true, &c, false, nil, true, &x.Enum, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object {
|
||||
x.GPUClass = &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *GPUUnion) MarshalJSON() ([]byte, error) {
|
||||
return marshalUnion(nil, nil, x.Bool, nil, false, nil, x.GPUClass != nil, x.GPUClass, false, nil, x.Enum != nil, x.Enum, false)
|
||||
}
|
||||
|
||||
// A command to run locally (i.e Your host machine, cloud VM) before anything else. This
|
||||
// command is run before "onCreateCommand". If this is a single string, it will be run in a
|
||||
// shell. If this is an array of strings, it will be run as a single command without shell.
|
||||
// If this is an object, each provided command will be run in parallel.
|
||||
//
|
||||
// A command to run when creating the container. This command is run after
|
||||
// "initializeCommand" and before "updateContentCommand". If this is a single string, it
|
||||
// will be run in a shell. If this is an array of strings, it will be run as a single
|
||||
// command without shell. If this is an object, each provided command will be run in
|
||||
// parallel.
|
||||
//
|
||||
// A command to run when attaching to the container. This command is run after
|
||||
// "postStartCommand". If this is a single string, it will be run in a shell. If this is an
|
||||
// array of strings, it will be run as a single command without shell. If this is an object,
|
||||
// each provided command will be run in parallel.
|
||||
//
|
||||
// A command to run after creating the container. This command is run after
|
||||
// "updateContentCommand" and before "postStartCommand". If this is a single string, it will
|
||||
// be run in a shell. If this is an array of strings, it will be run as a single command
|
||||
// without shell. If this is an object, each provided command will be run in parallel.
|
||||
//
|
||||
// A command to run after starting the container. This command is run after
|
||||
// "postCreateCommand" and before "postAttachCommand". If this is a single string, it will
|
||||
// be run in a shell. If this is an array of strings, it will be run as a single command
|
||||
// without shell. If this is an object, each provided command will be run in parallel.
|
||||
//
|
||||
// A command to run when creating the container and rerun when the workspace content was
|
||||
// updated while creating the container. This command is run after "onCreateCommand" and
|
||||
// before "postCreateCommand". If this is a single string, it will be run in a shell. If
|
||||
// this is an array of strings, it will be run as a single command without shell. If this is
|
||||
// an object, each provided command will be run in parallel.
|
||||
type Command struct {
|
||||
String *string
|
||||
StringArray []string
|
||||
UnionMap map[string]*CacheFrom
|
||||
}
|
||||
|
||||
func (x *Command) UnmarshalJSON(data []byte) error {
|
||||
x.StringArray = nil
|
||||
x.UnionMap = nil
|
||||
object, err := unmarshalUnion(data, nil, nil, nil, &x.String, true, &x.StringArray, false, nil, true, &x.UnionMap, false, nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Command) MarshalJSON() ([]byte, error) {
|
||||
return marshalUnion(nil, nil, nil, x.String, x.StringArray != nil, x.StringArray, false, nil, x.UnionMap != nil, x.UnionMap, false, nil, false)
|
||||
}
|
||||
|
||||
type MountElement struct {
|
||||
Mount *Mount
|
||||
String *string
|
||||
}
|
||||
|
||||
func (x *MountElement) UnmarshalJSON(data []byte) error {
|
||||
x.Mount = nil
|
||||
var c Mount
|
||||
object, err := unmarshalUnion(data, nil, nil, nil, &x.String, false, nil, true, &c, false, nil, false, nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object {
|
||||
x.Mount = &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *MountElement) MarshalJSON() ([]byte, error) {
|
||||
return marshalUnion(nil, nil, nil, x.String, false, nil, x.Mount != nil, x.Mount, false, nil, false, nil, false)
|
||||
}
|
||||
|
||||
func unmarshalUnion(data []byte, pi **int64, pf **float64, pb **bool, ps **string, haveArray bool, pa interface{}, haveObject bool, pc interface{}, haveMap bool, pm interface{}, haveEnum bool, pe interface{}, nullable bool) (bool, error) {
|
||||
if pi != nil {
|
||||
*pi = nil
|
||||
}
|
||||
if pf != nil {
|
||||
*pf = nil
|
||||
}
|
||||
if pb != nil {
|
||||
*pb = nil
|
||||
}
|
||||
if ps != nil {
|
||||
*ps = nil
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.UseNumber()
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
switch v := tok.(type) {
|
||||
case json.Number:
|
||||
if pi != nil {
|
||||
i, err := v.Int64()
|
||||
if err == nil {
|
||||
*pi = &i
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if pf != nil {
|
||||
f, err := v.Float64()
|
||||
if err == nil {
|
||||
*pf = &f
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.New("Unparsable number")
|
||||
}
|
||||
return false, errors.New("Union does not contain number")
|
||||
case float64:
|
||||
return false, errors.New("Decoder should not return float64")
|
||||
case bool:
|
||||
if pb != nil {
|
||||
*pb = &v
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.New("Union does not contain bool")
|
||||
case string:
|
||||
if haveEnum {
|
||||
return false, json.Unmarshal(data, pe)
|
||||
}
|
||||
if ps != nil {
|
||||
*ps = &v
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.New("Union does not contain string")
|
||||
case nil:
|
||||
if nullable {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.New("Union does not contain null")
|
||||
case json.Delim:
|
||||
if v == '{' {
|
||||
if haveObject {
|
||||
return true, json.Unmarshal(data, pc)
|
||||
}
|
||||
if haveMap {
|
||||
return false, json.Unmarshal(data, pm)
|
||||
}
|
||||
return false, errors.New("Union does not contain object")
|
||||
}
|
||||
if v == '[' {
|
||||
if haveArray {
|
||||
return false, json.Unmarshal(data, pa)
|
||||
}
|
||||
return false, errors.New("Union does not contain array")
|
||||
}
|
||||
return false, errors.New("Cannot handle delimiter")
|
||||
}
|
||||
return false, errors.New("Cannot unmarshal union")
|
||||
}
|
||||
|
||||
func marshalUnion(pi *int64, pf *float64, pb *bool, ps *string, haveArray bool, pa interface{}, haveObject bool, pc interface{}, haveMap bool, pm interface{}, haveEnum bool, pe interface{}, nullable bool) ([]byte, error) {
|
||||
if pi != nil {
|
||||
return json.Marshal(*pi)
|
||||
}
|
||||
if pf != nil {
|
||||
return json.Marshal(*pf)
|
||||
}
|
||||
if pb != nil {
|
||||
return json.Marshal(*pb)
|
||||
}
|
||||
if ps != nil {
|
||||
return json.Marshal(*ps)
|
||||
}
|
||||
if haveArray {
|
||||
return json.Marshal(pa)
|
||||
}
|
||||
if haveObject {
|
||||
return json.Marshal(pc)
|
||||
}
|
||||
if haveMap {
|
||||
return json.Marshal(pm)
|
||||
}
|
||||
if haveEnum {
|
||||
return json.Marshal(pe)
|
||||
}
|
||||
if nullable {
|
||||
return json.Marshal(nil)
|
||||
}
|
||||
return nil, errors.New("Union must not be null")
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package dcspec_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/dcspec"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
)
|
||||
|
||||
func TestUnmarshalDevContainer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
file string
|
||||
wantErr bool
|
||||
want dcspec.DevContainer
|
||||
}
|
||||
tests := []testCase{
|
||||
{
|
||||
name: "minimal",
|
||||
file: filepath.Join("testdata", "minimal.json"),
|
||||
want: dcspec.DevContainer{
|
||||
Image: ptr.Ref("test-image"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "arrays",
|
||||
file: filepath.Join("testdata", "arrays.json"),
|
||||
want: dcspec.DevContainer{
|
||||
Image: ptr.Ref("test-image"),
|
||||
RunArgs: []string{"--network=host", "--privileged"},
|
||||
ForwardPorts: []dcspec.ForwardPort{
|
||||
{
|
||||
Integer: ptr.Ref[int64](8080),
|
||||
},
|
||||
{
|
||||
String: ptr.Ref("3000:3000"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "devcontainers/template-starter",
|
||||
file: filepath.Join("testdata", "devcontainers-template-starter.json"),
|
||||
wantErr: false,
|
||||
want: dcspec.DevContainer{
|
||||
Image: ptr.Ref("mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye"),
|
||||
Features: &dcspec.Features{},
|
||||
Customizations: map[string]interface{}{
|
||||
"vscode": map[string]interface{}{
|
||||
"extensions": []interface{}{
|
||||
"mads-hartmann.bash-ide-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
},
|
||||
},
|
||||
},
|
||||
PostCreateCommand: &dcspec.Command{
|
||||
String: ptr.Ref("npm install -g @devcontainers/cli"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var missingTests []string
|
||||
files, err := filepath.Glob("testdata/*.json")
|
||||
require.NoError(t, err, "glob test files failed")
|
||||
for _, file := range files {
|
||||
if !slices.ContainsFunc(tests, func(tt testCase) bool {
|
||||
return tt.file == file
|
||||
}) {
|
||||
missingTests = append(missingTests, file)
|
||||
}
|
||||
}
|
||||
require.Empty(t, missingTests, "missing tests case for files: %v", missingTests)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
data, err := os.ReadFile(tt.file)
|
||||
require.NoError(t, err, "read test file failed")
|
||||
|
||||
got, err := dcspec.UnmarshalDevContainer(data)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err, "want error but got nil")
|
||||
return
|
||||
}
|
||||
require.NoError(t, err, "unmarshal DevContainer failed")
|
||||
|
||||
// Compare the unmarshaled data with the expected data.
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
require.Empty(t, diff, "UnmarshalDevContainer() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Test that marshaling works (without comparing to original).
|
||||
marshaled, err := got.Marshal()
|
||||
require.NoError(t, err, "marshal DevContainer back to JSON failed")
|
||||
require.NotEmpty(t, marshaled, "marshaled JSON should not be empty")
|
||||
|
||||
// Verify the marshaled JSON can be unmarshaled back.
|
||||
var unmarshaled interface{}
|
||||
err = json.Unmarshal(marshaled, &unmarshaled)
|
||||
require.NoError(t, err, "unmarshal marshaled JSON failed")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalDevContainer_EdgeCases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty JSON",
|
||||
json: "{}",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
json: "{not valid json",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := dcspec.UnmarshalDevContainer([]byte(tt.json))
|
||||
if tt.wantErr {
|
||||
require.Error(t, err, "want error but got nil")
|
||||
return
|
||||
}
|
||||
require.NoError(t, err, "unmarshal DevContainer failed")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,771 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2019-09/schema",
|
||||
"description": "Defines a dev container",
|
||||
"allowComments": true,
|
||||
"allowTrailingCommas": false,
|
||||
"definitions": {
|
||||
"devContainerCommon": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "The JSON schema of the `devcontainer.json` file."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "A name for the dev container which can be displayed to the user."
|
||||
},
|
||||
"features": {
|
||||
"type": "object",
|
||||
"description": "Features to add to the dev container.",
|
||||
"properties": {
|
||||
"fish": {
|
||||
"deprecated": true,
|
||||
"deprecationMessage": "Legacy feature not supported. Please check https://containers.dev/features for replacements."
|
||||
},
|
||||
"maven": {
|
||||
"deprecated": true,
|
||||
"deprecationMessage": "Legacy feature will be removed in the future. Please check https://containers.dev/features for replacements. E.g., `ghcr.io/devcontainers/features/java` has an option to install Maven."
|
||||
},
|
||||
"gradle": {
|
||||
"deprecated": true,
|
||||
"deprecationMessage": "Legacy feature will be removed in the future. Please check https://containers.dev/features for replacements. E.g., `ghcr.io/devcontainers/features/java` has an option to install Gradle."
|
||||
},
|
||||
"homebrew": {
|
||||
"deprecated": true,
|
||||
"deprecationMessage": "Legacy feature not supported. Please check https://containers.dev/features for replacements."
|
||||
},
|
||||
"jupyterlab": {
|
||||
"deprecated": true,
|
||||
"deprecationMessage": "Legacy feature will be removed in the future. Please check https://containers.dev/features for replacements. E.g., `ghcr.io/devcontainers/features/python` has an option to install JupyterLab."
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"overrideFeatureInstallOrder": {
|
||||
"type": "array",
|
||||
"description": "Array consisting of the Feature id (without the semantic version) of Features in the order the user wants them to be installed.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"type": "object",
|
||||
"description": "Recommended secrets for this dev container. Recommendations are provided as environment variable keys with optional metadata.",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z_][a-zA-Z0-9_]*$": {
|
||||
"type": "object",
|
||||
"description": "Environment variable keys following unix-style naming conventions. eg: ^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A description of the secret."
|
||||
},
|
||||
"documentationUrl": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"description": "A URL to documentation about the secret."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"forwardPorts": {
|
||||
"type": "array",
|
||||
"description": "Ports that are forwarded from the container to the local machine. Can be an integer port number, or a string of the format \"host:port_number\".",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "integer",
|
||||
"maximum": 65535,
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^([a-z0-9-]+):(\\d{1,5})$"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"portsAttributes": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"(^\\d+(-\\d+)?$)|(.+)": {
|
||||
"type": "object",
|
||||
"description": "A port, range of ports (ex. \"40000-55000\"), or regular expression (ex. \".+\\\\/server.js\"). For a port number or range, the attributes will apply to that port number or range of port numbers. Attributes which use a regular expression will apply to ports whose associated process command line matches the expression.",
|
||||
"properties": {
|
||||
"onAutoForward": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"notify",
|
||||
"openBrowser",
|
||||
"openBrowserOnce",
|
||||
"openPreview",
|
||||
"silent",
|
||||
"ignore"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Shows a notification when a port is automatically forwarded.",
|
||||
"Opens the browser when the port is automatically forwarded. Depending on your settings, this could open an embedded browser.",
|
||||
"Opens the browser when the port is automatically forwarded, but only the first time the port is forward during a session. Depending on your settings, this could open an embedded browser.",
|
||||
"Opens a preview in the same window when the port is automatically forwarded.",
|
||||
"Shows no notification and takes no action when this port is automatically forwarded.",
|
||||
"This port will not be automatically forwarded."
|
||||
],
|
||||
"description": "Defines the action that occurs when the port is discovered for automatic forwarding",
|
||||
"default": "notify"
|
||||
},
|
||||
"elevateIfNeeded": {
|
||||
"type": "boolean",
|
||||
"description": "Automatically prompt for elevation (if needed) when this port is forwarded. Elevate is required if the local port is a privileged port.",
|
||||
"default": false
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Label that will be shown in the UI for this port.",
|
||||
"default": "Application"
|
||||
},
|
||||
"requireLocalPort": {
|
||||
"type": "boolean",
|
||||
"markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.",
|
||||
"default": false
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"description": "The protocol to use when forwarding this port."
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"label": "Application",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
}
|
||||
},
|
||||
"markdownDescription": "Set default properties that are applied when a specific port number is forwarded. For example:\n\n```\n\"3000\": {\n \"label\": \"Application\"\n},\n\"40000-55000\": {\n \"onAutoForward\": \"ignore\"\n},\n\".+\\\\/server.js\": {\n \"onAutoForward\": \"openPreview\"\n}\n```",
|
||||
"defaultSnippets": [
|
||||
{
|
||||
"body": {
|
||||
"${1:3000}": {
|
||||
"label": "${2:Application}",
|
||||
"onAutoForward": "notify"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"otherPortsAttributes": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"onAutoForward": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"notify",
|
||||
"openBrowser",
|
||||
"openPreview",
|
||||
"silent",
|
||||
"ignore"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Shows a notification when a port is automatically forwarded.",
|
||||
"Opens the browser when the port is automatically forwarded. Depending on your settings, this could open an embedded browser.",
|
||||
"Opens a preview in the same window when the port is automatically forwarded.",
|
||||
"Shows no notification and takes no action when this port is automatically forwarded.",
|
||||
"This port will not be automatically forwarded."
|
||||
],
|
||||
"description": "Defines the action that occurs when the port is discovered for automatic forwarding",
|
||||
"default": "notify"
|
||||
},
|
||||
"elevateIfNeeded": {
|
||||
"type": "boolean",
|
||||
"description": "Automatically prompt for elevation (if needed) when this port is forwarded. Elevate is required if the local port is a privileged port.",
|
||||
"default": false
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Label that will be shown in the UI for this port.",
|
||||
"default": "Application"
|
||||
},
|
||||
"requireLocalPort": {
|
||||
"type": "boolean",
|
||||
"markdownDescription": "When true, a modal dialog will show if the chosen local port isn't used for forwarding.",
|
||||
"default": false
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"description": "The protocol to use when forwarding this port."
|
||||
}
|
||||
},
|
||||
"defaultSnippets": [
|
||||
{
|
||||
"body": {
|
||||
"onAutoForward": "ignore"
|
||||
}
|
||||
}
|
||||
],
|
||||
"markdownDescription": "Set default properties that are applied to all ports that don't get properties from the setting `remote.portsAttributes`. For example:\n\n```\n{\n \"onAutoForward\": \"ignore\"\n}\n```",
|
||||
"additionalProperties": false
|
||||
},
|
||||
"updateRemoteUserUID": {
|
||||
"type": "boolean",
|
||||
"description": "Controls whether on Linux the container's user should be updated with the local user's UID and GID. On by default when opening from a local folder."
|
||||
},
|
||||
"containerEnv": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Container environment variables."
|
||||
},
|
||||
"containerUser": {
|
||||
"type": "string",
|
||||
"description": "The user the container will be started with. The default is the user on the Docker image."
|
||||
},
|
||||
"mounts": {
|
||||
"type": "array",
|
||||
"description": "Mount points to set up when creating the container. See Docker's documentation for the --mount option for the supported syntax.",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Mount"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"init": {
|
||||
"type": "boolean",
|
||||
"description": "Passes the --init flag when creating the dev container."
|
||||
},
|
||||
"privileged": {
|
||||
"type": "boolean",
|
||||
"description": "Passes the --privileged flag when creating the dev container."
|
||||
},
|
||||
"capAdd": {
|
||||
"type": "array",
|
||||
"description": "Passes docker capabilities to include when creating the dev container.",
|
||||
"examples": [
|
||||
"SYS_PTRACE"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"securityOpt": {
|
||||
"type": "array",
|
||||
"description": "Passes docker security options to include when creating the dev container.",
|
||||
"examples": [
|
||||
"seccomp=unconfined"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"remoteEnv": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": "Remote environment variables to set for processes spawned in the container including lifecycle scripts and any remote editor/IDE server process."
|
||||
},
|
||||
"remoteUser": {
|
||||
"type": "string",
|
||||
"description": "The username to use for spawning processes in the container including lifecycle scripts and any remote editor/IDE server process. The default is the same user as the container."
|
||||
},
|
||||
"initializeCommand": {
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"object"
|
||||
],
|
||||
"description": "A command to run locally (i.e Your host machine, cloud VM) before anything else. This command is run before \"onCreateCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"onCreateCommand": {
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"object"
|
||||
],
|
||||
"description": "A command to run when creating the container. This command is run after \"initializeCommand\" and before \"updateContentCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"updateContentCommand": {
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"object"
|
||||
],
|
||||
"description": "A command to run when creating the container and rerun when the workspace content was updated while creating the container. This command is run after \"onCreateCommand\" and before \"postCreateCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"postCreateCommand": {
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"object"
|
||||
],
|
||||
"description": "A command to run after creating the container. This command is run after \"updateContentCommand\" and before \"postStartCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"postStartCommand": {
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"object"
|
||||
],
|
||||
"description": "A command to run after starting the container. This command is run after \"postCreateCommand\" and before \"postAttachCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"postAttachCommand": {
|
||||
"type": [
|
||||
"string",
|
||||
"array",
|
||||
"object"
|
||||
],
|
||||
"description": "A command to run when attaching to the container. This command is run after \"postStartCommand\". If this is a single string, it will be run in a shell. If this is an array of strings, it will be run as a single command without shell. If this is an object, each provided command will be run in parallel.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"waitFor": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"initializeCommand",
|
||||
"onCreateCommand",
|
||||
"updateContentCommand",
|
||||
"postCreateCommand",
|
||||
"postStartCommand"
|
||||
],
|
||||
"description": "The user command to wait for before continuing execution in the background while the UI is starting up. The default is \"updateContentCommand\"."
|
||||
},
|
||||
"userEnvProbe": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"loginShell",
|
||||
"loginInteractiveShell",
|
||||
"interactiveShell"
|
||||
],
|
||||
"description": "User environment probe to run. The default is \"loginInteractiveShell\"."
|
||||
},
|
||||
"hostRequirements": {
|
||||
"type": "object",
|
||||
"description": "Host hardware requirements.",
|
||||
"properties": {
|
||||
"cpus": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Number of required CPUs."
|
||||
},
|
||||
"memory": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+([tgmk]b)?$",
|
||||
"description": "Amount of required RAM in bytes. Supports units tb, gb, mb and kb."
|
||||
},
|
||||
"storage": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+([tgmk]b)?$",
|
||||
"description": "Amount of required disk space in bytes. Supports units tb, gb, mb and kb."
|
||||
},
|
||||
"gpu": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": [
|
||||
"boolean",
|
||||
"string"
|
||||
],
|
||||
"enum": [
|
||||
true,
|
||||
false,
|
||||
"optional"
|
||||
],
|
||||
"description": "Indicates whether a GPU is required. The string \"optional\" indicates that a GPU is optional. An object value can be used to configure more detailed requirements."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cores": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Number of required cores."
|
||||
},
|
||||
"memory": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+([tgmk]b)?$",
|
||||
"description": "Amount of required RAM in bytes. Supports units tb, gb, mb and kb."
|
||||
}
|
||||
},
|
||||
"description": "Indicates whether a GPU is required. The string \"optional\" indicates that a GPU is optional. An object value can be used to configure more detailed requirements.",
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"unevaluatedProperties": false
|
||||
},
|
||||
"customizations": {
|
||||
"type": "object",
|
||||
"description": "Tool-specific configuration. Each tool should use a JSON object subproperty with a unique name to group its customizations."
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"nonComposeBase": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"appPort": {
|
||||
"type": [
|
||||
"integer",
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"description": "Application ports that are exposed by the container. This can be a single port or an array of ports. Each port can be a number or a string. A number is mapped to the same port on the host. A string is passed to Docker unchanged and can be used to map ports differently, e.g. \"8000:8010\".",
|
||||
"items": {
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
},
|
||||
"runArgs": {
|
||||
"type": "array",
|
||||
"description": "The arguments required when starting in the container.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"shutdownAction": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"stopContainer"
|
||||
],
|
||||
"description": "Action to take when the user disconnects from the container in their editor. The default is to stop the container."
|
||||
},
|
||||
"overrideCommand": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to overwrite the command specified in the image. The default is true."
|
||||
},
|
||||
"workspaceFolder": {
|
||||
"type": "string",
|
||||
"description": "The path of the workspace folder inside the container."
|
||||
},
|
||||
"workspaceMount": {
|
||||
"type": "string",
|
||||
"description": "The --mount parameter for docker run. The default is to mount the project folder at /workspaces/$project."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dockerfileContainer": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"build": {
|
||||
"type": "object",
|
||||
"description": "Docker build-related options.",
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dockerfile": {
|
||||
"type": "string",
|
||||
"description": "The location of the Dockerfile that defines the contents of the container. The path is relative to the folder containing the `devcontainer.json` file."
|
||||
},
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "The location of the context folder for building the Docker image. The path is relative to the folder containing the `devcontainer.json` file."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"dockerfile"
|
||||
]
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/buildOptions"
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"build"
|
||||
]
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dockerFile": {
|
||||
"type": "string",
|
||||
"description": "The location of the Dockerfile that defines the contents of the container. The path is relative to the folder containing the `devcontainer.json` file."
|
||||
},
|
||||
"context": {
|
||||
"type": "string",
|
||||
"description": "The location of the context folder for building the Docker image. The path is relative to the folder containing the `devcontainer.json` file."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"dockerFile"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"build": {
|
||||
"description": "Docker build-related options.",
|
||||
"$ref": "#/definitions/buildOptions"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"buildOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Target stage in a multi-stage build."
|
||||
},
|
||||
"args": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"description": "Build arguments."
|
||||
},
|
||||
"cacheFrom": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"description": "The image to consider as a cache. Use an array to specify multiple images.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"description": "Additional arguments passed to the build command.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"imageContainer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": {
|
||||
"type": "string",
|
||||
"description": "The docker image that will be used to create the container."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"image"
|
||||
]
|
||||
},
|
||||
"composeContainer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dockerComposeFile": {
|
||||
"type": [
|
||||
"string",
|
||||
"array"
|
||||
],
|
||||
"description": "The name of the docker-compose file(s) used to start the services.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"service": {
|
||||
"type": "string",
|
||||
"description": "The service you want to work on. This is considered the primary container for your dev environment which your editor will connect to."
|
||||
},
|
||||
"runServices": {
|
||||
"type": "array",
|
||||
"description": "An array of services that should be started and stopped.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"workspaceFolder": {
|
||||
"type": "string",
|
||||
"description": "The path of the workspace folder inside the container. This is typically the target path of a volume mount in the docker-compose.yml."
|
||||
},
|
||||
"shutdownAction": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"none",
|
||||
"stopCompose"
|
||||
],
|
||||
"description": "Action to take when the user disconnects from the primary container in their editor. The default is to stop all of the compose containers."
|
||||
},
|
||||
"overrideCommand": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to overwrite the command specified in the image. The default is false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"dockerComposeFile",
|
||||
"service",
|
||||
"workspaceFolder"
|
||||
]
|
||||
},
|
||||
"Mount": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"bind",
|
||||
"volume"
|
||||
],
|
||||
"description": "Mount type."
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Mount source."
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Mount target."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"target"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"oneOf": [
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"oneOf": [
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/definitions/dockerfileContainer"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/imageContainer"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/nonComposeBase"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/composeContainer"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/devContainerCommon"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/devContainerCommon",
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"unevaluatedProperties": false
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// Package dcspec contains an automatically generated Devcontainer
|
||||
// specification.
|
||||
package dcspec
|
||||
|
||||
//go:generate ./gen.sh
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# This script requires quicktype to be installed.
|
||||
# While you can install it using npm, we have it in our devDependencies
|
||||
# in ${PROJECT_ROOT}/package.json.
|
||||
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
|
||||
if ! pnpm list | grep quicktype &>/dev/null; then
|
||||
echo "quicktype is required to run this script!"
|
||||
echo "Ensure that it is present in the devDependencies of ${PROJECT_ROOT}/package.json and then run pnpm install."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEST_FILENAME="dcspec_gen.go"
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
DEST_PATH="${SCRIPT_DIR}/${DEST_FILENAME}"
|
||||
|
||||
# Location of the JSON schema for the devcontainer specification.
|
||||
SCHEMA_SRC="https://raw.githubusercontent.com/devcontainers/spec/refs/heads/main/schemas/devContainer.base.schema.json"
|
||||
SCHEMA_DEST="${SCRIPT_DIR}/devContainer.base.schema.json"
|
||||
|
||||
UPDATE_SCHEMA="${UPDATE_SCHEMA:-false}"
|
||||
if [[ "${UPDATE_SCHEMA}" = true || ! -f "${SCHEMA_DEST}" ]]; then
|
||||
# Download the latest schema.
|
||||
echo "Updating schema..."
|
||||
curl --fail --silent --show-error --location --output "${SCHEMA_DEST}" "${SCHEMA_SRC}"
|
||||
else
|
||||
echo "Using existing schema..."
|
||||
fi
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rfv "$TMPDIR"' EXIT
|
||||
|
||||
show_stderr=1
|
||||
exec 3>&2
|
||||
if [[ " $* " == *" --quiet "* ]] || [[ ${DCSPEC_QUIET:-false} == "true" ]]; then
|
||||
# Redirect stderr to log because quicktype can't infer all types and
|
||||
# we don't care right now.
|
||||
show_stderr=0
|
||||
exec 2>"${TMPDIR}/stderr.log"
|
||||
fi
|
||||
|
||||
if ! pnpm exec quicktype \
|
||||
--src-lang schema \
|
||||
--lang go \
|
||||
--top-level "DevContainer" \
|
||||
--out "${TMPDIR}/${DEST_FILENAME}" \
|
||||
--package "dcspec" \
|
||||
"${SCHEMA_DEST}"; then
|
||||
echo "quicktype failed to generate Go code." >&3
|
||||
if [[ "${show_stderr}" -eq 1 ]]; then
|
||||
cat "${TMPDIR}/stderr.log" >&3
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${show_stderr}" -eq 0 ]]; then
|
||||
# Restore stderr.
|
||||
exec 2>&3
|
||||
fi
|
||||
exec 3>&-
|
||||
|
||||
# Format the generated code.
|
||||
go run mvdan.cc/gofumpt@v0.4.0 -w -l "${TMPDIR}/${DEST_FILENAME}"
|
||||
|
||||
# Add a header so that Go recognizes this as a generated file.
|
||||
if grep -q -- "\[-i extension\]" < <(sed -h 2>&1); then
|
||||
# darwin sed
|
||||
sed -i '' '1s/^/\/\/ Code generated by dcspec\/gen.sh. DO NOT EDIT.\n\/\/\n/' "${TMPDIR}/${DEST_FILENAME}"
|
||||
else
|
||||
sed -i'' '1s/^/\/\/ Code generated by dcspec\/gen.sh. DO NOT EDIT.\n\/\/\n/' "${TMPDIR}/${DEST_FILENAME}"
|
||||
fi
|
||||
|
||||
mv -v "${TMPDIR}/${DEST_FILENAME}" "${DEST_PATH}"
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"image": "test-image",
|
||||
"runArgs": ["--network=host", "--privileged"],
|
||||
"forwardPorts": [8080, "3000:3000"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": ["mads-hartmann.bash-ide-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "npm install -g @devcontainers/cli"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{ "image": "test-image" }
|
||||
@@ -1,108 +0,0 @@
|
||||
package agentcontainers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
// DevcontainerLocalFolderLabel is the label that contains the path to
|
||||
// the local workspace folder for a devcontainer.
|
||||
DevcontainerLocalFolderLabel = "devcontainer.local_folder"
|
||||
// DevcontainerConfigFileLabel is the label that contains the path to
|
||||
// the devcontainer.json configuration file.
|
||||
DevcontainerConfigFileLabel = "devcontainer.config_file"
|
||||
)
|
||||
|
||||
const devcontainerUpScriptTemplate = `
|
||||
if ! which devcontainer > /dev/null 2>&1; then
|
||||
echo "ERROR: Unable to start devcontainer, @devcontainers/cli is not installed."
|
||||
exit 1
|
||||
fi
|
||||
devcontainer up %s
|
||||
`
|
||||
|
||||
// ExtractAndInitializeDevcontainerScripts extracts devcontainer scripts from
|
||||
// the given scripts and devcontainers. The devcontainer scripts are removed
|
||||
// from the returned scripts so that they can be run separately.
|
||||
//
|
||||
// Dev Containers have an inherent dependency on start scripts, since they
|
||||
// initialize the workspace (e.g. git clone, npm install, etc). This is
|
||||
// important if e.g. a Coder module to install @devcontainer/cli is used.
|
||||
func ExtractAndInitializeDevcontainerScripts(
|
||||
logger slog.Logger,
|
||||
expandPath func(string) (string, error),
|
||||
devcontainers []codersdk.WorkspaceAgentDevcontainer,
|
||||
scripts []codersdk.WorkspaceAgentScript,
|
||||
) (filteredScripts []codersdk.WorkspaceAgentScript, devcontainerScripts []codersdk.WorkspaceAgentScript) {
|
||||
ScriptLoop:
|
||||
for _, script := range scripts {
|
||||
for _, dc := range devcontainers {
|
||||
// The devcontainer scripts match the devcontainer ID for
|
||||
// identification.
|
||||
if script.ID == dc.ID {
|
||||
dc = expandDevcontainerPaths(logger, expandPath, dc)
|
||||
devcontainerScripts = append(devcontainerScripts, devcontainerStartupScript(dc, script))
|
||||
continue ScriptLoop
|
||||
}
|
||||
}
|
||||
|
||||
filteredScripts = append(filteredScripts, script)
|
||||
}
|
||||
|
||||
return filteredScripts, devcontainerScripts
|
||||
}
|
||||
|
||||
func devcontainerStartupScript(dc codersdk.WorkspaceAgentDevcontainer, script codersdk.WorkspaceAgentScript) codersdk.WorkspaceAgentScript {
|
||||
args := []string{
|
||||
"--log-format json",
|
||||
fmt.Sprintf("--workspace-folder %q", dc.WorkspaceFolder),
|
||||
}
|
||||
if dc.ConfigPath != "" {
|
||||
args = append(args, fmt.Sprintf("--config %q", dc.ConfigPath))
|
||||
}
|
||||
cmd := fmt.Sprintf(devcontainerUpScriptTemplate, strings.Join(args, " "))
|
||||
script.Script = cmd
|
||||
// Disable RunOnStart, scripts have this set so that when devcontainers
|
||||
// have not been enabled, a warning will be surfaced in the agent logs.
|
||||
script.RunOnStart = false
|
||||
return script
|
||||
}
|
||||
|
||||
func expandDevcontainerPaths(logger slog.Logger, expandPath func(string) (string, error), dc codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
|
||||
logger = logger.With(slog.F("devcontainer", dc.Name), slog.F("workspace_folder", dc.WorkspaceFolder), slog.F("config_path", dc.ConfigPath))
|
||||
|
||||
if wf, err := expandPath(dc.WorkspaceFolder); err != nil {
|
||||
logger.Warn(context.Background(), "expand devcontainer workspace folder failed", slog.Error(err))
|
||||
} else {
|
||||
dc.WorkspaceFolder = wf
|
||||
}
|
||||
if dc.ConfigPath != "" {
|
||||
// Let expandPath handle home directory, otherwise assume relative to
|
||||
// workspace folder or absolute.
|
||||
if dc.ConfigPath[0] == '~' {
|
||||
if cp, err := expandPath(dc.ConfigPath); err != nil {
|
||||
logger.Warn(context.Background(), "expand devcontainer config path failed", slog.Error(err))
|
||||
} else {
|
||||
dc.ConfigPath = cp
|
||||
}
|
||||
} else {
|
||||
dc.ConfigPath = relativePathToAbs(dc.WorkspaceFolder, dc.ConfigPath)
|
||||
}
|
||||
}
|
||||
return dc
|
||||
}
|
||||
|
||||
func relativePathToAbs(workdir, path string) string {
|
||||
path = os.ExpandEnv(path)
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(workdir, path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
package agentcontainers_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func TestExtractAndInitializeDevcontainerScripts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptIDs := []uuid.UUID{uuid.New(), uuid.New()}
|
||||
devcontainerIDs := []uuid.UUID{uuid.New(), uuid.New()}
|
||||
|
||||
type args struct {
|
||||
expandPath func(string) (string, error)
|
||||
devcontainers []codersdk.WorkspaceAgentDevcontainer
|
||||
scripts []codersdk.WorkspaceAgentScript
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantFilteredScripts []codersdk.WorkspaceAgentScript
|
||||
wantDevcontainerScripts []codersdk.WorkspaceAgentScript
|
||||
|
||||
skipOnWindowsDueToPathSeparator bool
|
||||
}{
|
||||
{
|
||||
name: "no scripts",
|
||||
args: args{
|
||||
expandPath: nil,
|
||||
devcontainers: nil,
|
||||
scripts: nil,
|
||||
},
|
||||
wantFilteredScripts: nil,
|
||||
wantDevcontainerScripts: nil,
|
||||
},
|
||||
{
|
||||
name: "no devcontainers",
|
||||
args: args{
|
||||
expandPath: nil,
|
||||
devcontainers: nil,
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0]},
|
||||
{ID: scriptIDs[1]},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0]},
|
||||
{ID: scriptIDs[1]},
|
||||
},
|
||||
wantDevcontainerScripts: nil,
|
||||
},
|
||||
{
|
||||
name: "no scripts match devcontainers",
|
||||
args: args{
|
||||
expandPath: nil,
|
||||
devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{ID: devcontainerIDs[0]},
|
||||
{ID: devcontainerIDs[1]},
|
||||
},
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0]},
|
||||
{ID: scriptIDs[1]},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0]},
|
||||
{ID: scriptIDs[1]},
|
||||
},
|
||||
wantDevcontainerScripts: nil,
|
||||
},
|
||||
{
|
||||
name: "scripts match devcontainers and sets RunOnStart=false",
|
||||
args: args{
|
||||
expandPath: nil,
|
||||
devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{ID: devcontainerIDs[0], WorkspaceFolder: "workspace1"},
|
||||
{ID: devcontainerIDs[1], WorkspaceFolder: "workspace2"},
|
||||
},
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0], RunOnStart: true},
|
||||
{ID: scriptIDs[1], RunOnStart: true},
|
||||
{ID: devcontainerIDs[0], RunOnStart: true},
|
||||
{ID: devcontainerIDs[1], RunOnStart: true},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: scriptIDs[0], RunOnStart: true},
|
||||
{ID: scriptIDs[1], RunOnStart: true},
|
||||
},
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"workspace1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"workspace2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scripts match devcontainers with config path",
|
||||
args: args{
|
||||
expandPath: nil,
|
||||
devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
WorkspaceFolder: "workspace1",
|
||||
ConfigPath: "config1",
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
WorkspaceFolder: "workspace2",
|
||||
ConfigPath: "config2",
|
||||
},
|
||||
},
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: devcontainerIDs[0]},
|
||||
{ID: devcontainerIDs[1]},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{},
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"workspace1\" --config \"workspace1/config1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"workspace2\" --config \"workspace2/config2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
skipOnWindowsDueToPathSeparator: true,
|
||||
},
|
||||
{
|
||||
name: "scripts match devcontainers with expand path",
|
||||
args: args{
|
||||
expandPath: func(s string) (string, error) {
|
||||
return "/home/" + s, nil
|
||||
},
|
||||
devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
WorkspaceFolder: "workspace1",
|
||||
ConfigPath: "config1",
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
WorkspaceFolder: "workspace2",
|
||||
ConfigPath: "config2",
|
||||
},
|
||||
},
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: devcontainerIDs[0], RunOnStart: true},
|
||||
{ID: devcontainerIDs[1], RunOnStart: true},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{},
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace1\" --config \"/home/workspace1/config1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace2\" --config \"/home/workspace2/config2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
skipOnWindowsDueToPathSeparator: true,
|
||||
},
|
||||
{
|
||||
name: "expand config path when ~",
|
||||
args: args{
|
||||
expandPath: func(s string) (string, error) {
|
||||
s = strings.Replace(s, "~/", "", 1)
|
||||
if filepath.IsAbs(s) {
|
||||
return s, nil
|
||||
}
|
||||
return "/home/" + s, nil
|
||||
},
|
||||
devcontainers: []codersdk.WorkspaceAgentDevcontainer{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
WorkspaceFolder: "workspace1",
|
||||
ConfigPath: "~/config1",
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
WorkspaceFolder: "workspace2",
|
||||
ConfigPath: "/config2",
|
||||
},
|
||||
},
|
||||
scripts: []codersdk.WorkspaceAgentScript{
|
||||
{ID: devcontainerIDs[0], RunOnStart: true},
|
||||
{ID: devcontainerIDs[1], RunOnStart: true},
|
||||
},
|
||||
},
|
||||
wantFilteredScripts: []codersdk.WorkspaceAgentScript{},
|
||||
wantDevcontainerScripts: []codersdk.WorkspaceAgentScript{
|
||||
{
|
||||
ID: devcontainerIDs[0],
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace1\" --config \"/home/config1\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
{
|
||||
ID: devcontainerIDs[1],
|
||||
Script: "devcontainer up --log-format json --workspace-folder \"/home/workspace2\" --config \"/config2\"",
|
||||
RunOnStart: false,
|
||||
},
|
||||
},
|
||||
skipOnWindowsDueToPathSeparator: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if tt.skipOnWindowsDueToPathSeparator && filepath.Separator == '\\' {
|
||||
t.Skip("Skipping test on Windows due to path separator difference.")
|
||||
}
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
if tt.args.expandPath == nil {
|
||||
tt.args.expandPath = func(s string) (string, error) {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
gotFilteredScripts, gotDevcontainerScripts := agentcontainers.ExtractAndInitializeDevcontainerScripts(
|
||||
logger,
|
||||
tt.args.expandPath,
|
||||
tt.args.devcontainers,
|
||||
tt.args.scripts,
|
||||
)
|
||||
|
||||
if diff := cmp.Diff(tt.wantFilteredScripts, gotFilteredScripts, cmpopts.EquateEmpty()); diff != "" {
|
||||
t.Errorf("ExtractAndInitializeDevcontainerScripts() gotFilteredScripts mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// Preprocess the devcontainer scripts to remove scripting part.
|
||||
for i := range gotDevcontainerScripts {
|
||||
gotDevcontainerScripts[i].Script = textGrep("devcontainer up", gotDevcontainerScripts[i].Script)
|
||||
require.NotEmpty(t, gotDevcontainerScripts[i].Script, "devcontainer up script not found")
|
||||
}
|
||||
if diff := cmp.Diff(tt.wantDevcontainerScripts, gotDevcontainerScripts); diff != "" {
|
||||
t.Errorf("ExtractAndInitializeDevcontainerScripts() gotDevcontainerScripts mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// textGrep returns matching lines from multiline string.
|
||||
func textGrep(want, got string) (filtered string) {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(got, "\n") {
|
||||
if strings.Contains(line, want) {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package agentcontainers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
)
|
||||
|
||||
// DevcontainerCLI is an interface for the devcontainer CLI.
|
||||
type DevcontainerCLI interface {
|
||||
Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error)
|
||||
}
|
||||
|
||||
// DevcontainerCLIUpOptions are options for the devcontainer CLI up
|
||||
// command.
|
||||
type DevcontainerCLIUpOptions func(*devcontainerCLIUpConfig)
|
||||
|
||||
// WithRemoveExistingContainer is an option to remove the existing
|
||||
// container.
|
||||
func WithRemoveExistingContainer() DevcontainerCLIUpOptions {
|
||||
return func(o *devcontainerCLIUpConfig) {
|
||||
o.removeExistingContainer = true
|
||||
}
|
||||
}
|
||||
|
||||
type devcontainerCLIUpConfig struct {
|
||||
removeExistingContainer bool
|
||||
}
|
||||
|
||||
func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig {
|
||||
conf := devcontainerCLIUpConfig{
|
||||
removeExistingContainer: false,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(&conf)
|
||||
}
|
||||
}
|
||||
return conf
|
||||
}
|
||||
|
||||
type devcontainerCLI struct {
|
||||
logger slog.Logger
|
||||
execer agentexec.Execer
|
||||
}
|
||||
|
||||
var _ DevcontainerCLI = &devcontainerCLI{}
|
||||
|
||||
func NewDevcontainerCLI(logger slog.Logger, execer agentexec.Execer) DevcontainerCLI {
|
||||
return &devcontainerCLI{
|
||||
execer: execer,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (string, error) {
|
||||
conf := applyDevcontainerCLIUpOptions(opts)
|
||||
logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath), slog.F("recreate", conf.removeExistingContainer))
|
||||
|
||||
args := []string{
|
||||
"up",
|
||||
"--log-format", "json",
|
||||
"--workspace-folder", workspaceFolder,
|
||||
}
|
||||
if configPath != "" {
|
||||
args = append(args, "--config", configPath)
|
||||
}
|
||||
if conf.removeExistingContainer {
|
||||
args = append(args, "--remove-existing-container")
|
||||
}
|
||||
cmd := d.execer.CommandContext(ctx, "devcontainer", args...)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = io.MultiWriter(&stdout, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))})
|
||||
cmd.Stderr = &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if _, err2 := parseDevcontainerCLILastLine(ctx, logger, stdout.Bytes()); err2 != nil {
|
||||
err = errors.Join(err, err2)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
result, err := parseDevcontainerCLILastLine(ctx, logger, stdout.Bytes())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result.ContainerID, nil
|
||||
}
|
||||
|
||||
// parseDevcontainerCLILastLine parses the last line of the devcontainer CLI output
|
||||
// which is a JSON object.
|
||||
func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []byte) (result devcontainerCLIResult, err error) {
|
||||
s := bufio.NewScanner(bytes.NewReader(p))
|
||||
var lastLine []byte
|
||||
for s.Scan() {
|
||||
b := s.Bytes()
|
||||
if len(b) == 0 || b[0] != '{' {
|
||||
continue
|
||||
}
|
||||
lastLine = b
|
||||
}
|
||||
if err = s.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if len(lastLine) == 0 || lastLine[0] != '{' {
|
||||
logger.Error(ctx, "devcontainer result is not json", slog.F("result", string(lastLine)))
|
||||
return result, xerrors.Errorf("devcontainer result is not json: %q", string(lastLine))
|
||||
}
|
||||
if err = json.Unmarshal(lastLine, &result); err != nil {
|
||||
logger.Error(ctx, "parse devcontainer result failed", slog.Error(err), slog.F("result", string(lastLine)))
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, result.Err()
|
||||
}
|
||||
|
||||
// devcontainerCLIResult is the result of the devcontainer CLI command.
|
||||
// It is parsed from the last line of the devcontainer CLI stdout which
|
||||
// is a JSON object.
|
||||
type devcontainerCLIResult struct {
|
||||
Outcome string `json:"outcome"` // "error", "success".
|
||||
|
||||
// The following fields are set if outcome is success.
|
||||
ContainerID string `json:"containerId"`
|
||||
RemoteUser string `json:"remoteUser"`
|
||||
RemoteWorkspaceFolder string `json:"remoteWorkspaceFolder"`
|
||||
|
||||
// The following fields are set if outcome is error.
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (r devcontainerCLIResult) Err() error {
|
||||
if r.Outcome == "success" {
|
||||
return nil
|
||||
}
|
||||
return xerrors.Errorf("devcontainer up failed: %s (description: %s, message: %s)", r.Outcome, r.Description, r.Message)
|
||||
}
|
||||
|
||||
// devcontainerCLIJSONLogLine is a log line from the devcontainer CLI.
|
||||
type devcontainerCLIJSONLogLine struct {
|
||||
Type string `json:"type"` // "progress", "raw", "start", "stop", "text", etc.
|
||||
Level int `json:"level"` // 1, 2, 3.
|
||||
Timestamp int `json:"timestamp"` // Unix timestamp in milliseconds.
|
||||
Text string `json:"text"`
|
||||
|
||||
// More fields can be added here as needed.
|
||||
}
|
||||
|
||||
// devcontainerCLILogWriter splits on newlines and logs each line
|
||||
// separately.
|
||||
type devcontainerCLILogWriter struct {
|
||||
ctx context.Context
|
||||
logger slog.Logger
|
||||
}
|
||||
|
||||
func (l *devcontainerCLILogWriter) Write(p []byte) (n int, err error) {
|
||||
s := bufio.NewScanner(bytes.NewReader(p))
|
||||
for s.Scan() {
|
||||
line := s.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if line[0] != '{' {
|
||||
l.logger.Debug(l.ctx, "@devcontainer/cli", slog.F("line", string(line)))
|
||||
continue
|
||||
}
|
||||
var logLine devcontainerCLIJSONLogLine
|
||||
if err := json.Unmarshal(line, &logLine); err != nil {
|
||||
l.logger.Error(l.ctx, "parse devcontainer json log line failed", slog.Error(err), slog.F("line", string(line)))
|
||||
continue
|
||||
}
|
||||
if logLine.Level >= 3 {
|
||||
l.logger.Info(l.ctx, "@devcontainer/cli", slog.F("line", string(line)))
|
||||
continue
|
||||
}
|
||||
l.logger.Debug(l.ctx, "@devcontainer/cli", slog.F("line", string(line)))
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
l.logger.Error(l.ctx, "devcontainer log line scan failed", slog.Error(err))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
package agentcontainers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/dockertest/v3"
|
||||
"github.com/ory/dockertest/v3/docker"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testExePath, err := os.Executable()
|
||||
require.NoError(t, err, "get test executable path")
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
|
||||
t.Run("Up", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
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",
|
||||
logFile: "up.log",
|
||||
workspace: "/test/workspace",
|
||||
opts: []agentcontainers.DevcontainerCLIUpOptions{
|
||||
agentcontainers.WithRemoveExistingContainer(),
|
||||
},
|
||||
wantArgs: "up --log-format json --workspace-folder /test/workspace --remove-existing-container",
|
||||
wantError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
|
||||
testExecer := &testDevcontainerExecer{
|
||||
testExePath: testExePath,
|
||||
wantArgs: tt.wantArgs,
|
||||
wantError: tt.wantError,
|
||||
logFile: filepath.Join("testdata", "devcontainercli", "parse", tt.logFile),
|
||||
}
|
||||
|
||||
dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer)
|
||||
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")
|
||||
assert.NotEmpty(t, containerID, "expected non-empty container ID")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// testDevcontainerExecer implements the agentexec.Execer interface for testing.
|
||||
type testDevcontainerExecer struct {
|
||||
testExePath string
|
||||
wantArgs string
|
||||
wantError bool
|
||||
logFile string
|
||||
}
|
||||
|
||||
// CommandContext returns a test binary command that simulates devcontainer responses.
|
||||
func (e *testDevcontainerExecer) CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||
// Only handle "devcontainer" commands.
|
||||
if name != "devcontainer" {
|
||||
// For non-devcontainer commands, use a standard execer.
|
||||
return agentexec.DefaultExecer.CommandContext(ctx, name, args...)
|
||||
}
|
||||
|
||||
// Create a command that runs the test binary with special flags
|
||||
// that tell it to simulate a devcontainer command.
|
||||
testArgs := []string{
|
||||
"-test.run=TestDevcontainerHelperProcess",
|
||||
"--",
|
||||
name,
|
||||
}
|
||||
testArgs = append(testArgs, args...)
|
||||
|
||||
//nolint:gosec // This is a test binary, so we don't need to worry about command injection.
|
||||
cmd := exec.CommandContext(ctx, e.testExePath, testArgs...)
|
||||
// Set this environment variable so the child process knows it's the helper.
|
||||
cmd.Env = append(os.Environ(),
|
||||
"TEST_DEVCONTAINER_WANT_HELPER_PROCESS=1",
|
||||
"TEST_DEVCONTAINER_WANT_ARGS="+e.wantArgs,
|
||||
"TEST_DEVCONTAINER_WANT_ERROR="+fmt.Sprintf("%v", e.wantError),
|
||||
"TEST_DEVCONTAINER_LOG_FILE="+e.logFile,
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// PTYCommandContext returns a PTY command.
|
||||
func (*testDevcontainerExecer) PTYCommandContext(_ context.Context, name string, args ...string) *pty.Cmd {
|
||||
// This method shouldn't be called for our devcontainer tests.
|
||||
panic("PTYCommandContext not expected in devcontainer tests")
|
||||
}
|
||||
|
||||
// This is a special test helper that is executed as a subprocess.
|
||||
// It simulates the behavior of the devcontainer CLI.
|
||||
//
|
||||
//nolint:revive,paralleltest // This is a test helper function.
|
||||
func TestDevcontainerHelperProcess(t *testing.T) {
|
||||
// If not called by the test as a helper process, do nothing.
|
||||
if os.Getenv("TEST_DEVCONTAINER_WANT_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
helperArgs := flag.Args()
|
||||
if len(helperArgs) < 1 {
|
||||
fmt.Fprintf(os.Stderr, "No command\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if helperArgs[0] != "devcontainer" {
|
||||
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", helperArgs[0])
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Verify arguments against expected arguments and skip
|
||||
// "devcontainer", it's not included in the input args.
|
||||
wantArgs := os.Getenv("TEST_DEVCONTAINER_WANT_ARGS")
|
||||
gotArgs := strings.Join(helperArgs[1:], " ")
|
||||
if gotArgs != wantArgs {
|
||||
fmt.Fprintf(os.Stderr, "Arguments don't match.\nWant: %q\nGot: %q\n",
|
||||
wantArgs, gotArgs)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
logFilePath := os.Getenv("TEST_DEVCONTAINER_LOG_FILE")
|
||||
output, err := os.ReadFile(logFilePath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Reading log file %s failed: %v\n", logFilePath, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
_, _ = io.Copy(os.Stdout, bytes.NewReader(output))
|
||||
if os.Getenv("TEST_DEVCONTAINER_WANT_ERROR") == "true" {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// TestDockerDevcontainerCLI tests the DevcontainerCLI component with real Docker containers.
|
||||
// This test verifies that containers can be created and recreated using the actual
|
||||
// devcontainer CLI and Docker. It is skipped by default and can be run with:
|
||||
//
|
||||
// CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestDockerDevcontainerCLI
|
||||
//
|
||||
// The test requires Docker to be installed and running.
|
||||
func TestDockerDevcontainerCLI(t *testing.T) {
|
||||
t.Parallel()
|
||||
if os.Getenv("CODER_TEST_USE_DOCKER") != "1" {
|
||||
t.Skip("skipping Docker test; set CODER_TEST_USE_DOCKER=1 to run")
|
||||
}
|
||||
if _, err := exec.LookPath("devcontainer"); err != nil {
|
||||
t.Fatal("this test requires the devcontainer CLI: npm install -g @devcontainers/cli")
|
||||
}
|
||||
|
||||
// Connect to Docker.
|
||||
pool, err := dockertest.NewPool("")
|
||||
require.NoError(t, err, "connect to Docker")
|
||||
|
||||
t.Run("ContainerLifecycle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Set up workspace directory with a devcontainer configuration.
|
||||
workspaceFolder := t.TempDir()
|
||||
configPath := setupDevcontainerWorkspace(t, workspaceFolder)
|
||||
|
||||
// Use a long timeout because container operations are slow.
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
|
||||
// Create the devcontainer CLI under test.
|
||||
dccli := agentcontainers.NewDevcontainerCLI(logger, agentexec.DefaultExecer)
|
||||
|
||||
// Create a container.
|
||||
firstID, err := dccli.Up(ctx, workspaceFolder, configPath)
|
||||
require.NoError(t, err, "create container")
|
||||
require.NotEmpty(t, firstID, "container ID should not be empty")
|
||||
defer removeDevcontainerByID(t, pool, firstID)
|
||||
|
||||
// Verify container exists.
|
||||
firstContainer, found := findDevcontainerByID(t, pool, firstID)
|
||||
require.True(t, found, "container should exist")
|
||||
|
||||
// Remember the container creation time.
|
||||
firstCreated := firstContainer.Created
|
||||
|
||||
// Recreate the container.
|
||||
secondID, err := dccli.Up(ctx, workspaceFolder, configPath, agentcontainers.WithRemoveExistingContainer())
|
||||
require.NoError(t, err, "recreate container")
|
||||
require.NotEmpty(t, secondID, "recreated container ID should not be empty")
|
||||
defer removeDevcontainerByID(t, pool, secondID)
|
||||
|
||||
// Verify the new container exists and is different.
|
||||
secondContainer, found := findDevcontainerByID(t, pool, secondID)
|
||||
require.True(t, found, "recreated container should exist")
|
||||
|
||||
// Verify it's a different container by checking creation time.
|
||||
secondCreated := secondContainer.Created
|
||||
assert.NotEqual(t, firstCreated, secondCreated, "recreated container should have different creation time")
|
||||
|
||||
// Verify the first container is removed by the recreation.
|
||||
_, found = findDevcontainerByID(t, pool, firstID)
|
||||
assert.False(t, found, "first container should be removed")
|
||||
})
|
||||
}
|
||||
|
||||
// setupDevcontainerWorkspace prepares a test environment with a minimal
|
||||
// devcontainer.json configuration and returns the path to the config file.
|
||||
func setupDevcontainerWorkspace(t *testing.T, workspaceFolder string) string {
|
||||
t.Helper()
|
||||
|
||||
// Create the devcontainer directory structure.
|
||||
devcontainerDir := filepath.Join(workspaceFolder, ".devcontainer")
|
||||
err := os.MkdirAll(devcontainerDir, 0o755)
|
||||
require.NoError(t, err, "create .devcontainer directory")
|
||||
|
||||
// Write a minimal configuration with test labels for identification.
|
||||
configPath := filepath.Join(devcontainerDir, "devcontainer.json")
|
||||
content := `{
|
||||
"image": "alpine:latest",
|
||||
"containerEnv": {
|
||||
"TEST_CONTAINER": "true"
|
||||
},
|
||||
"runArgs": ["--label", "com.coder.test=devcontainercli"]
|
||||
}`
|
||||
err = os.WriteFile(configPath, []byte(content), 0o600)
|
||||
require.NoError(t, err, "create devcontainer.json file")
|
||||
|
||||
return configPath
|
||||
}
|
||||
|
||||
// findDevcontainerByID locates a container by its ID and verifies it has our
|
||||
// test label. Returns the container and whether it was found.
|
||||
func findDevcontainerByID(t *testing.T, pool *dockertest.Pool, id string) (*docker.Container, bool) {
|
||||
t.Helper()
|
||||
|
||||
container, err := pool.Client.InspectContainer(id)
|
||||
if err != nil {
|
||||
t.Logf("Inspect container failed: %v", err)
|
||||
return nil, false
|
||||
}
|
||||
require.Equal(t, "devcontainercli", container.Config.Labels["com.coder.test"], "sanity check failed: container should have the test label")
|
||||
|
||||
return container, true
|
||||
}
|
||||
|
||||
// removeDevcontainerByID safely cleans up a test container by ID, verifying
|
||||
// it has our test label before removal to prevent accidental deletion.
|
||||
func removeDevcontainerByID(t *testing.T, pool *dockertest.Pool, id string) {
|
||||
t.Helper()
|
||||
|
||||
errNoSuchContainer := &docker.NoSuchContainer{}
|
||||
|
||||
// Check if the container has the expected label.
|
||||
container, err := pool.Client.InspectContainer(id)
|
||||
if err != nil {
|
||||
if errors.As(err, &errNoSuchContainer) {
|
||||
t.Logf("Container %s not found, skipping removal", id)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err, "inspect container")
|
||||
}
|
||||
require.Equal(t, "devcontainercli", container.Config.Labels["com.coder.test"], "sanity check failed: container should have the test label")
|
||||
|
||||
t.Logf("Removing container with ID: %s", id)
|
||||
err = pool.Client.RemoveContainer(docker.RemoveContainerOptions{
|
||||
ID: container.ID,
|
||||
Force: true,
|
||||
RemoveVolumes: true,
|
||||
})
|
||||
if err != nil && !errors.As(err, &errNoSuchContainer) {
|
||||
assert.NoError(t, err, "remove container failed")
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Id": "fdc75ebefdc0243c0fce959e7685931691ac7aede278664a0e2c23af8a1e8d6a",
|
||||
"Created": "2025-03-11T17:58:43.522505027Z",
|
||||
"Path": "sleep",
|
||||
"Args": [
|
||||
"infinity"
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 644296,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2025-03-11T17:58:43.569966691Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"Image": "sha256:d4ccddb816ba27eaae22ef3d56175d53f47998e2acb99df1ae0e5b426b28a076",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/fdc75ebefdc0243c0fce959e7685931691ac7aede278664a0e2c23af8a1e8d6a/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/fdc75ebefdc0243c0fce959e7685931691ac7aede278664a0e2c23af8a1e8d6a/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/fdc75ebefdc0243c0fce959e7685931691ac7aede278664a0e2c23af8a1e8d6a/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/fdc75ebefdc0243c0fce959e7685931691ac7aede278664a0e2c23af8a1e8d6a/fdc75ebefdc0243c0fce959e7685931691ac7aede278664a0e2c23af8a1e8d6a-json.log",
|
||||
"Name": "/silly_beaver",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": [
|
||||
"/tmp/test/a:/var/coder/a:ro",
|
||||
"/tmp/test/b:/var/coder/b"
|
||||
],
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "bridge",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "no",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"ConsoleSize": [
|
||||
108,
|
||||
176
|
||||
],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": [],
|
||||
"DnsOptions": [],
|
||||
"DnsSearch": [],
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 10,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": [],
|
||||
"BlkioDeviceReadBps": [],
|
||||
"BlkioDeviceWriteBps": [],
|
||||
"BlkioDeviceReadIOps": [],
|
||||
"BlkioDeviceWriteIOps": [],
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": [],
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": [],
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/asound",
|
||||
"/proc/acpi",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/sys/firmware",
|
||||
"/sys/devices/virtual/powercap"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"ID": "fdc75ebefdc0243c0fce959e7685931691ac7aede278664a0e2c23af8a1e8d6a",
|
||||
"LowerDir": "/var/lib/docker/overlay2/c1519be93f8e138757310f6ed8c3946a524cdae2580ad8579913d19c3fe9ffd2-init/diff:/var/lib/docker/overlay2/4b4c37dfbdc0dc01b68d4fb1ddb86109398a2d73555439b874dbd23b87cd5c4b/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/c1519be93f8e138757310f6ed8c3946a524cdae2580ad8579913d19c3fe9ffd2/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/c1519be93f8e138757310f6ed8c3946a524cdae2580ad8579913d19c3fe9ffd2/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/c1519be93f8e138757310f6ed8c3946a524cdae2580ad8579913d19c3fe9ffd2/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/tmp/test/a",
|
||||
"Destination": "/var/coder/a",
|
||||
"Mode": "ro",
|
||||
"RW": false,
|
||||
"Propagation": "rprivate"
|
||||
},
|
||||
{
|
||||
"Type": "bind",
|
||||
"Source": "/tmp/test/b",
|
||||
"Destination": "/var/coder/b",
|
||||
"Mode": "",
|
||||
"RW": true,
|
||||
"Propagation": "rprivate"
|
||||
}
|
||||
],
|
||||
"Config": {
|
||||
"Hostname": "fdc75ebefdc0",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": false,
|
||||
"AttachStderr": false,
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
],
|
||||
"Cmd": [
|
||||
"sleep",
|
||||
"infinity"
|
||||
],
|
||||
"Image": "debian:bookworm",
|
||||
"Volumes": null,
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": [],
|
||||
"OnBuild": null,
|
||||
"Labels": {}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "46f98b32002740b63709e3ebf87c78efe652adfaa8753b85d79b814f26d88107",
|
||||
"SandboxKey": "/var/run/docker/netns/46f98b320027",
|
||||
"Ports": {},
|
||||
"HairpinMode": false,
|
||||
"LinkLocalIPv6Address": "",
|
||||
"LinkLocalIPv6PrefixLen": 0,
|
||||
"SecondaryIPAddresses": null,
|
||||
"SecondaryIPv6Addresses": null,
|
||||
"EndpointID": "356e429f15e354dd23250c7a3516aecf1a2afe9d58ea1dc2e97e33a75ac346a8",
|
||||
"Gateway": "172.17.0.1",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "22:2c:26:d9:da:83",
|
||||
"Networks": {
|
||||
"bridge": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": null,
|
||||
"MacAddress": "22:2c:26:d9:da:83",
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "c4dd768ab4945e41ad23fe3907c960dac811141592a861cc40038df7086a1ce1",
|
||||
"EndpointID": "356e429f15e354dd23250c7a3516aecf1a2afe9d58ea1dc2e97e33a75ac346a8",
|
||||
"Gateway": "172.17.0.1",
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,222 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Id": "3090de8b72b1224758a94a11b827c82ba2b09c45524f1263dc4a2d83e19625ea",
|
||||
"Created": "2025-03-11T17:57:08.862545133Z",
|
||||
"Path": "sleep",
|
||||
"Args": [
|
||||
"infinity"
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 640137,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2025-03-11T17:57:08.909898821Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"Image": "sha256:d4ccddb816ba27eaae22ef3d56175d53f47998e2acb99df1ae0e5b426b28a076",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/3090de8b72b1224758a94a11b827c82ba2b09c45524f1263dc4a2d83e19625ea/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/3090de8b72b1224758a94a11b827c82ba2b09c45524f1263dc4a2d83e19625ea/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/3090de8b72b1224758a94a11b827c82ba2b09c45524f1263dc4a2d83e19625ea/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/3090de8b72b1224758a94a11b827c82ba2b09c45524f1263dc4a2d83e19625ea/3090de8b72b1224758a94a11b827c82ba2b09c45524f1263dc4a2d83e19625ea-json.log",
|
||||
"Name": "/boring_ellis",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": null,
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "bridge",
|
||||
"PortBindings": {
|
||||
"23456/tcp": [
|
||||
{
|
||||
"HostIp": "",
|
||||
"HostPort": "12345"
|
||||
}
|
||||
]
|
||||
},
|
||||
"RestartPolicy": {
|
||||
"Name": "no",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"ConsoleSize": [
|
||||
108,
|
||||
176
|
||||
],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": [],
|
||||
"DnsOptions": [],
|
||||
"DnsSearch": [],
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 10,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": [],
|
||||
"BlkioDeviceReadBps": [],
|
||||
"BlkioDeviceWriteBps": [],
|
||||
"BlkioDeviceReadIOps": [],
|
||||
"BlkioDeviceWriteIOps": [],
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": [],
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": [],
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/asound",
|
||||
"/proc/acpi",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/sys/firmware",
|
||||
"/sys/devices/virtual/powercap"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"ID": "3090de8b72b1224758a94a11b827c82ba2b09c45524f1263dc4a2d83e19625ea",
|
||||
"LowerDir": "/var/lib/docker/overlay2/e9f2dda207bde1f4b277f973457107d62cff259881901adcd9bcccfea9a92231-init/diff:/var/lib/docker/overlay2/4b4c37dfbdc0dc01b68d4fb1ddb86109398a2d73555439b874dbd23b87cd5c4b/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/e9f2dda207bde1f4b277f973457107d62cff259881901adcd9bcccfea9a92231/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/e9f2dda207bde1f4b277f973457107d62cff259881901adcd9bcccfea9a92231/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/e9f2dda207bde1f4b277f973457107d62cff259881901adcd9bcccfea9a92231/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [],
|
||||
"Config": {
|
||||
"Hostname": "3090de8b72b1",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": false,
|
||||
"AttachStderr": false,
|
||||
"ExposedPorts": {
|
||||
"23456/tcp": {}
|
||||
},
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
],
|
||||
"Cmd": [
|
||||
"sleep",
|
||||
"infinity"
|
||||
],
|
||||
"Image": "debian:bookworm",
|
||||
"Volumes": null,
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": [],
|
||||
"OnBuild": null,
|
||||
"Labels": {}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "ebcd8b749b4c719f90d80605c352b7aa508e4c61d9dcd2919654f18f17eb2840",
|
||||
"SandboxKey": "/var/run/docker/netns/ebcd8b749b4c",
|
||||
"Ports": {
|
||||
"23456/tcp": [
|
||||
{
|
||||
"HostIp": "0.0.0.0",
|
||||
"HostPort": "12345"
|
||||
},
|
||||
{
|
||||
"HostIp": "::",
|
||||
"HostPort": "12345"
|
||||
}
|
||||
]
|
||||
},
|
||||
"HairpinMode": false,
|
||||
"LinkLocalIPv6Address": "",
|
||||
"LinkLocalIPv6PrefixLen": 0,
|
||||
"SecondaryIPAddresses": null,
|
||||
"SecondaryIPv6Addresses": null,
|
||||
"EndpointID": "465824b3cc6bdd2b307e9c614815fd458b1baac113dee889c3620f0cac3183fa",
|
||||
"Gateway": "172.17.0.1",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "52:b6:f6:7b:4b:5b",
|
||||
"Networks": {
|
||||
"bridge": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": null,
|
||||
"MacAddress": "52:b6:f6:7b:4b:5b",
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "c4dd768ab4945e41ad23fe3907c960dac811141592a861cc40038df7086a1ce1",
|
||||
"EndpointID": "465824b3cc6bdd2b307e9c614815fd458b1baac113dee889c3620f0cac3183fa",
|
||||
"Gateway": "172.17.0.1",
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,204 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Id": "bd8818e670230fc6f36145b21cf8d6d35580355662aa4d9fe5ae1b188a4c905f",
|
||||
"Created": "2025-03-11T20:03:28.071706536Z",
|
||||
"Path": "sleep",
|
||||
"Args": [
|
||||
"infinity"
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 913862,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2025-03-11T20:03:28.123599065Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"Image": "sha256:d4ccddb816ba27eaae22ef3d56175d53f47998e2acb99df1ae0e5b426b28a076",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/bd8818e670230fc6f36145b21cf8d6d35580355662aa4d9fe5ae1b188a4c905f/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/bd8818e670230fc6f36145b21cf8d6d35580355662aa4d9fe5ae1b188a4c905f/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/bd8818e670230fc6f36145b21cf8d6d35580355662aa4d9fe5ae1b188a4c905f/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/bd8818e670230fc6f36145b21cf8d6d35580355662aa4d9fe5ae1b188a4c905f/bd8818e670230fc6f36145b21cf8d6d35580355662aa4d9fe5ae1b188a4c905f-json.log",
|
||||
"Name": "/fervent_bardeen",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": null,
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "bridge",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "no",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"ConsoleSize": [
|
||||
108,
|
||||
176
|
||||
],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": [],
|
||||
"DnsOptions": [],
|
||||
"DnsSearch": [],
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 10,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": [],
|
||||
"BlkioDeviceReadBps": [],
|
||||
"BlkioDeviceWriteBps": [],
|
||||
"BlkioDeviceReadIOps": [],
|
||||
"BlkioDeviceWriteIOps": [],
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": [],
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": [],
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/asound",
|
||||
"/proc/acpi",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/sys/firmware",
|
||||
"/sys/devices/virtual/powercap"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"ID": "bd8818e670230fc6f36145b21cf8d6d35580355662aa4d9fe5ae1b188a4c905f",
|
||||
"LowerDir": "/var/lib/docker/overlay2/55fc45976c381040c7d261c198333e6331889c51afe1500e2e7837c189a1b794-init/diff:/var/lib/docker/overlay2/4b4c37dfbdc0dc01b68d4fb1ddb86109398a2d73555439b874dbd23b87cd5c4b/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/55fc45976c381040c7d261c198333e6331889c51afe1500e2e7837c189a1b794/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/55fc45976c381040c7d261c198333e6331889c51afe1500e2e7837c189a1b794/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/55fc45976c381040c7d261c198333e6331889c51afe1500e2e7837c189a1b794/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [],
|
||||
"Config": {
|
||||
"Hostname": "bd8818e67023",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": false,
|
||||
"AttachStderr": false,
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
],
|
||||
"Cmd": [
|
||||
"sleep",
|
||||
"infinity"
|
||||
],
|
||||
"Image": "debian:bookworm",
|
||||
"Volumes": null,
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": [],
|
||||
"OnBuild": null,
|
||||
"Labels": {
|
||||
"baz": "zap",
|
||||
"foo": "bar"
|
||||
}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "24faa8b9aaa58c651deca0d85a3f7bcc6c3e5e1a24b6369211f736d6e82f8ab0",
|
||||
"SandboxKey": "/var/run/docker/netns/24faa8b9aaa5",
|
||||
"Ports": {},
|
||||
"HairpinMode": false,
|
||||
"LinkLocalIPv6Address": "",
|
||||
"LinkLocalIPv6PrefixLen": 0,
|
||||
"SecondaryIPAddresses": null,
|
||||
"SecondaryIPv6Addresses": null,
|
||||
"EndpointID": "c686f97d772d75c8ceed9285e06c1f671b71d4775d5513f93f26358c0f0b4671",
|
||||
"Gateway": "172.17.0.1",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "96:88:4e:3b:11:44",
|
||||
"Networks": {
|
||||
"bridge": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": null,
|
||||
"MacAddress": "96:88:4e:3b:11:44",
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "c4dd768ab4945e41ad23fe3907c960dac811141592a861cc40038df7086a1ce1",
|
||||
"EndpointID": "c686f97d772d75c8ceed9285e06c1f671b71d4775d5513f93f26358c0f0b4671",
|
||||
"Gateway": "172.17.0.1",
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,222 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Id": "4eac5ce199d27b2329d0ff0ce1a6fc595612ced48eba3669aadb6c57ebef3fa2",
|
||||
"Created": "2025-03-11T17:56:34.842164541Z",
|
||||
"Path": "sleep",
|
||||
"Args": [
|
||||
"infinity"
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 638449,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2025-03-11T17:56:34.894488648Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"Image": "sha256:d4ccddb816ba27eaae22ef3d56175d53f47998e2acb99df1ae0e5b426b28a076",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/4eac5ce199d27b2329d0ff0ce1a6fc595612ced48eba3669aadb6c57ebef3fa2/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/4eac5ce199d27b2329d0ff0ce1a6fc595612ced48eba3669aadb6c57ebef3fa2/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/4eac5ce199d27b2329d0ff0ce1a6fc595612ced48eba3669aadb6c57ebef3fa2/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/4eac5ce199d27b2329d0ff0ce1a6fc595612ced48eba3669aadb6c57ebef3fa2/4eac5ce199d27b2329d0ff0ce1a6fc595612ced48eba3669aadb6c57ebef3fa2-json.log",
|
||||
"Name": "/modest_varahamihira",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": null,
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "bridge",
|
||||
"PortBindings": {
|
||||
"12345/tcp": [
|
||||
{
|
||||
"HostIp": "",
|
||||
"HostPort": "12345"
|
||||
}
|
||||
]
|
||||
},
|
||||
"RestartPolicy": {
|
||||
"Name": "no",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"ConsoleSize": [
|
||||
108,
|
||||
176
|
||||
],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": [],
|
||||
"DnsOptions": [],
|
||||
"DnsSearch": [],
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 10,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": [],
|
||||
"BlkioDeviceReadBps": [],
|
||||
"BlkioDeviceWriteBps": [],
|
||||
"BlkioDeviceReadIOps": [],
|
||||
"BlkioDeviceWriteIOps": [],
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": [],
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": [],
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/asound",
|
||||
"/proc/acpi",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/sys/firmware",
|
||||
"/sys/devices/virtual/powercap"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"ID": "4eac5ce199d27b2329d0ff0ce1a6fc595612ced48eba3669aadb6c57ebef3fa2",
|
||||
"LowerDir": "/var/lib/docker/overlay2/35deac62dd3f610275aaf145d091aaa487f73a3c99de5a90df8ab871c969bc0b-init/diff:/var/lib/docker/overlay2/4b4c37dfbdc0dc01b68d4fb1ddb86109398a2d73555439b874dbd23b87cd5c4b/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/35deac62dd3f610275aaf145d091aaa487f73a3c99de5a90df8ab871c969bc0b/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/35deac62dd3f610275aaf145d091aaa487f73a3c99de5a90df8ab871c969bc0b/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/35deac62dd3f610275aaf145d091aaa487f73a3c99de5a90df8ab871c969bc0b/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [],
|
||||
"Config": {
|
||||
"Hostname": "4eac5ce199d2",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": false,
|
||||
"AttachStderr": false,
|
||||
"ExposedPorts": {
|
||||
"12345/tcp": {}
|
||||
},
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
],
|
||||
"Cmd": [
|
||||
"sleep",
|
||||
"infinity"
|
||||
],
|
||||
"Image": "debian:bookworm",
|
||||
"Volumes": null,
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": [],
|
||||
"OnBuild": null,
|
||||
"Labels": {}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "5e966e97ba02013054e0ef15ef87f8629f359ad882fad4c57b33c768ad9b90dc",
|
||||
"SandboxKey": "/var/run/docker/netns/5e966e97ba02",
|
||||
"Ports": {
|
||||
"12345/tcp": [
|
||||
{
|
||||
"HostIp": "0.0.0.0",
|
||||
"HostPort": "12345"
|
||||
},
|
||||
{
|
||||
"HostIp": "::",
|
||||
"HostPort": "12345"
|
||||
}
|
||||
]
|
||||
},
|
||||
"HairpinMode": false,
|
||||
"LinkLocalIPv6Address": "",
|
||||
"LinkLocalIPv6PrefixLen": 0,
|
||||
"SecondaryIPAddresses": null,
|
||||
"SecondaryIPv6Addresses": null,
|
||||
"EndpointID": "f9e1896fc0ef48f3ea9aff3b4e98bc4291ba246412178331345f7b0745cccba9",
|
||||
"Gateway": "172.17.0.1",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "be:a6:89:39:7e:b0",
|
||||
"Networks": {
|
||||
"bridge": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": null,
|
||||
"MacAddress": "be:a6:89:39:7e:b0",
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "c4dd768ab4945e41ad23fe3907c960dac811141592a861cc40038df7086a1ce1",
|
||||
"EndpointID": "f9e1896fc0ef48f3ea9aff3b4e98bc4291ba246412178331345f7b0745cccba9",
|
||||
"Gateway": "172.17.0.1",
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,51 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Id": "a",
|
||||
"Created": "2025-03-11T17:56:34.842164541Z",
|
||||
"State": {
|
||||
"Running": true,
|
||||
"ExitCode": 0,
|
||||
"Error": ""
|
||||
},
|
||||
"Name": "/a",
|
||||
"Mounts": [],
|
||||
"Config": {
|
||||
"Image": "debian:bookworm",
|
||||
"Labels": {}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Ports": {
|
||||
"8001/tcp": [
|
||||
{
|
||||
"HostIp": "0.0.0.0",
|
||||
"HostPort": "8000"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Id": "b",
|
||||
"Created": "2025-03-11T17:56:34.842164541Z",
|
||||
"State": {
|
||||
"Running": true,
|
||||
"ExitCode": 0,
|
||||
"Error": ""
|
||||
},
|
||||
"Name": "/b",
|
||||
"Config": {
|
||||
"Image": "debian:bookworm",
|
||||
"Labels": {}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Ports": {
|
||||
"8001/tcp": [
|
||||
{
|
||||
"HostIp": "::",
|
||||
"HostPort": "8000"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,201 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Id": "6b539b8c60f5230b8b0fde2502cd2332d31c0d526a3e6eb6eef1cc39439b3286",
|
||||
"Created": "2025-03-11T17:55:58.091280203Z",
|
||||
"Path": "sleep",
|
||||
"Args": [
|
||||
"infinity"
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 636855,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2025-03-11T17:55:58.142417459Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"Image": "sha256:d4ccddb816ba27eaae22ef3d56175d53f47998e2acb99df1ae0e5b426b28a076",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/6b539b8c60f5230b8b0fde2502cd2332d31c0d526a3e6eb6eef1cc39439b3286/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/6b539b8c60f5230b8b0fde2502cd2332d31c0d526a3e6eb6eef1cc39439b3286/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/6b539b8c60f5230b8b0fde2502cd2332d31c0d526a3e6eb6eef1cc39439b3286/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/6b539b8c60f5230b8b0fde2502cd2332d31c0d526a3e6eb6eef1cc39439b3286/6b539b8c60f5230b8b0fde2502cd2332d31c0d526a3e6eb6eef1cc39439b3286-json.log",
|
||||
"Name": "/eloquent_kowalevski",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": null,
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "bridge",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "no",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"ConsoleSize": [
|
||||
108,
|
||||
176
|
||||
],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": [],
|
||||
"DnsOptions": [],
|
||||
"DnsSearch": [],
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 10,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": [],
|
||||
"BlkioDeviceReadBps": [],
|
||||
"BlkioDeviceWriteBps": [],
|
||||
"BlkioDeviceReadIOps": [],
|
||||
"BlkioDeviceWriteIOps": [],
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": [],
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": [],
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/asound",
|
||||
"/proc/acpi",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/sys/firmware",
|
||||
"/sys/devices/virtual/powercap"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"ID": "6b539b8c60f5230b8b0fde2502cd2332d31c0d526a3e6eb6eef1cc39439b3286",
|
||||
"LowerDir": "/var/lib/docker/overlay2/4093560d7757c088e24060e5ff6f32807d8e733008c42b8af7057fe4fe6f56ba-init/diff:/var/lib/docker/overlay2/4b4c37dfbdc0dc01b68d4fb1ddb86109398a2d73555439b874dbd23b87cd5c4b/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/4093560d7757c088e24060e5ff6f32807d8e733008c42b8af7057fe4fe6f56ba/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/4093560d7757c088e24060e5ff6f32807d8e733008c42b8af7057fe4fe6f56ba/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/4093560d7757c088e24060e5ff6f32807d8e733008c42b8af7057fe4fe6f56ba/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [],
|
||||
"Config": {
|
||||
"Hostname": "6b539b8c60f5",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": false,
|
||||
"AttachStderr": false,
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
],
|
||||
"Cmd": [
|
||||
"sleep",
|
||||
"infinity"
|
||||
],
|
||||
"Image": "debian:bookworm",
|
||||
"Volumes": null,
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": [],
|
||||
"OnBuild": null,
|
||||
"Labels": {}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "08f2f3218a6d63ae149ab77672659d96b88bca350e85889240579ecb427e8011",
|
||||
"SandboxKey": "/var/run/docker/netns/08f2f3218a6d",
|
||||
"Ports": {},
|
||||
"HairpinMode": false,
|
||||
"LinkLocalIPv6Address": "",
|
||||
"LinkLocalIPv6PrefixLen": 0,
|
||||
"SecondaryIPAddresses": null,
|
||||
"SecondaryIPv6Addresses": null,
|
||||
"EndpointID": "f83bd20711df6d6ff7e2f44f4b5799636cd94596ae25ffe507a70f424073532c",
|
||||
"Gateway": "172.17.0.1",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "f6:84:26:7a:10:5b",
|
||||
"Networks": {
|
||||
"bridge": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": null,
|
||||
"MacAddress": "f6:84:26:7a:10:5b",
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "c4dd768ab4945e41ad23fe3907c960dac811141592a861cc40038df7086a1ce1",
|
||||
"EndpointID": "f83bd20711df6d6ff7e2f44f4b5799636cd94596ae25ffe507a70f424073532c",
|
||||
"Gateway": "172.17.0.1",
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,214 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Id": "b3688d98c007f53402a55e46d803f2f3ba9181d8e3f71a2eb19b392cf0377b4e",
|
||||
"Created": "2025-03-11T17:59:42.039484134Z",
|
||||
"Path": "sleep",
|
||||
"Args": [
|
||||
"infinity"
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 646777,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2025-03-11T17:59:42.081315917Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"Image": "sha256:d4ccddb816ba27eaae22ef3d56175d53f47998e2acb99df1ae0e5b426b28a076",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/b3688d98c007f53402a55e46d803f2f3ba9181d8e3f71a2eb19b392cf0377b4e/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/b3688d98c007f53402a55e46d803f2f3ba9181d8e3f71a2eb19b392cf0377b4e/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/b3688d98c007f53402a55e46d803f2f3ba9181d8e3f71a2eb19b392cf0377b4e/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/b3688d98c007f53402a55e46d803f2f3ba9181d8e3f71a2eb19b392cf0377b4e/b3688d98c007f53402a55e46d803f2f3ba9181d8e3f71a2eb19b392cf0377b4e-json.log",
|
||||
"Name": "/upbeat_carver",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": [
|
||||
"testvol:/testvol"
|
||||
],
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "bridge",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "no",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"ConsoleSize": [
|
||||
108,
|
||||
176
|
||||
],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": [],
|
||||
"DnsOptions": [],
|
||||
"DnsSearch": [],
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 10,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": [],
|
||||
"BlkioDeviceReadBps": [],
|
||||
"BlkioDeviceWriteBps": [],
|
||||
"BlkioDeviceReadIOps": [],
|
||||
"BlkioDeviceWriteIOps": [],
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": [],
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": [],
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/asound",
|
||||
"/proc/acpi",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/sys/firmware",
|
||||
"/sys/devices/virtual/powercap"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"ID": "b3688d98c007f53402a55e46d803f2f3ba9181d8e3f71a2eb19b392cf0377b4e",
|
||||
"LowerDir": "/var/lib/docker/overlay2/d71790d2558bf17d7535451094e332780638a4e92697c021176f3447fc4c50f4-init/diff:/var/lib/docker/overlay2/4b4c37dfbdc0dc01b68d4fb1ddb86109398a2d73555439b874dbd23b87cd5c4b/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/d71790d2558bf17d7535451094e332780638a4e92697c021176f3447fc4c50f4/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/d71790d2558bf17d7535451094e332780638a4e92697c021176f3447fc4c50f4/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/d71790d2558bf17d7535451094e332780638a4e92697c021176f3447fc4c50f4/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": "testvol",
|
||||
"Source": "/var/lib/docker/volumes/testvol/_data",
|
||||
"Destination": "/testvol",
|
||||
"Driver": "local",
|
||||
"Mode": "z",
|
||||
"RW": true,
|
||||
"Propagation": ""
|
||||
}
|
||||
],
|
||||
"Config": {
|
||||
"Hostname": "b3688d98c007",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": false,
|
||||
"AttachStderr": false,
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
],
|
||||
"Cmd": [
|
||||
"sleep",
|
||||
"infinity"
|
||||
],
|
||||
"Image": "debian:bookworm",
|
||||
"Volumes": null,
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": [],
|
||||
"OnBuild": null,
|
||||
"Labels": {}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "e617ea865af5690d06c25df1c9a0154b98b4da6bbb9e0afae3b80ad29902538a",
|
||||
"SandboxKey": "/var/run/docker/netns/e617ea865af5",
|
||||
"Ports": {},
|
||||
"HairpinMode": false,
|
||||
"LinkLocalIPv6Address": "",
|
||||
"LinkLocalIPv6PrefixLen": 0,
|
||||
"SecondaryIPAddresses": null,
|
||||
"SecondaryIPv6Addresses": null,
|
||||
"EndpointID": "1a7bb5bbe4af0674476c95c5d1c913348bc82a5f01fd1c1b394afc44d1cf5a49",
|
||||
"Gateway": "172.17.0.1",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "4a:d8:a5:47:1c:54",
|
||||
"Networks": {
|
||||
"bridge": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": null,
|
||||
"MacAddress": "4a:d8:a5:47:1c:54",
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "c4dd768ab4945e41ad23fe3907c960dac811141592a861cc40038df7086a1ce1",
|
||||
"EndpointID": "1a7bb5bbe4af0674476c95c5d1c913348bc82a5f01fd1c1b394afc44d1cf5a49",
|
||||
"Gateway": "172.17.0.1",
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,230 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Id": "52d23691f4b954d083f117358ea763e20f69af584e1c08f479c5752629ee0be3",
|
||||
"Created": "2025-03-11T17:02:42.613747761Z",
|
||||
"Path": "/bin/sh",
|
||||
"Args": [
|
||||
"-c",
|
||||
"echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
|
||||
"-"
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 526198,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2025-03-11T17:02:42.658905789Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"Image": "sha256:d4ccddb816ba27eaae22ef3d56175d53f47998e2acb99df1ae0e5b426b28a076",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/52d23691f4b954d083f117358ea763e20f69af584e1c08f479c5752629ee0be3/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/52d23691f4b954d083f117358ea763e20f69af584e1c08f479c5752629ee0be3/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/52d23691f4b954d083f117358ea763e20f69af584e1c08f479c5752629ee0be3/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/52d23691f4b954d083f117358ea763e20f69af584e1c08f479c5752629ee0be3/52d23691f4b954d083f117358ea763e20f69af584e1c08f479c5752629ee0be3-json.log",
|
||||
"Name": "/suspicious_margulis",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": null,
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "bridge",
|
||||
"PortBindings": {
|
||||
"8080/tcp": [
|
||||
{
|
||||
"HostIp": "",
|
||||
"HostPort": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"RestartPolicy": {
|
||||
"Name": "no",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"ConsoleSize": [
|
||||
108,
|
||||
176
|
||||
],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": [],
|
||||
"DnsOptions": [],
|
||||
"DnsSearch": [],
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 10,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": [],
|
||||
"BlkioDeviceReadBps": [],
|
||||
"BlkioDeviceWriteBps": [],
|
||||
"BlkioDeviceReadIOps": [],
|
||||
"BlkioDeviceWriteIOps": [],
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": [],
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": [],
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/asound",
|
||||
"/proc/acpi",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/sys/firmware",
|
||||
"/sys/devices/virtual/powercap"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"ID": "52d23691f4b954d083f117358ea763e20f69af584e1c08f479c5752629ee0be3",
|
||||
"LowerDir": "/var/lib/docker/overlay2/e204eab11c98b3cacc18d5a0e7290c0c286a96d918c31e5c2fed4124132eec4f-init/diff:/var/lib/docker/overlay2/4b4c37dfbdc0dc01b68d4fb1ddb86109398a2d73555439b874dbd23b87cd5c4b/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/e204eab11c98b3cacc18d5a0e7290c0c286a96d918c31e5c2fed4124132eec4f/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/e204eab11c98b3cacc18d5a0e7290c0c286a96d918c31e5c2fed4124132eec4f/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/e204eab11c98b3cacc18d5a0e7290c0c286a96d918c31e5c2fed4124132eec4f/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [],
|
||||
"Config": {
|
||||
"Hostname": "52d23691f4b9",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": true,
|
||||
"AttachStderr": true,
|
||||
"ExposedPorts": {
|
||||
"8080/tcp": {}
|
||||
},
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
],
|
||||
"Cmd": [
|
||||
"-c",
|
||||
"echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
|
||||
"-"
|
||||
],
|
||||
"Image": "debian:bookworm",
|
||||
"Volumes": null,
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": [
|
||||
"/bin/sh"
|
||||
],
|
||||
"OnBuild": null,
|
||||
"Labels": {
|
||||
"devcontainer.config_file": "/home/coder/src/coder/coder/agent/agentcontainers/testdata/devcontainer_appport.json",
|
||||
"devcontainer.metadata": "[]"
|
||||
}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "e4fa65f769e331c72e27f43af2d65073efca638fd413b7c57f763ee9ebf69020",
|
||||
"SandboxKey": "/var/run/docker/netns/e4fa65f769e3",
|
||||
"Ports": {
|
||||
"8080/tcp": [
|
||||
{
|
||||
"HostIp": "0.0.0.0",
|
||||
"HostPort": "32768"
|
||||
},
|
||||
{
|
||||
"HostIp": "::",
|
||||
"HostPort": "32768"
|
||||
}
|
||||
]
|
||||
},
|
||||
"HairpinMode": false,
|
||||
"LinkLocalIPv6Address": "",
|
||||
"LinkLocalIPv6PrefixLen": 0,
|
||||
"SecondaryIPAddresses": null,
|
||||
"SecondaryIPv6Addresses": null,
|
||||
"EndpointID": "14531bbbb26052456a4509e6d23753de45096ca8355ac11684c631d1656248ad",
|
||||
"Gateway": "172.17.0.1",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "36:88:48:04:4e:b4",
|
||||
"Networks": {
|
||||
"bridge": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": null,
|
||||
"MacAddress": "36:88:48:04:4e:b4",
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "c4dd768ab4945e41ad23fe3907c960dac811141592a861cc40038df7086a1ce1",
|
||||
"EndpointID": "14531bbbb26052456a4509e6d23753de45096ca8355ac11684c631d1656248ad",
|
||||
"Gateway": "172.17.0.1",
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,209 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Id": "4a16af2293fb75dc827a6949a3905dd57ea28cc008823218ce24fab1cb66c067",
|
||||
"Created": "2025-03-11T17:03:55.022053072Z",
|
||||
"Path": "/bin/sh",
|
||||
"Args": [
|
||||
"-c",
|
||||
"echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
|
||||
"-"
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 529591,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2025-03-11T17:03:55.064323762Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"Image": "sha256:d4ccddb816ba27eaae22ef3d56175d53f47998e2acb99df1ae0e5b426b28a076",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/4a16af2293fb75dc827a6949a3905dd57ea28cc008823218ce24fab1cb66c067/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/4a16af2293fb75dc827a6949a3905dd57ea28cc008823218ce24fab1cb66c067/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/4a16af2293fb75dc827a6949a3905dd57ea28cc008823218ce24fab1cb66c067/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/4a16af2293fb75dc827a6949a3905dd57ea28cc008823218ce24fab1cb66c067/4a16af2293fb75dc827a6949a3905dd57ea28cc008823218ce24fab1cb66c067-json.log",
|
||||
"Name": "/serene_khayyam",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": null,
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "bridge",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "no",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"ConsoleSize": [
|
||||
108,
|
||||
176
|
||||
],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": [],
|
||||
"DnsOptions": [],
|
||||
"DnsSearch": [],
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 10,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": [],
|
||||
"BlkioDeviceReadBps": [],
|
||||
"BlkioDeviceWriteBps": [],
|
||||
"BlkioDeviceReadIOps": [],
|
||||
"BlkioDeviceWriteIOps": [],
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": [],
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": [],
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/asound",
|
||||
"/proc/acpi",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/sys/firmware",
|
||||
"/sys/devices/virtual/powercap"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"ID": "4a16af2293fb75dc827a6949a3905dd57ea28cc008823218ce24fab1cb66c067",
|
||||
"LowerDir": "/var/lib/docker/overlay2/1974a49367024c771135c80dd6b62ba46cdebfa866e67a5408426c88a30bac3e-init/diff:/var/lib/docker/overlay2/4b4c37dfbdc0dc01b68d4fb1ddb86109398a2d73555439b874dbd23b87cd5c4b/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/1974a49367024c771135c80dd6b62ba46cdebfa866e67a5408426c88a30bac3e/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/1974a49367024c771135c80dd6b62ba46cdebfa866e67a5408426c88a30bac3e/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/1974a49367024c771135c80dd6b62ba46cdebfa866e67a5408426c88a30bac3e/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [],
|
||||
"Config": {
|
||||
"Hostname": "4a16af2293fb",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": true,
|
||||
"AttachStderr": true,
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
],
|
||||
"Cmd": [
|
||||
"-c",
|
||||
"echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
|
||||
"-"
|
||||
],
|
||||
"Image": "debian:bookworm",
|
||||
"Volumes": null,
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": [
|
||||
"/bin/sh"
|
||||
],
|
||||
"OnBuild": null,
|
||||
"Labels": {
|
||||
"devcontainer.config_file": "/home/coder/src/coder/coder/agent/agentcontainers/testdata/devcontainer_forwardport.json",
|
||||
"devcontainer.metadata": "[]"
|
||||
}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "e1c3bddb359d16c45d6d132561b83205af7809b01ed5cb985a8cb1b416b2ddd5",
|
||||
"SandboxKey": "/var/run/docker/netns/e1c3bddb359d",
|
||||
"Ports": {},
|
||||
"HairpinMode": false,
|
||||
"LinkLocalIPv6Address": "",
|
||||
"LinkLocalIPv6PrefixLen": 0,
|
||||
"SecondaryIPAddresses": null,
|
||||
"SecondaryIPv6Addresses": null,
|
||||
"EndpointID": "2899f34f5f8b928619952dc32566d82bc121b033453f72e5de4a743feabc423b",
|
||||
"Gateway": "172.17.0.1",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "3e:94:61:83:1f:58",
|
||||
"Networks": {
|
||||
"bridge": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": null,
|
||||
"MacAddress": "3e:94:61:83:1f:58",
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "c4dd768ab4945e41ad23fe3907c960dac811141592a861cc40038df7086a1ce1",
|
||||
"EndpointID": "2899f34f5f8b928619952dc32566d82bc121b033453f72e5de4a743feabc423b",
|
||||
"Gateway": "172.17.0.1",
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,209 +0,0 @@
|
||||
[
|
||||
{
|
||||
"Id": "0b2a9fcf5727d9562943ce47d445019f4520e37a2aa7c6d9346d01af4f4f9aed",
|
||||
"Created": "2025-03-11T17:01:05.751972661Z",
|
||||
"Path": "/bin/sh",
|
||||
"Args": [
|
||||
"-c",
|
||||
"echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
|
||||
"-"
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Paused": false,
|
||||
"Restarting": false,
|
||||
"OOMKilled": false,
|
||||
"Dead": false,
|
||||
"Pid": 521929,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"StartedAt": "2025-03-11T17:01:06.002539252Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"Image": "sha256:d4ccddb816ba27eaae22ef3d56175d53f47998e2acb99df1ae0e5b426b28a076",
|
||||
"ResolvConfPath": "/var/lib/docker/containers/0b2a9fcf5727d9562943ce47d445019f4520e37a2aa7c6d9346d01af4f4f9aed/resolv.conf",
|
||||
"HostnamePath": "/var/lib/docker/containers/0b2a9fcf5727d9562943ce47d445019f4520e37a2aa7c6d9346d01af4f4f9aed/hostname",
|
||||
"HostsPath": "/var/lib/docker/containers/0b2a9fcf5727d9562943ce47d445019f4520e37a2aa7c6d9346d01af4f4f9aed/hosts",
|
||||
"LogPath": "/var/lib/docker/containers/0b2a9fcf5727d9562943ce47d445019f4520e37a2aa7c6d9346d01af4f4f9aed/0b2a9fcf5727d9562943ce47d445019f4520e37a2aa7c6d9346d01af4f4f9aed-json.log",
|
||||
"Name": "/optimistic_hopper",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"MountLabel": "",
|
||||
"ProcessLabel": "",
|
||||
"AppArmorProfile": "",
|
||||
"ExecIDs": null,
|
||||
"HostConfig": {
|
||||
"Binds": null,
|
||||
"ContainerIDFile": "",
|
||||
"LogConfig": {
|
||||
"Type": "json-file",
|
||||
"Config": {}
|
||||
},
|
||||
"NetworkMode": "bridge",
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {
|
||||
"Name": "no",
|
||||
"MaximumRetryCount": 0
|
||||
},
|
||||
"AutoRemove": false,
|
||||
"VolumeDriver": "",
|
||||
"VolumesFrom": null,
|
||||
"ConsoleSize": [
|
||||
108,
|
||||
176
|
||||
],
|
||||
"CapAdd": null,
|
||||
"CapDrop": null,
|
||||
"CgroupnsMode": "private",
|
||||
"Dns": [],
|
||||
"DnsOptions": [],
|
||||
"DnsSearch": [],
|
||||
"ExtraHosts": null,
|
||||
"GroupAdd": null,
|
||||
"IpcMode": "private",
|
||||
"Cgroup": "",
|
||||
"Links": null,
|
||||
"OomScoreAdj": 10,
|
||||
"PidMode": "",
|
||||
"Privileged": false,
|
||||
"PublishAllPorts": false,
|
||||
"ReadonlyRootfs": false,
|
||||
"SecurityOpt": null,
|
||||
"UTSMode": "",
|
||||
"UsernsMode": "",
|
||||
"ShmSize": 67108864,
|
||||
"Runtime": "runc",
|
||||
"Isolation": "",
|
||||
"CpuShares": 0,
|
||||
"Memory": 0,
|
||||
"NanoCpus": 0,
|
||||
"CgroupParent": "",
|
||||
"BlkioWeight": 0,
|
||||
"BlkioWeightDevice": [],
|
||||
"BlkioDeviceReadBps": [],
|
||||
"BlkioDeviceWriteBps": [],
|
||||
"BlkioDeviceReadIOps": [],
|
||||
"BlkioDeviceWriteIOps": [],
|
||||
"CpuPeriod": 0,
|
||||
"CpuQuota": 0,
|
||||
"CpuRealtimePeriod": 0,
|
||||
"CpuRealtimeRuntime": 0,
|
||||
"CpusetCpus": "",
|
||||
"CpusetMems": "",
|
||||
"Devices": [],
|
||||
"DeviceCgroupRules": null,
|
||||
"DeviceRequests": null,
|
||||
"MemoryReservation": 0,
|
||||
"MemorySwap": 0,
|
||||
"MemorySwappiness": null,
|
||||
"OomKillDisable": null,
|
||||
"PidsLimit": null,
|
||||
"Ulimits": [],
|
||||
"CpuCount": 0,
|
||||
"CpuPercent": 0,
|
||||
"IOMaximumIOps": 0,
|
||||
"IOMaximumBandwidth": 0,
|
||||
"MaskedPaths": [
|
||||
"/proc/asound",
|
||||
"/proc/acpi",
|
||||
"/proc/kcore",
|
||||
"/proc/keys",
|
||||
"/proc/latency_stats",
|
||||
"/proc/timer_list",
|
||||
"/proc/timer_stats",
|
||||
"/proc/sched_debug",
|
||||
"/proc/scsi",
|
||||
"/sys/firmware",
|
||||
"/sys/devices/virtual/powercap"
|
||||
],
|
||||
"ReadonlyPaths": [
|
||||
"/proc/bus",
|
||||
"/proc/fs",
|
||||
"/proc/irq",
|
||||
"/proc/sys",
|
||||
"/proc/sysrq-trigger"
|
||||
]
|
||||
},
|
||||
"GraphDriver": {
|
||||
"Data": {
|
||||
"ID": "0b2a9fcf5727d9562943ce47d445019f4520e37a2aa7c6d9346d01af4f4f9aed",
|
||||
"LowerDir": "/var/lib/docker/overlay2/b698fd9f03f25014d4936cdc64ed258342fe685f0dfd8813ed6928dd6de75219-init/diff:/var/lib/docker/overlay2/4b4c37dfbdc0dc01b68d4fb1ddb86109398a2d73555439b874dbd23b87cd5c4b/diff",
|
||||
"MergedDir": "/var/lib/docker/overlay2/b698fd9f03f25014d4936cdc64ed258342fe685f0dfd8813ed6928dd6de75219/merged",
|
||||
"UpperDir": "/var/lib/docker/overlay2/b698fd9f03f25014d4936cdc64ed258342fe685f0dfd8813ed6928dd6de75219/diff",
|
||||
"WorkDir": "/var/lib/docker/overlay2/b698fd9f03f25014d4936cdc64ed258342fe685f0dfd8813ed6928dd6de75219/work"
|
||||
},
|
||||
"Name": "overlay2"
|
||||
},
|
||||
"Mounts": [],
|
||||
"Config": {
|
||||
"Hostname": "0b2a9fcf5727",
|
||||
"Domainname": "",
|
||||
"User": "",
|
||||
"AttachStdin": false,
|
||||
"AttachStdout": true,
|
||||
"AttachStderr": true,
|
||||
"Tty": false,
|
||||
"OpenStdin": false,
|
||||
"StdinOnce": false,
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
],
|
||||
"Cmd": [
|
||||
"-c",
|
||||
"echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
|
||||
"-"
|
||||
],
|
||||
"Image": "debian:bookworm",
|
||||
"Volumes": null,
|
||||
"WorkingDir": "",
|
||||
"Entrypoint": [
|
||||
"/bin/sh"
|
||||
],
|
||||
"OnBuild": null,
|
||||
"Labels": {
|
||||
"devcontainer.config_file": "/home/coder/src/coder/coder/agent/agentcontainers/testdata/devcontainer_simple.json",
|
||||
"devcontainer.metadata": "[]"
|
||||
}
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "25a29a57c1330e0d0d2342af6e3291ffd3e812aca1a6e3f6a1630e74b73d0fc6",
|
||||
"SandboxKey": "/var/run/docker/netns/25a29a57c133",
|
||||
"Ports": {},
|
||||
"HairpinMode": false,
|
||||
"LinkLocalIPv6Address": "",
|
||||
"LinkLocalIPv6PrefixLen": 0,
|
||||
"SecondaryIPAddresses": null,
|
||||
"SecondaryIPv6Addresses": null,
|
||||
"EndpointID": "5c5ebda526d8fca90e841886ea81b77d7cc97fed56980c2aa89d275b84af7df2",
|
||||
"Gateway": "172.17.0.1",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"MacAddress": "32:b6:d9:ab:c3:61",
|
||||
"Networks": {
|
||||
"bridge": {
|
||||
"IPAMConfig": null,
|
||||
"Links": null,
|
||||
"Aliases": null,
|
||||
"MacAddress": "32:b6:d9:ab:c3:61",
|
||||
"DriverOpts": null,
|
||||
"GwPriority": 0,
|
||||
"NetworkID": "c4dd768ab4945e41ad23fe3907c960dac811141592a861cc40038df7086a1ce1",
|
||||
"EndpointID": "5c5ebda526d8fca90e841886ea81b77d7cc97fed56980c2aa89d275b84af7df2",
|
||||
"Gateway": "172.17.0.1",
|
||||
"IPAddress": "172.17.0.2",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"DNSNames": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,68 +0,0 @@
|
||||
{"type":"text","level":3,"timestamp":1744102135254,"text":"@devcontainers/cli 0.75.0. Node.js v23.9.0. darwin 24.4.0 arm64."}
|
||||
{"type":"start","level":2,"timestamp":1744102135254,"text":"Run: docker buildx version"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135300,"text":"Run: docker buildx version","startTimestamp":1744102135254}
|
||||
{"type":"text","level":2,"timestamp":1744102135300,"text":"github.com/docker/buildx v0.21.2 1360a9e8d25a2c3d03c2776d53ae62e6ff0a843d\r\n"}
|
||||
{"type":"text","level":2,"timestamp":1744102135300,"text":"\u001b[1m\u001b[31m\u001b[39m\u001b[22m\r\n"}
|
||||
{"type":"start","level":2,"timestamp":1744102135300,"text":"Run: docker -v"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135309,"text":"Run: docker -v","startTimestamp":1744102135300}
|
||||
{"type":"start","level":2,"timestamp":1744102135309,"text":"Resolving Remote"}
|
||||
{"type":"start","level":2,"timestamp":1744102135311,"text":"Run: git rev-parse --show-cdup"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135316,"text":"Run: git rev-parse --show-cdup","startTimestamp":1744102135311}
|
||||
{"type":"start","level":2,"timestamp":1744102135316,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135333,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json","startTimestamp":1744102135316}
|
||||
{"type":"start","level":2,"timestamp":1744102135333,"text":"Run: docker inspect --type container 4f22413fe134"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135347,"text":"Run: docker inspect --type container 4f22413fe134","startTimestamp":1744102135333}
|
||||
{"type":"start","level":2,"timestamp":1744102135348,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135364,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json","startTimestamp":1744102135348}
|
||||
{"type":"start","level":2,"timestamp":1744102135364,"text":"Run: docker inspect --type container 4f22413fe134"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135378,"text":"Run: docker inspect --type container 4f22413fe134","startTimestamp":1744102135364}
|
||||
{"type":"start","level":2,"timestamp":1744102135379,"text":"Inspecting container"}
|
||||
{"type":"start","level":2,"timestamp":1744102135379,"text":"Run: docker inspect --type container 4f22413fe13472200500a66ca057df5aafba6b45743afd499c3f26fc886eb236"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135393,"text":"Run: docker inspect --type container 4f22413fe13472200500a66ca057df5aafba6b45743afd499c3f26fc886eb236","startTimestamp":1744102135379}
|
||||
{"type":"stop","level":2,"timestamp":1744102135393,"text":"Inspecting container","startTimestamp":1744102135379}
|
||||
{"type":"start","level":2,"timestamp":1744102135393,"text":"Run in container: /bin/sh"}
|
||||
{"type":"start","level":2,"timestamp":1744102135394,"text":"Run in container: uname -m"}
|
||||
{"type":"text","level":2,"timestamp":1744102135428,"text":"aarch64\n"}
|
||||
{"type":"text","level":2,"timestamp":1744102135428,"text":""}
|
||||
{"type":"stop","level":2,"timestamp":1744102135428,"text":"Run in container: uname -m","startTimestamp":1744102135394}
|
||||
{"type":"start","level":2,"timestamp":1744102135428,"text":"Run in container: (cat /etc/os-release || cat /usr/lib/os-release) 2>/dev/null"}
|
||||
{"type":"text","level":2,"timestamp":1744102135428,"text":"PRETTY_NAME=\"Debian GNU/Linux 11 (bullseye)\"\nNAME=\"Debian GNU/Linux\"\nVERSION_ID=\"11\"\nVERSION=\"11 (bullseye)\"\nVERSION_CODENAME=bullseye\nID=debian\nHOME_URL=\"https://www.debian.org/\"\nSUPPORT_URL=\"https://www.debian.org/support\"\nBUG_REPORT_URL=\"https://bugs.debian.org/\"\n"}
|
||||
{"type":"text","level":2,"timestamp":1744102135428,"text":""}
|
||||
{"type":"stop","level":2,"timestamp":1744102135428,"text":"Run in container: (cat /etc/os-release || cat /usr/lib/os-release) 2>/dev/null","startTimestamp":1744102135428}
|
||||
{"type":"start","level":2,"timestamp":1744102135429,"text":"Run in container: (command -v getent >/dev/null 2>&1 && getent passwd 'node' || grep -E '^node|^[^:]*:[^:]*:node:' /etc/passwd || true)"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135429,"text":"Run in container: (command -v getent >/dev/null 2>&1 && getent passwd 'node' || grep -E '^node|^[^:]*:[^:]*:node:' /etc/passwd || true)","startTimestamp":1744102135429}
|
||||
{"type":"start","level":2,"timestamp":1744102135430,"text":"Run in container: test -f '/var/devcontainer/.patchEtcEnvironmentMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135430,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135430,"text":""}
|
||||
{"type":"stop","level":2,"timestamp":1744102135430,"text":"Run in container: test -f '/var/devcontainer/.patchEtcEnvironmentMarker'","startTimestamp":1744102135430}
|
||||
{"type":"start","level":2,"timestamp":1744102135430,"text":"Run in container: test -f '/var/devcontainer/.patchEtcProfileMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135430,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135430,"text":""}
|
||||
{"type":"stop","level":2,"timestamp":1744102135430,"text":"Run in container: test -f '/var/devcontainer/.patchEtcProfileMarker'","startTimestamp":1744102135430}
|
||||
{"type":"text","level":2,"timestamp":1744102135431,"text":"userEnvProbe: loginInteractiveShell (default)"}
|
||||
{"type":"text","level":1,"timestamp":1744102135431,"text":"LifecycleCommandExecutionMap: {\n \"onCreateCommand\": [],\n \"updateContentCommand\": [],\n \"postCreateCommand\": [\n {\n \"origin\": \"devcontainer.json\",\n \"command\": \"npm install -g @devcontainers/cli\"\n }\n ],\n \"postStartCommand\": [],\n \"postAttachCommand\": [],\n \"initializeCommand\": []\n}"}
|
||||
{"type":"text","level":2,"timestamp":1744102135431,"text":"userEnvProbe: not found in cache"}
|
||||
{"type":"text","level":2,"timestamp":1744102135431,"text":"userEnvProbe shell: /bin/bash"}
|
||||
{"type":"start","level":2,"timestamp":1744102135431,"text":"Run in container: /bin/bash -lic echo -n 5805f204-cd2b-4911-8a88-96de28d5deb7; cat /proc/self/environ; echo -n 5805f204-cd2b-4911-8a88-96de28d5deb7"}
|
||||
{"type":"start","level":2,"timestamp":1744102135431,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.onCreateCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.onCreateCommandMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135432,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135432,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135432,"text":"Exit code 1"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135432,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.onCreateCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.onCreateCommandMarker'","startTimestamp":1744102135431}
|
||||
{"type":"start","level":2,"timestamp":1744102135432,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.updateContentCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.updateContentCommandMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135434,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135434,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135434,"text":"Exit code 1"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135434,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.updateContentCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.updateContentCommandMarker'","startTimestamp":1744102135432}
|
||||
{"type":"start","level":2,"timestamp":1744102135434,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.postCreateCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.postCreateCommandMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135435,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135435,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135435,"text":"Exit code 1"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135435,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.postCreateCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-07T09:21:41.201379807Z}\" != '2025-04-07T09:21:41.201379807Z' ] && echo '2025-04-07T09:21:41.201379807Z' > '/home/node/.devcontainer/.postCreateCommandMarker'","startTimestamp":1744102135434}
|
||||
{"type":"start","level":2,"timestamp":1744102135435,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.postStartCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-08T08:48:29.406495039Z}\" != '2025-04-08T08:48:29.406495039Z' ] && echo '2025-04-08T08:48:29.406495039Z' > '/home/node/.devcontainer/.postStartCommandMarker'"}
|
||||
{"type":"text","level":2,"timestamp":1744102135436,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135436,"text":""}
|
||||
{"type":"text","level":2,"timestamp":1744102135436,"text":"Exit code 1"}
|
||||
{"type":"stop","level":2,"timestamp":1744102135436,"text":"Run in container: mkdir -p '/home/node/.devcontainer' && CONTENT=\"$(cat '/home/node/.devcontainer/.postStartCommandMarker' 2>/dev/null || echo ENOENT)\" && [ \"${CONTENT:-2025-04-08T08:48:29.406495039Z}\" != '2025-04-08T08:48:29.406495039Z' ] && echo '2025-04-08T08:48:29.406495039Z' > '/home/node/.devcontainer/.postStartCommandMarker'","startTimestamp":1744102135435}
|
||||
{"type":"stop","level":2,"timestamp":1744102135436,"text":"Resolving Remote","startTimestamp":1744102135309}
|
||||
{"outcome":"success","containerId":"4f22413fe13472200500a66ca057df5aafba6b45743afd499c3f26fc886eb236","remoteUser":"node","remoteWorkspaceFolder":"/workspaces/devcontainers-template-starter"}
|
||||
@@ -1 +0,0 @@
|
||||
bad outcome
|
||||
@@ -1,13 +0,0 @@
|
||||
{"type":"text","level":3,"timestamp":1744102042893,"text":"@devcontainers/cli 0.75.0. Node.js v23.9.0. darwin 24.4.0 arm64."}
|
||||
{"type":"start","level":2,"timestamp":1744102042893,"text":"Run: docker buildx version"}
|
||||
{"type":"stop","level":2,"timestamp":1744102042941,"text":"Run: docker buildx version","startTimestamp":1744102042893}
|
||||
{"type":"text","level":2,"timestamp":1744102042941,"text":"github.com/docker/buildx v0.21.2 1360a9e8d25a2c3d03c2776d53ae62e6ff0a843d\r\n"}
|
||||
{"type":"text","level":2,"timestamp":1744102042941,"text":"\u001b[1m\u001b[31m\u001b[39m\u001b[22m\r\n"}
|
||||
{"type":"start","level":2,"timestamp":1744102042941,"text":"Run: docker -v"}
|
||||
{"type":"stop","level":2,"timestamp":1744102042950,"text":"Run: docker -v","startTimestamp":1744102042941}
|
||||
{"type":"start","level":2,"timestamp":1744102042950,"text":"Resolving Remote"}
|
||||
{"type":"start","level":2,"timestamp":1744102042952,"text":"Run: git rev-parse --show-cdup"}
|
||||
{"type":"stop","level":2,"timestamp":1744102042957,"text":"Run: git rev-parse --show-cdup","startTimestamp":1744102042952}
|
||||
{"type":"start","level":2,"timestamp":1744102042957,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json"}
|
||||
{"type":"stop","level":2,"timestamp":1744102042967,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json","startTimestamp":1744102042957}
|
||||
{"outcome":"error","message":"Command failed: docker ps -q -a --filter label=devcontainer.local_folder=/code/devcontainers-template-starter --filter label=devcontainer.config_file=/code/devcontainers-template-starter/.devcontainer/devcontainer.json","description":"An error occurred setting up the container."}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{"type":"text","level":3,"timestamp":1744102555495,"text":"@devcontainers/cli 0.75.0. Node.js v23.9.0. darwin 24.4.0 arm64."}
|
||||
{"type":"start","level":2,"timestamp":1744102555495,"text":"Run: docker buildx version"}
|
||||
{"type":"stop","level":2,"timestamp":1744102555539,"text":"Run: docker buildx version","startTimestamp":1744102555495}
|
||||
{"type":"text","level":2,"timestamp":1744102555539,"text":"github.com/docker/buildx v0.21.2 1360a9e8d25a2c3d03c2776d53ae62e6ff0a843d\r\n"}
|
||||
{"type":"text","level":2,"timestamp":1744102555539,"text":"\u001b[1m\u001b[31m\u001b[39m\u001b[22m\r\n"}
|
||||
{"type":"start","level":2,"timestamp":1744102555539,"text":"Run: docker -v"}
|
||||
{"type":"stop","level":2,"timestamp":1744102555548,"text":"Run: docker -v","startTimestamp":1744102555539}
|
||||
{"type":"start","level":2,"timestamp":1744102555548,"text":"Resolving Remote"}
|
||||
Error: Dev container config (/code/devcontainers-template-starter/foo/.devcontainer/devcontainer.json) not found.
|
||||
at H6 (/opt/homebrew/Cellar/devcontainer/0.75.0/libexec/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:484:3219)
|
||||
at async BC (/opt/homebrew/Cellar/devcontainer/0.75.0/libexec/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:484:4957)
|
||||
at async d7 (/opt/homebrew/Cellar/devcontainer/0.75.0/libexec/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:665:202)
|
||||
at async f7 (/opt/homebrew/Cellar/devcontainer/0.75.0/libexec/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:664:14804)
|
||||
at async /opt/homebrew/Cellar/devcontainer/0.75.0/libexec/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:484:1188
|
||||
{"outcome":"error","message":"Dev container config (/code/devcontainers-template-starter/foo/.devcontainer/devcontainer.json) not found.","description":"Dev container config (/code/devcontainers-template-starter/foo/.devcontainer/devcontainer.json) not found."}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,48 +0,0 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// NewNoop creates a new watcher that does nothing.
|
||||
func NewNoop() Watcher {
|
||||
return &noopWatcher{done: make(chan struct{})}
|
||||
}
|
||||
|
||||
type noopWatcher struct {
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func (*noopWatcher) Add(string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*noopWatcher) Remove(string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Next blocks until the context is canceled or the watcher is closed.
|
||||
func (n *noopWatcher) Next(ctx context.Context) (*fsnotify.Event, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-n.done:
|
||||
return nil, ErrClosed
|
||||
}
|
||||
}
|
||||
|
||||
func (n *noopWatcher) Close() error {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.closed {
|
||||
return ErrClosed
|
||||
}
|
||||
n.closed = true
|
||||
close(n.done)
|
||||
return nil
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package watcher_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestNoopWatcher(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create the noop watcher under test.
|
||||
wut := watcher.NewNoop()
|
||||
|
||||
// Test adding/removing files (should have no effect).
|
||||
err := wut.Add("some-file.txt")
|
||||
assert.NoError(t, err, "noop watcher should not return error on Add")
|
||||
|
||||
err = wut.Remove("some-file.txt")
|
||||
assert.NoError(t, err, "noop watcher should not return error on Remove")
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
// Start a goroutine to wait for Next to return.
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := wut.Next(ctx)
|
||||
errC <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-errC:
|
||||
require.Fail(t, "want Next to block")
|
||||
default:
|
||||
}
|
||||
|
||||
// Cancel the context and check that Next returns.
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errC:
|
||||
assert.Error(t, err, "want Next error when context is canceled")
|
||||
case <-time.After(testutil.WaitShort):
|
||||
t.Fatal("want Next to return after context was canceled")
|
||||
}
|
||||
|
||||
// Test Close.
|
||||
err = wut.Close()
|
||||
assert.NoError(t, err, "want no error on Close")
|
||||
}
|
||||
|
||||
func TestNoopWatcher_CloseBeforeNext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wut := watcher.NewNoop()
|
||||
|
||||
err := wut.Close()
|
||||
require.NoError(t, err, "close watcher failed")
|
||||
|
||||
ctx := context.Background()
|
||||
_, err = wut.Next(ctx)
|
||||
assert.Error(t, err, "want Next to return error when watcher is closed")
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
// Package watcher provides file system watching capabilities for the
|
||||
// agent. It defines an interface for monitoring file changes and
|
||||
// implementations that can be used to detect when configuration files
|
||||
// are modified. This is primarily used to track changes to devcontainer
|
||||
// configuration files and notify users when containers need to be
|
||||
// recreated to apply the new configuration.
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
var ErrClosed = xerrors.New("watcher closed")
|
||||
|
||||
// Watcher defines an interface for monitoring file system changes.
|
||||
// Implementations track file modifications and provide an event stream
|
||||
// that clients can consume to react to changes.
|
||||
type Watcher interface {
|
||||
// Add starts watching a file for changes.
|
||||
Add(file string) error
|
||||
|
||||
// Remove stops watching a file for changes.
|
||||
Remove(file string) error
|
||||
|
||||
// Next blocks until a file system event occurs or the context is canceled.
|
||||
// It returns the next event or an error if the watcher encountered a problem.
|
||||
Next(context.Context) (*fsnotify.Event, error)
|
||||
|
||||
// Close shuts down the watcher and releases any resources.
|
||||
Close() error
|
||||
}
|
||||
|
||||
type fsnotifyWatcher struct {
|
||||
*fsnotify.Watcher
|
||||
|
||||
mu sync.Mutex // Protects following.
|
||||
watchedFiles map[string]bool // Files being watched (absolute path -> bool).
|
||||
watchedDirs map[string]int // Refcount of directories being watched (absolute path -> count).
|
||||
closed bool // Protects closing of done.
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewFSNotify creates a new file system watcher that watches parent directories
|
||||
// instead of individual files for more reliable event detection.
|
||||
func NewFSNotify() (Watcher, error) {
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create fsnotify watcher: %w", err)
|
||||
}
|
||||
return &fsnotifyWatcher{
|
||||
Watcher: w,
|
||||
done: make(chan struct{}),
|
||||
watchedFiles: make(map[string]bool),
|
||||
watchedDirs: make(map[string]int),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fsnotifyWatcher) Add(file string) error {
|
||||
absPath, err := filepath.Abs(file)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("absolute path: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(absPath)
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
// Already watching this file.
|
||||
if f.closed || f.watchedFiles[absPath] {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start watching the parent directory if not already watching.
|
||||
if f.watchedDirs[dir] == 0 {
|
||||
if err := f.Watcher.Add(dir); err != nil {
|
||||
return xerrors.Errorf("add directory to watcher: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Increment the reference count for this directory.
|
||||
f.watchedDirs[dir]++
|
||||
// Mark this file as watched.
|
||||
f.watchedFiles[absPath] = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fsnotifyWatcher) Remove(file string) error {
|
||||
absPath, err := filepath.Abs(file)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("absolute path: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(absPath)
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
// Not watching this file.
|
||||
if f.closed || !f.watchedFiles[absPath] {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove the file from our watch list.
|
||||
delete(f.watchedFiles, absPath)
|
||||
|
||||
// Decrement the reference count for this directory.
|
||||
f.watchedDirs[dir]--
|
||||
|
||||
// If no more files in this directory are being watched, stop
|
||||
// watching the directory.
|
||||
if f.watchedDirs[dir] <= 0 {
|
||||
f.watchedDirs[dir] = 0 // Ensure non-negative count.
|
||||
if err := f.Watcher.Remove(dir); err != nil {
|
||||
return xerrors.Errorf("remove directory from watcher: %w", err)
|
||||
}
|
||||
delete(f.watchedDirs, dir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fsnotifyWatcher) Next(ctx context.Context) (event *fsnotify.Event, err error) {
|
||||
defer func() {
|
||||
if ctx.Err() != nil {
|
||||
event = nil
|
||||
err = ctx.Err()
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case evt, ok := <-f.Events:
|
||||
if !ok {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
|
||||
// Get the absolute path to match against our watched files.
|
||||
absPath, err := filepath.Abs(evt.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
if f.closed {
|
||||
f.mu.Unlock()
|
||||
return nil, ErrClosed
|
||||
}
|
||||
isWatched := f.watchedFiles[absPath]
|
||||
f.mu.Unlock()
|
||||
if !isWatched {
|
||||
continue // Ignore events for files not being watched.
|
||||
}
|
||||
|
||||
return &evt, nil
|
||||
|
||||
case err, ok := <-f.Errors:
|
||||
if !ok {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
return nil, xerrors.Errorf("watcher error: %w", err)
|
||||
case <-f.done:
|
||||
return nil, ErrClosed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fsnotifyWatcher) Close() (err error) {
|
||||
f.mu.Lock()
|
||||
f.watchedFiles = nil
|
||||
f.watchedDirs = nil
|
||||
closed := f.closed
|
||||
f.closed = true
|
||||
f.mu.Unlock()
|
||||
|
||||
if closed {
|
||||
return ErrClosed
|
||||
}
|
||||
|
||||
close(f.done)
|
||||
|
||||
if err := f.Watcher.Close(); err != nil {
|
||||
return xerrors.Errorf("close watcher: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package watcher_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestFSNotifyWatcher(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create test files.
|
||||
dir := t.TempDir()
|
||||
testFile := filepath.Join(dir, "test.json")
|
||||
err := os.WriteFile(testFile, []byte(`{"test": "initial"}`), 0o600)
|
||||
require.NoError(t, err, "create test file failed")
|
||||
|
||||
// Create the watcher under test.
|
||||
wut, err := watcher.NewFSNotify()
|
||||
require.NoError(t, err, "create FSNotify watcher failed")
|
||||
defer wut.Close()
|
||||
|
||||
// Add the test file to the watch list.
|
||||
err = wut.Add(testFile)
|
||||
require.NoError(t, err, "add file to watcher failed")
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
// Modify the test file to trigger an event.
|
||||
err = os.WriteFile(testFile, []byte(`{"test": "modified"}`), 0o600)
|
||||
require.NoError(t, err, "modify test file failed")
|
||||
|
||||
// Verify that we receive the event we want.
|
||||
for {
|
||||
event, err := wut.Next(ctx)
|
||||
require.NoError(t, err, "next event failed")
|
||||
|
||||
require.NotNil(t, event, "want non-nil event")
|
||||
if !event.Has(fsnotify.Write) {
|
||||
t.Logf("Ignoring event: %s", event)
|
||||
continue
|
||||
}
|
||||
require.Truef(t, event.Has(fsnotify.Write), "want write event: %s", event.String())
|
||||
require.Equal(t, event.Name, testFile, "want event for test file")
|
||||
break
|
||||
}
|
||||
|
||||
// Rename the test file to trigger a rename event.
|
||||
err = os.Rename(testFile, testFile+".bak")
|
||||
require.NoError(t, err, "rename test file failed")
|
||||
|
||||
// Verify that we receive the event we want.
|
||||
for {
|
||||
event, err := wut.Next(ctx)
|
||||
require.NoError(t, err, "next event failed")
|
||||
require.NotNil(t, event, "want non-nil event")
|
||||
if !event.Has(fsnotify.Rename) {
|
||||
t.Logf("Ignoring event: %s", event)
|
||||
continue
|
||||
}
|
||||
require.Truef(t, event.Has(fsnotify.Rename), "want rename event: %s", event.String())
|
||||
require.Equal(t, event.Name, testFile, "want event for test file")
|
||||
break
|
||||
}
|
||||
|
||||
err = os.WriteFile(testFile, []byte(`{"test": "new"}`), 0o600)
|
||||
require.NoError(t, err, "write new test file failed")
|
||||
|
||||
// Verify that we receive the event we want.
|
||||
for {
|
||||
event, err := wut.Next(ctx)
|
||||
require.NoError(t, err, "next event failed")
|
||||
require.NotNil(t, event, "want non-nil event")
|
||||
if !event.Has(fsnotify.Create) {
|
||||
t.Logf("Ignoring event: %s", event)
|
||||
continue
|
||||
}
|
||||
require.Truef(t, event.Has(fsnotify.Create), "want create event: %s", event.String())
|
||||
require.Equal(t, event.Name, testFile, "want event for test file")
|
||||
break
|
||||
}
|
||||
|
||||
err = os.WriteFile(testFile+".atomic", []byte(`{"test": "atomic"}`), 0o600)
|
||||
require.NoError(t, err, "write new atomic test file failed")
|
||||
|
||||
err = os.Rename(testFile+".atomic", testFile)
|
||||
require.NoError(t, err, "rename atomic test file failed")
|
||||
|
||||
// Verify that we receive the event we want.
|
||||
for {
|
||||
event, err := wut.Next(ctx)
|
||||
require.NoError(t, err, "next event failed")
|
||||
require.NotNil(t, event, "want non-nil event")
|
||||
if !event.Has(fsnotify.Create) {
|
||||
t.Logf("Ignoring event: %s", event)
|
||||
continue
|
||||
}
|
||||
require.Truef(t, event.Has(fsnotify.Create), "want create event: %s", event.String())
|
||||
require.Equal(t, event.Name, testFile, "want event for test file")
|
||||
break
|
||||
}
|
||||
|
||||
// Test removing the file from the watcher.
|
||||
err = wut.Remove(testFile)
|
||||
require.NoError(t, err, "remove file from watcher failed")
|
||||
}
|
||||
|
||||
func TestFSNotifyWatcher_CloseBeforeNext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wut, err := watcher.NewFSNotify()
|
||||
require.NoError(t, err, "create FSNotify watcher failed")
|
||||
|
||||
err = wut.Close()
|
||||
require.NoError(t, err, "close watcher failed")
|
||||
|
||||
ctx := context.Background()
|
||||
_, err = wut.Next(ctx)
|
||||
assert.Error(t, err, "want Next to return error when watcher is closed")
|
||||
}
|
||||
@@ -17,8 +17,6 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
"golang.org/x/xerrors"
|
||||
"kernel.org/pub/linux/libs/security/libcap/cap"
|
||||
|
||||
"github.com/coder/coder/v2/agent/usershell"
|
||||
)
|
||||
|
||||
// CLI runs the agent-exec command. It should only be called by the cli package.
|
||||
@@ -116,8 +114,7 @@ func CLI() error {
|
||||
|
||||
// Remove environment variables specific to the agentexec command. This is
|
||||
// especially important for environments that are attempting to develop Coder in Coder.
|
||||
ei := usershell.SystemEnvInfo{}
|
||||
env := ei.Environ()
|
||||
env := os.Environ()
|
||||
env = slices.DeleteFunc(env, func(e string) bool {
|
||||
return strings.HasPrefix(e, EnvProcPrioMgmt) ||
|
||||
strings.HasPrefix(e, EnvProcOOMScore) ||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
package agentrsa
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"math/big"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
// GenerateDeterministicKey generates an RSA private key deterministically based on the provided seed.
|
||||
// This function uses a deterministic random source to generate the primes p and q, ensuring that the
|
||||
// same seed will always produce the same private key. The generated key is 2048 bits in size.
|
||||
//
|
||||
// Reference: https://pkg.go.dev/crypto/rsa#GenerateKey
|
||||
func GenerateDeterministicKey(seed int64) *rsa.PrivateKey {
|
||||
// Since the standard lib purposefully does not generate
|
||||
// deterministic rsa keys, we need to do it ourselves.
|
||||
|
||||
// Create deterministic random source
|
||||
// nolint: gosec
|
||||
deterministicRand := rand.New(rand.NewSource(seed))
|
||||
|
||||
// Use fixed values for p and q based on the seed
|
||||
p := big.NewInt(0)
|
||||
q := big.NewInt(0)
|
||||
e := big.NewInt(65537) // Standard RSA public exponent
|
||||
|
||||
for {
|
||||
// Generate deterministic primes using the seeded random
|
||||
// Each prime should be ~1024 bits to get a 2048-bit key
|
||||
for {
|
||||
p.SetBit(p, 1024, 1) // Ensure it's large enough
|
||||
for i := range 1024 {
|
||||
if deterministicRand.Int63()%2 == 1 {
|
||||
p.SetBit(p, i, 1)
|
||||
} else {
|
||||
p.SetBit(p, i, 0)
|
||||
}
|
||||
}
|
||||
p1 := new(big.Int).Sub(p, big.NewInt(1))
|
||||
if p.ProbablyPrime(20) && new(big.Int).GCD(nil, nil, e, p1).Cmp(big.NewInt(1)) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
q.SetBit(q, 1024, 1) // Ensure it's large enough
|
||||
for i := range 1024 {
|
||||
if deterministicRand.Int63()%2 == 1 {
|
||||
q.SetBit(q, i, 1)
|
||||
} else {
|
||||
q.SetBit(q, i, 0)
|
||||
}
|
||||
}
|
||||
q1 := new(big.Int).Sub(q, big.NewInt(1))
|
||||
if q.ProbablyPrime(20) && p.Cmp(q) != 0 && new(big.Int).GCD(nil, nil, e, q1).Cmp(big.NewInt(1)) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate phi = (p-1) * (q-1)
|
||||
p1 := new(big.Int).Sub(p, big.NewInt(1))
|
||||
q1 := new(big.Int).Sub(q, big.NewInt(1))
|
||||
phi := new(big.Int).Mul(p1, q1)
|
||||
|
||||
// Calculate private exponent d
|
||||
d := new(big.Int).ModInverse(e, phi)
|
||||
if d != nil {
|
||||
// Calculate n = p * q
|
||||
n := new(big.Int).Mul(p, q)
|
||||
|
||||
// Create the private key
|
||||
privateKey := &rsa.PrivateKey{
|
||||
PublicKey: rsa.PublicKey{
|
||||
N: n,
|
||||
E: int(e.Int64()),
|
||||
},
|
||||
D: d,
|
||||
Primes: []*big.Int{p, q},
|
||||
}
|
||||
|
||||
// Compute precomputed values
|
||||
privateKey.Precompute()
|
||||
|
||||
return privateKey
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package agentrsa_test
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentrsa"
|
||||
)
|
||||
|
||||
func TestGenerateDeterministicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key1 := agentrsa.GenerateDeterministicKey(1234)
|
||||
key2 := agentrsa.GenerateDeterministicKey(1234)
|
||||
|
||||
assert.Equal(t, key1, key2)
|
||||
assert.EqualExportedValues(t, key1, key2)
|
||||
}
|
||||
|
||||
var result *rsa.PrivateKey
|
||||
|
||||
func BenchmarkGenerateDeterministicKey(b *testing.B) {
|
||||
var r *rsa.PrivateKey
|
||||
|
||||
for range b.N {
|
||||
// always record the result of DeterministicPrivateKey to prevent
|
||||
// the compiler eliminating the function call.
|
||||
// #nosec G404 - Using math/rand is acceptable for benchmarking deterministic keys
|
||||
r = agentrsa.GenerateDeterministicKey(rand.Int64())
|
||||
}
|
||||
|
||||
// always store the result to a package level variable
|
||||
// so the compiler cannot eliminate the Benchmark itself.
|
||||
result = r
|
||||
}
|
||||
|
||||
func FuzzGenerateDeterministicKey(f *testing.F) {
|
||||
testcases := []int64{0, 1234, 1010101010}
|
||||
for _, tc := range testcases {
|
||||
f.Add(tc) // Use f.Add to provide a seed corpus
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, seed int64) {
|
||||
key1 := agentrsa.GenerateDeterministicKey(seed)
|
||||
key2 := agentrsa.GenerateDeterministicKey(seed)
|
||||
assert.Equal(t, key1, key2)
|
||||
assert.EqualExportedValues(t, key1, key2)
|
||||
})
|
||||
}
|
||||
@@ -80,21 +80,6 @@ func New(opts Options) *Runner {
|
||||
|
||||
type ScriptCompletedFunc func(context.Context, *proto.WorkspaceAgentScriptCompletedRequest) (*proto.WorkspaceAgentScriptCompletedResponse, error)
|
||||
|
||||
type runnerScript struct {
|
||||
runOnPostStart bool
|
||||
codersdk.WorkspaceAgentScript
|
||||
}
|
||||
|
||||
func toRunnerScript(scripts ...codersdk.WorkspaceAgentScript) []runnerScript {
|
||||
var rs []runnerScript
|
||||
for _, s := range scripts {
|
||||
rs = append(rs, runnerScript{
|
||||
WorkspaceAgentScript: s,
|
||||
})
|
||||
}
|
||||
return rs
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
Options
|
||||
|
||||
@@ -105,7 +90,7 @@ type Runner struct {
|
||||
closeMutex sync.Mutex
|
||||
cron *cron.Cron
|
||||
initialized atomic.Bool
|
||||
scripts []runnerScript
|
||||
scripts []codersdk.WorkspaceAgentScript
|
||||
dataDir string
|
||||
scriptCompleted ScriptCompletedFunc
|
||||
|
||||
@@ -134,35 +119,16 @@ func (r *Runner) RegisterMetrics(reg prometheus.Registerer) {
|
||||
reg.MustRegister(r.scriptsExecuted)
|
||||
}
|
||||
|
||||
// InitOption describes an option for the runner initialization.
|
||||
type InitOption func(*Runner)
|
||||
|
||||
// WithPostStartScripts adds scripts that should be run after the workspace
|
||||
// start scripts but before the workspace is marked as started.
|
||||
func WithPostStartScripts(scripts ...codersdk.WorkspaceAgentScript) InitOption {
|
||||
return func(r *Runner) {
|
||||
for _, s := range scripts {
|
||||
r.scripts = append(r.scripts, runnerScript{
|
||||
runOnPostStart: true,
|
||||
WorkspaceAgentScript: s,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the runner with the provided scripts.
|
||||
// It also schedules any scripts that have a schedule.
|
||||
// This function must be called before Execute.
|
||||
func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted ScriptCompletedFunc, opts ...InitOption) error {
|
||||
func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted ScriptCompletedFunc) error {
|
||||
if r.initialized.Load() {
|
||||
return xerrors.New("init: already initialized")
|
||||
}
|
||||
r.initialized.Store(true)
|
||||
r.scripts = toRunnerScript(scripts...)
|
||||
r.scripts = scripts
|
||||
r.scriptCompleted = scriptCompleted
|
||||
for _, opt := range opts {
|
||||
opt(r)
|
||||
}
|
||||
r.Logger.Info(r.cronCtx, "initializing agent scripts", slog.F("script_count", len(scripts)), slog.F("log_dir", r.LogDir))
|
||||
|
||||
err := r.Filesystem.MkdirAll(r.ScriptBinDir(), 0o700)
|
||||
@@ -170,13 +136,13 @@ func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted S
|
||||
return xerrors.Errorf("create script bin dir: %w", err)
|
||||
}
|
||||
|
||||
for _, script := range r.scripts {
|
||||
for _, script := range scripts {
|
||||
if script.Cron == "" {
|
||||
continue
|
||||
}
|
||||
script := script
|
||||
_, err := r.cron.AddFunc(script.Cron, func() {
|
||||
err := r.trackRun(r.cronCtx, script.WorkspaceAgentScript, ExecuteCronScripts)
|
||||
err := r.trackRun(r.cronCtx, script, ExecuteCronScripts)
|
||||
if err != nil {
|
||||
r.Logger.Warn(context.Background(), "run agent script on schedule", slog.Error(err))
|
||||
}
|
||||
@@ -220,7 +186,6 @@ type ExecuteOption int
|
||||
const (
|
||||
ExecuteAllScripts ExecuteOption = iota
|
||||
ExecuteStartScripts
|
||||
ExecutePostStartScripts
|
||||
ExecuteStopScripts
|
||||
ExecuteCronScripts
|
||||
)
|
||||
@@ -231,7 +196,6 @@ func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error {
|
||||
for _, script := range r.scripts {
|
||||
runScript := (option == ExecuteStartScripts && script.RunOnStart) ||
|
||||
(option == ExecuteStopScripts && script.RunOnStop) ||
|
||||
(option == ExecutePostStartScripts && script.runOnPostStart) ||
|
||||
(option == ExecuteCronScripts && script.Cron != "") ||
|
||||
option == ExecuteAllScripts
|
||||
|
||||
@@ -241,7 +205,7 @@ func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error {
|
||||
|
||||
script := script
|
||||
eg.Go(func() error {
|
||||
err := r.trackRun(ctx, script.WorkspaceAgentScript, option)
|
||||
err := r.trackRun(ctx, script, option)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run agent script %q: %w", script.LogSourceID, err)
|
||||
}
|
||||
@@ -319,14 +283,14 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript,
|
||||
cmdCtx, ctxCancel = context.WithTimeout(ctx, script.Timeout)
|
||||
defer ctxCancel()
|
||||
}
|
||||
cmdPty, err := r.SSHServer.CreateCommand(cmdCtx, script.Script, nil, nil)
|
||||
cmdPty, err := r.SSHServer.CreateCommand(cmdCtx, script.Script, nil)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("%s script: create command: %w", logPath, err)
|
||||
}
|
||||
cmd = cmdPty.AsExec()
|
||||
cmd.SysProcAttr = cmdSysProcAttr()
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
cmd.Cancel = cmdCancel(ctx, logger, cmd)
|
||||
cmd.Cancel = cmdCancel(cmd)
|
||||
|
||||
// Expose env vars that can be used in the script for storing data
|
||||
// and binaries. In the future, we may want to expose more env vars
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
package agentscripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
func cmdSysProcAttr() *syscall.SysProcAttr {
|
||||
@@ -16,9 +13,8 @@ func cmdSysProcAttr() *syscall.SysProcAttr {
|
||||
}
|
||||
}
|
||||
|
||||
func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error {
|
||||
func cmdCancel(cmd *exec.Cmd) func() error {
|
||||
return func() error {
|
||||
logger.Debug(ctx, "cmdCancel: sending SIGHUP to process and children", slog.F("pid", cmd.Process.Pid))
|
||||
return syscall.Kill(-cmd.Process.Pid, syscall.SIGHUP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -44,7 +42,7 @@ func TestExecuteBasic(t *testing.T) {
|
||||
}}, aAPI.ScriptCompleted)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, runner.Execute(context.Background(), agentscripts.ExecuteAllScripts))
|
||||
log := testutil.TryReceive(ctx, t, fLogger.logs)
|
||||
log := testutil.RequireRecvCtx(ctx, t, fLogger.logs)
|
||||
require.Equal(t, "hello", log.Output)
|
||||
}
|
||||
|
||||
@@ -102,16 +100,13 @@ func TestEnv(t *testing.T) {
|
||||
|
||||
func TestTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "darwin" {
|
||||
t.Skip("this test is flaky on macOS, see https://github.com/coder/internal/issues/329")
|
||||
}
|
||||
runner := setup(t, nil)
|
||||
defer runner.Close()
|
||||
aAPI := agenttest.NewFakeAgentAPI(t, testutil.Logger(t), nil, nil)
|
||||
err := runner.Init([]codersdk.WorkspaceAgentScript{{
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "sleep infinity",
|
||||
Timeout: 100 * time.Millisecond,
|
||||
Timeout: time.Millisecond,
|
||||
}}, aAPI.ScriptCompleted)
|
||||
require.NoError(t, err)
|
||||
require.ErrorIs(t, runner.Execute(context.Background(), agentscripts.ExecuteAllScripts), agentscripts.ErrTimeout)
|
||||
@@ -136,7 +131,7 @@ func TestScriptReportsTiming(t *testing.T) {
|
||||
require.NoError(t, runner.Execute(ctx, agentscripts.ExecuteAllScripts))
|
||||
runner.Close()
|
||||
|
||||
log := testutil.TryReceive(ctx, t, fLogger.logs)
|
||||
log := testutil.RequireRecvCtx(ctx, t, fLogger.logs)
|
||||
require.Equal(t, "hello", log.Output)
|
||||
|
||||
timings := aAPI.GetTimings()
|
||||
@@ -156,160 +151,11 @@ func TestCronClose(t *testing.T) {
|
||||
require.NoError(t, runner.Close(), "close runner")
|
||||
}
|
||||
|
||||
func TestExecuteOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
startScript := codersdk.WorkspaceAgentScript{
|
||||
ID: uuid.New(),
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo start",
|
||||
RunOnStart: true,
|
||||
}
|
||||
stopScript := codersdk.WorkspaceAgentScript{
|
||||
ID: uuid.New(),
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo stop",
|
||||
RunOnStop: true,
|
||||
}
|
||||
postStartScript := codersdk.WorkspaceAgentScript{
|
||||
ID: uuid.New(),
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo poststart",
|
||||
}
|
||||
regularScript := codersdk.WorkspaceAgentScript{
|
||||
ID: uuid.New(),
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo regular",
|
||||
}
|
||||
|
||||
scripts := []codersdk.WorkspaceAgentScript{
|
||||
startScript,
|
||||
stopScript,
|
||||
regularScript,
|
||||
}
|
||||
allScripts := append(slices.Clone(scripts), postStartScript)
|
||||
|
||||
scriptByID := func(t *testing.T, id uuid.UUID) codersdk.WorkspaceAgentScript {
|
||||
for _, script := range allScripts {
|
||||
if script.ID == id {
|
||||
return script
|
||||
}
|
||||
}
|
||||
t.Fatal("script not found")
|
||||
return codersdk.WorkspaceAgentScript{}
|
||||
}
|
||||
|
||||
wantOutput := map[uuid.UUID]string{
|
||||
startScript.ID: "start",
|
||||
stopScript.ID: "stop",
|
||||
postStartScript.ID: "poststart",
|
||||
regularScript.ID: "regular",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
option agentscripts.ExecuteOption
|
||||
wantRun []uuid.UUID
|
||||
}{
|
||||
{
|
||||
name: "ExecuteAllScripts",
|
||||
option: agentscripts.ExecuteAllScripts,
|
||||
wantRun: []uuid.UUID{startScript.ID, stopScript.ID, regularScript.ID, postStartScript.ID},
|
||||
},
|
||||
{
|
||||
name: "ExecuteStartScripts",
|
||||
option: agentscripts.ExecuteStartScripts,
|
||||
wantRun: []uuid.UUID{startScript.ID},
|
||||
},
|
||||
{
|
||||
name: "ExecutePostStartScripts",
|
||||
option: agentscripts.ExecutePostStartScripts,
|
||||
wantRun: []uuid.UUID{postStartScript.ID},
|
||||
},
|
||||
{
|
||||
name: "ExecuteStopScripts",
|
||||
option: agentscripts.ExecuteStopScripts,
|
||||
wantRun: []uuid.UUID{stopScript.ID},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
executedScripts := make(map[uuid.UUID]bool)
|
||||
fLogger := &executeOptionTestLogger{
|
||||
tb: t,
|
||||
executedScripts: executedScripts,
|
||||
wantOutput: wantOutput,
|
||||
}
|
||||
|
||||
runner := setup(t, func(uuid.UUID) agentscripts.ScriptLogger {
|
||||
return fLogger
|
||||
})
|
||||
defer runner.Close()
|
||||
|
||||
aAPI := agenttest.NewFakeAgentAPI(t, testutil.Logger(t), nil, nil)
|
||||
err := runner.Init(
|
||||
scripts,
|
||||
aAPI.ScriptCompleted,
|
||||
agentscripts.WithPostStartScripts(postStartScript),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = runner.Execute(ctx, tc.option)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotRun := map[uuid.UUID]bool{}
|
||||
for _, id := range tc.wantRun {
|
||||
gotRun[id] = true
|
||||
require.True(t, executedScripts[id],
|
||||
"script %s should have run when using filter %s", scriptByID(t, id).Script, tc.name)
|
||||
}
|
||||
|
||||
for _, script := range allScripts {
|
||||
if _, ok := gotRun[script.ID]; ok {
|
||||
continue
|
||||
}
|
||||
require.False(t, executedScripts[script.ID],
|
||||
"script %s should not have run when using filter %s", script.Script, tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type executeOptionTestLogger struct {
|
||||
tb testing.TB
|
||||
executedScripts map[uuid.UUID]bool
|
||||
wantOutput map[uuid.UUID]string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (l *executeOptionTestLogger) Send(_ context.Context, logs ...agentsdk.Log) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for _, log := range logs {
|
||||
l.tb.Log(log.Output)
|
||||
for id, output := range l.wantOutput {
|
||||
if log.Output == output {
|
||||
l.executedScripts[id] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*executeOptionTestLogger) Flush(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setup(t *testing.T, getScriptLogger func(logSourceID uuid.UUID) agentscripts.ScriptLogger) *agentscripts.Runner {
|
||||
t.Helper()
|
||||
if getScriptLogger == nil {
|
||||
// noop
|
||||
getScriptLogger = func(uuid.UUID) agentscripts.ScriptLogger {
|
||||
getScriptLogger = func(uuid uuid.UUID) agentscripts.ScriptLogger {
|
||||
return noopScriptLogger{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
package agentscripts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
func cmdSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{}
|
||||
}
|
||||
|
||||
func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error {
|
||||
func cmdCancel(cmd *exec.Cmd) func() error {
|
||||
return func() error {
|
||||
logger.Debug(ctx, "cmdCancel: sending interrupt to process", slog.F("pid", cmd.Process.Pid))
|
||||
return cmd.Process.Signal(os.Interrupt)
|
||||
}
|
||||
}
|
||||
|
||||
+101
-378
@@ -3,6 +3,8 @@ package agentssh
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,7 +14,6 @@ import (
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -29,9 +30,7 @@ import (
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/agentrsa"
|
||||
"github.com/coder/coder/v2/agent/usershell"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty"
|
||||
@@ -43,6 +42,14 @@ const (
|
||||
// unlikely to shadow other exit codes, which are typically 1, 2, 3, etc.
|
||||
MagicSessionErrorCode = 229
|
||||
|
||||
// MagicSessionTypeEnvironmentVariable is used to track the purpose behind an SSH connection.
|
||||
// This is stripped from any commands being executed, and is counted towards connection stats.
|
||||
MagicSessionTypeEnvironmentVariable = "CODER_SSH_SESSION_TYPE"
|
||||
// MagicSessionTypeVSCode is set in the SSH config by the VS Code extension to identify itself.
|
||||
MagicSessionTypeVSCode = "vscode"
|
||||
// MagicSessionTypeJetBrains is set in the SSH config by the JetBrains
|
||||
// extension to identify itself.
|
||||
MagicSessionTypeJetBrains = "jetbrains"
|
||||
// MagicProcessCmdlineJetBrains is a string in a process's command line that
|
||||
// uniquely identifies it as JetBrains software.
|
||||
MagicProcessCmdlineJetBrains = "idea.vendor.name=JetBrains"
|
||||
@@ -53,42 +60,9 @@ const (
|
||||
BlockedFileTransferErrorMessage = "File transfer has been disabled."
|
||||
)
|
||||
|
||||
// MagicSessionType is a type that represents the type of session that is being
|
||||
// established.
|
||||
type MagicSessionType string
|
||||
|
||||
const (
|
||||
// MagicSessionTypeEnvironmentVariable is used to track the purpose behind an SSH connection.
|
||||
// This is stripped from any commands being executed, and is counted towards connection stats.
|
||||
MagicSessionTypeEnvironmentVariable = "CODER_SSH_SESSION_TYPE"
|
||||
// ContainerEnvironmentVariable is used to specify the target container for an SSH connection.
|
||||
// This is stripped from any commands being executed.
|
||||
// Only available if CODER_AGENT_DEVCONTAINERS_ENABLE=true.
|
||||
ContainerEnvironmentVariable = "CODER_CONTAINER"
|
||||
// ContainerUserEnvironmentVariable is used to specify the container user for
|
||||
// an SSH connection.
|
||||
// Only available if CODER_AGENT_DEVCONTAINERS_ENABLE=true.
|
||||
ContainerUserEnvironmentVariable = "CODER_CONTAINER_USER"
|
||||
)
|
||||
|
||||
// MagicSessionType enums.
|
||||
const (
|
||||
// MagicSessionTypeUnknown means the session type could not be determined.
|
||||
MagicSessionTypeUnknown MagicSessionType = "unknown"
|
||||
// MagicSessionTypeSSH is the default session type.
|
||||
MagicSessionTypeSSH MagicSessionType = "ssh"
|
||||
// MagicSessionTypeVSCode is set in the SSH config by the VS Code extension to identify itself.
|
||||
MagicSessionTypeVSCode MagicSessionType = "vscode"
|
||||
// MagicSessionTypeJetBrains is set in the SSH config by the JetBrains
|
||||
// extension to identify itself.
|
||||
MagicSessionTypeJetBrains MagicSessionType = "jetbrains"
|
||||
)
|
||||
|
||||
// BlockedFileTransferCommands contains a list of restricted file transfer commands.
|
||||
var BlockedFileTransferCommands = []string{"nc", "rsync", "scp", "sftp"}
|
||||
|
||||
type reportConnectionFunc func(id uuid.UUID, sessionType MagicSessionType, ip string) (disconnected func(code int, reason string))
|
||||
|
||||
// Config sets configuration parameters for the agent SSH server.
|
||||
type Config struct {
|
||||
// MaxTimeout sets the absolute connection timeout, none if empty. If set to
|
||||
@@ -111,11 +85,6 @@ type Config struct {
|
||||
X11DisplayOffset *int
|
||||
// BlockFileTransfer restricts use of file transfer applications.
|
||||
BlockFileTransfer bool
|
||||
// ReportConnection.
|
||||
ReportConnection reportConnectionFunc
|
||||
// Experimental: allow connecting to running containers if
|
||||
// CODER_AGENT_DEVCONTAINERS_ENABLE=true.
|
||||
ExperimentalDevContainersEnabled bool
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -124,7 +93,6 @@ type Server struct {
|
||||
listeners map[net.Listener]struct{}
|
||||
conns map[net.Conn]struct{}
|
||||
sessions map[ssh.Session]struct{}
|
||||
processes map[*os.Process]struct{}
|
||||
closing chan struct{}
|
||||
// Wait for goroutines to exit, waited without
|
||||
// a lock on mu but protected by closing.
|
||||
@@ -144,6 +112,17 @@ type Server struct {
|
||||
}
|
||||
|
||||
func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prometheus.Registry, fs afero.Fs, execer agentexec.Execer, config *Config) (*Server, error) {
|
||||
// Clients' should ignore the host key when connecting.
|
||||
// The agent needs to authenticate with coderd to SSH,
|
||||
// so SSH authentication doesn't improve security.
|
||||
randomHostKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
randomSigner, err := gossh.NewSignerFromKey(randomHostKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config == nil {
|
||||
config = &Config{}
|
||||
}
|
||||
@@ -169,9 +148,6 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
return home
|
||||
}
|
||||
}
|
||||
if config.ReportConnection == nil {
|
||||
config.ReportConnection = func(uuid.UUID, MagicSessionType, string) func(int, string) { return func(int, string) {} }
|
||||
}
|
||||
|
||||
forwardHandler := &ssh.ForwardedTCPHandler{}
|
||||
unixForwardHandler := newForwardedUnixHandler(logger)
|
||||
@@ -183,7 +159,6 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
fs: fs,
|
||||
conns: make(map[net.Conn]struct{}),
|
||||
sessions: make(map[ssh.Session]struct{}),
|
||||
processes: make(map[*os.Process]struct{}),
|
||||
logger: logger,
|
||||
|
||||
config: config,
|
||||
@@ -195,7 +170,7 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
ChannelHandlers: map[string]ssh.ChannelHandler{
|
||||
"direct-tcpip": func(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
|
||||
// Wrapper is designed to find and track JetBrains Gateway connections.
|
||||
wrapped := NewJetbrainsChannelWatcher(ctx, s.logger, s.config.ReportConnection, newChan, &s.connCountJetBrains)
|
||||
wrapped := NewJetbrainsChannelWatcher(ctx, s.logger, newChan, &s.connCountJetBrains)
|
||||
ssh.DirectTCPIPHandler(srv, conn, wrapped, ctx)
|
||||
},
|
||||
"direct-streamlocal@openssh.com": directStreamLocalHandler,
|
||||
@@ -214,10 +189,8 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
slog.F("local_addr", conn.LocalAddr()),
|
||||
slog.Error(err))
|
||||
},
|
||||
Handler: s.sessionHandler,
|
||||
// HostSigners are intentionally empty, as the host key will
|
||||
// be set before we start listening.
|
||||
HostSigners: []ssh.Signer{},
|
||||
Handler: s.sessionHandler,
|
||||
HostSigners: []ssh.Signer{randomSigner},
|
||||
LocalPortForwardingCallback: func(ctx ssh.Context, destinationHost string, destinationPort uint32) bool {
|
||||
// Allow local port forwarding all!
|
||||
s.logger.Debug(ctx, "local port forward",
|
||||
@@ -225,7 +198,7 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
slog.F("destination_port", destinationPort))
|
||||
return true
|
||||
},
|
||||
PtyCallback: func(_ ssh.Context, _ ssh.Pty) bool {
|
||||
PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
|
||||
return true
|
||||
},
|
||||
ReversePortForwardingCallback: func(ctx ssh.Context, bindHost string, bindPort uint32) bool {
|
||||
@@ -242,7 +215,7 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
"cancel-streamlocal-forward@openssh.com": unixForwardHandler.HandleSSHRequest,
|
||||
},
|
||||
X11Callback: s.x11Callback,
|
||||
ServerConfigCallback: func(_ ssh.Context) *gossh.ServerConfig {
|
||||
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
|
||||
return &gossh.ServerConfig{
|
||||
NoClientAuth: true,
|
||||
}
|
||||
@@ -282,135 +255,35 @@ func (s *Server) ConnStats() ConnStats {
|
||||
}
|
||||
}
|
||||
|
||||
func extractMagicSessionType(env []string) (magicType MagicSessionType, rawType string, filteredEnv []string) {
|
||||
for _, kv := range env {
|
||||
if !strings.HasPrefix(kv, MagicSessionTypeEnvironmentVariable) {
|
||||
continue
|
||||
}
|
||||
|
||||
rawType = strings.TrimPrefix(kv, MagicSessionTypeEnvironmentVariable+"=")
|
||||
// Keep going, we'll use the last instance of the env.
|
||||
}
|
||||
|
||||
// Always force lowercase checking to be case-insensitive.
|
||||
switch MagicSessionType(strings.ToLower(rawType)) {
|
||||
case MagicSessionTypeVSCode:
|
||||
magicType = MagicSessionTypeVSCode
|
||||
case MagicSessionTypeJetBrains:
|
||||
magicType = MagicSessionTypeJetBrains
|
||||
case "", MagicSessionTypeSSH:
|
||||
magicType = MagicSessionTypeSSH
|
||||
default:
|
||||
magicType = MagicSessionTypeUnknown
|
||||
}
|
||||
|
||||
return magicType, rawType, slices.DeleteFunc(env, func(kv string) bool {
|
||||
return strings.HasPrefix(kv, MagicSessionTypeEnvironmentVariable+"=")
|
||||
})
|
||||
}
|
||||
|
||||
// sessionCloseTracker is a wrapper around Session that tracks the exit code.
|
||||
type sessionCloseTracker struct {
|
||||
ssh.Session
|
||||
exitOnce sync.Once
|
||||
code atomic.Int64
|
||||
}
|
||||
|
||||
var _ ssh.Session = &sessionCloseTracker{}
|
||||
|
||||
func (s *sessionCloseTracker) track(code int) {
|
||||
s.exitOnce.Do(func() {
|
||||
s.code.Store(int64(code))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sessionCloseTracker) exitCode() int {
|
||||
return int(s.code.Load())
|
||||
}
|
||||
|
||||
func (s *sessionCloseTracker) Exit(code int) error {
|
||||
s.track(code)
|
||||
return s.Session.Exit(code)
|
||||
}
|
||||
|
||||
func (s *sessionCloseTracker) Close() error {
|
||||
s.track(1)
|
||||
return s.Session.Close()
|
||||
}
|
||||
|
||||
func extractContainerInfo(env []string) (container, containerUser string, filteredEnv []string) {
|
||||
for _, kv := range env {
|
||||
if strings.HasPrefix(kv, ContainerEnvironmentVariable+"=") {
|
||||
container = strings.TrimPrefix(kv, ContainerEnvironmentVariable+"=")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(kv, ContainerUserEnvironmentVariable+"=") {
|
||||
containerUser = strings.TrimPrefix(kv, ContainerUserEnvironmentVariable+"=")
|
||||
}
|
||||
}
|
||||
|
||||
return container, containerUser, slices.DeleteFunc(env, func(kv string) bool {
|
||||
return strings.HasPrefix(kv, ContainerEnvironmentVariable+"=") || strings.HasPrefix(kv, ContainerUserEnvironmentVariable+"=")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) sessionHandler(session ssh.Session) {
|
||||
ctx := session.Context()
|
||||
id := uuid.New()
|
||||
logger := s.logger.With(
|
||||
slog.F("remote_addr", session.RemoteAddr()),
|
||||
slog.F("local_addr", session.LocalAddr()),
|
||||
// Assigning a random uuid for each session is useful for tracking
|
||||
// logs for the same ssh session.
|
||||
slog.F("id", id.String()),
|
||||
slog.F("id", uuid.NewString()),
|
||||
)
|
||||
logger.Info(ctx, "handling ssh session")
|
||||
|
||||
env := session.Environ()
|
||||
magicType, magicTypeRaw, env := extractMagicSessionType(env)
|
||||
|
||||
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, session.RemoteAddr().String())
|
||||
defer disconnected(1, reason)
|
||||
|
||||
logger.Info(ctx, reason)
|
||||
// See (*Server).Close() for why we call Close instead of Exit.
|
||||
_ = session.Close()
|
||||
logger.Info(ctx, "unable to accept new session, server is closing")
|
||||
return
|
||||
}
|
||||
defer s.trackSession(session, false)
|
||||
|
||||
reportSession := true
|
||||
|
||||
switch magicType {
|
||||
case MagicSessionTypeVSCode:
|
||||
s.connCountVSCode.Add(1)
|
||||
defer s.connCountVSCode.Add(-1)
|
||||
case MagicSessionTypeJetBrains:
|
||||
// Do nothing here because JetBrains launches hundreds of ssh sessions.
|
||||
// We instead track JetBrains in the single persistent tcp forwarding channel.
|
||||
reportSession = false
|
||||
case MagicSessionTypeSSH:
|
||||
s.connCountSSHSession.Add(1)
|
||||
defer s.connCountSSHSession.Add(-1)
|
||||
case MagicSessionTypeUnknown:
|
||||
logger.Warn(ctx, "invalid magic ssh session type specified", slog.F("raw_type", magicTypeRaw))
|
||||
}
|
||||
|
||||
closeCause := func(string) {}
|
||||
if reportSession {
|
||||
var reason string
|
||||
closeCause = func(r string) { reason = r }
|
||||
|
||||
scr := &sessionCloseTracker{Session: session}
|
||||
session = scr
|
||||
|
||||
disconnected := s.config.ReportConnection(id, magicType, session.RemoteAddr().String())
|
||||
defer func() {
|
||||
disconnected(scr.exitCode(), reason)
|
||||
}()
|
||||
extraEnv := make([]string, 0)
|
||||
x11, hasX11 := session.X11()
|
||||
if hasX11 {
|
||||
display, handled := s.x11Handler(session.Context(), x11)
|
||||
if !handled {
|
||||
_ = session.Exit(1)
|
||||
logger.Error(ctx, "x11 handler failed")
|
||||
return
|
||||
}
|
||||
extraEnv = append(extraEnv, fmt.Sprintf("DISPLAY=localhost:%d.%d", display, x11.ScreenNumber))
|
||||
}
|
||||
|
||||
if s.fileTransferBlocked(session) {
|
||||
@@ -421,52 +294,22 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
errorMessage := fmt.Sprintf("\x02%s\n", BlockedFileTransferErrorMessage)
|
||||
_, _ = session.Write([]byte(errorMessage))
|
||||
}
|
||||
closeCause("file transfer blocked")
|
||||
_ = session.Exit(BlockedFileTransferErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
container, containerUser, env := extractContainerInfo(env)
|
||||
if container != "" {
|
||||
s.logger.Debug(ctx, "container info",
|
||||
slog.F("container", container),
|
||||
slog.F("container_user", containerUser),
|
||||
)
|
||||
}
|
||||
|
||||
switch ss := session.Subsystem(); ss {
|
||||
case "":
|
||||
case "sftp":
|
||||
if s.config.ExperimentalDevContainersEnabled && container != "" {
|
||||
closeCause("sftp not yet supported with containers")
|
||||
_ = session.Exit(1)
|
||||
return
|
||||
}
|
||||
err := s.sftpHandler(logger, session)
|
||||
if err != nil {
|
||||
closeCause(err.Error())
|
||||
}
|
||||
s.sftpHandler(logger, session)
|
||||
return
|
||||
default:
|
||||
logger.Warn(ctx, "unsupported subsystem", slog.F("subsystem", ss))
|
||||
closeCause(fmt.Sprintf("unsupported subsystem: %s", ss))
|
||||
_ = session.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
x11, hasX11 := session.X11()
|
||||
if hasX11 {
|
||||
display, handled := s.x11Handler(session.Context(), x11)
|
||||
if !handled {
|
||||
logger.Error(ctx, "x11 handler failed")
|
||||
closeCause("x11 handler failed")
|
||||
_ = session.Exit(1)
|
||||
return
|
||||
}
|
||||
env = append(env, fmt.Sprintf("DISPLAY=localhost:%d.%d", display, x11.ScreenNumber))
|
||||
}
|
||||
|
||||
err := s.sessionStart(logger, session, env, magicType, container, containerUser)
|
||||
err := s.sessionStart(logger, session, extraEnv)
|
||||
var exitError *exec.ExitError
|
||||
if xerrors.As(err, &exitError) {
|
||||
code := exitError.ExitCode()
|
||||
@@ -487,8 +330,6 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
slog.F("exit_code", code),
|
||||
)
|
||||
|
||||
closeCause(fmt.Sprintf("process exited with error status: %d", exitError.ExitCode()))
|
||||
|
||||
// TODO(mafredri): For signal exit, there's also an "exit-signal"
|
||||
// request (session.Exit sends "exit-status"), however, since it's
|
||||
// not implemented on the session interface and not used by
|
||||
@@ -500,7 +341,6 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
logger.Warn(ctx, "ssh session failed", slog.Error(err))
|
||||
// This exit code is designed to be unlikely to be confused for a legit exit code
|
||||
// from the process.
|
||||
closeCause(err.Error())
|
||||
_ = session.Exit(MagicSessionErrorCode)
|
||||
return
|
||||
}
|
||||
@@ -539,27 +379,42 @@ func (s *Server) fileTransferBlocked(session ssh.Session) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []string, magicType MagicSessionType, container, containerUser string) (retErr error) {
|
||||
func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, extraEnv []string) (retErr error) {
|
||||
ctx := session.Context()
|
||||
env := append(session.Environ(), extraEnv...)
|
||||
var magicType string
|
||||
for index, kv := range env {
|
||||
if !strings.HasPrefix(kv, MagicSessionTypeEnvironmentVariable) {
|
||||
continue
|
||||
}
|
||||
magicType = strings.ToLower(strings.TrimPrefix(kv, MagicSessionTypeEnvironmentVariable+"="))
|
||||
env = append(env[:index], env[index+1:]...)
|
||||
}
|
||||
|
||||
// Always force lowercase checking to be case-insensitive.
|
||||
switch magicType {
|
||||
case MagicSessionTypeVSCode:
|
||||
s.connCountVSCode.Add(1)
|
||||
defer s.connCountVSCode.Add(-1)
|
||||
case MagicSessionTypeJetBrains:
|
||||
// Do nothing here because JetBrains launches hundreds of ssh sessions.
|
||||
// We instead track JetBrains in the single persistent tcp forwarding channel.
|
||||
case "":
|
||||
s.connCountSSHSession.Add(1)
|
||||
defer s.connCountSSHSession.Add(-1)
|
||||
default:
|
||||
logger.Warn(ctx, "invalid magic ssh session type specified", slog.F("type", magicType))
|
||||
}
|
||||
|
||||
magicTypeLabel := magicTypeMetricLabel(magicType)
|
||||
sshPty, windowSize, isPty := session.Pty()
|
||||
ptyLabel := "no"
|
||||
if isPty {
|
||||
ptyLabel = "yes"
|
||||
}
|
||||
|
||||
var ei usershell.EnvInfoer
|
||||
var err error
|
||||
if s.config.ExperimentalDevContainersEnabled && container != "" {
|
||||
ei, err = agentcontainers.EnvInfo(ctx, s.Execer, container, containerUser)
|
||||
if err != nil {
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "container_env_info").Add(1)
|
||||
return err
|
||||
}
|
||||
}
|
||||
cmd, err := s.CreateCommand(ctx, session.RawCommand(), env, ei)
|
||||
cmd, err := s.CreateCommand(ctx, session.RawCommand(), env)
|
||||
if err != nil {
|
||||
ptyLabel := "no"
|
||||
if isPty {
|
||||
ptyLabel = "yes"
|
||||
}
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "create_command").Add(1)
|
||||
return err
|
||||
}
|
||||
@@ -567,6 +422,11 @@ func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []str
|
||||
if ssh.AgentRequested(session) {
|
||||
l, err := ssh.NewAgentListener()
|
||||
if err != nil {
|
||||
ptyLabel := "no"
|
||||
if isPty {
|
||||
ptyLabel = "yes"
|
||||
}
|
||||
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, ptyLabel, "listener").Add(1)
|
||||
return xerrors.Errorf("new agent listener: %w", err)
|
||||
}
|
||||
@@ -584,15 +444,6 @@ func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, env []str
|
||||
func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, magicTypeLabel string, cmd *exec.Cmd) error {
|
||||
s.metrics.sessionsTotal.WithLabelValues(magicTypeLabel, "no").Add(1)
|
||||
|
||||
// Create a process group and send SIGHUP to child processes,
|
||||
// otherwise context cancellation will not propagate properly
|
||||
// and SSH server close may be delayed.
|
||||
cmd.SysProcAttr = cmdSysProcAttr()
|
||||
|
||||
// to match OpenSSH, we don't actually tear a non-TTY command down, even if the session ends.
|
||||
// c.f. https://github.com/coder/coder/issues/18519#issuecomment-3019118271
|
||||
cmd.Cancel = nil
|
||||
|
||||
cmd.Stdout = session
|
||||
cmd.Stderr = session.Stderr()
|
||||
// This blocks forever until stdin is received if we don't
|
||||
@@ -614,16 +465,6 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "start_command").Add(1)
|
||||
return xerrors.Errorf("start: %w", err)
|
||||
}
|
||||
|
||||
// Since we don't cancel the process when the session stops, we still need to tear it down if we are closing. So
|
||||
// track it here.
|
||||
if !s.trackProcess(cmd.Process, true) {
|
||||
// must be closing
|
||||
err = cmdCancel(logger, cmd.Process)
|
||||
return xerrors.Errorf("failed to track process: %w", err)
|
||||
}
|
||||
defer s.trackProcess(cmd.Process, false)
|
||||
|
||||
sigs := make(chan ssh.Signal, 1)
|
||||
session.Signals(sigs)
|
||||
defer func() {
|
||||
@@ -632,7 +473,7 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
|
||||
}()
|
||||
go func() {
|
||||
for sig := range sigs {
|
||||
handleSignal(logger, sig, cmd.Process, s.metrics, magicTypeLabel)
|
||||
s.handleSignal(logger, sig, cmd.Process, magicTypeLabel)
|
||||
}
|
||||
}()
|
||||
return cmd.Wait()
|
||||
@@ -717,13 +558,12 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy
|
||||
sigs = nil
|
||||
continue
|
||||
}
|
||||
handleSignal(logger, sig, process, s.metrics, magicTypeLabel)
|
||||
s.handleSignal(logger, sig, process, magicTypeLabel)
|
||||
case win, ok := <-windowSize:
|
||||
if !ok {
|
||||
windowSize = nil
|
||||
continue
|
||||
}
|
||||
// #nosec G115 - Safe conversions for terminal dimensions which are expected to be within uint16 range
|
||||
resizeErr := ptty.Resize(uint16(win.Height), uint16(win.Width))
|
||||
// If the pty is closed, then command has exited, no need to log.
|
||||
if resizeErr != nil && !errors.Is(resizeErr, pty.ErrClosed) {
|
||||
@@ -772,7 +612,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleSignal(logger slog.Logger, ssig ssh.Signal, signaler interface{ Signal(os.Signal) error }, metrics *sshServerMetrics, magicTypeLabel string) {
|
||||
func (s *Server) handleSignal(logger slog.Logger, ssig ssh.Signal, signaler interface{ Signal(os.Signal) error }, magicTypeLabel string) {
|
||||
ctx := context.Background()
|
||||
sig := osSignalFrom(ssig)
|
||||
logger = logger.With(slog.F("ssh_signal", ssig), slog.F("signal", sig.String()))
|
||||
@@ -780,11 +620,11 @@ func handleSignal(logger slog.Logger, ssig ssh.Signal, signaler interface{ Signa
|
||||
err := signaler.Signal(sig)
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "signaling the process failed", slog.Error(err))
|
||||
metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "signal").Add(1)
|
||||
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "signal").Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sftpHandler(logger slog.Logger, session ssh.Session) error {
|
||||
func (s *Server) sftpHandler(logger slog.Logger, session ssh.Session) {
|
||||
s.metrics.sftpConnectionsTotal.Add(1)
|
||||
|
||||
ctx := session.Context()
|
||||
@@ -808,7 +648,7 @@ func (s *Server) sftpHandler(logger slog.Logger, session ssh.Session) error {
|
||||
server, err := sftp.NewServer(session, opts...)
|
||||
if err != nil {
|
||||
logger.Debug(ctx, "initialize sftp server", slog.Error(err))
|
||||
return xerrors.Errorf("initialize sftp server: %w", err)
|
||||
return
|
||||
}
|
||||
defer server.Close()
|
||||
|
||||
@@ -823,32 +663,24 @@ func (s *Server) sftpHandler(logger slog.Logger, session ssh.Session) error {
|
||||
// code but `scp` on macOS does (when using the default
|
||||
// SFTP backend).
|
||||
_ = session.Exit(0)
|
||||
return nil
|
||||
return
|
||||
}
|
||||
logger.Warn(ctx, "sftp server closed with error", slog.Error(err))
|
||||
s.metrics.sftpServerErrors.Add(1)
|
||||
_ = session.Exit(1)
|
||||
return xerrors.Errorf("sftp server closed with error: %w", err)
|
||||
}
|
||||
|
||||
// CreateCommand processes raw command input with OpenSSH-like behavior.
|
||||
// If the script provided is empty, it will default to the users shell.
|
||||
// This injects environment variables specified by the user at launch too.
|
||||
// The final argument is an interface that allows the caller to provide
|
||||
// alternative implementations for the dependencies of CreateCommand.
|
||||
// This is useful when creating a command to be run in a separate environment
|
||||
// (for example, a Docker container). Pass in nil to use the default.
|
||||
func (s *Server) CreateCommand(ctx context.Context, script string, env []string, ei usershell.EnvInfoer) (*pty.Cmd, error) {
|
||||
if ei == nil {
|
||||
ei = &usershell.SystemEnvInfo{}
|
||||
}
|
||||
currentUser, err := ei.User()
|
||||
func (s *Server) CreateCommand(ctx context.Context, script string, env []string) (*pty.Cmd, error) {
|
||||
currentUser, err := user.Current()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get current user: %w", err)
|
||||
}
|
||||
username := currentUser.Username
|
||||
|
||||
shell, err := ei.Shell(username)
|
||||
shell, err := usershell.Get(username)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get user shell: %w", err)
|
||||
}
|
||||
@@ -896,18 +728,7 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string,
|
||||
}
|
||||
}
|
||||
|
||||
// Modify command prior to execution. This will usually be a no-op, but not
|
||||
// always. For example, to run a command in a Docker container, we need to
|
||||
// modify the command to be `docker exec -it <container> <command>`.
|
||||
modifiedName, modifiedArgs := ei.ModifyCommand(name, args...)
|
||||
// Log if the command was modified.
|
||||
if modifiedName != name && slices.Compare(modifiedArgs, args) != 0 {
|
||||
s.logger.Debug(ctx, "modified command",
|
||||
slog.F("before", append([]string{name}, args...)),
|
||||
slog.F("after", append([]string{modifiedName}, modifiedArgs...)),
|
||||
)
|
||||
}
|
||||
cmd := s.Execer.PTYCommandContext(ctx, modifiedName, modifiedArgs...)
|
||||
cmd := s.Execer.PTYCommandContext(ctx, name, args...)
|
||||
cmd.Dir = s.config.WorkingDirectory()
|
||||
|
||||
// If the metadata directory doesn't exist, we run the command
|
||||
@@ -915,17 +736,14 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string,
|
||||
_, err = os.Stat(cmd.Dir)
|
||||
if cmd.Dir == "" || err != nil {
|
||||
// Default to user home if a directory is not set.
|
||||
homedir, err := ei.HomeDir()
|
||||
homedir, err := userHomeDir()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get home dir: %w", err)
|
||||
}
|
||||
cmd.Dir = homedir
|
||||
}
|
||||
cmd.Env = append(ei.Environ(), env...)
|
||||
// Set login variables (see `man login`).
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username))
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("LOGNAME=%s", username))
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("SHELL=%s", shell))
|
||||
|
||||
// Set SSH connection environment variables (these are also set by OpenSSH
|
||||
// and thus expected to be present by SSH clients). Since the agent does
|
||||
@@ -944,18 +762,7 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string,
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// Serve starts the server to handle incoming connections on the provided listener.
|
||||
// It returns an error if no host keys are set or if there is an issue accepting connections.
|
||||
func (s *Server) Serve(l net.Listener) (retErr error) {
|
||||
// Ensure we're not mutating HostSigners as we're reading it.
|
||||
s.mu.RLock()
|
||||
noHostKeys := len(s.srv.HostSigners) == 0
|
||||
s.mu.RUnlock()
|
||||
|
||||
if noHostKeys {
|
||||
return xerrors.New("no host keys set")
|
||||
}
|
||||
|
||||
s.logger.Info(context.Background(), "started serving listener", slog.F("listen_addr", l.Addr()))
|
||||
defer func() {
|
||||
s.logger.Info(context.Background(), "stopped serving listener",
|
||||
@@ -1067,27 +874,6 @@ func (s *Server) trackSession(ss ssh.Session, add bool) (ok bool) {
|
||||
return true
|
||||
}
|
||||
|
||||
// trackCommand registers the process with the server. If the server is
|
||||
// closing, the process is not registered and should be closed.
|
||||
//
|
||||
//nolint:revive
|
||||
func (s *Server) trackProcess(p *os.Process, add bool) (ok bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if add {
|
||||
if s.closing != nil {
|
||||
// Server closed.
|
||||
return false
|
||||
}
|
||||
s.wg.Add(1)
|
||||
s.processes[p] = struct{}{}
|
||||
return true
|
||||
}
|
||||
s.wg.Done()
|
||||
delete(s.processes, p)
|
||||
return true
|
||||
}
|
||||
|
||||
// Close the server and all active connections. Server can be re-used
|
||||
// after Close is done.
|
||||
func (s *Server) Close() error {
|
||||
@@ -1096,47 +882,32 @@ func (s *Server) Close() error {
|
||||
// Guard against multiple calls to Close and
|
||||
// accepting new connections during close.
|
||||
if s.closing != nil {
|
||||
closing := s.closing
|
||||
s.mu.Unlock()
|
||||
<-closing
|
||||
return xerrors.New("server is closed")
|
||||
return xerrors.New("server is closing")
|
||||
}
|
||||
s.closing = make(chan struct{})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
s.logger.Debug(ctx, "closing server")
|
||||
|
||||
// Stop accepting new connections.
|
||||
s.logger.Debug(ctx, "closing all active listeners", slog.F("count", len(s.listeners)))
|
||||
for l := range s.listeners {
|
||||
_ = l.Close()
|
||||
}
|
||||
|
||||
// Close all active sessions to gracefully
|
||||
// terminate client connections.
|
||||
s.logger.Debug(ctx, "closing all active sessions", slog.F("count", len(s.sessions)))
|
||||
for ss := range s.sessions {
|
||||
// We call Close on the underlying channel here because we don't
|
||||
// want to send an exit status to the client (via Exit()).
|
||||
// Typically OpenSSH clients will return 255 as the exit status.
|
||||
_ = ss.Close()
|
||||
}
|
||||
s.logger.Debug(ctx, "closing all active connections", slog.F("count", len(s.conns)))
|
||||
|
||||
// Close all active listeners and connections.
|
||||
for l := range s.listeners {
|
||||
_ = l.Close()
|
||||
}
|
||||
for c := range s.conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
|
||||
for p := range s.processes {
|
||||
_ = cmdCancel(s.logger, p)
|
||||
}
|
||||
|
||||
s.logger.Debug(ctx, "closing SSH server")
|
||||
// Close the underlying SSH server.
|
||||
err := s.srv.Close()
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
s.logger.Debug(ctx, "waiting for all goroutines to exit")
|
||||
s.wg.Wait() // Wait for all goroutines to exit.
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -1144,35 +915,15 @@ func (s *Server) Close() error {
|
||||
s.closing = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
s.logger.Debug(ctx, "closing server done")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Shutdown stops accepting new connections. The current implementation
|
||||
// calls Close() for simplicity instead of waiting for existing
|
||||
// connections to close. If the context times out, Shutdown will return
|
||||
// but Close() may not have completed.
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
ch := make(chan error, 1)
|
||||
go func() {
|
||||
// TODO(mafredri): Implement shutdown, SIGHUP running commands, etc.
|
||||
// For now we just close the server.
|
||||
ch <- s.Close()
|
||||
}()
|
||||
var err error
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err = ctx.Err()
|
||||
case err = <-ch:
|
||||
}
|
||||
// Re-check for context cancellation precedence.
|
||||
if ctx.Err() != nil {
|
||||
err = ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return xerrors.Errorf("close server: %w", err)
|
||||
}
|
||||
// Shutdown gracefully closes all active SSH connections and stops
|
||||
// accepting new connections.
|
||||
//
|
||||
// Shutdown is not implemented.
|
||||
func (*Server) Shutdown(_ context.Context) error {
|
||||
// TODO(mafredri): Implement shutdown, SIGHUP running commands, etc.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1266,31 +1017,3 @@ func userHomeDir() (string, error) {
|
||||
}
|
||||
return u.HomeDir, nil
|
||||
}
|
||||
|
||||
// UpdateHostSigner updates the host signer with a new key generated from the provided seed.
|
||||
// If an existing host key exists with the same algorithm, it is overwritten
|
||||
func (s *Server) UpdateHostSigner(seed int64) error {
|
||||
key, err := CoderSigner(seed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.srv.AddHostKey(key)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CoderSigner generates a deterministic SSH signer based on the provided seed.
|
||||
// It uses RSA with a key size of 2048 bits.
|
||||
func CoderSigner(seed int64) (gossh.Signer, error) {
|
||||
// Clients should ignore the host key when connecting.
|
||||
// The agent needs to authenticate with coderd to SSH,
|
||||
// so SSH authentication doesn't improve security.
|
||||
coderHostKey := agentrsa.GenerateDeterministicKey(seed)
|
||||
|
||||
coderSigner, err := gossh.NewSignerFromKey(coderHostKey)
|
||||
return coderSigner, err
|
||||
}
|
||||
|
||||
@@ -39,8 +39,6 @@ func Test_sessionStart_orphan(t *testing.T) {
|
||||
s, err := NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
err = s.UpdateHostSigner(42)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Here we're going to call the handler directly with a faked SSH session
|
||||
// that just uses io.Pipes instead of a network socket. There is a large
|
||||
|
||||
+41
-143
@@ -8,12 +8,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/spf13/afero"
|
||||
@@ -22,7 +20,6 @@ import (
|
||||
"go.uber.org/goleak"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
@@ -43,8 +40,6 @@ func TestNewServer_ServeClient(t *testing.T) {
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
err = s.UpdateHostSigner(42)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
@@ -92,7 +87,7 @@ func TestNewServer_ExecuteShebang(t *testing.T) {
|
||||
t.Run("Basic", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd, err := s.CreateCommand(ctx, `#!/bin/bash
|
||||
echo test`, nil, nil)
|
||||
echo test`, nil)
|
||||
require.NoError(t, err)
|
||||
output, err := cmd.AsExec().CombinedOutput()
|
||||
require.NoError(t, err)
|
||||
@@ -101,153 +96,60 @@ func TestNewServer_ExecuteShebang(t *testing.T) {
|
||||
t.Run("Args", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
cmd, err := s.CreateCommand(ctx, `#!/usr/bin/env bash
|
||||
echo test`, nil, nil)
|
||||
echo test`, nil)
|
||||
require.NoError(t, err)
|
||||
output, err := cmd.AsExec().CombinedOutput()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test\n", string(output))
|
||||
})
|
||||
t.Run("CustomEnvInfoer", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ei := &fakeEnvInfoer{
|
||||
CurrentUserFn: func() (u *user.User, err error) {
|
||||
return nil, assert.AnError
|
||||
},
|
||||
}
|
||||
_, err := s.CreateCommand(ctx, `whatever`, nil, ei)
|
||||
require.ErrorIs(t, err, assert.AnError)
|
||||
})
|
||||
}
|
||||
|
||||
type fakeEnvInfoer struct {
|
||||
CurrentUserFn func() (*user.User, error)
|
||||
EnvironFn func() []string
|
||||
UserHomeDirFn func() (string, error)
|
||||
UserShellFn func(string) (string, error)
|
||||
}
|
||||
|
||||
func (f *fakeEnvInfoer) User() (u *user.User, err error) {
|
||||
return f.CurrentUserFn()
|
||||
}
|
||||
|
||||
func (f *fakeEnvInfoer) Environ() []string {
|
||||
return f.EnvironFn()
|
||||
}
|
||||
|
||||
func (f *fakeEnvInfoer) HomeDir() (string, error) {
|
||||
return f.UserHomeDirFn()
|
||||
}
|
||||
|
||||
func (f *fakeEnvInfoer) Shell(u string) (string, error) {
|
||||
return f.UserShellFn(u)
|
||||
}
|
||||
|
||||
func (*fakeEnvInfoer) ModifyCommand(cmd string, args ...string) (string, []string) {
|
||||
return cmd, args
|
||||
}
|
||||
|
||||
func TestNewServer_CloseActiveConnections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prepare := func(ctx context.Context, t *testing.T) (*agentssh.Server, func()) {
|
||||
t.Helper()
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = s.Close()
|
||||
})
|
||||
err = s.UpdateHostSigner(42)
|
||||
ctx := context.Background()
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := s.Serve(ln)
|
||||
assert.Error(t, err) // Server is closed.
|
||||
}()
|
||||
|
||||
pty := ptytest.New(t)
|
||||
|
||||
doClose := make(chan struct{})
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c := sshClient(t, ln.Addr().String())
|
||||
sess, err := c.NewSession()
|
||||
assert.NoError(t, err)
|
||||
sess.Stdin = pty.Input()
|
||||
sess.Stdout = pty.Output()
|
||||
sess.Stderr = pty.Output()
|
||||
|
||||
assert.NoError(t, err)
|
||||
err = sess.Start("")
|
||||
assert.NoError(t, err)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
close(doClose)
|
||||
err = sess.Wait()
|
||||
assert.Error(t, err)
|
||||
}()
|
||||
|
||||
waitConns := make([]chan struct{}, 4)
|
||||
<-doClose
|
||||
err = s.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1 + len(waitConns))
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := s.Serve(ln)
|
||||
assert.Error(t, err) // Server is closed.
|
||||
}()
|
||||
|
||||
for i := 0; i < len(waitConns); i++ {
|
||||
waitConns[i] = make(chan struct{})
|
||||
go func(ch chan struct{}) {
|
||||
defer wg.Done()
|
||||
c := sshClient(t, ln.Addr().String())
|
||||
sess, err := c.NewSession()
|
||||
assert.NoError(t, err)
|
||||
pty := ptytest.New(t)
|
||||
sess.Stdin = pty.Input()
|
||||
sess.Stdout = pty.Output()
|
||||
sess.Stderr = pty.Output()
|
||||
|
||||
// Every other session will request a PTY.
|
||||
if i%2 == 0 {
|
||||
err = sess.RequestPty("xterm", 80, 80, nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
// The 60 seconds here is intended to be longer than the
|
||||
// test. The shutdown should propagate.
|
||||
if runtime.GOOS == "windows" {
|
||||
// Best effort to at least partially test this in Windows.
|
||||
err = sess.Start("echo start\"ed\" && sleep 60")
|
||||
} else {
|
||||
err = sess.Start("/bin/bash -c 'trap \"sleep 60\" SIGTERM; echo start\"ed\"; sleep 60'")
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Allow the session to settle (i.e. reach echo).
|
||||
pty.ExpectMatchContext(ctx, "started")
|
||||
// Sleep a bit to ensure the sleep has started.
|
||||
time.Sleep(testutil.IntervalMedium)
|
||||
|
||||
close(ch)
|
||||
|
||||
err = sess.Wait()
|
||||
assert.Error(t, err)
|
||||
}(waitConns[i])
|
||||
}
|
||||
|
||||
for _, ch := range waitConns {
|
||||
<-ch
|
||||
}
|
||||
|
||||
return s, wg.Wait
|
||||
}
|
||||
|
||||
t.Run("Close", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
s, wait := prepare(ctx, t)
|
||||
err := s.Close()
|
||||
require.NoError(t, err)
|
||||
wait()
|
||||
})
|
||||
|
||||
t.Run("Shutdown", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
s, wait := prepare(ctx, t)
|
||||
err := s.Shutdown(ctx)
|
||||
require.NoError(t, err)
|
||||
wait()
|
||||
})
|
||||
|
||||
t.Run("Shutdown Early", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
s, wait := prepare(ctx, t)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
cancel()
|
||||
err := s.Shutdown(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
wait()
|
||||
})
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestNewServer_Signal(t *testing.T) {
|
||||
@@ -261,8 +163,6 @@ func TestNewServer_Signal(t *testing.T) {
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
err = s.UpdateHostSigner(42)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
@@ -328,8 +228,6 @@ func TestNewServer_Signal(t *testing.T) {
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), afero.NewMemMapFs(), agentexec.DefaultExecer, nil)
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
err = s.UpdateHostSigner(42)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package agentssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
func cmdSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func cmdCancel(logger slog.Logger, p *os.Process) error {
|
||||
logger.Debug(context.Background(), "cmdCancel: sending SIGHUP to process and children", slog.F("pid", p.Pid))
|
||||
return syscall.Kill(-p.Pid, syscall.SIGHUP)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package agentssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
func cmdSysProcAttr() *syscall.SysProcAttr {
|
||||
return &syscall.SysProcAttr{}
|
||||
}
|
||||
|
||||
func cmdCancel(logger slog.Logger, p *os.Process) error {
|
||||
logger.Debug(context.Background(), "cmdCancel: killing process", slog.F("pid", p.Pid))
|
||||
// Windows doesn't support sending signals to process groups, so we
|
||||
// have to kill the process directly. In the future, we may want to
|
||||
// implement a more sophisticated solution for process groups on
|
||||
// Windows, but for now, this is a simple way to ensure that the
|
||||
// process is terminated when the context is cancelled.
|
||||
return p.Kill()
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/atomic"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
@@ -29,11 +28,9 @@ type JetbrainsChannelWatcher struct {
|
||||
gossh.NewChannel
|
||||
jetbrainsCounter *atomic.Int64
|
||||
logger slog.Logger
|
||||
originAddr string
|
||||
reportConnection reportConnectionFunc
|
||||
}
|
||||
|
||||
func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger, reportConnection reportConnectionFunc, newChannel gossh.NewChannel, counter *atomic.Int64) gossh.NewChannel {
|
||||
func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger, newChannel gossh.NewChannel, counter *atomic.Int64) gossh.NewChannel {
|
||||
d := localForwardChannelData{}
|
||||
if err := gossh.Unmarshal(newChannel.ExtraData(), &d); err != nil {
|
||||
// If the data fails to unmarshal, do nothing.
|
||||
@@ -64,17 +61,12 @@ func NewJetbrainsChannelWatcher(ctx ssh.Context, logger slog.Logger, reportConne
|
||||
NewChannel: newChannel,
|
||||
jetbrainsCounter: counter,
|
||||
logger: logger.With(slog.F("destination_port", d.DestPort)),
|
||||
originAddr: d.OriginAddr,
|
||||
reportConnection: reportConnection,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *JetbrainsChannelWatcher) Accept() (gossh.Channel, <-chan *gossh.Request, error) {
|
||||
disconnected := w.reportConnection(uuid.New(), MagicSessionTypeJetBrains, w.originAddr)
|
||||
|
||||
c, r, err := w.NewChannel.Accept()
|
||||
if err != nil {
|
||||
disconnected(1, err.Error())
|
||||
return c, r, err
|
||||
}
|
||||
w.jetbrainsCounter.Add(1)
|
||||
@@ -85,7 +77,6 @@ func (w *JetbrainsChannelWatcher) Accept() (gossh.Channel, <-chan *gossh.Request
|
||||
Channel: c,
|
||||
done: func() {
|
||||
w.jetbrainsCounter.Add(-1)
|
||||
disconnected(0, "")
|
||||
// nolint: gocritic // JetBrains is a proper noun and should be capitalized
|
||||
w.logger.Debug(context.Background(), "JetBrains watcher channel closed")
|
||||
},
|
||||
|
||||
@@ -71,15 +71,15 @@ func newSSHServerMetrics(registerer prometheus.Registerer) *sshServerMetrics {
|
||||
}
|
||||
}
|
||||
|
||||
func magicTypeMetricLabel(magicType MagicSessionType) string {
|
||||
func magicTypeMetricLabel(magicType string) string {
|
||||
switch magicType {
|
||||
case MagicSessionTypeVSCode:
|
||||
case MagicSessionTypeJetBrains:
|
||||
case MagicSessionTypeSSH:
|
||||
case MagicSessionTypeUnknown:
|
||||
case "":
|
||||
magicType = "ssh"
|
||||
default:
|
||||
magicType = MagicSessionTypeUnknown
|
||||
magicType = "unknown"
|
||||
}
|
||||
// Always be case insensitive
|
||||
return strings.ToLower(string(magicType))
|
||||
return strings.ToLower(magicType)
|
||||
}
|
||||
|
||||
@@ -116,8 +116,7 @@ func (s *Server) x11Handler(ctx ssh.Context, x11 ssh.X11) (displayNumber int, ha
|
||||
OriginatorPort uint32
|
||||
}{
|
||||
OriginatorAddress: tcpAddr.IP.String(),
|
||||
// #nosec G115 - Safe conversion as TCP port numbers are within uint32 range (0-65535)
|
||||
OriginatorPort: uint32(tcpAddr.Port),
|
||||
OriginatorPort: uint32(tcpAddr.Port),
|
||||
}))
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to open X11 channel", slog.Error(err))
|
||||
@@ -295,7 +294,6 @@ func addXauthEntry(ctx context.Context, fs afero.Fs, host string, display string
|
||||
return xerrors.Errorf("failed to write family: %w", err)
|
||||
}
|
||||
|
||||
// #nosec G115 - Safe conversion for host name length which is expected to be within uint16 range
|
||||
err = binary.Write(file, binary.BigEndian, uint16(len(host)))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to write host length: %w", err)
|
||||
@@ -305,7 +303,6 @@ func addXauthEntry(ctx context.Context, fs afero.Fs, host string, display string
|
||||
return xerrors.Errorf("failed to write host: %w", err)
|
||||
}
|
||||
|
||||
// #nosec G115 - Safe conversion for display name length which is expected to be within uint16 range
|
||||
err = binary.Write(file, binary.BigEndian, uint16(len(display)))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to write display length: %w", err)
|
||||
@@ -315,7 +312,6 @@ func addXauthEntry(ctx context.Context, fs afero.Fs, host string, display string
|
||||
return xerrors.Errorf("failed to write display: %w", err)
|
||||
}
|
||||
|
||||
// #nosec G115 - Safe conversion for auth protocol length which is expected to be within uint16 range
|
||||
err = binary.Write(file, binary.BigEndian, uint16(len(authProtocol)))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to write auth protocol length: %w", err)
|
||||
@@ -325,7 +321,6 @@ func addXauthEntry(ctx context.Context, fs afero.Fs, host string, display string
|
||||
return xerrors.Errorf("failed to write auth protocol: %w", err)
|
||||
}
|
||||
|
||||
// #nosec G115 - Safe conversion for auth cookie length which is expected to be within uint16 range
|
||||
err = binary.Write(file, binary.BigEndian, uint16(len(authCookieBytes)))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to write auth cookie length: %w", err)
|
||||
|
||||
@@ -38,8 +38,6 @@ func TestServer_X11(t *testing.T) {
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, agentexec.DefaultExecer, &agentssh.Config{})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
err = s.UpdateHostSigner(42)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
+13
-62
@@ -3,7 +3,6 @@ package agenttest
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -13,9 +12,9 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
"storj.io/drpc/drpcmux"
|
||||
"storj.io/drpc/drpcserver"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -97,8 +96,8 @@ func (c *Client) Close() {
|
||||
c.derpMapOnce.Do(func() { close(c.derpMapUpdates) })
|
||||
}
|
||||
|
||||
func (c *Client) ConnectRPC24(ctx context.Context) (
|
||||
agentproto.DRPCAgentClient24, proto.DRPCTailnetClient24, error,
|
||||
func (c *Client) ConnectRPC23(ctx context.Context) (
|
||||
agentproto.DRPCAgentClient23, proto.DRPCTailnetClient23, error,
|
||||
) {
|
||||
conn, lis := drpcsdk.MemTransportPipe()
|
||||
c.LastWorkspaceAgent = func() {
|
||||
@@ -158,28 +157,21 @@ func (c *Client) SetLogsChannel(ch chan<- *agentproto.BatchCreateLogsRequest) {
|
||||
c.fakeAgentAPI.SetLogsChannel(ch)
|
||||
}
|
||||
|
||||
func (c *Client) GetConnectionReports() []*agentproto.ReportConnectionRequest {
|
||||
return c.fakeAgentAPI.GetConnectionReports()
|
||||
}
|
||||
|
||||
type FakeAgentAPI struct {
|
||||
sync.Mutex
|
||||
t testing.TB
|
||||
logger slog.Logger
|
||||
|
||||
manifest *agentproto.Manifest
|
||||
startupCh chan *agentproto.Startup
|
||||
statsCh chan *agentproto.Stats
|
||||
appHealthCh chan *agentproto.BatchUpdateAppHealthRequest
|
||||
logsCh chan<- *agentproto.BatchCreateLogsRequest
|
||||
lifecycleStates []codersdk.WorkspaceAgentLifecycle
|
||||
metadata map[string]agentsdk.Metadata
|
||||
timings []*agentproto.Timing
|
||||
connectionReports []*agentproto.ReportConnectionRequest
|
||||
manifest *agentproto.Manifest
|
||||
startupCh chan *agentproto.Startup
|
||||
statsCh chan *agentproto.Stats
|
||||
appHealthCh chan *agentproto.BatchUpdateAppHealthRequest
|
||||
logsCh chan<- *agentproto.BatchCreateLogsRequest
|
||||
lifecycleStates []codersdk.WorkspaceAgentLifecycle
|
||||
metadata map[string]agentsdk.Metadata
|
||||
timings []*agentproto.Timing
|
||||
|
||||
getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error)
|
||||
getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, error)
|
||||
pushResourcesMonitoringUsageFunc func(*agentproto.PushResourcesMonitoringUsageRequest) (*agentproto.PushResourcesMonitoringUsageResponse, error)
|
||||
getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error)
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) GetManifest(context.Context, *agentproto.GetManifestRequest) (*agentproto.Manifest, error) {
|
||||
@@ -220,33 +212,6 @@ func (f *FakeAgentAPI) GetAnnouncementBanners(context.Context, *agentproto.GetAn
|
||||
return &agentproto.GetAnnouncementBannersResponse{AnnouncementBanners: bannersProto}, nil
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) GetResourcesMonitoringConfiguration(_ context.Context, _ *agentproto.GetResourcesMonitoringConfigurationRequest) (*agentproto.GetResourcesMonitoringConfigurationResponse, error) {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
|
||||
if f.getResourcesMonitoringConfigurationFunc == nil {
|
||||
return &agentproto.GetResourcesMonitoringConfigurationResponse{
|
||||
Config: &agentproto.GetResourcesMonitoringConfigurationResponse_Config{
|
||||
CollectionIntervalSeconds: 10,
|
||||
NumDatapoints: 20,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return f.getResourcesMonitoringConfigurationFunc()
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) PushResourcesMonitoringUsage(_ context.Context, req *agentproto.PushResourcesMonitoringUsageRequest) (*agentproto.PushResourcesMonitoringUsageResponse, error) {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
|
||||
if f.pushResourcesMonitoringUsageFunc == nil {
|
||||
return &agentproto.PushResourcesMonitoringUsageResponse{}, nil
|
||||
}
|
||||
|
||||
return f.pushResourcesMonitoringUsageFunc(req)
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsRequest) (*agentproto.UpdateStatsResponse, error) {
|
||||
f.logger.Debug(ctx, "update stats called", slog.F("req", req))
|
||||
// empty request is sent to get the interval; but our tests don't want empty stats requests
|
||||
@@ -344,26 +309,12 @@ func (f *FakeAgentAPI) BatchCreateLogs(ctx context.Context, req *agentproto.Batc
|
||||
|
||||
func (f *FakeAgentAPI) ScriptCompleted(_ context.Context, req *agentproto.WorkspaceAgentScriptCompletedRequest) (*agentproto.WorkspaceAgentScriptCompletedResponse, error) {
|
||||
f.Lock()
|
||||
f.timings = append(f.timings, req.GetTiming())
|
||||
f.timings = append(f.timings, req.Timing)
|
||||
f.Unlock()
|
||||
|
||||
return &agentproto.WorkspaceAgentScriptCompletedResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) ReportConnection(_ context.Context, req *agentproto.ReportConnectionRequest) (*emptypb.Empty, error) {
|
||||
f.Lock()
|
||||
f.connectionReports = append(f.connectionReports, req)
|
||||
f.Unlock()
|
||||
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) GetConnectionReports() []*agentproto.ReportConnectionRequest {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
return slices.Clone(f.connectionReports)
|
||||
}
|
||||
|
||||
func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI {
|
||||
return &FakeAgentAPI{
|
||||
t: t,
|
||||
|
||||
+2
-38
@@ -7,12 +7,11 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentcontainers"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func (a *agent) apiHandler() (http.Handler, func() error) {
|
||||
func (a *agent) apiHandler() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
||||
@@ -36,51 +35,16 @@ func (a *agent) apiHandler() (http.Handler, func() error) {
|
||||
ignorePorts: cpy,
|
||||
cacheDuration: cacheDuration,
|
||||
}
|
||||
|
||||
if a.experimentalDevcontainersEnabled {
|
||||
containerAPIOpts := []agentcontainers.Option{
|
||||
agentcontainers.WithExecer(a.execer),
|
||||
}
|
||||
manifest := a.manifest.Load()
|
||||
if manifest != nil && len(manifest.Devcontainers) > 0 {
|
||||
containerAPIOpts = append(
|
||||
containerAPIOpts,
|
||||
agentcontainers.WithDevcontainers(manifest.Devcontainers),
|
||||
)
|
||||
}
|
||||
|
||||
// Append after to allow the agent options to override the default options.
|
||||
containerAPIOpts = append(containerAPIOpts, a.containerAPIOptions...)
|
||||
|
||||
containerAPI := agentcontainers.NewAPI(a.logger.Named("containers"), containerAPIOpts...)
|
||||
r.Mount("/api/v0/containers", containerAPI.Routes())
|
||||
a.containerAPI.Store(containerAPI)
|
||||
} else {
|
||||
r.HandleFunc("/api/v0/containers", func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), w, http.StatusForbidden, codersdk.Response{
|
||||
Message: "The agent dev containers feature is experimental and not enabled by default.",
|
||||
Detail: "To enable this feature, set CODER_AGENT_DEVCONTAINERS_ENABLE=true in your template.",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
|
||||
|
||||
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("/debug/logs", a.HandleHTTPDebugLogs)
|
||||
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
|
||||
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)
|
||||
r.Get("/debug/manifest", a.HandleHTTPDebugManifest)
|
||||
r.Get("/debug/prometheus", promHandler.ServeHTTP)
|
||||
|
||||
return r, func() error {
|
||||
if containerAPI := a.containerAPI.Load(); containerAPI != nil {
|
||||
return containerAPI.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
type listeningPortsHandler struct {
|
||||
|
||||
+2
-2
@@ -167,8 +167,8 @@ func shouldStartTicker(app codersdk.WorkspaceApp) bool {
|
||||
return app.Healthcheck.URL != "" && app.Healthcheck.Interval > 0 && app.Healthcheck.Threshold > 0
|
||||
}
|
||||
|
||||
func healthChanged(old map[uuid.UUID]codersdk.WorkspaceAppHealth, updated map[uuid.UUID]codersdk.WorkspaceAppHealth) bool {
|
||||
for name, newValue := range updated {
|
||||
func healthChanged(old map[uuid.UUID]codersdk.WorkspaceAppHealth, new map[uuid.UUID]codersdk.WorkspaceAppHealth) bool {
|
||||
for name, newValue := range new {
|
||||
oldValue, found := old[name]
|
||||
if !found {
|
||||
return true
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestAppHealth_Healthy(t *testing.T) {
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // app2 is now healthy
|
||||
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered
|
||||
update := testutil.TryReceive(ctx, t, fakeAPI.AppHealthCh())
|
||||
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 2)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[1].Health)
|
||||
@@ -101,7 +101,7 @@ func TestAppHealth_Healthy(t *testing.T) {
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // app3 is now healthy
|
||||
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered
|
||||
update = testutil.TryReceive(ctx, t, fakeAPI.AppHealthCh())
|
||||
update = testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 2)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[1].Health)
|
||||
@@ -155,7 +155,7 @@ func TestAppHealth_500(t *testing.T) {
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // 2nd check, crosses threshold
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx) // 2nd report, sends update
|
||||
|
||||
update := testutil.TryReceive(ctx, t, fakeAPI.AppHealthCh())
|
||||
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 1)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthUnhealthy, apps[0].Health)
|
||||
@@ -223,7 +223,7 @@ func TestAppHealth_Timeout(t *testing.T) {
|
||||
timeoutTrap.MustWait(ctx).Release()
|
||||
mClock.Set(ms(3001)).MustWait(ctx) // report tick, sends changes
|
||||
|
||||
update := testutil.TryReceive(ctx, t, fakeAPI.AppHealthCh())
|
||||
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 1)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthUnhealthy, apps[0].Health)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user