Compare commits

..

17 Commits

Author SHA1 Message Date
Stephen Kirby d65eea8132 fix: fix workspace actions options (#13572) (#14071)
* fix: fix workspace actions options (#13572)

(cherry picked from commit 07cd9acb2c)

* fix: change time format string from 15:40 to 15:04 (#14033)

* Change string format to constant value

(cherry picked from commit eacdfb9f9c)

---------

Co-authored-by: Kayla Washburn-Love <mckayla@hey.com>
Co-authored-by: Charlie Voiselle <464492+angrycub@users.noreply.github.com>
2024-08-01 13:35:28 -05:00
Steven Masley 2f0c2d77dc chore: keep active users active in scim (#13955) (#13974)
* chore: scim should keep active users active
 * chore: add a unit test to excercise dormancy bug

  (cherry picked from commit 03c5d42233)
2024-07-22 16:24:56 -05:00
Stephen Kirby 82ed9e4dc7 chore: patch 2.12.4 (#13925)
* fix(site): enable dormant workspace to be deleted (#13850)

(cherry picked from commit 01b30eaa32)

* chore: add SVG desktop icon (#13765)

* chore: add SVG desktop icon

* fix: add desktop icon to to icons.json

(cherry picked from commit 21a923a7a0)

* fix: update import order for Storybook

---------

Co-authored-by: Bruno Quaresma <bruno@coder.com>
Co-authored-by: Michael Smith <throwawayclover@gmail.com>
2024-07-17 20:36:52 -04:00
Steven Masley 534d4ea752 chore: external auth validate response "Forbidden" should return invalid, not an error (#13446)
* chore: add unit test to delete workspace from suspended user
* chore: account for forbidden as well as unauthorized response codes

(cherry picked from commit 27f26910b6)
2024-06-24 17:38:32 +00:00
Stephen Kirby 8ce8700424 fixed changelog script release channel flag (#13649)
(cherry picked from commit b9d83c75de)
2024-06-24 17:38:12 +00:00
Stephen Kirby b9779af5b2 fixed script ref (#13647)
(cherry picked from commit 3d6c9799e3)
2024-06-24 17:38:02 +00:00
Mathias Fredriksson e54ff57a9a chore(scripts): fix release promote stable to set latest tag (#13471)
(cherry picked from commit 9a757f8e74)
2024-06-21 19:37:12 +00:00
Mathias Fredriksson ae220f52e7 chore(scripts): fix dry run for autoversion in release.sh (#13470)
(cherry picked from commit 3b7f9534fb)
2024-06-21 19:37:06 +00:00
Kyle Carberry 90f82da311 fix: write server config to telemetry (#13590)
* fix: add external auth configs to telemetry

* Refactor telemetry to send the entire config

* gen

* Fix linting

(cherry picked from commit 3a1fa04590)
2024-06-21 19:36:53 +00:00
Kyle Carberry 201cb1cbed fix: display trial errors in the dashboard (#13601)
* fix: display trial errors in the dashboard

The error was essentially being ignored before!

* Remove day mention in product of trial

* fmt

(cherry picked from commit 7049d7a881)
2024-06-21 19:36:37 +00:00
Kyle Carberry b701620a01 feat: add cross-origin reporting for telemetry in the dashboard (#13612)
* feat: add cross-origin reporting for telemetry in the dashboard

* Respect the telemetry flag

* Fix embedded metadata

* Fix compilation error

* Fix linting

(cherry picked from commit 0793a4b35b)
2024-06-21 19:36:31 +00:00
Kyle Carberry 0703fc6888 fix: track login page correctly (#13618)
(cherry picked from commit 495eea452f)
2024-06-21 19:36:25 +00:00
Kyle Carberry a9e5648557 fix: remove connected button (#13625)
It didn't make a lot of sense in current form. It will when we improve autostop.

(cherry picked from commit 3ef12ac284)
2024-06-21 19:36:19 +00:00
Jon Ayers 3fbfb534d0 fix: only render tooltip when require_active_version enabled (#13484)
(cherry picked from commit 7995d7c3d6)
2024-06-06 02:53:47 +00:00
Colin Adler 5e69a9d18b fix(site): show workspace start button when require active version is enabled (#13482)
(cherry picked from commit f1b42a15fa)
2024-06-06 02:35:33 +00:00
Mathias Fredriksson ba0bf43de4 chore(scripts): fix unbound variable in tag_version.sh (#13428)
(cherry picked from commit a51076a4cd)
2024-06-04 16:15:21 +00:00
Colin Adler 40af6206cc chore: upgrade terraform to v1.8.5 (#13429)
(cherry picked from commit b723da9e91)
2024-06-04 16:14:45 +00:00
8012 changed files with 196037 additions and 427278 deletions
-122
View File
@@ -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.
+10 -15
View File
@@ -1,18 +1,13 @@
{
"name": "Development environments on your infrastructure",
"image": "codercom/oss-dogfood:latest",
"name": "Development environments on your infrastructure",
"image": "codercom/oss-dogfood:latest",
"features": {
// See all possible options here https://github.com/devcontainers/features/tree/main/src/docker-in-docker
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": "false"
}
},
// SYS_PTRACE to enable go debugging
"runArgs": ["--cap-add=SYS_PTRACE"],
"customizations": {
"vscode": {
"extensions": ["biomejs.biome"]
}
}
"features": {
// See all possible options here https://github.com/devcontainers/features/tree/main/src/docker-in-docker
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": "false"
}
},
// SYS_PTRACE to enable go debugging
"runArgs": ["--cap-add=SYS_PTRACE"]
}
+6
View File
@@ -0,0 +1,6 @@
# Ignore all files and folders
**
# Include flake.nix and flake.lock
!flake.nix
!flake.lock
+1 -1
View File
@@ -7,7 +7,7 @@ trim_trailing_whitespace = true
insert_final_newline = true
indent_style = tab
[*.{yaml,yml,tf,tfvars,nix}]
[*.{md,json,yaml,yml,tf,tfvars,nix}]
indent_style = space
indent_size = 2
-2
View File
@@ -3,5 +3,3 @@
# chore: format code with semicolons when using prettier (#9555)
988c9af0153561397686c119da9d1336d2433fdd
# chore: use tabs for prettier and biome (#14283)
95a7c0c4f087744a22c2e88dd3c5d30024d5fb02
+2 -5
View File
@@ -1,10 +1,7 @@
# 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
docs/api/*.md linguist-generated=true
docs/cli/*.md linguist-generated=true
coderd/apidoc/swagger.json linguist-generated=true
coderd/database/dump.sql linguist-generated=true
peerbroker/proto/*.go linguist-generated=true
-28
View File
@@ -1,28 +0,0 @@
dirs:
- docs
excludedDirs:
# Downstream bug in linkspector means large markdown files fail to parse
# but these are autogenerated and shouldn't need checking
- docs/reference
# Older changelogs may contain broken links
- docs/changelogs
ignorePatterns:
- pattern: "localhost"
- pattern: "example.com"
- pattern: "mailto:"
- pattern: "127.0.0.1"
- pattern: "0.0.0.0"
- pattern: "JFROG_URL"
- pattern: "coder.company.org"
# These real sites were blocking the linkspector action / GitHub runner IPs(?)
- pattern: "i.imgur.com"
- pattern: "code.visualstudio.com"
- 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
-78
View File
@@ -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"
-10
View File
@@ -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"
-10
View File
@@ -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
+2 -2
View File
@@ -4,12 +4,12 @@ description: |
inputs:
version:
description: "The Go version to use."
default: "1.24.2"
default: "1.22.3"
runs:
using: "composite"
steps:
- name: Setup Go
uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
uses: buildjet/setup-go@v5
with:
go-version: ${{ inputs.version }}
-27
View File
@@ -1,27 +0,0 @@
name: "Setup ImDisk"
if: runner.os == 'Windows'
description: |
Sets up the ImDisk toolkit for Windows and creates a RAM disk on drive R:.
runs:
using: "composite"
steps:
- name: Download ImDisk
if: runner.os == 'Windows'
shell: bash
run: |
mkdir imdisk
cd imdisk
curl -L -o files.cab https://github.com/coder/imdisk-artifacts/raw/92a17839ebc0ee3e69be019f66b3e9b5d2de4482/files.cab
curl -L -o install.bat https://github.com/coder/imdisk-artifacts/raw/92a17839ebc0ee3e69be019f66b3e9b5d2de4482/install.bat
cd ..
- name: Install ImDisk
shell: cmd
run: |
cd imdisk
install.bat /silent
- name: Create RAM Disk
shell: cmd
run: |
imdisk -a -s 4096M -m R: -p "/fs:ntfs /q /y"
+6 -6
View File
@@ -11,16 +11,16 @@ runs:
using: "composite"
steps:
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0
- name: Setup Node
uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
uses: pnpm/action-setup@v3
with:
node-version: 20.16.0
version: 8
- name: Setup Node
uses: buildjet/setup-node@v4.0.1
with:
node-version: 18.19.0
# See https://github.com/actions/setup-node#caching-global-packages-data
cache: "pnpm"
cache-dependency-path: ${{ inputs.directory }}/pnpm-lock.yaml
- name: Install root node_modules
shell: bash
run: ./scripts/pnpm_install.sh
+2 -2
View File
@@ -5,6 +5,6 @@ runs:
using: "composite"
steps:
- name: Setup sqlc
uses: sqlc-dev/setup-sqlc@c0209b9199cd1cce6a14fc27cabcec491b651761 # v4.0.0
uses: sqlc-dev/setup-sqlc@v4
with:
sqlc-version: "1.27.0"
sqlc-version: "1.25.0"
+2 -2
View File
@@ -5,7 +5,7 @@ runs:
using: "composite"
steps:
- name: Install Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.11.4
terraform_version: 1.8.4
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 }}
+1 -2
View File
@@ -1,6 +1,5 @@
name: Upload tests to datadog
description: |
Uploads the test results to datadog.
if: always()
inputs:
api-key:
description: "Datadog API key"
-2
View File
@@ -1,2 +0,0 @@
enabled: true
preservePullRequestTitle: true
+53 -45
View File
@@ -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:
@@ -24,10 +39,6 @@ updates:
prefix: "chore"
labels: []
open-pull-requests-limit: 15
groups:
x:
patterns:
- "golang.org/x/*"
ignore:
# Ignore patch updates for all dependencies
- dependency-name: "*"
@@ -36,14 +47,7 @@ updates:
# Update our Dockerfile.
- package-ecosystem: "docker"
directories:
- "/dogfood/coder"
- "/dogfood/coder-envbuilder"
- "/scripts"
- "/examples/templates/docker/build"
- "/examples/parameters/build"
- "/scaletest/templates/scaletest-runner"
- "/scripts/ironbank"
directory: "/scripts/"
schedule:
interval: "weekly"
time: "06:00"
@@ -57,12 +61,7 @@ updates:
- dependency-name: "terraform"
- package-ecosystem: "npm"
directories:
- "/site"
- "/offlinedocs"
- "/scripts"
- "/scripts/apidocgen"
directory: "/site/"
schedule:
interval: "monthly"
time: "06:00"
@@ -72,35 +71,44 @@ updates:
commit-message:
prefix: "chore"
labels: []
groups:
xterm:
patterns:
- "@xterm*"
mui:
patterns:
- "@mui*"
react:
patterns:
- "react"
- "react-dom"
- "@types/react"
- "@types/react-dom"
emotion:
patterns:
- "@emotion*"
exclude-patterns:
- "jest-runner-eslint"
jest:
patterns:
- "jest"
- "@types/jest"
vite:
patterns:
- "vite*"
- "@vitejs/plugin-react"
ignore:
# Ignore major version updates to avoid breaking changes
# Ignore patch updates for all dependencies
- dependency-name: "*"
update-types:
- version-update:semver-patch
# Ignore major updates to Node.js types, because they need to
# correspond to the Node.js engine version
- dependency-name: "@types/node"
update-types:
- version-update:semver-major
open-pull-requests-limit: 15
groups:
site:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/offlinedocs/"
schedule:
interval: "monthly"
time: "06:00"
timezone: "America/Chicago"
reviewers:
- "coder/ts"
commit-message:
prefix: "chore"
labels: []
ignore:
# Ignore patch updates for all dependencies
- dependency-name: "*"
update-types:
- version-update:semver-patch
# Ignore major updates to Node.js types, because they need to
# correspond to the Node.js engine version
- dependency-name: "@types/node"
update-types:
- version-update:semver-major
groups:
offlinedocs:
patterns:
- "*"
+159 -801
View File
File diff suppressed because it is too large Load Diff
+17 -14
View File
@@ -2,7 +2,7 @@ name: contrib
on:
issue_comment:
types: [created, edited]
types: [created]
pull_request_target:
types:
- opened
@@ -10,30 +10,35 @@ on:
- synchronize
- labeled
- unlabeled
- opened
- reopened
- edited
# For jobs that don't run on draft PRs.
- ready_for_review
permissions:
contents: read
# Only run one instance per PR to ensure in-order execution.
concurrency: pr-${{ github.ref }}
jobs:
cla:
# Dependabot is annoying, but this makes it a bit less so.
auto-approve-dependabot:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_target'
permissions:
pull-requests: write
steps:
- name: auto-approve dependabot
uses: hmarr/auto-approve-action@v4
if: github.actor == 'dependabot[bot]'
cla:
runs-on: ubuntu-latest
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'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
uses: contributor-assistant/github-action@v2.4.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN: ${{ secrets.CDRCI2_GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.CDRCOMMUNITY_GITHUB_TOKEN }}
with:
remote-organization-name: "coder"
remote-repository-name: "cla"
@@ -46,13 +51,11 @@ 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_target' && success() && !github.event.pull_request.draft }}
steps:
- name: release-labels
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@v7
with:
# This script ensures PR title and labels are in sync:
#
@@ -84,7 +87,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({
-88
View File
@@ -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 }}
+8 -15
View File
@@ -22,6 +22,10 @@ on:
permissions:
contents: read
# Necessary to push docker images to ghcr.io.
packages: write
# Necessary for depot.dev authentication.
id-token: write
# Avoid running multiple jobs for the same commit.
concurrency:
@@ -29,24 +33,14 @@ concurrency:
jobs:
build:
permissions:
# Necessary for depot.dev authentication.
id-token: write
# Necessary to push docker images to ghcr.io.
packages: write
runs-on: ubuntu-latest
if: github.repository_owner == 'coder'
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Docker login
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -56,17 +50,16 @@ jobs:
run: mkdir base-build-context
- name: Install depot.dev CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
uses: depot/setup-action@v1
# This uses OIDC authentication, so no auth variables are required.
- name: Build base Docker image via depot.dev
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
uses: depot/build-push-action@v1
with:
project: wl5hnrrkns
context: base-build-context
file: scripts/Dockerfile.base
platforms: linux/amd64,linux/arm64,linux/arm/v7
provenance: true
pull: true
no-cache: true
push: ${{ github.event_name != 'pull_request' }}
-48
View File
@@ -1,48 +0,0 @@
name: Docs CI
on:
push:
branches:
- main
paths:
- "docs/**"
- "**.md"
- ".github/workflows/docs-ci.yaml"
pull_request:
paths:
- "docs/**"
- "**.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
- name: Setup Node
uses: ./.github/actions/setup-node
- uses: tj-actions/changed-files@5426ecc3f5c2b10effaefbd374f0abdc6a571b2f # v45.0.7
id: changed-files
with:
files: |
docs/**
**.md
separator: ","
- name: lint
if: steps.changed-files.outputs.any_changed == 'true'
run: |
pnpm exec markdownlint-cli2 ${{ steps.changed-files.outputs.all_changed_files }}
- name: fmt
if: steps.changed-files.outputs.any_changed == 'true'
run: |
# markdown-table-formatter requires a space separated list of files
echo ${{ steps.changed-files.outputs.all_changed_files }} | tr ',' '\n' | pnpm exec markdown-table-formatter --check
+35 -86
View File
@@ -17,48 +17,16 @@ on:
- "flake.nix"
workflow_dispatch:
permissions:
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
id-token: write
jobs:
build_image:
if: github.actor != 'dependabot[bot]' # Skip Dependabot PRs
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || 'ubuntu-latest' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@5bb6a3b3abe66fd09bbf250dce8ada94f856a703 # v30
- 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
uses: actions/checkout@v4
- name: Get branch name
id: branch-name
uses: tj-actions/branch-names@dde14ac574a8b9b1cedc59a1cf312788af43d8d8 # v8.2.1
uses: tj-actions/branch-names@v8
- name: "Branch name to Docker tag name"
id: docker-tag-name
@@ -69,81 +37,58 @@ jobs:
echo "tag=${tag}" >> $GITHUB_OUTPUT
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
uses: depot/setup-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
if: github.ref == 'refs/heads/main'
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Build and push Non-Nix image
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
uses: depot/build-push-action@v1
with:
project: b4q6ltmpzh
token: ${{ secrets.DEPOT_TOKEN }}
buildx-fallback: true
context: "{{defaultContext}}:dogfood/coder"
context: "{{defaultContext}}:dogfood"
pull: true
save: true
push: ${{ github.ref == 'refs/heads/main' }}
tags: "codercom/oss-dogfood:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood:latest"
- name: Build Nix image
run: nix build .#dev_image
- name: Push Nix image
if: github.ref == 'refs/heads/main'
run: |
docker load -i result
CURRENT_SYSTEM=$(nix eval --impure --raw --expr 'builtins.currentSystem')
docker image tag codercom/oss-dogfood-nix:latest-$CURRENT_SYSTEM codercom/oss-dogfood-nix:${{ steps.docker-tag-name.outputs.tag }}
docker image push codercom/oss-dogfood-nix:${{ steps.docker-tag-name.outputs.tag }}
docker image tag codercom/oss-dogfood-nix:latest-$CURRENT_SYSTEM codercom/oss-dogfood-nix:latest
docker image push codercom/oss-dogfood-nix:latest
- name: Build and push Nix image
uses: depot/build-push-action@v1
with:
project: b4q6ltmpzh
token: ${{ secrets.DEPOT_TOKEN }}
buildx-fallback: true
context: "."
file: "dogfood/Dockerfile.nix"
pull: true
save: true
push: ${{ github.ref == 'refs/heads/main' }}
tags: "codercom/oss-dogfood-nix:${{ steps.docker-tag-name.outputs.tag }},codercom/oss-dogfood-nix:latest"
deploy_template:
needs: build_image
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Setup Terraform
uses: ./.github/actions/setup-tf
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
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
terraform validate
popd
pushd dogfood/coder-envbuilder
terraform init
terraform validate
popd
- name: Get short commit SHA
if: github.ref == 'refs/heads/main'
@@ -155,18 +100,22 @@ jobs:
id: message
run: echo "pr_title=$(git log --format=%s -n 1 ${{ github.sha }})" >> $GITHUB_OUTPUT
- name: "Get latest Coder binary from the server"
if: github.ref == 'refs/heads/main'
run: |
curl -fsSL "https://dev.coder.com/bin/coder-linux-amd64" -o "./coder"
chmod +x "./coder"
- name: "Push template"
if: github.ref == 'refs/heads/main'
run: |
cd dogfood
terraform apply -auto-approve
./coder templates push $CODER_TEMPLATE_NAME --directory $CODER_TEMPLATE_DIR --yes --name=$CODER_TEMPLATE_VERSION --message="$CODER_TEMPLATE_MESSAGE"
env:
# Consumed by coderd provider
# Consumed by Coder CLI
CODER_URL: https://dev.coder.com
CODER_SESSION_TOKEN: ${{ secrets.CODER_SESSION_TOKEN }}
# 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_MESSAGE: ${{ steps.message.outputs.pr_title }}
TF_LOG: info
CODER_TEMPLATE_NAME: ${{ secrets.CODER_TEMPLATE_NAME }}
CODER_TEMPLATE_VERSION: ${{ steps.vars.outputs.sha_short }}
CODER_TEMPLATE_DIR: ./dogfood
CODER_TEMPLATE_MESSAGE: ${{ steps.message.outputs.pr_title }}
+26
View File
@@ -0,0 +1,26 @@
{
"ignorePatterns": [
{
"pattern": "://localhost"
},
{
"pattern": "://.*.?example\\.com"
},
{
"pattern": "developer.github.com"
},
{
"pattern": "docs.github.com"
},
{
"pattern": "support.google.com"
},
{
"pattern": "tailscale.com"
},
{
"pattern": "wireguard.com"
}
],
"aliveStatusCodes": [200, 0]
}
+36 -118
View File
@@ -3,38 +3,21 @@
name: nightly-gauntlet
on:
schedule:
# Every day at 4AM
- cron: "0 4 * * 1-5"
# Every day at midnight
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
test-go-pg:
runs-on: ${{ matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'depot-macos-latest' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-16-cores' || matrix.os }}
if: github.ref == 'refs/heads/main'
# This timeout must be greater than the timeout set by `go test` in
# `make test-postgres` to ensure we receive a trace of running
# goroutines. Setting this to the timeout +5m should work quite well
# even if some of the preceding steps are slow.
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
os:
- macos-latest
- windows-2022
go-race:
# While GitHub's toaster runners are likelier to flake, we want consistency
# between this environment and the regular test environment for DataDog
# statistics and to only show real workflow threats.
runs-on: "buildjet-8vcpu-ubuntu-2204"
# This runner costs 0.016 USD per minute,
# so 0.016 * 240 = 3.84 USD per run.
timeout-minutes: 240
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/setup-go
@@ -42,101 +25,36 @@ jobs:
- name: Setup Terraform
uses: ./.github/actions/setup-tf
# Sets up the ImDisk toolkit for Windows and creates a RAM disk on drive R:.
- name: Setup ImDisk
if: runner.os == 'Windows'
uses: ./.github/actions/setup-imdisk
- name: Test with PostgreSQL Database
env:
POSTGRES_VERSION: "13"
TS_DEBUG_DISCO: "true"
LC_CTYPE: "en_US.UTF-8"
LC_ALL: "en_US.UTF-8"
shell: bash
- name: Run Tests
run: |
# if macOS, install google-chrome for scaletests
# As another concern, should we really have this kind of external dependency
# requirement on standard CI?
if [ "${{ matrix.os }}" == "macos-latest" ]; then
brew install google-chrome
fi
# -race is likeliest to catch flaky tests
# due to correctness detection and its performance
# impact.
gotestsum --junitfile="gotests.xml" -- -timeout=240m -count=10 -race ./...
# By default Go will use the number of logical CPUs, which
# is a fine default.
PARALLEL_FLAG=""
# macOS will output "The default interactive shell is now zsh"
# intermittently in CI...
if [ "${{ matrix.os }}" == "macos-latest" ]; then
touch ~/.bash_profile && echo "export BASH_SILENCE_DEPRECATION_WARNING=1" >> ~/.bash_profile
fi
if [ "${{ runner.os }}" == "Windows" ]; then
# Create a temp dir on the R: ramdisk drive for Windows. The default
# C: drive is extremely slow: https://github.com/actions/runner-images/issues/8755
mkdir -p "R:/temp/embedded-pg"
go run scripts/embedded-pg/main.go -path "R:/temp/embedded-pg"
else
go run scripts/embedded-pg/main.go
fi
# Reduce test parallelism, mirroring what we do for race tests.
# We'd been encountering issues with timing related flakes, and
# this seems to help.
DB=ci gotestsum --format standard-quiet -- -v -short -count=1 -parallel 4 -p 4 ./...
- name: Upload test stats to Datadog
timeout-minutes: 1
continue-on-error: true
- name: Upload test results to DataDog
uses: ./.github/actions/upload-datadog
if: success() || failure()
if: always()
with:
api-key: ${{ secrets.DATADOG_API_KEY }}
notify-slack-on-failure:
needs:
- test-go-pg
runs-on: ubuntu-latest
if: failure() && github.ref == 'refs/heads/main'
go-timing:
# We run these tests with p=1 so we don't need a lot of compute.
runs-on: "buildjet-2vcpu-ubuntu-2204"
timeout-minutes: 10
steps:
- name: Send Slack notification
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: ./.github/actions/setup-go
- name: Run Tests
run: |
curl -X POST -H 'Content-type: application/json' \
--data '{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "❌ Nightly gauntlet failed",
"emoji": true
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Workflow:*\n${{ github.workflow }}"
},
{
"type": "mrkdwn",
"text": "*Committer:*\n${{ github.actor }}"
},
{
"type": "mrkdwn",
"text": "*Commit:*\n${{ github.sha }}"
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*View failure:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Click here>"
}
}
]
}' ${{ secrets.CI_FAILURE_SLACK_WEBHOOK }}
gotestsum --junitfile="gotests.xml" -- --tags="timing" -p=1 -run='_Timing/' ./...
- name: Upload test results to DataDog
uses: ./.github/actions/upload-datadog
if: always()
with:
api-key: ${{ secrets.DATADOG_API_KEY }}
+1 -6
View File
@@ -13,10 +13,5 @@ jobs:
assign-author:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Assign author
uses: toshimaru/auto-author-assign@16f0022cf3d7970c106d8d1105f75a1165edb516 # v2.1.1
uses: toshimaru/auto-author-assign@v2.1.0
+2 -10
View File
@@ -9,20 +9,12 @@ on:
required: true
permissions:
contents: read
packages: write
jobs:
cleanup:
runs-on: "ubuntu-latest"
permissions:
# Necessary to delete docker images from ghcr.io.
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Get PR number
id: pr_number
run: |
@@ -34,7 +26,7 @@ jobs:
- name: Delete image
continue-on-error: true
uses: bots-house/ghcr-delete-image-action@3827559c68cb4dcdf54d813ea9853be6d468d3a4 # v1.1.0
uses: bots-house/ghcr-delete-image-action@v1.1.0
with:
owner: coder
name: coder-preview
+31 -75
View File
@@ -7,7 +7,6 @@ on:
push:
branches-ignore:
- main
- "temp-cherry-pick-*"
workflow_dispatch:
inputs:
experiments:
@@ -31,6 +30,8 @@ env:
permissions:
contents: read
packages: write
pull-requests: write # needed for commenting on PRs
jobs:
check_pr:
@@ -38,13 +39,8 @@ jobs:
outputs:
PR_OPEN: ${{ steps.check_pr.outputs.pr_open }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Check if PR is open
id: check_pr
@@ -73,13 +69,8 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -110,8 +101,8 @@ jobs:
run: |
set -euo pipefail
mkdir -p ~/.kube
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config
chmod 644 ~/.kube/config
export KUBECONFIG=~/.kube/config
- name: Check if the helm deployment already exists
@@ -128,7 +119,7 @@ jobs:
echo "NEW=$NEW" >> $GITHUB_OUTPUT
- name: Check changed files
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
uses: dorny/paths-filter@v3
id: filter
with:
base: ${{ github.ref }}
@@ -163,23 +154,16 @@ jobs:
set -euo pipefail
# build if the workflow is manually triggered and the deployment doesn't exist (first build or force rebuild)
echo "first_or_force_build=${{ (github.event_name == 'workflow_dispatch' && steps.check_deployment.outputs.NEW == 'true') || github.event.inputs.build == 'true' }}" >> $GITHUB_OUTPUT
# build if the deployment already exist and there are changes in the files that we care about (automatic updates)
# build if the deployment alreday exist and there are changes in the files that we care about (automatic updates)
echo "automatic_rebuild=${{ steps.check_deployment.outputs.NEW == 'false' && steps.filter.outputs.all_count > steps.filter.outputs.ignored_count }}" >> $GITHUB_OUTPUT
comment-pr:
needs: get_info
if: needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true'
runs-on: "ubuntu-latest"
permissions:
pull-requests: write # needed for commenting on PRs
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Find Comment
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
uses: peter-evans/find-comment@v3
id: fc
with:
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
@@ -189,7 +173,7 @@ jobs:
- name: Comment on PR
id: comment_id
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
uses: peter-evans/create-or-update-comment@v4
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ needs.get_info.outputs.PR_NUMBER }}
@@ -205,11 +189,8 @@ jobs:
needs: get_info
# Run build job only if there are changes in the files that we care about or if the workflow is manually triggered with --build flag
if: needs.get_info.outputs.BUILD == 'true'
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
permissions:
# Necessary to push docker images to ghcr.io.
packages: write
# This concurrency only cancels build jobs if a new build is triggred. It will avoid cancelling the current deployemtn in case of docs changes.
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
# This concurrency only cancels build jobs if a new build is triggred. It will avoid cancelling the current deployemtn in case of docs chnages.
concurrency:
group: build-${{ github.workflow }}-${{ github.ref }}-${{ needs.get_info.outputs.BUILD }}
cancel-in-progress: true
@@ -217,13 +198,8 @@ jobs:
DOCKER_CLI_EXPERIMENTAL: "enabled"
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -237,7 +213,7 @@ jobs:
uses: ./.github/actions/setup-sqlc
- name: GHCR Login
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -266,8 +242,6 @@ jobs:
always() && (needs.build.result == 'success' || needs.build.result == 'skipped') &&
(needs.get_info.outputs.BUILD == 'true' || github.event.inputs.deploy == 'true')
runs-on: "ubuntu-latest"
permissions:
pull-requests: write # needed for commenting on PRs
env:
CODER_IMAGE_TAG: ${{ needs.get_info.outputs.CODER_IMAGE_TAG }}
PR_NUMBER: ${{ needs.get_info.outputs.PR_NUMBER }}
@@ -275,17 +249,12 @@ jobs:
PR_URL: ${{ needs.get_info.outputs.PR_URL }}
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
with:
egress-policy: audit
- name: Set up kubeconfig
run: |
set -euo pipefail
mkdir -p ~/.kube
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
chmod 600 ~/.kube/config
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config
chmod 644 ~/.kube/config
export KUBECONFIG=~/.kube/config
- name: Check if image exists
@@ -325,7 +294,7 @@ jobs:
kubectl create namespace "pr${{ env.PR_NUMBER }}"
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- name: Check and Create Certificate
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
@@ -422,14 +391,14 @@ jobs:
"${DEST}" version
mv "${DEST}" /usr/local/bin/coder
- name: Create first user
- name: Create first user, template and workspace
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
id: setup_deployment
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Create first user
# create a masked random password 12 characters long
password=$(openssl rand -base64 16 | tr -d "=+/" | cut -c1-12)
@@ -438,22 +407,20 @@ jobs:
echo "password=$password" >> $GITHUB_OUTPUT
coder login \
--first-user-username pr${{ env.PR_NUMBER }}-admin \
--first-user-username coder \
--first-user-email pr${{ env.PR_NUMBER }}@coder.com \
--first-user-password $password \
--first-user-trial=false \
--first-user-trial \
--use-token-as-session \
https://${{ env.PR_HOSTNAME }}
# Create a user for the github.actor
# TODO: update once https://github.com/coder/coder/issues/15466 is resolved
# coder users create \
# --username ${{ github.actor }} \
# --login-type github
# Create template
cd ./.github/pr-deployments/template
coder templates push -y --variable namespace=pr${{ env.PR_NUMBER }} kubernetes
# promote the user to admin role
# coder org members edit-role ${{ github.actor }} organization-admin
# TODO: update once https://github.com/coder/internal/issues/207 is resolved
# Create workspace
coder create --template="kubernetes" kube --parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y
coder stop kube -y
- name: Send Slack notification
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
@@ -465,7 +432,7 @@ jobs:
"pr_url": "'"${{ env.PR_URL }}"'",
"pr_title": "'"${{ env.PR_TITLE }}"'",
"pr_access_url": "'"https://${{ env.PR_HOSTNAME }}"'",
"pr_username": "'"pr${{ env.PR_NUMBER }}-admin"'",
"pr_username": "'"test"'",
"pr_email": "'"pr${{ env.PR_NUMBER }}@coder.com"'",
"pr_password": "'"${{ steps.setup_deployment.outputs.password }}"'",
"pr_actor": "'"${{ github.actor }}"'"
@@ -474,7 +441,7 @@ jobs:
echo "Slack notification sent"
- name: Find Comment
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
uses: peter-evans/find-comment@v3
id: fc
with:
issue-number: ${{ env.PR_NUMBER }}
@@ -483,7 +450,7 @@ jobs:
direction: last
- name: Comment on PR
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
uses: peter-evans/create-or-update-comment@v4
env:
STATUS: ${{ needs.get_info.outputs.NEW == 'true' && 'Created' || 'Updated' }}
with:
@@ -498,14 +465,3 @@ jobs:
cc: @${{ github.actor }}
reactions: rocket
reactions-edit-mode: replace
- name: Create template and workspace
if: needs.get_info.outputs.NEW == 'true' || github.event.inputs.deploy == 'true'
run: |
set -euo pipefail
cd .github/pr-deployments/template
coder templates push -y --variable namespace=pr${{ env.PR_NUMBER }} kubernetes
# Create workspace
coder create --template="kubernetes" kube --parameter cpu=2 --parameter memory=4 --parameter home_disk_size=2 -y
coder stop kube -y
-28
View File
@@ -1,28 +0,0 @@
name: release-validation
on:
push:
tags:
- "v*"
permissions:
contents: read
jobs:
network-performance:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Run Schmoder CI
uses: benc-uk/workflow-dispatch@e2e5e9a103e331dad343f381a29e654aea3cf8fc # v1.2.4
with:
workflow: ci.yaml
repo: coder/schmoder
inputs: '{ "num_releases": "3", "commit": "${{ github.sha }}" }'
token: ${{ secrets.CDRCI_SCHMODER_ACTIONS_TOKEN }}
ref: main
+35 -424
View File
@@ -18,7 +18,12 @@ on:
default: false
permissions:
contents: read
# Required to publish a release
contents: write
# Necessary to push docker images to ghcr.io.
packages: write
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
id-token: write
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -32,114 +37,17 @@ env:
CODER_RELEASE_NOTES: ${{ inputs.release_notes }}
jobs:
# build-dylib is a separate job to build the dylib on macOS.
build-dylib:
runs-on: ${{ github.repository_owner == 'coder' && 'depot-macos-latest' || 'macos-latest' }}
steps:
# Harden Runner doesn't work on macOS.
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
# If the event that triggered the build was an annotated tag (which our
# tags are supposed to be), actions/checkout has a bug where the tag in
# question is only a lightweight tag and not a full annotated tag. This
# command seems to fix it.
# https://github.com/actions/checkout/issues/290
- name: Fetch git tags
run: git fetch --tags --force
- name: Setup build tools
run: |
brew install bash gnu-getopt make
echo "$(brew --prefix bash)/bin" >> $GITHUB_PATH
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
- name: Install rcodesign
run: |
set -euo pipefail
wget -O /tmp/rcodesign.tar.gz https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F0.22.0/apple-codesign-0.22.0-macos-universal.tar.gz
sudo tar -xzf /tmp/rcodesign.tar.gz \
-C /usr/local/bin \
--strip-components=1 \
apple-codesign-0.22.0-macos-universal/rcodesign
rm /tmp/rcodesign.tar.gz
- name: Setup Apple Developer certificate and API key
run: |
set -euo pipefail
touch /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
chmod 600 /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
echo "$AC_CERTIFICATE_P12_BASE64" | base64 -d > /tmp/apple_cert.p12
echo "$AC_CERTIFICATE_PASSWORD" > /tmp/apple_cert_password.txt
echo "$AC_APIKEY_P8_BASE64" | base64 -d > /tmp/apple_apikey.p8
env:
AC_CERTIFICATE_P12_BASE64: ${{ secrets.AC_CERTIFICATE_P12_BASE64 }}
AC_CERTIFICATE_PASSWORD: ${{ secrets.AC_CERTIFICATE_PASSWORD }}
AC_APIKEY_P8_BASE64: ${{ secrets.AC_APIKEY_P8_BASE64 }}
- name: Build dylibs
run: |
set -euxo pipefail
go mod download
make gen/mark-fresh
make build/coder-dylib
env:
CODER_SIGN_DARWIN: 1
AC_CERTIFICATE_FILE: /tmp/apple_cert.p12
AC_CERTIFICATE_PASSWORD_FILE: /tmp/apple_cert_password.txt
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: dylibs
path: |
./build/*.h
./build/*.dylib
retention-days: 7
- name: Delete Apple Developer certificate and API key
run: rm -f /tmp/{apple_cert.p12,apple_cert_password.txt,apple_apikey.p8}
release:
name: Build and publish
needs: build-dylib
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
permissions:
# Required to publish a release
contents: write
# 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
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
env:
# Necessary for Docker manifest
DOCKER_CLI_EXPERIMENTAL: "enabled"
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -208,7 +116,7 @@ jobs:
cat "$CODER_RELEASE_NOTES_FILE"
- name: Docker Login
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -222,14 +130,11 @@ jobs:
# Necessary for signing Windows binaries.
- name: Setup Java
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
uses: actions/setup-java@v4
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
@@ -250,12 +155,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
@@ -281,31 +180,19 @@ jobs:
- name: Test migrations from current ref to main
run: |
POSTGRES_VERSION=13 make test-migrations
make test-migrations
# 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@v2
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@v2"
- name: Build binaries
run: |
@@ -323,7 +210,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 }}
@@ -359,19 +245,17 @@ jobs:
- name: Install depot.dev CLI
if: steps.image-base-tag.outputs.tag != ''
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
uses: depot/setup-action@v1
# This uses OIDC authentication, so no auth variables are required.
- name: Build base Docker image via depot.dev
if: steps.image-base-tag.outputs.tag != ''
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
uses: depot/build-push-action@v1
with:
project: wl5hnrrkns
context: base-build-context
file: scripts/Dockerfile.base
platforms: linux/amd64,linux/arm64,linux/arm/v7
provenance: true
sbom: true
pull: true
no-cache: true
push: true
@@ -379,7 +263,6 @@ jobs:
${{ steps.image-base-tag.outputs.tag }}
- name: Verify that images are pushed properly
if: steps.image-base-tag.outputs.tag != ''
run: |
# retry 10 times with a 5 second delay as the images may not be
# available immediately
@@ -408,55 +291,14 @@ 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
# build Docker images for each architecture
version="$(./scripts/version.sh)"
make -j build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
# we can't build multi-arch if the images aren't pushed, so quit now
# if dry-running
if [[ "$CODER_RELEASE" != *t* ]]; then
@@ -464,166 +306,22 @@ jobs:
exit 0
fi
# build Docker images for each architecture
version="$(./scripts/version.sh)"
make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
# build and push multi-arch manifest, this depends on the other images
# 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
make -j push/build/coder_"$version"_linux.tag
# 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 +343,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@v2
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@v2"
- name: Publish Helm Chart
if: ${{ !inputs.dry_run }}
@@ -696,7 +383,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@v4
with:
name: release-artifacts
path: |
@@ -707,25 +394,16 @@ 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
- name: Start Packer builds
if: ${{ !inputs.dry_run }}
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
repository: coder/packages
event-type: coder-release
client-payload: '{"coder_version": "${{ steps.version.outputs.version }}", "release_channel": "${{ inputs.release_channel }}"}'
client-payload: '{"coder_version": "${{ steps.version.outputs.version }}"}'
publish-homebrew:
name: Publish to Homebrew tap
@@ -736,11 +414,6 @@ jobs:
steps:
# 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
with:
egress-policy: audit
- name: Update homebrew
env:
# Variables used by the `gh` command
@@ -812,18 +485,13 @@ jobs:
if: ${{ !inputs.dry_run }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Sync fork
run: gh repo sync cdrci/winget-pkgs -b master
env:
GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -902,13 +570,8 @@ jobs:
needs: release
if: ${{ !inputs.dry_run }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 1
@@ -924,55 +587,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
-52
View File
@@ -1,52 +0,0 @@
name: OpenSSF Scorecard
on:
branch_protection_rule:
schedule:
- cron: "27 7 * * 3" # A random time to run weekly
push:
branches: ["main"]
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
with:
results_file: results.sarif
results_format: sarif
repo_token: ${{ secrets.GITHUB_TOKEN }}
publish_results: true
# Upload the results as artifacts.
- name: "Upload artifact"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
with:
sarif_file: results.sarif
+26 -44
View File
@@ -3,6 +3,7 @@ name: "security"
permissions:
actions: read
contents: read
security-events: write
on:
workflow_dispatch:
@@ -22,23 +23,16 @@ concurrency:
jobs:
codeql:
permissions:
security-events: write
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
- 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@v3
with:
languages: go, javascript
@@ -48,7 +42,7 @@ jobs:
rm Makefile
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16
uses: github/codeql-action/analyze@v3
- name: Send Slack notification on failure
if: ${{ failure() }}
@@ -62,17 +56,10 @@ jobs:
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
trivy:
permissions:
security-events: write
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -85,39 +72,26 @@ 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
run: go run github.com/mikefarah/yq/v4@v4.30.6
- name: Install mockgen
run: go install go.uber.org/mock/mockgen@v0.5.0
run: go install go.uber.org/mock/mockgen@v0.4.0
- name: Install protoc-gen-go
run: go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.30
- name: Install protoc-gen-go-drpc
run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.34
run: go install storj.io/drpc/cmd/protoc-gen-go-drpc@v0.0.33
- name: Install Protoc
run: |
# protoc must be in lockstep with our dogfood Dockerfile or the
# version in the comments will differ. This is also defined in
# ci.yaml.
set -euxo pipefail
cd dogfood/coder
mkdir -p /usr/local/bin
mkdir -p /usr/local/include
set -x
cd dogfood
DOCKER_BUILDKIT=1 docker build . --target proto -t protoc
protoc_path=/usr/local/bin/protoc
docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_path
chmod +x $protoc_path
protoc --version
# Copy the generated files to the include directory.
docker run --rm -v /usr/local/include:/target protoc cp -r /tmp/include/google /target/
ls -la /usr/local/include/google/protobuf/
stat /usr/local/include/google/protobuf/timestamp.proto
- name: Build Coder linux amd64 Docker image
id: build
@@ -136,13 +110,11 @@ jobs:
# the registry.
export CODER_IMAGE_BUILD_BASE_TAG="$(CODER_IMAGE_BASE=coder-base ./scripts/image_tag.sh --version "$version")"
# We would like to use make -j here, but it doesn't work with the some recent additions
# to our code generation.
make "$image_job"
make -j "$image_job"
echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5
uses: aquasecurity/trivy-action@fd25fed6972e341ff0007ddb61f77e88103953c2
with:
image-ref: ${{ steps.build.outputs.image }}
format: sarif
@@ -150,18 +122,28 @@ 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@v3
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@v4
with:
name: trivy
path: trivy-results.sarif
retention-days: 7
# Prisma cloud scan runs last because it fails the entire job if it
# detects vulnerabilities. :|
- name: Run Prisma Cloud image scan
uses: PaloAltoNetworks/prisma-cloud-scan@v1
with:
pcc_console_url: ${{ secrets.PRISMA_CLOUD_URL }}
pcc_user: ${{ secrets.PRISMA_CLOUD_ACCESS_KEY }}
pcc_pass: ${{ secrets.PRISMA_CLOUD_SECRET_KEY }}
image_name: ${{ steps.build.outputs.image }}
- name: Send Slack notification on failure
if: ${{ failure() }}
run: |
+10 -39
View File
@@ -1,36 +1,23 @@
name: Stale Issue, Branch and Old Workflows Cleanup
name: Stale Issue, Banch and Old Workflows Cleanup
on:
schedule:
# Every day at midnight
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
issues:
runs-on: ubuntu-latest
permissions:
# Needed to close issues.
issues: write
# Needed to close PRs.
pull-requests: write
actions: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: stale
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
uses: actions/stale@v9.0.0
with:
stale-issue-label: "stale"
stale-pr-label: "stale"
# days-before-stale: 180
# essentially disabled for now while we work through polish issues
days-before-stale: 3650
days-before-stale: 180
# Pull Requests become stale more quickly due to merge conflicts.
# Also, we promote minimizing WIP.
days-before-pr-stale: 7
@@ -44,7 +31,7 @@ jobs:
# Start with the oldest issues, always.
ascending: true
- name: "Close old issues labeled likely-no"
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
@@ -70,7 +57,7 @@ jobs:
});
const labelEvent = timeline.data.find(event => event.event === 'labeled' && event.label.name === 'likely-no');
if (labelEvent) {
console.log(`Issue #${issue.number} was labeled with 'likely-no' at ${labelEvent.created_at}`);
@@ -91,19 +78,11 @@ jobs:
branches:
runs-on: ubuntu-latest
permissions:
# Needed to delete branches.
contents: 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
uses: actions/checkout@v4
- name: Run delete-old-branches-action
uses: beatlabs/delete-old-branches-action@4eeeb8740ff8b3cb310296ddd6b43c3387734588 # v0.0.11
uses: beatlabs/delete-old-branches-action@v0.0.10
with:
repo_token: ${{ github.token }}
date: "6 months ago"
@@ -113,17 +92,9 @@ jobs:
exclude_open_pr_branches: true
del_runs:
runs-on: ubuntu-latest
permissions:
# Needed to delete workflow runs.
actions: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Delete PR Cleanup workflow runs
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
uses: Mattraks/delete-workflow-runs@v2
with:
token: ${{ github.token }}
repository: ${{ github.repository }}
@@ -132,7 +103,7 @@ jobs:
delete_workflow_pattern: pr-cleanup.yaml
- name: Delete PR Deploy workflow skipped runs
uses: Mattraks/delete-workflow-runs@39f0bbed25d76b34de5594dceab824811479e5de # v2.0.6
uses: Mattraks/delete-workflow-runs@v2
with:
token: ${{ github.token }}
repository: ${{ github.repository }}
-35
View File
@@ -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
+3 -17
View File
@@ -1,6 +1,3 @@
[default]
extend-ignore-identifiers-re = ["gho_.*"]
[default.extend-identifiers]
alog = "alog"
Jetbrains = "JetBrains"
@@ -17,17 +14,8 @@ darcula = "darcula"
Hashi = "Hashi"
trialer = "trialer"
encrypter = "encrypter"
# as in helsinki
hel = "hel"
# this is used as proto node
pn = "pn"
# typos doesn't like the EDE in TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA
EDE = "EDE"
# HELO is an SMTP command
HELO = "HELO"
LKE = "LKE"
byt = "byt"
typ = "typ"
hel = "hel" # as in helsinki
pn = "pn" # this is used as proto node
[files]
extend-exclude = [
@@ -39,13 +27,11 @@ extend-exclude = [
# These files contain base64 strings that confuse the detector
"**XService**.ts",
"**identity.go",
"scripts/ci-report/testdata/**",
"**/*_test.go",
"**/*.test.tsx",
"**/pnpm-lock.yaml",
"tailnet/testdata/**",
"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/**"
]
+8 -18
View File
@@ -10,33 +10,23 @@ on:
paths:
- "docs/**"
permissions:
contents: read
jobs:
check-docs:
# later versions of Ubuntu have disabled unprivileged user namespaces, which are required by the action
runs-on: ubuntu-22.04
permissions:
pull-requests: write # required to post PR review comments by the action
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@master
- name: Check Markdown links
uses: umbrelladocs/action-linkspector@a0567ce1c7c13de4a2358587492ed43cab5d0102 # v1.3.4
uses: gaurav-nelson/github-action-markdown-link-check@v1
id: markdown-link-check
# checks all markdown files from /docs including all subfolders
with:
reporter: github-pr-review
config_file: ".github/.linkspector.yml"
fail_on_error: "true"
filter_mode: "nofilter"
use-quiet-mode: "yes"
use-verbose-mode: "yes"
config-file: ".github/workflows/mlc_config.json"
folder-path: "docs/"
file-path: "./README.md"
- name: Send Slack notification
if: failure() && github.event_name == 'schedule'
+1 -15
View File
@@ -17,8 +17,6 @@ yarn-error.log
# Allow VSCode recommendations and default settings in project root.
!/.vscode/extensions.json
!/.vscode/settings.json
# Allow code snippets
!/.vscode/*.code-snippets
# Front-end ignore patterns.
.next/
@@ -32,12 +30,10 @@ 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
bin/
build/
dist/
out/
@@ -56,7 +52,6 @@ site/stats/
# direnv
.envrc
.direnv
*.test
# Loadtesting
@@ -73,12 +68,3 @@ result
# Filebrowser.db
**/filebrowser.db
# pnpm
.pnpm-store/
# Zed
.zed_server
# dlv debug binaries for go tests
__debug_bin*
+32 -21
View File
@@ -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
@@ -151,6 +175,8 @@ linters-settings:
- name: modifies-value-receiver
- name: package-comments
- name: range
- name: range-val-address
- name: range-val-in-closure
- name: receiver-naming
- name: redefines-builtin-id
- name: string-of-int
@@ -164,30 +190,12 @@ linters-settings:
- name: unnecessary-stmt
- name: unreachable-code
- name: unused-parameter
exclude: "**/*_test.go"
- name: unused-receiver
- name: var-declaration
- name: var-naming
- name: waitgroup-by-value
# irrelevant as of Go v1.22: https://go.dev/blog/loopvar-preview
govet:
disable:
- loopclosure
gosec:
excludes:
# Implicit memory aliasing of items from a range statement (irrelevant as of Go v1.22)
- 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 +207,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
@@ -223,6 +233,7 @@ linters:
- errname
- errorlint
- exhaustruct
- exportloopref
- forcetypeassert
- gocritic
# gocyclo is may be useful in the future when we start caring
-31
View File
@@ -1,31 +0,0 @@
// Example markdownlint configuration with all properties set to their default value
{
"MD010": { "spaces_per_tab": 4}, // No hard tabs: we use 4 spaces per tab
"MD013": false, // Line length: we are not following a strict line lnegth in markdown files
"MD024": { "siblings_only": true }, // Multiple headings with the same content:
"MD033": false, // Inline HTML: we use it in some places
"MD034": false, // Bare URL: we use it in some places in generated docs e.g.
// codersdk/deployment.go L597, L1177, L2287, L2495, L2533
// codersdk/workspaceproxy.go L196, L200-L201
// coderd/tracing/exporter.go L26
// cli/exp_scaletest.go L-9
"MD041": false, // First line in file should be a top level heading: All of our changelogs do not start with a top level heading
// TODO: We need to update /home/coder/repos/coder/coder/scripts/release/generate_release_notes.sh to generate changelogs that follow this rule
"MD052": false, // Image reference: Not a valid reference in generated docs
// docs/reference/cli/server.md L628
"MD055": false, // Table pipe style: Some of the generated tables do not have ending pipes
// docs/reference/api/schema.md
// docs/reference/api/templates.md
// docs/reference/cli/server.md
"MD056": false // Table column count: Some of the auto-generated tables have issues. TODO: This is probably because of splitting cell content to multiple lines.
// docs/reference/api/schema.md
// docs/reference/api/templates.md
}
+94
View File
@@ -0,0 +1,94 @@
# Code generated by Makefile (.gitignore .prettierignore.include). DO NOT EDIT.
# .gitignore:
# Common ignore patterns, these rules applies in both root and subdirectories.
.DS_Store
.eslintcache
.gitpod.yml
.idea
**/*.swp
gotests.coverage
gotests.xml
gotests_stats.json
gotests.json
node_modules/
vendor/
yarn-error.log
# VSCode settings.
**/.vscode/*
# Allow VSCode recommendations and default settings in project root.
!/.vscode/extensions.json
!/.vscode/settings.json
# Front-end ignore patterns.
.next/
site/build-storybook.log
site/coverage/
site/storybook-static/
site/test-results/*
site/e2e/test-results/*
site/e2e/states/*.json
site/e2e/.auth.json
site/playwright-report/*
site/.swc
# Make target for updating golden files (any dir).
.gen-golden
# Build
build/
dist/
out/
# Bundle analysis
site/stats/
*.tfstate
*.tfstate.backup
*.tfplan
*.lock.hcl
.terraform/
**/.coderv2/*
**/__debug_bin
# direnv
.envrc
*.test
# Loadtesting
./scaletest/terraform/.terraform
./scaletest/terraform/.terraform.lock.hcl
scaletest/terraform/secrets.tfvars
.terraform.tfstate.*
# Nix
result
# Data dumps from unit tests
**/*.test.sql
# Filebrowser.db
**/filebrowser.db
# .prettierignore.include:
# Helm templates contain variables that are invalid YAML and can't be formatted
# by Prettier.
helm/**/templates/*.yaml
# Terraform state files used in tests, these are automatically generated.
# Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json
**/testdata/**/*.tf*.json
# Testdata shouldn't be formatted.
scripts/apitypings/testdata/**/*.ts
enterprise/tailnet/testdata/*.golden.html
tailnet/testdata/*.golden.html
# Generated files shouldn't be formatted.
site/e2e/provisionerGenerated.ts
**/pnpm-lock.yaml
# Ignore generated JSON (e.g. examples/examples.gen.json).
**/*.gen.json
+20
View File
@@ -0,0 +1,20 @@
# Helm templates contain variables that are invalid YAML and can't be formatted
# by Prettier.
helm/**/templates/*.yaml
# Terraform state files used in tests, these are automatically generated.
# Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json
**/testdata/**/*.tf*.json
# Testdata shouldn't be formatted.
scripts/apitypings/testdata/**/*.ts
enterprise/tailnet/testdata/*.golden.html
tailnet/testdata/*.golden.html
# Generated files shouldn't be formatted.
site/e2e/provisionerGenerated.ts
**/pnpm-lock.yaml
# Ignore generated JSON (e.g. examples/examples.gen.json).
**/*.gen.json
+3 -3
View File
@@ -4,13 +4,13 @@
printWidth: 80
proseWrap: always
trailingComma: all
useTabs: true
useTabs: false
tabWidth: 2
overrides:
- files:
- README.md
- docs/reference/api/**/*.md
- docs/reference/cli/**/*.md
- docs/api/**/*.md
- docs/cli/**/*.md
- docs/changelogs/*.md
- .github/**/*.{yaml,yml,toml}
- scripts/**/*.{yaml,yml,toml}
+13 -14
View File
@@ -1,16 +1,15 @@
{
"recommendations": [
"biomejs.biome",
"bradlc.vscode-tailwindcss",
"DavidAnson.vscode-markdownlint",
"EditorConfig.EditorConfig",
"emeraldwalk.runonsave",
"foxundermoon.shell-format",
"github.vscode-codeql",
"golang.go",
"hashicorp.terraform",
"redhat.vscode-yaml",
"tekumara.typos-vscode",
"zxh404.vscode-proto3"
]
"recommendations": [
"github.vscode-codeql",
"golang.go",
"hashicorp.terraform",
"esbenp.prettier-vscode",
"foxundermoon.shell-format",
"emeraldwalk.runonsave",
"zxh404.vscode-proto3",
"redhat.vscode-yaml",
"streetsidesoftware.code-spell-checker",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig"
]
}
-45
View File
@@ -1,45 +0,0 @@
{
// 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",
"body": [
"> [!${1|CAUTION,IMPORTANT,NOTE,TIP,WARNING|}]",
"> ${TM_SELECTED_TEXT:${2:add info here}}\n"
],
"description": "callout admonition caution important note tip warning"
},
"fenced code block": {
"prefix": "#codeblock",
"body": ["```${1|apache,bash,console,diff,Dockerfile,env,go,hcl,ini,json,lisp,md,powershell,shell,sql,text,tf,tsx,yaml|}", "${TM_SELECTED_TEXT}$0", "```"],
"description": "fenced code block"
},
"image": {
"prefix": "#image",
"body": "![${TM_SELECTED_TEXT:${1:alt}}](${2:url})$0",
"description": "image"
},
"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"
]
},
"tabs": {
"prefix": "#tabs",
"body": [
"<div class=\"tabs\">\n",
"${1:optional description}\n",
"## ${2:tab title}\n",
"${TM_SELECTED_TEXT:${3:first tab content}}\n",
"## ${4:tab title}\n",
"${5:second tab content}\n",
"## ${6:tab title}\n",
"${7:third tab content}\n",
"</div>\n"
],
"description": "tabs"
}
}
+225 -62
View File
@@ -1,64 +1,227 @@
{
"emeraldwalk.runonsave": {
"commands": [
{
"match": "database/queries/*.sql",
"cmd": "make gen"
},
{
"match": "provisionerd/proto/provisionerd.proto",
"cmd": "make provisionerd/proto/provisionerd.pb.go"
}
]
},
"search.exclude": {
"**.pb.go": true,
"**/*.gen.json": true,
"**/testdata/*": true,
"coderd/apidoc/**": true,
"docs/reference/api/*.md": true,
"docs/reference/cli/*.md": true,
"docs/templates/*.md": true,
"LICENSE": true,
"scripts/metricsdocgen/metrics": true,
"site/out/**": true,
"site/storybook-static/**": true,
"**.map": true,
"pnpm-lock.yaml": true
},
// Ensure files always have a newline.
"files.insertFinalNewline": true,
"go.lintTool": "golangci-lint",
"go.lintFlags": ["--fast"],
"go.coverageDecorator": {
"type": "gutter",
"coveredGutterStyle": "blockgreen",
"uncoveredGutterStyle": "blockred"
},
// The codersdk is used by coderd another other packages extensively.
// To reduce redundancy in tests, it's covered by other packages.
// Since package coverage pairing can't be defined, all packages cover
// all other packages.
"go.testFlags": ["-short", "-coverpkg=./..."],
// We often use a version of TypeScript that's ahead of the version shipped
// with VS Code.
"typescript.tsdk": "./site/node_modules/typescript/lib",
// Playwright tests in VSCode will open a browser to live "view" the test.
"playwright.reuseBrowser": true,
"[javascript][javascriptreact][json][jsonc][typescript][typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit"
// "source.organizeImports.biome": "explicit"
}
},
"[css][html][markdown][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typos.config": ".github/workflows/typos.toml",
"[markdown]": {
"editor.defaultFormatter": "DavidAnson.vscode-markdownlint"
}
"cSpell.words": [
"afero",
"agentsdk",
"apps",
"ASKPASS",
"authcheck",
"autostop",
"awsidentity",
"bodyclose",
"buildinfo",
"buildname",
"circbuf",
"cliflag",
"cliui",
"codecov",
"coderd",
"coderdenttest",
"coderdtest",
"codersdk",
"contravariance",
"cronstrue",
"databasefake",
"dbgen",
"dbmem",
"dbtype",
"DERP",
"derphttp",
"derpmap",
"devel",
"devtunnel",
"dflags",
"drpc",
"drpcconn",
"drpcmux",
"drpcserver",
"Dsts",
"embeddedpostgres",
"enablements",
"enterprisemeta",
"errgroup",
"eventsourcemock",
"externalauth",
"Failf",
"fatih",
"Formik",
"gitauth",
"gitsshkey",
"goarch",
"gographviz",
"goleak",
"gonet",
"gossh",
"gsyslog",
"GTTY",
"hashicorp",
"hclsyntax",
"httpapi",
"httpmw",
"idtoken",
"Iflag",
"incpatch",
"initialisms",
"ipnstate",
"isatty",
"Jobf",
"Keygen",
"kirsle",
"Kubernetes",
"ldflags",
"magicsock",
"manifoldco",
"mapstructure",
"mattn",
"mitchellh",
"moby",
"namesgenerator",
"namespacing",
"netaddr",
"netip",
"netmap",
"netns",
"netstack",
"nettype",
"nfpms",
"nhooyr",
"nmcfg",
"nolint",
"nosec",
"ntqry",
"OIDC",
"oneof",
"opty",
"paralleltest",
"parameterscopeid",
"pqtype",
"prometheusmetrics",
"promhttp",
"protobuf",
"provisionerd",
"provisionerdserver",
"provisionersdk",
"ptty",
"ptys",
"ptytest",
"quickstart",
"reconfig",
"replicasync",
"retrier",
"rpty",
"SCIM",
"sdkproto",
"sdktrace",
"Signup",
"slogtest",
"sourcemapped",
"spinbutton",
"Srcs",
"stdbuf",
"stretchr",
"STTY",
"stuntest",
"tailbroker",
"tailcfg",
"tailexchange",
"tailnet",
"tailnettest",
"Tailscale",
"tanstack",
"tbody",
"TCGETS",
"tcpip",
"TCSETS",
"templateversions",
"testdata",
"testid",
"testutil",
"tfexec",
"tfjson",
"tfplan",
"tfstate",
"thead",
"tios",
"tmpdir",
"tokenconfig",
"Topbar",
"tparallel",
"trialer",
"trimprefix",
"tsdial",
"tslogger",
"tstun",
"turnconn",
"typegen",
"typesafe",
"unconvert",
"Untar",
"Userspace",
"VMID",
"walkthrough",
"weblinks",
"webrtc",
"wgcfg",
"wgconfig",
"wgengine",
"wgmonitor",
"wgnet",
"workspaceagent",
"workspaceagents",
"workspaceapp",
"workspaceapps",
"workspacebuilds",
"workspacename",
"wsjson",
"xerrors",
"xlarge",
"xsmall",
"yamux"
],
"cSpell.ignorePaths": ["site/package.json", ".vscode/settings.json"],
"emeraldwalk.runonsave": {
"commands": [
{
"match": "database/queries/*.sql",
"cmd": "make gen"
},
{
"match": "provisionerd/proto/provisionerd.proto",
"cmd": "make provisionerd/proto/provisionerd.pb.go"
}
]
},
"eslint.workingDirectories": ["./site"],
"search.exclude": {
"**.pb.go": true,
"**/*.gen.json": true,
"**/testdata/*": true,
"coderd/apidoc/**": true,
"docs/api/*.md": true,
"docs/templates/*.md": true,
"LICENSE": true,
"scripts/metricsdocgen/metrics": true,
"site/out/**": true,
"site/storybook-static/**": true,
"**.map": true,
"pnpm-lock.yaml": true
},
// Ensure files always have a newline.
"files.insertFinalNewline": true,
"go.lintTool": "golangci-lint",
"go.lintFlags": ["--fast"],
"go.coverageDecorator": {
"type": "gutter",
"coveredGutterStyle": "blockgreen",
"uncoveredGutterStyle": "blockred"
},
// The codersdk is used by coderd another other packages extensively.
// To reduce redundancy in tests, it's covered by other packages.
// Since package coverage pairing can't be defined, all packages cover
// all other packages.
"go.testFlags": ["-short", "-coverpkg=./..."],
// We often use a version of TypeScript that's ahead of the version shipped
// with VS Code.
"typescript.tsdk": "./site/node_modules/typescript/lib",
// Playwright tests in VSCode will open a browser to live "view" the test.
"playwright.reuseBrowser": true
}
-6
View File
@@ -1,6 +0,0 @@
# These APIs are versioned, so any changes need to be carefully reviewed for whether
# to bump API major or minor versions.
agent/proto/ @spikecurtis @johnstcn
tailnet/proto/ @spikecurtis @johnstcn
vpn/vpn.proto @spikecurtis @johnstcn
vpn/version.go @spikecurtis @johnstcn
-2
View File
@@ -1,2 +0,0 @@
<!-- markdownlint-disable MD041 -->
[https://coder.com/docs/contributing/CODE_OF_CONDUCT](https://coder.com/docs/contributing/CODE_OF_CONDUCT)
-2
View File
@@ -1,2 +0,0 @@
<!-- markdownlint-disable MD041 -->
[https://coder.com/docs/CONTRIBUTING](https://coder.com/docs/CONTRIBUTING)
+172 -339
View File
@@ -36,7 +36,6 @@ GOOS := $(shell go env GOOS)
GOARCH := $(shell go env GOARCH)
GOOS_BIN_EXT := $(if $(filter windows, $(GOOS)),.exe,)
VERSION := $(shell ./scripts/version.sh)
POSTGRES_VERSION ?= 16
# Use the highest ZSTD compression level in CI.
ifdef CI
@@ -54,16 +53,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')
@@ -89,12 +78,8 @@ PACKAGE_OS_ARCHES := linux_amd64 linux_armv7 linux_arm64
# All architectures we build Docker images for (Linux only).
DOCKER_ARCHES := amd64 arm64 armv7
# All ${OS}_${ARCH} combos we build the desktop dylib for.
DYLIB_ARCHES := darwin_amd64 darwin_arm64
# Computed variables based on the above.
CODER_SLIM_BINARIES := $(addprefix build/coder-slim_$(VERSION)_,$(OS_ARCHES))
CODER_DYLIBS := $(foreach os_arch, $(DYLIB_ARCHES), build/coder-vpn_$(VERSION)_$(os_arch).dylib)
CODER_FAT_BINARIES := $(addprefix build/coder_$(VERSION)_,$(OS_ARCHES))
CODER_ALL_BINARIES := $(CODER_SLIM_BINARIES) $(CODER_FAT_BINARIES)
CODER_TAR_GZ_ARCHIVES := $(foreach os_arch, $(ARCHIVE_TAR_GZ), build/coder_$(VERSION)_$(os_arch).tar.gz)
@@ -126,7 +111,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
@@ -252,26 +237,6 @@ $(CODER_ALL_BINARIES): go.mod go.sum \
cp "$@" "./site/out/bin/coder-$$os-$$arch$$dot_ext"
fi
# This task builds Coder Desktop dylibs
$(CODER_DYLIBS): go.mod go.sum $(MOST_GO_SRC_FILES)
@if [ "$(shell uname)" = "Darwin" ]; then
$(get-mode-os-arch-ext)
./scripts/build_go.sh \
--os "$$os" \
--arch "$$arch" \
--version "$(VERSION)" \
--output "$@" \
--dylib
else
echo "ERROR: Can't build dylib on non-Darwin OS" 1>&2
exit 1
fi
# This task builds both dylibs
build/coder-dylib: $(CODER_DYLIBS)
.PHONY: build/coder-dylib
# This task builds all archives. It parses the target name to get the metadata
# for the build, so it must be specified in this format:
# build/coder_${version}_${os}_${arch}.${format}
@@ -398,40 +363,15 @@ $(foreach chart,$(charts),build/$(chart)_helm_$(VERSION).tgz): build/%_helm_$(VE
--chart $* \
--output "$@"
node_modules/.installed: package.json pnpm-lock.yaml
./scripts/pnpm_install.sh
touch "$@"
offlinedocs/node_modules/.installed: offlinedocs/package.json offlinedocs/pnpm-lock.yaml
(cd offlinedocs/ && ../scripts/pnpm_install.sh)
touch "$@"
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_GEN_FILES := \
site/src/api/typesGenerated.ts \
site/src/api/rbacresourcesGenerated.ts \
site/src/api/countriesGenerated.ts \
site/src/theme/icons.json
site/out/index.html: \
site/node_modules/.installed \
site/static/install.sh \
$(SITE_GEN_FILES) \
$(shell find ./site $(FIND_EXCLUSIONS) -type f \( -name '*.ts' -o -name '*.tsx' \))
cd site/
site/out/index.html: site/package.json $(shell find ./site $(FIND_EXCLUSIONS) -type f \( -name '*.ts' -o -name '*.tsx' \))
cd site
# prevents this directory from getting to big, and causing "too much data" errors
rm -rf out/assets/
../scripts/pnpm_install.sh
pnpm build
offlinedocs/out/index.html: offlinedocs/node_modules/.installed $(shell find ./offlinedocs $(FIND_EXCLUSIONS) -type f) $(shell find ./docs $(FIND_EXCLUSIONS) -type f | sed 's: :\\ :g')
cd offlinedocs/
offlinedocs/out/index.html: $(shell find ./offlinedocs $(FIND_EXCLUSIONS) -type f) $(shell find ./docs $(FIND_EXCLUSIONS) -type f | sed 's: :\\ :g')
cd offlinedocs
../scripts/pnpm_install.sh
pnpm export
@@ -450,40 +390,32 @@ BOLD := $(shell tput bold 2>/dev/null)
GREEN := $(shell tput setaf 2 2>/dev/null)
RESET := $(shell tput sgr0 2>/dev/null)
fmt: fmt/ts fmt/go fmt/terraform fmt/shfmt fmt/biome fmt/markdown
fmt: fmt/eslint fmt/prettier fmt/terraform fmt/shfmt fmt/go
.PHONY: fmt
fmt/go:
go mod tidy
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/go$(RESET)"
# VS Code users should check out
# https://github.com/mvdan/gofumpt#visual-studio-code
find . $(FIND_EXCLUSIONS) -type f -name '*.go' -print0 | \
xargs -0 grep --null -L "DO NOT EDIT" | \
xargs -0 go run mvdan.cc/gofumpt@v0.4.0 -w -l
go run mvdan.cc/gofumpt@v0.4.0 -w -l .
.PHONY: fmt/go
fmt/ts: site/node_modules/.installed
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/ts$(RESET)"
fmt/eslint:
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/eslint$(RESET)"
cd site
# Avoid writing files in CI to reduce file write activity
ifdef CI
pnpm run check --linter-enabled=false
else
pnpm run check:fix
endif
.PHONY: fmt/ts
pnpm run lint:fix
.PHONY: fmt/eslint
fmt/biome: site/node_modules/.installed
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/biome$(RESET)"
cd site/
fmt/prettier:
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/prettier$(RESET)"
cd site
# Avoid writing files in CI to reduce file write activity
ifdef CI
pnpm run format:check
else
pnpm run format
endif
.PHONY: fmt/biome
.PHONY: fmt/prettier
fmt/terraform: $(wildcard *.tf)
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/terraform$(RESET)"
@@ -500,28 +432,23 @@ else
endif
.PHONY: fmt/shfmt
fmt/markdown: node_modules/.installed
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/markdown$(RESET)"
pnpm format-docs
.PHONY: fmt/markdown
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons lint/markdown
lint: lint/shellcheck lint/go lint/ts lint/examples lint/helm lint/site-icons
.PHONY: lint
lint/site-icons:
./scripts/check_site_icons.sh
.PHONY: lint/site-icons
lint/ts: site/node_modules/.installed
cd site/
pnpm lint
lint/ts:
cd site
pnpm i && pnpm lint
.PHONY: lint/ts
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)
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/Dockerfile | cut -d '=' -f 2)
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver
golangci-lint run
.PHONY: lint/go
lint/examples:
@@ -535,18 +462,13 @@ lint/shellcheck: $(SHELL_SRC_FILES)
.PHONY: lint/shellcheck
lint/helm:
cd helm/
cd helm
make lint
.PHONY: lint/helm
lint/markdown: node_modules/.installed
pnpm lint-docs
.PHONY: lint/markdown
# All files generated by the database should be added here, and this can be used
# as a target for jobs that need to run after the database is generated.
DB_GEN_FILES := \
coderd/database/dump.sql \
coderd/database/querier.go \
coderd/database/unique_constraint.go \
coderd/database/dbmem/dbmem.go \
@@ -554,55 +476,35 @@ DB_GEN_FILES := \
coderd/database/dbauthz/dbauthz.go \
coderd/database/dbmock/dbmock.go
TAILNETTEST_MOCKS := \
tailnet/tailnettest/coordinatormock.go \
tailnet/tailnettest/coordinateemock.go \
tailnet/tailnettest/workspaceupdatesprovidermock.go \
tailnet/tailnettest/subscriptionmock.go
GEN_FILES := \
# all gen targets should be added here and to gen/mark-fresh
gen: \
tailnet/proto/tailnet.pb.go \
agent/proto/agent.pb.go \
provisionersdk/proto/provisioner.pb.go \
provisionerd/proto/provisionerd.pb.go \
vpn/vpn.pb.go \
coderd/database/dump.sql \
$(DB_GEN_FILES) \
$(SITE_GEN_FILES) \
site/src/api/typesGenerated.ts \
coderd/rbac/object_gen.go \
codersdk/rbacresources_gen.go \
docs/admin/integrations/prometheus.md \
docs/reference/cli/index.md \
docs/admin/security/audit-logs.md \
docs/admin/prometheus.md \
docs/cli.md \
docs/admin/audit-logs.md \
coderd/apidoc/swagger.json \
docs/manifest.json \
.prettierignore.include \
.prettierignore \
provisioner/terraform/testdata/version \
site/.prettierrc.yaml \
site/.prettierignore \
site/.eslintignore \
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
# all gen targets should be added here and to gen/mark-fresh
gen: gen/db gen/golden-files $(GEN_FILES)
tailnet/tailnettest/coordinatormock.go \
tailnet/tailnettest/coordinateemock.go \
tailnet/tailnettest/multiagentmock.go
.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:
@@ -611,29 +513,26 @@ gen/mark-fresh:
agent/proto/agent.pb.go \
provisionersdk/proto/provisioner.pb.go \
provisionerd/proto/provisionerd.pb.go \
vpn/vpn.pb.go \
coderd/database/dump.sql \
$(DB_GEN_FILES) \
site/src/api/typesGenerated.ts \
coderd/rbac/object_gen.go \
codersdk/rbacresources_gen.go \
site/src/api/rbacresourcesGenerated.ts \
site/src/api/countriesGenerated.ts \
docs/admin/integrations/prometheus.md \
docs/reference/cli/index.md \
docs/admin/security/audit-logs.md \
docs/admin/prometheus.md \
docs/cli.md \
docs/admin/audit-logs.md \
coderd/apidoc/swagger.json \
docs/manifest.json \
.prettierignore.include \
.prettierignore \
site/.prettierrc.yaml \
site/.prettierignore \
site/.eslintignore \
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 \
"
tailnet/tailnettest/coordinatormock.go \
tailnet/tailnettest/coordinateemock.go \
tailnet/tailnettest/multiagentmock.go \
"
for file in $$files; do
echo "$$file"
if [ ! -f "$$file" ]; then
@@ -642,7 +541,7 @@ gen/mark-fresh:
fi
# touch sets the mtime of the file to the current time
touch "$$file"
touch $$file
done
.PHONY: gen/mark-fresh
@@ -650,42 +549,21 @@ 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
tailnet/tailnettest/coordinatormock.go tailnet/tailnettest/multiagentmock.go tailnet/tailnettest/coordinateemock.go: tailnet/coordinator.go tailnet/multiagent.go
go generate ./tailnet/tailnettest/
touch "$@"
tailnet/proto/tailnet.pb.go: tailnet/proto/tailnet.proto
protoc \
@@ -719,154 +597,92 @@ provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
--go-drpc_opt=paths=source_relative \
./provisionerd/proto/provisionerd.proto
vpn/vpn.pb.go: vpn/vpn.proto
protoc \
--go_out=. \
--go_opt=paths=source_relative \
./vpn/vpn.proto
site/src/api/typesGenerated.ts: $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
go run ./scripts/apitypings/ > $@
./scripts/pnpm_install.sh
pnpm exec prettier --write "$@"
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 "$@"
site/e2e/provisionerGenerated.ts: provisionerd/proto/provisionerd.pb.go provisionersdk/proto/provisioner.pb.go
cd site
../scripts/pnpm_install.sh
pnpm run gen:provisioner
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 "$@"
site/src/theme/icons.json: site/node_modules/.installed $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*)
site/src/theme/icons.json: $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*)
go run ./scripts/gensite/ -icons "$@"
(cd site/ && pnpm exec biome format --write src/theme/icons.json)
touch "$@"
./scripts/pnpm_install.sh
pnpm exec prettier --write "$@"
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 "$@"
coderd/rbac/object_gen.go: scripts/rbacgen/main.go coderd/rbac/object.go
go run scripts/rbacgen/main.go rbac > coderd/rbac/object_gen.go
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 "$@"
codersdk/rbacresources_gen.go: scripts/rbacgen/main.go coderd/rbac/object.go
go run scripts/rbacgen/main.go codersdk > codersdk/rbacresources_gen.go
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 "$@"
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 "$@"
docs/admin/integrations/prometheus.md: node_modules/.installed scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
docs/admin/prometheus.md: 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 "$@"
./scripts/pnpm_install.sh
pnpm exec prettier --write ./docs/admin/prometheus.md
docs/reference/cli/index.md: node_modules/.installed scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
docs/cli.md: 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 "$@"
./scripts/pnpm_install.sh
pnpm exec prettier --write ./docs/cli.md ./docs/cli/*.md ./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
docs/admin/audit-logs.md: 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 "$@"
./scripts/pnpm_install.sh
pnpm exec prettier --write ./docs/admin/audit-logs.md
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: $(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 "$@"
./scripts/pnpm_install.sh
pnpm exec prettier --write ./docs/api ./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 \
helm/coder/tests/testdata/.gen-golden \
helm/provisioner/tests/testdata/.gen-golden \
scripts/ci-report/testdata/.gen-golden \
enterprise/cli/testdata/.gen-golden \
enterprise/tailnet/testdata/.gen-golden \
tailnet/testdata/.gen-golden \
coderd/.gen-golden \
provisioner/terraform/testdata/.gen-golden
.PHONY: update-golden-files
clean/golden-files:
find . -type f -name '.gen-golden' -delete
find \
cli/testdata \
coderd/notifications/testdata \
coderd/testdata \
enterprise/cli/testdata \
enterprise/tailnet/testdata \
helm/coder/tests/testdata \
helm/provisioner/tests/testdata \
provisioner/terraform/testdata \
tailnet/testdata \
-type f -name '*.golden' -delete
.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)" -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
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 -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:
@@ -875,13 +691,73 @@ provisioner/terraform/testdata/version:
fi
.PHONY: provisioner/terraform/testdata/version
test:
$(GIT_FLAGS) gotestsum --format standard-quiet -- -v -short -count=1 ./... $(if $(RUN),-run $(RUN))
.PHONY: test
scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go)
go test ./scripts/ci-report -run=TestOutputMatchesGoldenFile -update
touch "$@"
test-cli:
$(GIT_FLAGS) gotestsum --format standard-quiet -- -v -short -count=1 ./cli/...
.PHONY: test-cli
# Generate a prettierrc for the site package that uses relative paths for
# overrides. This allows us to share the same prettier config between the
# site and the root of the repo.
site/.prettierrc.yaml: .prettierrc.yaml
. ./scripts/lib.sh
dependencies yq
echo "# Code generated by Makefile (../$<). DO NOT EDIT." > "$@"
echo "" >> "$@"
# Replace all listed override files with relative paths inside site/.
# - ./ -> ../
# - ./site -> ./
yq \
'.overrides[].files |= map(. | sub("^./"; "") | sub("^"; "../") | sub("../site/"; "./") | sub("../!"; "!../"))' \
"$<" >> "$@"
# Combine .gitignore with .prettierignore.include to generate .prettierignore.
.prettierignore: .gitignore .prettierignore.include
echo "# Code generated by Makefile ($^). DO NOT EDIT." > "$@"
echo "" >> "$@"
for f in $^; do
echo "# $${f}:" >> "$@"
cat "$$f" >> "$@"
done
# Generate ignore files based on gitignore into the site directory. We turn all
# rules into relative paths for the `site/` directory (where applicable),
# following the pattern format defined by git:
# https://git-scm.com/docs/gitignore#_pattern_format
#
# This is done for compatibility reasons, see:
# https://github.com/prettier/prettier/issues/8048
# https://github.com/prettier/prettier/issues/8506
# https://github.com/prettier/prettier/issues/8679
site/.eslintignore site/.prettierignore: .prettierignore Makefile
rm -f "$@"
touch "$@"
# Skip generated by header, inherit `.prettierignore` header as-is.
while read -r rule; do
# Remove leading ! if present to simplify rule, added back at the end.
tmp="$${rule#!}"
ignore="$${rule%"$$tmp"}"
rule="$$tmp"
case "$$rule" in
# Comments or empty lines (include).
\#*|'') ;;
# Generic rules (include).
\*\**) ;;
# Site prefixed rules (include).
site/*) rule="$${rule#site/}";;
./site/*) rule="$${rule#./site/}";;
# Rules that are non-generic and don't start with site (rewrite).
/*) rule=.."$$rule";;
*/?*) rule=../"$$rule";;
*) ;;
esac
echo "$${ignore}$${rule}" >> "$@"
done < "$<"
test:
$(GIT_FLAGS) gotestsum --format standard-quiet -- -v -short -count=1 ./...
.PHONY: test
# sqlc-cloud-is-setup will fail if no SQLc auth token is set. Use this as a
# dependency for any sqlc-cloud related targets.
@@ -916,7 +792,7 @@ sqlc-vet: test-postgres-docker
test-postgres: test-postgres-docker
# The postgres test is prone to failure, so we limit parallelism for
# more consistent execution.
$(GIT_FLAGS) DB=ci gotestsum \
$(GIT_FLAGS) DB=ci DB_FROM=$(shell go run scripts/migrate-ci/main.go) gotestsum \
--junitfile="gotests.xml" \
--jsonfile="gotests.json" \
--packages="./..." -- \
@@ -935,35 +811,10 @@ test-migrations: test-postgres-docker
if [[ "$${COMMIT_FROM}" == "$${COMMIT_TO}" ]]; then echo "Nothing to do!"; exit 0; fi
echo "DROP DATABASE IF EXISTS migrate_test_$${COMMIT_FROM}; CREATE DATABASE migrate_test_$${COMMIT_FROM};" | psql 'postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable'
go run ./scripts/migrate-test/main.go --from="$$COMMIT_FROM" --to="$$COMMIT_TO" --postgres-url="postgresql://postgres:postgres@localhost:5432/migrate_test_$${COMMIT_FROM}?sslmode=disable"
.PHONY: test-migrations
# NOTE: we set --memory to the same size as a GitHub runner.
test-postgres-docker:
docker rm -f test-postgres-docker-${POSTGRES_VERSION} || true
# Try pulling up to three times to avoid CI flakes.
docker pull gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION} || {
retries=2
for try in $(seq 1 ${retries}); do
echo "Failed to pull image, retrying (${try}/${retries})..."
sleep 1
if docker pull gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION}; then
break
fi
done
}
# Make sure to not overallocate work_mem and max_connections as each
# connection will be allowed to use this much memory. Try adjusting
# shared_buffers instead, if needed.
#
# - work_mem=8MB * max_connections=1000 = 8GB
# - shared_buffers=2GB + effective_cache_size=1GB = 3GB
#
# This leaves 5GB for the rest of the system _and_ storing the
# database in memory (--tmpfs).
#
# https://www.postgresql.org/docs/current/runtime-config-resource.html#GUC-WORK-MEM
docker rm -f test-postgres-docker || true
docker run \
--env POSTGRES_PASSWORD=postgres \
--env POSTGRES_USER=postgres \
@@ -971,14 +822,14 @@ test-postgres-docker:
--env PGDATA=/tmp \
--tmpfs /tmp \
--publish 5432:5432 \
--name test-postgres-docker-${POSTGRES_VERSION} \
--name test-postgres-docker \
--restart no \
--detach \
--memory 16GB \
gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION} \
-c shared_buffers=2GB \
gcr.io/coder-dev-1/postgres:13 \
-c shared_buffers=1GB \
-c work_mem=1GB \
-c effective_cache_size=1GB \
-c work_mem=8MB \
-c max_connections=1000 \
-c fsync=off \
-c synchronous_commit=off \
@@ -993,7 +844,7 @@ test-postgres-docker:
# Make sure to keep this in sync with test-go-race from .github/workflows/ci.yaml.
test-race:
$(GIT_FLAGS) gotestsum --junitfile="gotests.xml" -- -race -count=1 -parallel 4 -p 4 ./...
$(GIT_FLAGS) gotestsum --junitfile="gotests.xml" -- -race -count=1 ./...
.PHONY: test-race
test-tailnet-integration:
@@ -1007,7 +858,6 @@ test-tailnet-integration:
-timeout=5m \
-count=1 \
./tailnet/test/integration
.PHONY: test-tailnet-integration
# Note: we used to add this to the test target, but it's not necessary and we can
# achieve the desired result by specifying -count=1 in the go test invocation
@@ -1015,20 +865,3 @@ test-tailnet-integration:
test-clean:
go clean -testcache
.PHONY: test-clean
site/e2e/bin/coder: go.mod go.sum $(GO_SRC_FILES)
go build -o $@ \
-tags ts_omit_aws,ts_omit_bird,ts_omit_tap,ts_omit_kube \
./enterprise/cmd/coder
test-e2e: site/e2e/bin/coder site/node_modules/.installed site/out/index.html
cd site/
ifdef CI
DEBUG=pw:api pnpm playwright:test --forbid-only --workers 1
else
pnpm playwright:test
endif
.PHONY: test-e2e
dogfood/coder/nix.hash: flake.nix flake.lock
sha256sum flake.nix flake.lock >./dogfood/coder/nix.hash
+23 -27
View File
@@ -1,10 +1,9 @@
<!-- markdownlint-disable MD041 -->
<div align="center">
<a href="https://coder.com#gh-light-mode-only">
<img src="./docs/images/logo-black.png" alt="Coder Logo Light" style="width: 128px">
<img src="./docs/images/logo-black.png" style="width: 128px">
</a>
<a href="https://coder.com#gh-dark-mode-only">
<img src="./docs/images/logo-white.png" alt="Coder Logo Dark" style="width: 128px">
<img src="./docs/images/logo-white.png" style="width: 128px">
</a>
<h1>
@@ -12,28 +11,26 @@
</h1>
<a href="https://coder.com#gh-light-mode-only">
<img src="./docs/images/banner-black.png" alt="Coder Banner Light" style="width: 650px">
<img src="./docs/images/banner-black.png" style="width: 650px">
</a>
<a href="https://coder.com#gh-dark-mode-only">
<img src="./docs/images/banner-white.png" alt="Coder Banner Dark" style="width: 650px">
<img src="./docs/images/banner-white.png" style="width: 650px">
</a>
<br>
<br>
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Premium](https://coder.com/pricing#compare-plans)
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Enterprise](https://coder.com/docs/v2/latest/enterprise)
[![discord](https://img.shields.io/discord/747933592273027093?label=discord)](https://discord.gg/coder)
[![release](https://img.shields.io/github/v/release/coder/coder)](https://github.com/coder/coder/releases/latest)
[![godoc](https://pkg.go.dev/badge/github.com/coder/coder.svg)](https://pkg.go.dev/github.com/coder/coder)
[![Go Report Card](https://goreportcard.com/badge/github.com/coder/coder/v2)](https://goreportcard.com/report/github.com/coder/coder/v2)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/9511/badge)](https://www.bestpractices.dev/projects/9511)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/coder/coder/badge)](https://scorecard.dev/viewer/?uri=github.com%2Fcoder%2Fcoder)
[![Go Report Card](https://goreportcard.com/badge/github.com/coder/coder)](https://goreportcard.com/report/github.com/coder/coder)
[![license](https://img.shields.io/github/license/coder/coder)](./LICENSE)
</div>
[Coder](https://coder.com) enables organizations to set up development environments in their public or private cloud infrastructure. Cloud development environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and automatically shut down when not used to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads most beneficial to them.
[Coder](https://coder.com) enables organizations to set up development environments in their public or private cloud infrastructure. Cloud development environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and are automatically shut down when not in use to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads that are most beneficial to them.
- Define cloud development environments in Terraform
- EC2 VMs, Kubernetes Pods, Docker Containers, etc.
@@ -41,14 +38,14 @@
- Onboard developers in seconds instead of days
<p align="center">
<img src="./docs/images/hero-image.png" alt="Coder Hero Image">
<img src="./docs/images/hero-image.png">
</p>
## Quickstart
The most convenient way to try Coder is to install it on your local machine and experiment with provisioning cloud development environments using Docker (works on Linux, macOS, and Windows).
```shell
```
# First, install Coder
curl -L https://coder.com/install.sh | sh
@@ -56,7 +53,7 @@ curl -L https://coder.com/install.sh | sh
coder server
# Navigate to http://localhost:3000 to create your initial user,
# create a Docker template and provision a workspace
# create a Docker template, and provision a workspace
```
## Install
@@ -66,13 +63,13 @@ The easiest way to install Coder is to use our
and macOS. For Windows, use the latest `..._installer.exe` file from GitHub
Releases.
```shell
```bash
curl -L https://coder.com/install.sh | sh
```
You can run the install script with `--dry-run` to see the commands that will be used to install without executing them. Run the install script with `--help` for additional flags.
> See [install](https://coder.com/docs/install) for additional methods.
> See [install](https://coder.com/docs/v2/latest/install) for additional methods.
Once installed, you can start a production deployment with a single command:
@@ -84,27 +81,27 @@ coder server
coder server --postgres-url <url> --access-url <url>
```
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/install) for a complete walkthrough.
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/v2/latest/install) for a full walkthrough.
## Documentation
Browse our docs [here](https://coder.com/docs) or visit a specific section below:
Browse our docs [here](https://coder.com/docs/v2) or visit a specific section below:
- [**Templates**](https://coder.com/docs/templates): Templates are written in Terraform and describe the infrastructure for workspaces
- [**Workspaces**](https://coder.com/docs/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
- [**IDEs**](https://coder.com/docs/ides): Connect your existing editor to a workspace
- [**Administration**](https://coder.com/docs/admin): Learn how to operate Coder
- [**Premium**](https://coder.com/pricing#compare-plans): Learn about our paid features built for large teams
- [**Templates**](https://coder.com/docs/v2/latest/templates): Templates are written in Terraform and describe the infrastructure for workspaces
- [**Workspaces**](https://coder.com/docs/v2/latest/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
- [**IDEs**](https://coder.com/docs/v2/latest/ides): Connect your existing editor to a workspace
- [**Administration**](https://coder.com/docs/v2/latest/admin): Learn how to operate Coder
- [**Enterprise**](https://coder.com/docs/v2/latest/enterprise): Learn about our paid features built for large teams
## Support
Feel free to [open an issue](https://github.com/coder/coder/issues/new) if you have questions, run into bugs, or have a feature request.
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features and chat with the community using Coder!
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features, and chat with the community using Coder!
## Integrations
We are always working on new integrations. Please feel free to open an issue and ask for an integration. Contributions are welcome in any official or community repositories.
We are always working on new integrations. Feel free to open an issue to request an integration. Contributions are welcome in any official or community repositories.
### Official
@@ -114,7 +111,6 @@ We are always working on new integrations. Please feel free to open an issue and
- [**Module Registry**](https://registry.coder.com): Extend development environments with common use-cases
- [**Kubernetes Log Stream**](https://github.com/coder/coder-logstream-kube): Stream Kubernetes Pod events to the Coder startup logs
- [**Self-Hosted VS Code Extension Marketplace**](https://github.com/coder/code-marketplace): A private extension marketplace that works in restricted or airgapped networks integrating with [code-server](https://github.com/coder/code-server).
- [**Setup Coder**](https://github.com/marketplace/actions/setup-coder): An action to setup coder CLI in GitHub workflows.
### Community
@@ -124,9 +120,9 @@ We are always working on new integrations. Please feel free to open an issue and
## Contributing
We are always happy to see new contributors to Coder. If you are new to the Coder codebase, we have
[a guide on how to get started](https://coder.com/docs/CONTRIBUTING). We'd love to see your
[a guide on how to get started](https://coder.com/docs/v2/latest/CONTRIBUTING). We'd love to see your
contributions!
## Hiring
Apply [here](https://jobs.ashbyhq.com/coder?utm_source=github&utm_medium=readme&utm_campaign=unknown) if you're interested in joining our team.
Apply [here](https://cdr.co/github-apply) if you're interested in joining our team.
+6 -6
View File
@@ -8,7 +8,7 @@ to us, what we expect, what you can expect from us.
You can see the pretty version [here](https://coder.com/security/policy)
## Why Coder's security matters
# Why Coder's security matters
If an attacker could fully compromise a Coder installation, they could spin up
expensive workstations, steal valuable credentials, or steal proprietary source
@@ -16,13 +16,13 @@ code. We take this risk very seriously and employ routine pen testing,
vulnerability scanning, and code reviews. We also welcome the contributions from
the community that helped make this product possible.
## Where should I report security issues?
# Where should I report security issues?
Please report security issues to <security@coder.com>, providing all relevant
Please report security issues to security@coder.com, providing all relevant
information. The more details you provide, the easier it will be for us to
triage and fix the issue.
## Out of Scope
# Out of Scope
Our primary concern is around an abuse of the Coder application that allows an
attacker to gain access to another users workspace, or spin up unwanted
@@ -40,7 +40,7 @@ workspaces.
out-of-scope systems should be reported to the appropriate vendor or
applicable authority.
## Our Commitments
# Our Commitments
When working with us, according to this policy, you can expect us to:
@@ -53,7 +53,7 @@ When working with us, according to this policy, you can expect us to:
- Extend Safe Harbor for your vulnerability research that is related to this
policy.
## Our Expectations
# Our Expectations
In participating in our vulnerability disclosure program in good faith, we ask
that you:
+534 -540
View File
File diff suppressed because it is too large Load Diff
+424 -705
View File
File diff suppressed because it is too large Load Diff
-57
View File
@@ -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)
}
-4
View File
@@ -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
-540
View File
@@ -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
}
-163
View File
@@ -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
}
-718
View File
@@ -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
}
-24
View File
@@ -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)
}
})
}
}
-296
View File
@@ -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
}
-601
View File
@@ -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")
}
-148
View File
@@ -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
}
-5
View File
@@ -1,5 +0,0 @@
// Package dcspec contains an automatically generated Devcontainer
// specification.
package dcspec
//go:generate ./gen.sh
-74
View File
@@ -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}"
-5
View File
@@ -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
View File
@@ -1 +0,0 @@
{ "image": "test-image" }
-108
View File
@@ -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
}
-276
View File
@@ -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")
}
-193
View File
@@ -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."}
@@ -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
-48
View File
@@ -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")
}
-195
View File
@@ -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")
}
-205
View File
@@ -1,205 +0,0 @@
//go:build linux
// +build linux
package agentexec
import (
"flag"
"fmt"
"os"
"os/exec"
"runtime"
"slices"
"strconv"
"strings"
"syscall"
"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.
func CLI() error {
// We lock the OS thread here to avoid a race condition where the nice priority
// we set gets applied to a different thread than the one we exec the provided
// command on.
runtime.LockOSThread()
// Nop on success but we do it anyway in case of an error.
defer runtime.UnlockOSThread()
var (
fs = flag.NewFlagSet("agent-exec", flag.ExitOnError)
nice = fs.Int("coder-nice", unset, "")
oom = fs.Int("coder-oom", unset, "")
)
if len(os.Args) < 3 {
return xerrors.Errorf("malformed command %+v", os.Args)
}
// Parse everything after "coder agent-exec".
err := fs.Parse(os.Args[2:])
if err != nil {
return xerrors.Errorf("parse flags: %w", err)
}
// Get everything after "coder agent-exec --"
args := execArgs(os.Args)
if len(args) == 0 {
return xerrors.Errorf("no exec command provided %+v", os.Args)
}
if *oom == unset {
// If an explicit oom score isn't set, we use the default.
*oom, err = defaultOOMScore()
if err != nil {
return xerrors.Errorf("get default oom score: %w", err)
}
}
if *nice == unset {
// If an explicit nice score isn't set, we use the default.
*nice, err = defaultNiceScore()
if err != nil {
return xerrors.Errorf("get default nice score: %w", err)
}
}
// We drop effective caps prior to setting dumpable so that we limit the
// impact of someone attempting to hijack the process (i.e. with a debugger)
// to take advantage of the capabilities of the agent process. We encourage
// users to set cap_net_admin on the agent binary for improved networking
// performance and doing so results in the process having its SET_DUMPABLE
// attribute disabled (meaning we cannot adjust the oom score).
err = dropEffectiveCaps()
if err != nil {
printfStdErr("failed to drop effective caps: %v", err)
}
// Set dumpable to 1 so that we can adjust the oom score. If the process
// doesn't have capabilities or has an suid/sgid bit set, this is already
// set.
err = unix.Prctl(unix.PR_SET_DUMPABLE, 1, 0, 0, 0)
if err != nil {
printfStdErr("failed to set dumpable: %v", err)
}
err = writeOOMScoreAdj(*oom)
if err != nil {
// We alert the user instead of failing the command since it can be difficult to debug
// for a template admin otherwise. It's quite possible (and easy) to set an
// inappriopriate value for oom_score_adj.
printfStdErr("failed to adjust oom score to %d for cmd %+v: %v", *oom, execArgs(os.Args), err)
}
// Set dumpable back to 0 just to be safe. It's not inherited for execve anyways.
err = unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0)
if err != nil {
printfStdErr("failed to unset dumpable: %v", err)
}
err = unix.Setpriority(unix.PRIO_PROCESS, 0, *nice)
if err != nil {
// We alert the user instead of failing the command since it can be difficult to debug
// for a template admin otherwise. It's quite possible (and easy) to set an
// inappriopriate value for niceness.
printfStdErr("failed to adjust niceness to %d for cmd %+v: %v", *nice, args, err)
}
path, err := exec.LookPath(args[0])
if err != nil {
return xerrors.Errorf("look path: %w", err)
}
// 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 = slices.DeleteFunc(env, func(e string) bool {
return strings.HasPrefix(e, EnvProcPrioMgmt) ||
strings.HasPrefix(e, EnvProcOOMScore) ||
strings.HasPrefix(e, EnvProcNiceScore)
})
return syscall.Exec(path, args, env)
}
func defaultNiceScore() (int, error) {
score, err := unix.Getpriority(unix.PRIO_PROCESS, 0)
if err != nil {
return 0, xerrors.Errorf("get nice score: %w", err)
}
// See https://linux.die.net/man/2/setpriority#Notes
score = 20 - score
score += 5
if score > 19 {
return 19, nil
}
return score, nil
}
func defaultOOMScore() (int, error) {
score, err := oomScoreAdj()
if err != nil {
return 0, xerrors.Errorf("get oom score: %w", err)
}
// If the agent has a negative oom_score_adj, we set the child to 0
// so it's treated like every other process.
if score < 0 {
return 0, nil
}
// If the agent is already almost at the maximum then set it to the max.
if score >= 998 {
return 1000, nil
}
// If the agent oom_score_adj is >=0, we set the child to slightly
// less than the maximum. If users want a different score they set it
// directly.
return 998, nil
}
func oomScoreAdj() (int, error) {
scoreStr, err := os.ReadFile("/proc/self/oom_score_adj")
if err != nil {
return 0, xerrors.Errorf("read oom_score_adj: %w", err)
}
return strconv.Atoi(strings.TrimSpace(string(scoreStr)))
}
func writeOOMScoreAdj(score int) error {
return os.WriteFile(fmt.Sprintf("/proc/%d/oom_score_adj", os.Getpid()), []byte(fmt.Sprintf("%d", score)), 0o600)
}
// execArgs returns the arguments to pass to syscall.Exec after the "--" delimiter.
func execArgs(args []string) []string {
for i, arg := range args {
if arg == "--" {
return args[i+1:]
}
}
return nil
}
func printfStdErr(format string, a ...any) {
_, _ = fmt.Fprintf(os.Stderr, "coder-agent: %s\n", fmt.Sprintf(format, a...))
}
func dropEffectiveCaps() error {
proc := cap.GetProc()
err := proc.ClearFlag(cap.Effective)
if err != nil {
return xerrors.Errorf("clear effective caps: %w", err)
}
err = proc.SetProc()
if err != nil {
return xerrors.Errorf("set proc: %w", err)
}
return nil
}

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