Compare commits

..

19 Commits

Author SHA1 Message Date
Jon Ayers 2ed709b306 refactor(agent): migrate go func() calls to agentutil.Go()
This adds panic recovery to all goroutines in the agent package
by using the new agentutil.Go() helper which wraps goroutines
with defer/recover and logs panics before re-panicking.

Files modified:
- agent/agentutil/agentutil.go (new)
- agent/stats.go
- agent/agent.go
- agent/agentscripts/agentscripts.go
- agent/reconnectingpty/reconnectingpty.go
- agent/reconnectingpty/screen.go
- agent/reconnectingpty/server.go
- agent/reconnectingpty/buffered.go
- agent/agentcontainers/api.go
- agent/apphealth.go
- agent/boundarylogproxy/proxy.go
- agent/agentssh/forward.go
- agent/agentssh/x11.go
- agent/agentssh/bicopy.go
- agent/agentssh/agentssh.go
- agent/agentsocket/server.go
2026-02-05 03:17:50 +00:00
Jon Ayers da2490b9cb feat: add agentutil.Go() and lint rule for panic recovery 2026-02-04 04:51:15 +00:00
Jake Howell b0c09eab03 feat: implement proper <GlobalLayout /> (#21823)
> [!NOTE]  
> It should be noted that these #21781 #21807 #21809 pull-request are
required before we can merge this. This will stop us to battling the
`z-index` that is provided by MUI.

This is avoiding the changes that would be required in #21819

This pull-request removes on our reliance to control the scroll from
within another`<div />`, this means that we can actively make use of
`<ScrollRestoration />` where the page will return the top of the page
when you navigate to a new URL.
2026-02-04 13:12:42 +11:00
Jake Howell 014693ba34 feat: refactor <UserDropdown /> (#21809)
This pull-request takes our `<UserDropdown />` component and converts it
to a `<DropdownMenu />`. This is done so that we can more easily
standardise the content among multiple Dropdown's, and as an added bonus
helps us to remove MUI dependencies (win win).

<img
src="https://github.com/user-attachments/assets/1168ece2-b514-4b91-8cfd-4baf2744eb38"
/>


> [!NOTE]  
> I removed the avatar here whilst we debate internally on how we show
the user account. This differs from the screenshot below 🙂

| Old | New |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/d1fe8bcc-bdbb-4366-9ceb-39a63bd09da3"
/> | <img
src="https://github.com/user-attachments/assets/89252765-4203-433e-8b25-3087fd2fd754"
/> |
2026-02-04 13:08:10 +11:00
Jake Howell 62ba27b08f feat: add organization_icon to <TemplatesPageView /> (#21816) 2026-02-04 12:39:50 +11:00
blinkagent[bot] 99d8b7f8d0 docs: update multi-model support to use provider names (#21905)
Updates the multi-model support description in the Coder Research docs
to reference provider companies (Anthropic, xAI, OpenAI) instead of
specific model names (Claude sonnet-4/opus-4, Grok, GPT-5).

This makes the docs more stable as model names change frequently, while
provider names remain constant.

---------

Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com>
Co-authored-by: Matt Vollmer <matthewjvollmer@outlook.com>
2026-02-03 16:36:15 -05:00
Steven Masley b1e18f2398 fix: use dynamic parameter resolution in the cli (#21734)
Uses dynamic parameters EvaluateTemplateVersion vs TemplateVersionRichParameters to determine initial parameter state.

Closes https://github.com/coder/coder/issues/19879
2026-02-03 14:10:49 -06:00
Steven Masley 6759b51cd6 feat: add endpoint to fetch singular org member (#21732) 2026-02-03 12:48:25 -06:00
Ben Potter 1e2d2b92af chore: update AI governance docs for v2.30 release (#21870)
- remove beta labels
- clarify how AWB is measured
- reassurance of no downtimes when limit is reached

---------

Co-authored-by: Atif Ali <atif@coder.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matt Vollmer <matthewjvollmer@outlook.com>
2026-02-03 13:42:27 -05:00
Cian Johnston 91be688e39 chore(coderd/database): remove deprecated db2sdk.List(Lazy)? methods (#21902)
Removes deprecated methods db2sdk.List and db2sdk.ListLazy.
2026-02-03 17:52:07 +00:00
Matt Vollmer 2add69a33e fix(docs): update AI Governance Add-On licensing information (#21899)
This change better informs users and Blink.
2026-02-03 12:08:20 -05:00
Jake Howell d11f9bf094 fix: resize !size-icon-lg in <ProxyMenu /> (#21900)
This pull-request changes the size of our `▼` / downwards chevron to
match that update in #21781 . This was incorrectly changed in #21807.

| Old | New |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/a5ea1fbf-ac3e-44f8-8e6b-afd3d0dab28f"
/> | <img
src="https://github.com/user-attachments/assets/dffe408d-47a5-4c45-ad78-939663327695"
/> |
2026-02-03 16:46:31 +00:00
ケイラ 7fd13019e5 fix: disable task sharing (#21867) 2026-02-03 09:43:40 -07:00
Steven Masley a16debee76 test: template import should never complete, use Plan over apply (#21895)
Closes https://github.com/coder/internal/issues/1221
2026-02-03 10:16:53 -06:00
Sas Swart a502640431 chore: update aibridge (#21892)
Our dependency on AIBridge was already pointing to this commit. We now
have a tag for it, so its cleaner to point to the tag.
2026-02-03 18:10:17 +02:00
Michael Suchacz f7f025343f chore(dogfood): add project to mux module (#21894)
Adds `add-project` to the `mux` module in the dogfood Coder template so
Mux opens the cloned repo by default.

- Uses `local.repo_dir` (defaults to `/home/coder/coder`) so it stays
correct if the repo base dir parameter changes.

Testing:
- `terraform fmt -check dogfood/coder/main.tf`
2026-02-03 16:46:06 +01:00
Michael Suchacz b955e102ff docs: add Mux client configuration (#21888)
Adds a new AI Bridge client configuration page for **Mux** and lists it
in the client compatibility table.

- Add `docs/ai-coder/ai-bridge/clients/mux.md` with a short intro, UI +
env var + `~/.mux/providers.jsonc` examples
- Add Mux to the AI Bridge client compatibility table
- Add the new page to `docs/manifest.json`

Refs: https://mux.coder.com/config/providers#environment-variables
2026-02-03 15:42:58 +00:00
Jake Howell efe4cb1f66 feat: refactor Admin Settings (#21781)
This pull-request ensures that we're using `<DropdownMenu />` in the
`Admin Settings` button as things weren't uniform before. This is inline
with the Figma design with the darker ("black") background. This has an
added side-benefit of removing some MUI-specific code.

<img
src="https://github.com/user-attachments/assets/4eb9136b-91b3-44ac-81a0-5abd1cf2cdf2"
/>
2026-02-04 00:28:38 +11:00
dependabot[bot] f72f09c110 chore: bump otelhttp from 0.62.0 to 0.64.0 (#21568)
Bumps
[go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp](https://github.com/open-telemetry/opentelemetry-go-contrib)
from 0.62.0 to 0.64.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/releases">go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp's
releases</a>.</em></p>
<blockquote>
<h2>Release
v1.39.0/v2.1.0/v0.64.0/v0.33.0/v0.19.0/v0.14.0/v0.12.0/v0.11.0</h2>
<h2>Overview</h2>
<h3>Added</h3>
<ul>
<li><code>ParseYAML</code> in
<code>go.opentelemetry.io/contrib/otelconf</code> now supports
environment variables substitution in the format
<code>${[env:]VAR_NAME[:-defaultvalue]}</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/6215">#6215</a>)</li>
<li>Add the <code>http.route</code> metric attribute to
<code>go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7966">#7966</a>)</li>
<li>Support <code>db.client.operation.duration</code> metric for
<code>go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7983">#7983</a>)</li>
<li>Add a <code>WithSpanNameFormatter</code> option to
<code>go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7986">#7986</a>)</li>
<li>WithOnError option for otelecho middleware in
<code>go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho</code>
to specify the behavior when an error occurs. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8025">#8025</a>)</li>
<li>Updated <code>go.opentelemetry.io/contrib/otelconf</code> to include
the <a
href="https://github.com/open-telemetry/opentelemetry-configuration/releases/tag/v1.0.0-rc.2">v1.0.0-rc2</a>
release candidate of schema which includes backwards incompatible
changes. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8026">#8026</a>)</li>
<li>Introduce v1.0.0-rc.2 model in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8031">#8031</a>)</li>
<li>Add unmarshaling and validation for <code>CardinalityLimits</code>
and <code>SpanLimits</code> to v1.0.0 model in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8043">#8043</a>)</li>
<li>Add unmarshaling and validation for
<code>BatchLogRecordProcessor</code>, <code>BatchSpanProcessor</code>,
and <code>PeriodicMetricReader</code> to v1.0.0 model in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8049">#8049</a>)</li>
<li>Add unmarshaling and validation for <code>TextMapPropagator</code>
to v1.0.0 model in <code>go.opentelemetry.io/contrib/otelconf</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8052">#8052</a>)</li>
<li>Add
<code>jaeger.sampler.type</code>/<code>jaeger.sampler.param</code>
attributes for adaptive sampling support and option
<code>WithAttributesDisabled</code> in
<code>go.opentelemetry.io/contrib/samplers/jaegerremote</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8073">#8073</a>)</li>
<li>Add support for <code>OTEL_EXPERIMENTAL_CONFIG_FILE</code> via the
<code>NewSDK</code> function in
<code>go.opentelemetry.io/contrib/otelconf</code> (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8106">#8106</a>)</li>
<li>Add unmarshaling and validation for <code>OTLPHttpExporter</code>,
<code>OTLPGrpcExporter</code>, <code>OTLPGrpcMetricExporter</code> and
<code>OTLPHttpMetricExporter</code> to v1.0.0 model in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8112">#8112</a>)</li>
<li>Add unmarshaling and validation for <code>AttributeType</code>,
<code>AttributeNameValue</code>, <code>SimpleSpanProcessor</code>,
<code>SimpleLogRecordProcessor</code>, <code>ZipkinSpanExporter</code>,
<code>NameStringValuePair</code>, <code>InstrumentType</code>,
<code>ExperimentalPeerInstrumentationServiceMappingElem</code>,
<code>ExporterDefaultHistogramAggregation</code>,
<code>PullMetricReader</code> to v1.0.0 model in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8127">#8127</a>)</li>
<li>Add support for <code>container</code>, <code>host</code>,
<code>process</code> resource detectors in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8180">#8180</a>)</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Improve performance by reducing allocations in the gRPC stats
handler in
<code>go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8035">#8035</a>)</li>
<li>Export the <code>ReadEvents</code> and <code>WriteEvents</code>
constants in
<code>go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp</code>
so they can be used in <code>WithMessageEvents</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8153">#8153</a>)</li>
<li>Switched the default for <code>OTEL_SEMCONV_STABILITY_OPT_IN</code>
to emit the v1.37.0 semantic conventions by default in
<code>go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/mongo/otelmongo</code>.
Use the environment variable <code>OTEL_SEMCONV_STABILITY_OPT_IN</code>
to configure duplication with old semantic conventions if needed (i.e.
<code>OTEL_SEMCONV_STABILITY_OPT_IN=&quot;database/dup&quot;</code>).
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8230">#8230</a>)</li>
</ul>
<h3>Deprecated</h3>
<ul>
<li><code>WithRouteTag</code> in
<code>go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp</code>
is deprecated. The route is already added automatically for spans. For
metrics, the alternative is to use the
<code>WithMetricAttributesFn</code> option. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8117">#8117</a>)</li>
<li><code>WithPublicEndpoint</code> in
<code>go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp</code>
is deprecated. Use <code>WithPublicEndpointFn</code> instead. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8152">#8152</a>)</li>
<li><code>DefaultClient</code>, <code>Get</code>, <code>Head</code>,
<code>Post</code>, and <code>PostForm</code> in
<code>go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp</code>
are deprecated. Use a custom <code>*http.Client</code> with
<code>otelhttp.NewTransport(http.DefaultTransport)</code> instead. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8140">#8140</a>,
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8201">#8201</a>)</li>
</ul>
<h3>Removed</h3>
<ul>
<li>Drop support for <a href="https://go.dev/doc/go1.23">Go 1.23</a>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7831">#7831</a>)</li>
<li>Remove deprecated
<code>go.opentelemetry.io/contrib/detectors/aws/ec2</code> module,
please use <code>go.opentelemetry.io/contrib/detectors/aws/ec2/v2</code>
instead. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7841">#7841</a>)</li>
<li>Remove the deprecated <code>Extract</code> and <code>Inject</code>
functions from
<code>go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7952">#7952</a>)</li>
</ul>
<h2>What's Changed</h2>
<ul>
<li>chore(deps): update go-openapi packages by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/pull/7830">open-telemetry/opentelemetry-go-contrib#7830</a></li>
<li>chore(deps): update module github.com/spf13/pflag to v1.0.9 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/pull/7833">open-telemetry/opentelemetry-go-contrib#7833</a></li>
<li>fix(deps): update module github.com/shirou/gopsutil/v4 to v4.25.8 by
<a href="https://github.com/renovate"><code>@​renovate</code></a>[bot]
in <a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/pull/7834">open-telemetry/opentelemetry-go-contrib#7834</a></li>
<li>Remove support for Go 1.23 by <a
href="https://github.com/MrAlias"><code>@​MrAlias</code></a> in <a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/pull/7831">open-telemetry/opentelemetry-go-contrib#7831</a></li>
<li>fix(deps): update golang.org/x by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/pull/7733">open-telemetry/opentelemetry-go-contrib#7733</a></li>
<li>chore(deps): update googleapis to ef028d9 by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/pull/7835">open-telemetry/opentelemetry-go-contrib#7835</a></li>
<li>chore(deps): update module github.com/securego/gosec/v2 to v2.22.8
by <a
href="https://github.com/renovate"><code>@​renovate</code></a>[bot] in
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/pull/7836">open-telemetry/opentelemetry-go-contrib#7836</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md">go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp's
changelog</a>.</em></p>
<blockquote>
<h2>[1.39.0/2.1.0/0.64.0/0.33.0/0.19.0/0.14.0/0.12.0/0.11.0] -
2025-12-08</h2>
<h3>Added</h3>
<ul>
<li><code>ParseYAML</code> in
<code>go.opentelemetry.io/contrib/otelconf</code> now supports
environment variables substitution in the format
<code>${[env:]VAR_NAME[:-defaultvalue]}</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/6215">#6215</a>)</li>
<li>Add the <code>http.route</code> metric attribute to
<code>go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7966">#7966</a>)</li>
<li>Support <code>db.client.operation.duration</code> metric for
<code>go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7983">#7983</a>)</li>
<li>Add a <code>WithSpanNameFormatter</code> option to
<code>go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7986">#7986</a>)</li>
<li>WithOnError option for otelecho middleware in
<code>go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho</code>
to specify the behavior when an error occurs. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8025">#8025</a>)</li>
<li>Updated <code>go.opentelemetry.io/contrib/otelconf</code> to include
the <a
href="https://github.com/open-telemetry/opentelemetry-configuration/releases/tag/v1.0.0-rc.2">v1.0.0-rc2</a>
release candidate of schema which includes backwards incompatible
changes. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8026">#8026</a>)</li>
<li>Introduce v1.0.0-rc.2 model in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8031">#8031</a>)</li>
<li>Add unmarshaling and validation for <code>CardinalityLimits</code>
and <code>SpanLimits</code> to v1.0.0 model in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8043">#8043</a>)</li>
<li>Add unmarshaling and validation for
<code>BatchLogRecordProcessor</code>, <code>BatchSpanProcessor</code>,
and <code>PeriodicMetricReader</code> to v1.0.0 model in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8049">#8049</a>)</li>
<li>Add unmarshaling and validation for <code>TextMapPropagator</code>
to v1.0.0 model in <code>go.opentelemetry.io/contrib/otelconf</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8052">#8052</a>)</li>
<li>Add
<code>jaeger.sampler.type</code>/<code>jaeger.sampler.param</code>
attributes for adaptive sampling support and option
<code>WithAttributesDisabled</code> in
<code>go.opentelemetry.io/contrib/samplers/jaegerremote</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8073">#8073</a>)</li>
<li>Add support for <code>OTEL_EXPERIMENTAL_CONFIG_FILE</code> via the
<code>NewSDK</code> function in
<code>go.opentelemetry.io/contrib/otelconf</code> (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8106">#8106</a>)</li>
<li>Add unmarshaling and validation for <code>OTLPHttpExporter</code>,
<code>OTLPGrpcExporter</code>, <code>OTLPGrpcMetricExporter</code> and
<code>OTLPHttpMetricExporter</code> to v1.0.0 model in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8112">#8112</a>)</li>
<li>Add unmarshaling and validation for <code>AttributeType</code>,
<code>AttributeNameValue</code>, <code>SimpleSpanProcessor</code>,
<code>SimpleLogRecordProcessor</code>, <code>ZipkinSpanExporter</code>,
<code>NameStringValuePair</code>, <code>InstrumentType</code>,
<code>ExperimentalPeerInstrumentationServiceMappingElem</code>,
<code>ExporterDefaultHistogramAggregation</code>,
<code>PullMetricReader</code> to v1.0.0 model in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8127">#8127</a>)</li>
<li>Add support for <code>container</code>, <code>host</code>,
<code>process</code> resource detectors in
<code>go.opentelemetry.io/contrib/otelconf</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8180">#8180</a>)</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Improve performance by reducing allocations in the gRPC stats
handler in
<code>go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8035">#8035</a>)</li>
<li>Export the <code>ReadEvents</code> and <code>WriteEvents</code>
constants in
<code>go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp</code>
so they can be used in <code>WithMessageEvents</code>. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8153">#8153</a>)</li>
<li>Switched the default for <code>OTEL_SEMCONV_STABILITY_OPT_IN</code>
to emit the v1.37.0 semantic conventions by default in
<code>go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/mongo/otelmongo</code>.
Use the environment variable <code>OTEL_SEMCONV_STABILITY_OPT_IN</code>
to configure duplication with old semantic conventions if needed (i.e.
<code>OTEL_SEMCONV_STABILITY_OPT_IN=&quot;database/dup&quot;</code>).
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8230">#8230</a>)</li>
</ul>
<h3>Deprecated</h3>
<ul>
<li><code>WithRouteTag</code> in
<code>go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp</code>
is deprecated.
The route is already added automatically for spans.
For metrics, the alternative is to use the
<code>WithMetricAttributesFn</code> option. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8117">#8117</a>)</li>
<li><code>WithPublicEndpoint</code> in
<code>go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp</code>
is deprecated.
Use <code>WithPublicEndpointFn</code> instead. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8152">#8152</a>)</li>
<li><code>DefaultClient</code>, <code>Get</code>, <code>Head</code>,
<code>Post</code>, and <code>PostForm</code> in
<code>go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp</code>
are deprecated.
Use a custom <code>*http.Client</code> with
<code>otelhttp.NewTransport(http.DefaultTransport)</code> instead. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8140">#8140</a>,
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8201">#8201</a>)</li>
</ul>
<h3>Removed</h3>
<ul>
<li>Drop support for [Go 1.23]. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7831">#7831</a>)</li>
<li>Remove deprecated
<code>go.opentelemetry.io/contrib/detectors/aws/ec2</code> module,
please use <code>go.opentelemetry.io/contrib/detectors/aws/ec2/v2</code>
instead. (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7841">#7841</a>)</li>
<li>Remove the deprecated <code>Extract</code> and <code>Inject</code>
functions from
<code>go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc</code>.
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/7952">#7952</a>)</li>
</ul>
<h2>[1.38.0/2.0.0/0.63.0/0.32.0/0.18.0/0.13.0/0.11.0/0.10.0] -
2025-08-29</h2>
<p>This release is the last to support [Go 1.23].
The next release will require at least [Go 1.24].</p>
<h3>Added</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/commit/9a6a4d7dec6c950b12977cb166e1954bc74e8777"><code>9a6a4d7</code></a>
Release v1.39.0 (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8261">#8261</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/commit/14c6a7a655bb5d915dc3939aef2cff9df65c3a6c"><code>14c6a7a</code></a>
chore(deps): update module golang.org/x/sys to v0.39.0 (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8260">#8260</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/commit/829f498cc49a4879c523efc3496d019b0a5f5d55"><code>829f498</code></a>
chore(deps): update module golang.org/x/sync to v0.19.0 (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8259">#8259</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/commit/a77cddabf6f6861e701fcf976b1ad1f048f4d308"><code>a77cdda</code></a>
chore(deps): update module golang.org/x/oauth2 to v0.34.0 (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8257">#8257</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/commit/a98be56f3747cab50e0ba0c32d74cf56fcba17fe"><code>a98be56</code></a>
chore(deps): update module github.com/go-git/go-billy/v5 to v5.7.0 (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8255">#8255</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/commit/4385fbc6db3f5e4d63c5e927232f3498f737a48f"><code>4385fbc</code></a>
chore(deps): update github/codeql-action action to v4.31.7 (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8253">#8253</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/commit/3c3e7b27aff9b9c702e6411944b6ecef3292cd1c"><code>3c3e7b2</code></a>
otelconf: add support for parsing resource detectors (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8180">#8180</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/commit/6497853d28a651d83bf8940f1f44326555d0cdb1"><code>6497853</code></a>
otelconf: add support for OTEL_EXPERIMENTAL_CONFIG_FILE (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8106">#8106</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/commit/3f4d49c3dbd3a20a62736a9b385c885671e926ba"><code>3f4d49c</code></a>
Fix flaky canceled context in otelconf/trace test (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8250">#8250</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/commit/3ce5839c9632d2c0f8fa71efc7cb5c38e81ba9fc"><code>3ce5839</code></a>
fix(deps): update module github.com/golangci/golangci-lint/v2 to v2.7.1
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-go-contrib/issues/8252">#8252</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.62.0...zpages/v0.64.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp&package-manager=go_modules&previous-version=0.62.0&new-version=0.64.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 13:01:14 +00:00
111 changed files with 1327 additions and 1424 deletions
+25 -24
View File
@@ -39,6 +39,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/clistat"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentfiles"
"github.com/coder/coder/v2/agent/agentscripts"
@@ -553,7 +554,7 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient28
// Set up collect and report as a single ticker with two channels,
// this is to allow collection and reporting to be triggered
// independently of each other.
go func() {
agentutil.Go(ctx, a.logger, func() {
t := time.NewTicker(a.reportMetadataInterval)
defer func() {
t.Stop()
@@ -578,9 +579,9 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient28
wake(collect)
}
}
}()
})
go func() {
agentutil.Go(ctx, a.logger, func() {
defer close(collectDone)
var (
@@ -627,7 +628,7 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient28
// We send the result to the channel in the goroutine to avoid
// sending the same result multiple times. So, we don't care about
// the return values.
go flight.Do(md.Key, func() {
agentutil.Go(ctx, a.logger, func() { flight.Do(md.Key, func() {
ctx := slog.With(ctx, slog.F("key", md.Key))
lastCollectedAtMu.RLock()
collectedAt, ok := lastCollectedAts[md.Key]
@@ -680,10 +681,10 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient28
lastCollectedAts[md.Key] = now
lastCollectedAtMu.Unlock()
}
})
}) })
}
}
}()
})
// Gather metadata updates and report them once every interval. If a
// previous report is in flight, wait for it to complete before
@@ -734,14 +735,14 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient28
}
reportInFlight = true
go func() {
agentutil.Go(ctx, a.logger, func() {
a.logger.Debug(ctx, "batch updating metadata")
ctx, cancel := context.WithTimeout(ctx, reportTimeout)
defer cancel()
_, err := aAPI.BatchUpdateMetadata(ctx, &proto.BatchUpdateMetadataRequest{Metadata: metadata})
reportError <- err
}()
})
}
}
}
@@ -1518,10 +1519,10 @@ func (a *agent) trackGoroutine(fn func()) error {
return xerrors.Errorf("track conn goroutine: %w", ErrAgentClosing)
}
a.closeWaitGroup.Add(1)
go func() {
agentutil.Go(a.hardCtx, a.logger, func() {
defer a.closeWaitGroup.Done()
fn()
}()
})
return nil
}
@@ -1625,15 +1626,15 @@ func (a *agent) createTailnet(
clog.Info(ctx, "accepted conn")
wg.Add(1)
closed := make(chan struct{})
go func() {
agentutil.Go(ctx, clog, func() {
select {
case <-closed:
case <-a.hardCtx.Done():
_ = conn.Close()
}
wg.Done()
}()
go func() {
})
agentutil.Go(ctx, clog, func() {
defer close(closed)
sErr := speedtest.ServeConn(conn)
if sErr != nil {
@@ -1641,7 +1642,7 @@ func (a *agent) createTailnet(
return
}
clog.Info(ctx, "test ended")
}()
})
}
wg.Wait()
}); err != nil {
@@ -1668,13 +1669,13 @@ func (a *agent) createTailnet(
WriteTimeout: 20 * time.Second,
ErrorLog: slog.Stdlib(ctx, a.logger.Named("http_api_server"), slog.LevelInfo),
}
go func() {
agentutil.Go(ctx, a.logger, func() {
select {
case <-ctx.Done():
case <-a.hardCtx.Done():
}
_ = server.Close()
}()
})
apiServErr := server.Serve(apiListener)
if apiServErr != nil && !xerrors.Is(apiServErr, http.ErrServerClosed) && !strings.Contains(apiServErr.Error(), "use of closed network connection") {
@@ -1716,7 +1717,7 @@ func (a *agent) runCoordinator(ctx context.Context, tClient tailnetproto.DRPCTai
coordination := ctrl.New(coordinate)
errCh := make(chan error, 1)
go func() {
agentutil.Go(ctx, a.logger, func() {
defer close(errCh)
select {
case <-ctx.Done():
@@ -1728,7 +1729,7 @@ func (a *agent) runCoordinator(ctx context.Context, tClient tailnetproto.DRPCTai
case err := <-coordination.Wait():
errCh <- err
}
}()
})
return <-errCh
}
@@ -1819,7 +1820,7 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
continue
}
wg.Add(1)
go func() {
agentutil.Go(pingCtx, a.logger, func() {
defer wg.Done()
duration, p2p, _, err := a.network.Ping(pingCtx, addresses[0].Addr())
if err != nil {
@@ -1833,7 +1834,7 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
} else {
derpConns++
}
}()
})
}
wg.Wait()
sort.Float64s(durations)
@@ -2031,13 +2032,13 @@ func (a *agent) Close() error {
// Wait for the graceful shutdown to complete, but don't wait forever so
// that we don't break user expectations.
go func() {
agentutil.Go(a.hardCtx, a.logger, func() {
defer a.hardCancel()
select {
case <-a.hardCtx.Done():
case <-time.After(5 * time.Second):
}
}()
})
// Wait for lifecycle to be reported
lifecycleWaitLoop:
@@ -2127,13 +2128,13 @@ const EnvAgentSubsystem = "CODER_AGENT_SUBSYSTEM"
// eitherContext returns a context that is canceled when either context ends.
func eitherContext(a, b context.Context) context.Context {
ctx, cancel := context.WithCancel(a)
go func() {
agentutil.Go(ctx, slog.Logger{}, func() {
defer cancel()
select {
case <-a.Done():
case <-b.Done():
}
}()
})
return ctx
}
+10 -31
View File
@@ -28,6 +28,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentcontainers/ignore"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/usershell"
@@ -563,11 +564,11 @@ func (api *API) discoverDevcontainersInProject(projectPath string) error {
if dc.Status == codersdk.WorkspaceAgentDevcontainerStatusStarting {
api.asyncWg.Add(1)
go func() {
agentutil.Go(api.ctx, api.logger, func() {
defer api.asyncWg.Done()
_ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath)
}()
})
}
}
api.mu.Unlock()
@@ -1405,14 +1406,6 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques
httperror.WriteResponseError(ctx, w, err)
return
}
if dc.SubagentID.Valid {
api.mu.Unlock()
httpapi.Write(ctx, w, http.StatusForbidden, codersdk.Response{
Message: "Cannot rebuild Terraform-defined devcontainer",
Detail: fmt.Sprintf("Devcontainer %q has resources defined in Terraform and cannot be rebuilt from the UI. Update the workspace template to modify this devcontainer.", dc.Name),
})
return
}
if dc.Status.Transitioning() {
api.mu.Unlock()
@@ -1431,9 +1424,9 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques
api.knownDevcontainers[dc.WorkspaceFolder] = dc
api.broadcastUpdatesLocked()
go func() {
agentutil.Go(ctx, api.logger, func() {
_ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath, WithRemoveExistingContainer())
}()
})
api.mu.Unlock()
@@ -1635,25 +1628,16 @@ func (api *API) cleanupSubAgents(ctx context.Context) error {
api.mu.Lock()
defer api.mu.Unlock()
// Collect all subagent IDs that should be kept:
// 1. Subagents currently tracked by injectedSubAgentProcs
// 2. Subagents referenced by known devcontainers from the manifest
keep := make(map[uuid.UUID]bool, len(api.injectedSubAgentProcs)+len(api.knownDevcontainers))
injected := make(map[uuid.UUID]bool, len(api.injectedSubAgentProcs))
for _, proc := range api.injectedSubAgentProcs {
keep[proc.agent.ID] = true
}
for _, dc := range api.knownDevcontainers {
if dc.SubagentID.Valid {
keep[dc.SubagentID.UUID] = true
}
injected[proc.agent.ID] = true
}
ctx, cancel := context.WithTimeout(ctx, defaultOperationTimeout)
defer cancel()
var errs []error
for _, agent := range agents {
if keep[agent.ID] {
if injected[agent.ID] {
continue
}
client := *api.subAgentClient.Load()
@@ -1664,11 +1648,10 @@ func (api *API) cleanupSubAgents(ctx context.Context) error {
slog.F("agent_id", agent.ID),
slog.F("agent_name", agent.Name),
)
errs = append(errs, xerrors.Errorf("delete agent %s (%s): %w", agent.Name, agent.ID, err))
}
}
return errors.Join(errs...)
return nil
}
// maybeInjectSubAgentIntoContainerLocked injects a subagent into a dev
@@ -2019,11 +2002,7 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
// logger.Warn(ctx, "set CAP_NET_ADMIN on agent binary failed", slog.Error(err))
// }
// Only delete and recreate subagents that were dynamically created
// (ID == uuid.Nil). Terraform-defined subagents (subAgentConfig.ID !=
// uuid.Nil) must not be deleted because they have attached resources
// managed by terraform.
deleteSubAgent := subAgentConfig.ID == uuid.Nil && proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig)
deleteSubAgent := proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig)
if deleteSubAgent {
logger.Debug(ctx, "deleting existing subagent for recreation", slog.F("agent_id", proc.agent.ID))
client := *api.subAgentClient.Load()
+1 -29
View File
@@ -437,11 +437,7 @@ func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.S
}
}
// Only generate a new ID if one wasn't provided. Terraform-defined
// subagents have pre-existing IDs that should be preserved.
if agent.ID == uuid.Nil {
agent.ID = uuid.New()
}
agent.ID = uuid.New()
agent.AuthToken = uuid.New()
if m.agents == nil {
m.agents = make(map[uuid.UUID]agentcontainers.SubAgent)
@@ -1039,30 +1035,6 @@ func TestAPI(t *testing.T) {
wantStatus: []int{http.StatusAccepted, http.StatusConflict},
wantBody: []string{"Devcontainer recreation initiated", "is currently starting and cannot be restarted"},
},
{
name: "Terraform-defined devcontainer cannot be rebuilt",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-terraform",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Container: &devContainer1,
SubagentID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "<none>",
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusForbidden},
wantBody: []string{"Cannot rebuild Terraform-defined devcontainer"},
},
}
for _, tt := range tests {
+2 -10
View File
@@ -24,12 +24,10 @@ type SubAgent struct {
DisplayApps []codersdk.DisplayApp
}
// CloneConfig makes a copy of SubAgent using configuration from the
// devcontainer. The ID is inherited from dc.SubagentID if present, and
// the name is inherited from the devcontainer. AuthToken is not copied.
// CloneConfig makes a copy of SubAgent without ID and AuthToken. The
// name is inherited from the devcontainer.
func (s SubAgent) CloneConfig(dc codersdk.WorkspaceAgentDevcontainer) SubAgent {
return SubAgent{
ID: dc.SubagentID.UUID,
Name: dc.Name,
Directory: s.Directory,
Architecture: s.Architecture,
@@ -192,11 +190,6 @@ func (a *subAgentAPIClient) List(ctx context.Context) ([]SubAgent, error) {
func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (_ SubAgent, err error) {
a.logger.Debug(ctx, "creating sub agent", slog.F("name", agent.Name), slog.F("directory", agent.Directory))
var id []byte
if agent.ID != uuid.Nil {
id = agent.ID[:]
}
displayApps := make([]agentproto.CreateSubAgentRequest_DisplayApp, 0, len(agent.DisplayApps))
for _, displayApp := range agent.DisplayApps {
var app agentproto.CreateSubAgentRequest_DisplayApp
@@ -235,7 +228,6 @@ func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (_ SubAg
OperatingSystem: agent.OperatingSystem,
DisplayApps: displayApps,
Apps: apps,
Id: id,
})
if err != nil {
return SubAgent{}, err
-99
View File
@@ -306,102 +306,3 @@ func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) {
}
})
}
func TestSubAgent_CloneConfig(t *testing.T) {
t.Parallel()
t.Run("CopiesIDFromDevcontainer", func(t *testing.T) {
t.Parallel()
subAgent := agentcontainers.SubAgent{
ID: uuid.New(),
Name: "original-name",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
DisplayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop},
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
}
expectedID := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
dc := codersdk.WorkspaceAgentDevcontainer{
Name: "devcontainer-name",
SubagentID: uuid.NullUUID{UUID: expectedID, Valid: true},
}
cloned := subAgent.CloneConfig(dc)
assert.Equal(t, expectedID, cloned.ID)
assert.Equal(t, dc.Name, cloned.Name)
assert.Equal(t, subAgent.Directory, cloned.Directory)
assert.Equal(t, uuid.Nil, cloned.AuthToken, "AuthToken should not be copied")
})
t.Run("HandlesNilSubagentID", func(t *testing.T) {
t.Parallel()
subAgent := agentcontainers.SubAgent{
ID: uuid.New(),
Name: "original-name",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
}
dc := codersdk.WorkspaceAgentDevcontainer{
Name: "devcontainer-name",
SubagentID: uuid.NullUUID{Valid: false},
}
cloned := subAgent.CloneConfig(dc)
assert.Equal(t, uuid.Nil, cloned.ID)
})
}
func TestSubAgent_EqualConfig(t *testing.T) {
t.Parallel()
t.Run("TrueWhenFieldsMatch", func(t *testing.T) {
t.Parallel()
a := agentcontainers.SubAgent{
ID: uuid.MustParse("550e8400-e29b-41d4-a716-446655440000"),
Name: "test-agent",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
DisplayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop},
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
}
// Different ID but same config fields.
b := agentcontainers.SubAgent{
ID: uuid.MustParse("660e8400-e29b-41d4-a716-446655440000"),
Name: "test-agent",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
DisplayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop},
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
}
assert.True(t, a.EqualConfig(b), "EqualConfig compares config fields, not ID")
})
t.Run("FalseWhenFieldsDiffer", func(t *testing.T) {
t.Parallel()
a := agentcontainers.SubAgent{
Name: "test-agent",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
}
b := agentcontainers.SubAgent{
Name: "different-name",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
}
assert.False(t, a.EqualConfig(b))
})
}
+3 -2
View File
@@ -22,6 +22,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
@@ -473,10 +474,10 @@ func (r *Runner) trackCommandGoroutine(fn func()) error {
return xerrors.New("track command goroutine: closed")
}
r.cmdCloseWait.Add(1)
go func() {
agentutil.Go(r.cronCtx, r.Logger, func() {
defer r.cmdCloseWait.Done()
fn()
}()
})
return nil
}
+3 -2
View File
@@ -12,6 +12,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentsocket/proto"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/agent/unit"
"github.com/coder/coder/v2/codersdk/drpcsdk"
)
@@ -79,10 +80,10 @@ func NewServer(logger slog.Logger, opts ...Option) (*Server, error) {
server.logger.Info(server.ctx, "agent socket server started", slog.F("path", server.path))
server.wg.Add(1)
go func() {
agentutil.Go(server.ctx, server.logger, func() {
defer server.wg.Done()
server.acceptConnections()
}()
})
return server, nil
}
+11 -10
View File
@@ -29,6 +29,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentrsa"
"github.com/coder/coder/v2/agent/usershell"
@@ -634,13 +635,13 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "stdin_pipe").Add(1)
return xerrors.Errorf("create stdin pipe: %w", err)
}
go func() {
agentutil.Go(session.Context(), logger, func() {
_, err := io.Copy(stdinPipe, session)
if err != nil {
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "stdin_io_copy").Add(1)
}
_ = stdinPipe.Close()
}()
})
err = cmd.Start()
if err != nil {
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "start_command").Add(1)
@@ -662,11 +663,11 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
session.Signals(nil)
close(sigs)
}()
go func() {
agentutil.Go(session.Context(), logger, func() {
for sig := range sigs {
handleSignal(logger, sig, cmd.Process, s.metrics, magicTypeLabel)
}
}()
})
return cmd.Wait()
}
@@ -737,7 +738,7 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy
session.Signals(nil)
close(sigs)
}()
go func() {
agentutil.Go(ctx, logger, func() {
for {
if sigs == nil && windowSize == nil {
return
@@ -764,14 +765,14 @@ func (s *Server) startPTYSession(logger slog.Logger, session ptySession, magicTy
}
}
}
}()
})
go func() {
agentutil.Go(ctx, logger, func() {
_, err := io.Copy(ptty.InputWriter(), session)
if err != nil {
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "yes", "input_io_copy").Add(1)
}
}()
})
// We need to wait for the command output to finish copying. It's safe to
// just do this copy on the main handler goroutine because one of two things
@@ -1213,11 +1214,11 @@ func (s *Server) Close() error {
// but Close() may not have completed.
func (s *Server) Shutdown(ctx context.Context) error {
ch := make(chan error, 1)
go func() {
agentutil.Go(ctx, s.logger, func() {
// TODO(mafredri): Implement shutdown, SIGHUP running commands, etc.
// For now we just close the server.
ch <- s.Close()
}()
})
var err error
select {
case <-ctx.Done():
+5 -2
View File
@@ -4,6 +4,9 @@ import (
"context"
"io"
"sync"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentutil"
)
// Bicopy copies all of the data between the two connections and will close them
@@ -35,10 +38,10 @@ func Bicopy(ctx context.Context, c1, c2 io.ReadWriteCloser) {
// Convert waitgroup to a channel so we can also wait on the context.
done := make(chan struct{})
go func() {
agentutil.Go(ctx, slog.Logger{}, func() {
defer close(done)
wg.Wait()
}()
})
select {
case <-ctx.Done():
+7 -6
View File
@@ -16,6 +16,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentutil"
)
// streamLocalForwardPayload describes the extra data sent in a
@@ -130,11 +131,11 @@ func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server,
log.Debug(ctx, "SSH unix forward added to cache")
ctx, cancel := context.WithCancel(ctx)
go func() {
agentutil.Go(ctx, log, func() {
<-ctx.Done()
_ = ln.Close()
}()
go func() {
})
agentutil.Go(ctx, log, func() {
defer cancel()
for {
@@ -152,7 +153,7 @@ func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server,
SocketPath: addr,
})
go func() {
agentutil.Go(ctx, log, func() {
ch, reqs, err := conn.OpenChannel("forwarded-streamlocal@openssh.com", payload)
if err != nil {
h.log.Warn(ctx, "open SSH unix forward channel to client", slog.Error(err))
@@ -161,7 +162,7 @@ func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server,
}
go gossh.DiscardRequests(reqs)
Bicopy(ctx, ch, c)
}()
})
}
h.Lock()
@@ -171,7 +172,7 @@ func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server,
h.Unlock()
log.Debug(ctx, "SSH unix forward listener removed from cache")
_ = ln.Close()
}()
})
return true, nil
+5 -4
View File
@@ -22,6 +22,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentutil"
)
const (
@@ -122,10 +123,10 @@ func (x *x11Forwarder) x11Handler(sshCtx ssh.Context, sshSession ssh.Session) (d
}
// clean up the X11 session if the SSH session completes.
go func() {
agentutil.Go(ctx, x.logger, func() {
<-ctx.Done()
x.closeAndRemoveSession(x11session)
}()
})
go x.listenForConnections(ctx, x11session, serverConn, x11)
x.logger.Debug(ctx, "X11 forwarding started", slog.F("display", x11session.display))
@@ -206,10 +207,10 @@ func (x *x11Forwarder) listenForConnections(
_ = conn.Close()
continue
}
go func() {
agentutil.Go(ctx, x.logger, func() {
defer x.trackConn(conn, false)
Bicopy(ctx, conn, channel)
}()
})
}
}
+25
View File
@@ -0,0 +1,25 @@
package agentutil
import (
"context"
"runtime/debug"
"cdr.dev/slog/v3"
)
// Go runs the provided function in a goroutine, recovering from panics and
// logging them before re-panicking.
func Go(ctx context.Context, log slog.Logger, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Critical(ctx, "panic in goroutine",
slog.F("panic", r),
slog.F("stack", string(debug.Stack())),
)
panic(r)
}
}()
fn()
}()
}
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/quartz"
@@ -69,7 +70,7 @@ func NewAppHealthReporterWithClock(
continue
}
app := nextApp
go func() {
agentutil.Go(ctx, logger, func() {
_ = clk.TickerFunc(ctx, time.Duration(app.Healthcheck.Interval)*time.Second, func() error {
// We time out at the healthcheck interval to prevent getting too backed up, but
// set it 1ms early so that it's not simultaneous with the next tick in testing,
@@ -133,7 +134,7 @@ func NewAppHealthReporterWithClock(
}
return nil
}, "healthcheck", app.Slug)
}()
})
}
mu.Lock()
+3 -2
View File
@@ -15,6 +15,7 @@ import (
"google.golang.org/protobuf/proto"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/agent/boundarylogproxy/codec"
agentproto "github.com/coder/coder/v2/agent/proto"
)
@@ -133,11 +134,11 @@ func (s *Server) handleConnection(ctx context.Context, conn net.Conn) {
defer cancel()
s.wg.Add(1)
go func() {
agentutil.Go(ctx, s.logger, func() {
defer s.wg.Done()
<-ctx.Done()
_ = conn.Close()
}()
})
// This is intended to be a sane starting point for the read buffer size. It may be
// grown by codec.ReadFrame if necessary.
+5 -4
View File
@@ -14,6 +14,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/pty"
)
@@ -76,7 +77,7 @@ func newBuffered(ctx context.Context, logger slog.Logger, execer agentexec.Exece
// We do not need to separately monitor for the process exiting. When it
// exits, our ptty.OutputReader() will return EOF after reading all process
// output.
go func() {
agentutil.Go(ctx, logger, func() {
buffer := make([]byte, 1024)
for {
read, err := ptty.OutputReader().Read(buffer)
@@ -118,7 +119,7 @@ func newBuffered(ctx context.Context, logger slog.Logger, execer agentexec.Exece
}
rpty.state.cond.L.Unlock()
}
}()
})
return rpty
}
@@ -133,7 +134,7 @@ func (rpty *bufferedReconnectingPTY) lifecycle(ctx context.Context, logger slog.
logger.Debug(ctx, "reconnecting pty ready")
rpty.state.setState(StateReady, nil)
state, reasonErr := rpty.state.waitForStateOrContext(ctx, StateClosing)
state, reasonErr := rpty.state.waitForStateOrContext(ctx, StateClosing, logger)
if state < StateClosing {
// If we have not closed yet then the context is what unblocked us (which
// means the agent is shutting down) so move into the closing phase.
@@ -190,7 +191,7 @@ func (rpty *bufferedReconnectingPTY) Attach(ctx context.Context, connID string,
delete(rpty.activeConns, connID)
}()
state, err := rpty.state.waitForStateOrContext(ctx, StateReady)
state, err := rpty.state.waitForStateOrContext(ctx, StateReady, logger)
if state != StateReady {
return err
}
+4 -3
View File
@@ -15,6 +15,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/pty"
)
@@ -177,20 +178,20 @@ func (s *ptyState) waitForState(state State) (State, error) {
// waitForStateOrContext blocks until the state or a greater one is reached or
// the provided context ends.
func (s *ptyState) waitForStateOrContext(ctx context.Context, state State) (State, error) {
func (s *ptyState) waitForStateOrContext(ctx context.Context, state State, logger slog.Logger) (State, error) {
s.cond.L.Lock()
defer s.cond.L.Unlock()
nevermind := make(chan struct{})
defer close(nevermind)
go func() {
agentutil.Go(ctx, logger, func() {
select {
case <-ctx.Done():
// Wake up when the context ends.
s.cond.Broadcast()
case <-nevermind:
}
}()
})
for ctx.Err() == nil && state > s.state {
s.cond.Wait()
+5 -4
View File
@@ -20,6 +20,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/pty"
)
@@ -141,7 +142,7 @@ func (rpty *screenReconnectingPTY) lifecycle(ctx context.Context, logger slog.Lo
logger.Debug(ctx, "reconnecting pty ready")
rpty.state.setState(StateReady, nil)
state, reasonErr := rpty.state.waitForStateOrContext(ctx, StateClosing)
state, reasonErr := rpty.state.waitForStateOrContext(ctx, StateClosing, logger)
if state < StateClosing {
// If we have not closed yet then the context is what unblocked us (which
// means the agent is shutting down) so move into the closing phase.
@@ -166,7 +167,7 @@ func (rpty *screenReconnectingPTY) Attach(ctx context.Context, _ string, conn ne
ctx, cancel := context.WithCancel(ctx)
defer cancel()
state, err := rpty.state.waitForStateOrContext(ctx, StateReady)
state, err := rpty.state.waitForStateOrContext(ctx, StateReady, logger)
if state != StateReady {
return err
}
@@ -256,7 +257,7 @@ func (rpty *screenReconnectingPTY) doAttach(ctx context.Context, conn net.Conn,
// We do not need to separately monitor for the process exiting. When it
// exits, our ptty.OutputReader() will return EOF after reading all process
// output.
go func() {
agentutil.Go(ctx, logger, func() {
defer versionCancel()
defer func() {
err := conn.Close()
@@ -298,7 +299,7 @@ func (rpty *screenReconnectingPTY) doAttach(ctx context.Context, conn net.Conn,
break
}
}
}()
})
// Version seems to be the only command without a side effect (other than
// making the version pop up briefly) so use it to wait for the session to
+9 -8
View File
@@ -15,6 +15,7 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/agent/agentssh"
"github.com/coder/coder/v2/agent/usershell"
"github.com/coder/coder/v2/codersdk/workspacesdk"
@@ -90,7 +91,7 @@ func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr err
wg.Add(1)
disconnected := s.reportConnection(uuid.New(), remoteAddrString)
closed := make(chan struct{})
go func() {
agentutil.Go(ctx, clog, func() {
defer wg.Done()
select {
case <-closed:
@@ -98,9 +99,9 @@ func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr err
disconnected(1, "server shut down")
_ = conn.Close()
}
}()
})
wg.Add(1)
go func() {
agentutil.Go(ctx, clog, func() {
defer close(closed)
defer wg.Done()
err := s.handleConn(ctx, clog, conn)
@@ -113,7 +114,7 @@ func (s *Server) Serve(ctx, hardCtx context.Context, l net.Listener) (retErr err
} else {
disconnected(0, "")
}
}()
})
}
wg.Wait()
return retErr
@@ -226,18 +227,18 @@ func (s *Server) handleConn(ctx context.Context, logger slog.Logger, conn net.Co
)
done := make(chan struct{})
go func() {
agentutil.Go(ctx, connLogger, func() {
select {
case <-done:
case <-ctx.Done():
rpty.Close(ctx.Err())
}
}()
})
go func() {
agentutil.Go(ctx, connLogger, func() {
rpty.Wait()
s.reconnectingPTYs.Delete(msg.ID)
}()
})
connected = true
sendConnected <- rpty
+3 -2
View File
@@ -10,6 +10,7 @@ import (
"tailscale.com/types/netlogtype"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentutil"
"github.com/coder/coder/v2/agent/proto"
)
@@ -86,13 +87,13 @@ func (s *statsReporter) reportLoop(ctx context.Context, dest statsDest) error {
// use a separate goroutine to monitor the context so that we notice immediately, rather than
// waiting for the next callback (which might never come if we are closing!)
ctxDone := false
go func() {
agentutil.Go(ctx, s.logger, func() {
<-ctx.Done()
s.L.Lock()
defer s.L.Unlock()
ctxDone = true
s.Broadcast()
}()
})
defer s.logger.Debug(ctx, "reportLoop exiting")
s.L.Lock()
+5 -1
View File
@@ -69,7 +69,7 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
}
default:
text := "Enter a value"
if !templateVersionParameter.Required {
if defaultValue != "" {
text += fmt.Sprintf(" (default: %q)", defaultValue)
}
text += ":"
@@ -77,6 +77,10 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
value, err = Prompt(inv, PromptOptions{
Text: Bold(text),
Validate: func(value string) error {
// If empty, the default value will be used (if available).
if value == "" && defaultValue != "" {
value = defaultValue
}
return validateRichPrompt(value, templateVersionParameter)
},
})
+50 -3
View File
@@ -323,6 +323,7 @@ func (r *RootCmd) Create(opts CreateOptions) *serpent.Command {
Action: WorkspaceCreate,
TemplateVersionID: templateVersionID,
NewWorkspaceName: workspaceName,
Owner: workspaceOwner,
PresetParameters: presetParameters,
RichParameterFile: parameterFlags.richParameterFile,
@@ -456,6 +457,8 @@ type prepWorkspaceBuildArgs struct {
Action WorkspaceCLIAction
TemplateVersionID uuid.UUID
NewWorkspaceName string
// The owner is required when evaluating dynamic parameters
Owner string
LastBuildParameters []codersdk.WorkspaceBuildParameter
SourceWorkspaceParameters []codersdk.WorkspaceBuildParameter
@@ -550,9 +553,14 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
return nil, xerrors.Errorf("get template version: %w", err)
}
templateVersionParameters, err := client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID)
if err != nil {
return nil, xerrors.Errorf("get template version rich parameters: %w", err)
dynamicParameters := true
if templateVersion.TemplateID != nil {
// TODO: This fetch is often redundant, as the caller often has the template already.
template, err := client.Template(ctx, *templateVersion.TemplateID)
if err != nil {
return nil, xerrors.Errorf("get template: %w", err)
}
dynamicParameters = !template.UseClassicParameterFlow
}
parameterFile := map[string]string{}
@@ -574,6 +582,45 @@ func prepWorkspaceBuild(inv *serpent.Invocation, client *codersdk.Client, args p
WithRichParametersFile(parameterFile).
WithRichParametersDefaults(args.RichParameterDefaults).
WithUseParameterDefaults(args.UseParameterDefaults)
var templateVersionParameters []codersdk.TemplateVersionParameter
if !dynamicParameters {
templateVersionParameters, err = client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID)
if err != nil {
return nil, xerrors.Errorf("get template version rich parameters: %w", err)
}
} else {
var ownerID uuid.UUID
{ // Putting in its own block to limit scope of owningMember, as it might be nil
owningMember, err := client.OrganizationMember(ctx, templateVersion.OrganizationID.String(), args.Owner)
if err != nil {
// This is unfortunate, but if we are an org owner, then we can create workspaces
// for users that are not part of the organization.
owningUser, uerr := client.User(ctx, args.Owner)
if uerr != nil {
return nil, xerrors.Errorf("get owning member: %w", err)
}
ownerID = owningUser.ID
} else {
ownerID = owningMember.UserID
}
}
initial := make(map[string]string)
for _, v := range resolver.InitialValues() {
initial[v.Name] = v.Value
}
eval, err := client.EvaluateTemplateVersion(ctx, templateVersion.ID, ownerID, initial)
if err != nil {
return nil, xerrors.Errorf("evaluate template version dynamic parameters: %w", err)
}
for _, param := range eval.Parameters {
templateVersionParameters = append(templateVersionParameters, param.TemplateVersionParameter())
}
}
buildParameters, err := resolver.Resolve(inv, args.Action, templateVersionParameters)
if err != nil {
return nil, err
+303
View File
@@ -24,6 +24,309 @@ import (
"github.com/coder/coder/v2/testutil"
)
func TestCreateDynamic(t *testing.T) {
t.Parallel()
owner := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
first := coderdtest.CreateFirstUser(t, owner)
member, _ := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
// Terraform template with conditional parameters.
// The "region" parameter only appears when "enable_region" is true.
const conditionalParamTF = `
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
data "coder_workspace_owner" "me" {}
data "coder_parameter" "enable_region" {
name = "enable_region"
order = 1
type = "bool"
default = "false"
}
data "coder_parameter" "region" {
name = "region"
count = data.coder_parameter.enable_region.value == "true" ? 1 : 0
order = 2
type = "string"
# No default - this makes it required when it appears
}
`
// Test conditional parameters: a parameter that only appears when another
// parameter has a certain value.
t.Run("ConditionalParam", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
MainTF: conditionalParamTF,
})
// Test 1: Create without enabling region - region param should not exist
args := []string{
"create", "ws-no-region",
"--template", template.Name,
"--parameter", "enable_region=false",
"-y",
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
doneChan := make(chan error)
go func() {
doneChan <- inv.Run()
}()
pty.ExpectMatchContext(ctx, "has been created")
err := testutil.RequireReceive(ctx, t, doneChan)
require.NoError(t, err)
// Verify workspace created with only enable_region parameter
ws, err := member.WorkspaceByOwnerAndName(t.Context(), codersdk.Me, "ws-no-region", codersdk.WorkspaceOptions{})
require.NoError(t, err)
buildParams, err := member.WorkspaceBuildParameters(t.Context(), ws.LatestBuild.ID)
require.NoError(t, err)
require.Len(t, buildParams, 1, "expected only enable_region parameter when enable_region=false")
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "enable_region", Value: "false"})
// Test 2: Create with region enabled - region param should exist
args = []string{
"create", "ws-with-region",
"--template", template.Name,
"--parameter", "enable_region=true",
"--parameter", "region=us-east",
"-y",
}
inv, root = clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
pty = ptytest.New(t).Attach(inv)
doneChan = make(chan error)
go func() {
doneChan <- inv.Run()
}()
pty.ExpectMatchContext(ctx, "has been created")
err = testutil.RequireReceive(ctx, t, doneChan)
require.NoError(t, err)
// Verify workspace created with both parameters
ws, err = member.WorkspaceByOwnerAndName(t.Context(), codersdk.Me, "ws-with-region", codersdk.WorkspaceOptions{})
require.NoError(t, err)
buildParams, err = member.WorkspaceBuildParameters(t.Context(), ws.LatestBuild.ID)
require.NoError(t, err)
require.Len(t, buildParams, 2, "expected both enable_region and region parameters when enable_region=true")
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "enable_region", Value: "true"})
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "region", Value: "us-east"})
})
// Test that the CLI prompts for missing conditional parameters.
// When enable_region=true, the region parameter becomes required and CLI should prompt.
t.Run("PromptForConditionalParam", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
MainTF: conditionalParamTF,
})
// Only provide enable_region=true, don't provide region - CLI should prompt for it
args := []string{
"create", "ws-prompted",
"--template", template.Name,
"--parameter", "enable_region=true",
}
inv, root := clitest.New(t, args...)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
doneChan := make(chan error)
go func() {
doneChan <- inv.Run()
}()
// CLI should prompt for the region parameter since enable_region=true
pty.ExpectMatchContext(ctx, "region")
pty.WriteLine("eu-west")
// Confirm creation
pty.ExpectMatchContext(ctx, "Confirm create?")
pty.WriteLine("yes")
pty.ExpectMatchContext(ctx, "has been created")
err := <-doneChan
require.NoError(t, err)
// Verify workspace created with both parameters
ws, err := member.WorkspaceByOwnerAndName(t.Context(), codersdk.Me, "ws-prompted", codersdk.WorkspaceOptions{})
require.NoError(t, err)
buildParams, err := member.WorkspaceBuildParameters(t.Context(), ws.LatestBuild.ID)
require.NoError(t, err)
require.Len(t, buildParams, 2, "expected both enable_region and region parameters")
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "enable_region", Value: "true"})
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "region", Value: "eu-west"})
})
// Test that updating a template with a new required parameter causes start to fail
// when the user doesn't provide the new parameter value.
t.Run("UpdateTemplateRequiredParamStartFails", func(t *testing.T) {
t.Parallel()
// Initial template with just enable_region parameter (no default, so required)
const initialTF = `
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
data "coder_workspace_owner" "me" {}
data "coder_parameter" "enable_region" {
name = "enable_region"
type = "bool"
}
`
template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
MainTF: initialTF,
})
// Create workspace with initial template
inv, root := clitest.New(t, "create", "ws-update-test",
"--template", template.Name,
"--parameter", "enable_region=false",
"-y",
)
clitest.SetupConfig(t, member, root)
err := inv.Run()
require.NoError(t, err)
// Stop the workspace
inv, root = clitest.New(t, "stop", "ws-update-test", "-y")
clitest.SetupConfig(t, member, root)
err = inv.Run()
require.NoError(t, err)
const updatedTF = `
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
data "coder_workspace_owner" "me" {}
data "coder_parameter" "enable_region" {
name = "enable_region"
type = "bool"
}
data "coder_parameter" "region" {
count = data.coder_parameter.enable_region.value == "true" ? 1 : 0
name = "region"
type = "string"
# No default - required when enable_region is true
}
`
coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
MainTF: updatedTF,
TemplateID: template.ID,
})
// Try to start the workspace with update - should fail because region is now required
// (enable_region defaults to true, making region appear, but no value provided)
// and we're using -y to skip prompts
inv, root = clitest.New(t, "start", "ws-update-test", "-y", "--parameter", "enable_region=true")
clitest.SetupConfig(t, member, root)
err = inv.Run()
require.Error(t, err, "start should fail because new required parameter 'region' is missing")
require.Contains(t, err.Error(), "region")
})
// Test that dynamic validation allows values that would be invalid with static validation.
// A slider's max value is determined by another parameter, so a value of 8 is invalid
// when max_slider=5, but valid when max_slider=10.
t.Run("DynamicValidation", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
// Template where slider's max is controlled by another parameter
const dynamicValidationTF = `
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
data "coder_workspace_owner" "me" {}
data "coder_parameter" "max_slider" {
name = "max_slider"
type = "number"
default = 5
}
data "coder_parameter" "slider" {
name = "slider"
type = "number"
default = 1
validation {
min = 1
max = data.coder_parameter.max_slider.value
}
}
`
template, _ := coderdtest.DynamicParameterTemplate(t, owner, first.OrganizationID, coderdtest.DynamicParameterTemplateParams{
MainTF: dynamicValidationTF,
})
// Test 1: slider=8 should fail when max_slider=5 (default)
inv, root := clitest.New(t, "create", "ws-validation-fail",
"--template", template.Name,
"--parameter", "slider=8",
"-y",
)
clitest.SetupConfig(t, member, root)
err := inv.Run()
require.Error(t, err, "slider=8 should fail when max_slider=5")
// Test 2: slider=8 should succeed when max_slider=10
inv, root = clitest.New(t, "create", "ws-validation-pass",
"--template", template.Name,
"--parameter", "max_slider=10",
"--parameter", "slider=8",
"-y",
)
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
doneChan := make(chan error)
go func() {
doneChan <- inv.Run()
}()
pty.ExpectMatchContext(ctx, "has been created")
err = <-doneChan
require.NoError(t, err, "slider=8 should succeed when max_slider=10")
// Verify workspace created with correct parameters
ws, err := member.WorkspaceByOwnerAndName(t.Context(), codersdk.Me, "ws-validation-pass", codersdk.WorkspaceOptions{})
require.NoError(t, err)
buildParams, err := member.WorkspaceBuildParameters(t.Context(), ws.LatestBuild.ID)
require.NoError(t, err)
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "max_slider", Value: "10"})
require.Contains(t, buildParams, codersdk.WorkspaceBuildParameter{Name: "slider", Value: "8"})
})
}
func TestCreate(t *testing.T) {
t.Parallel()
t.Run("Create", func(t *testing.T) {
+3
View File
@@ -719,6 +719,7 @@ func (r *RootCmd) scaletestCreateWorkspaces() *serpent.Command {
Action: WorkspaceCreate,
TemplateVersionID: tpl.ActiveVersionID,
NewWorkspaceName: "scaletest-N", // TODO: the scaletest runner will pass in a different name here. Does this matter?
Owner: codersdk.Me,
RichParameterFile: parameterFlags.richParameterFile,
RichParameters: cliRichParameters,
@@ -1065,6 +1066,7 @@ func (r *RootCmd) scaletestWorkspaceUpdates() *serpent.Command {
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
Action: WorkspaceCreate,
TemplateVersionID: tpl.ActiveVersionID,
Owner: codersdk.Me,
RichParameterFile: parameterFlags.richParameterFile,
RichParameters: cliRichParameters,
@@ -1786,6 +1788,7 @@ func (r *RootCmd) scaletestAutostart() *serpent.Command {
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
Action: WorkspaceCreate,
TemplateVersionID: tpl.ActiveVersionID,
Owner: codersdk.Me,
RichParameterFile: parameterFlags.richParameterFile,
RichParameters: cliRichParameters,
+50 -4
View File
@@ -108,8 +108,8 @@ func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCL
staged = pr.resolveWithParametersMapFile(staged)
staged = pr.resolveWithCommandLineOrEnv(staged)
staged = pr.resolveWithSourceBuildParameters(staged, templateVersionParameters)
staged = pr.resolveWithLastBuildParameters(staged, templateVersionParameters)
staged = pr.resolveWithSourceBuildParametersInParameters(staged, templateVersionParameters)
staged = pr.resolveWithLastBuildParametersInParameters(staged, templateVersionParameters)
staged = pr.resolveWithPreset(staged) // Preset parameters take precedence from all other parameters
if err = pr.verifyConstraints(staged, action, templateVersionParameters); err != nil {
return nil, err
@@ -120,6 +120,18 @@ func (pr *ParameterResolver) Resolve(inv *serpent.Invocation, action WorkspaceCL
return staged, nil
}
func (pr *ParameterResolver) InitialValues() []codersdk.WorkspaceBuildParameter {
var staged []codersdk.WorkspaceBuildParameter
staged = pr.resolveWithParametersMapFile(staged)
staged = pr.resolveWithCommandLineOrEnv(staged)
staged = pr.resolveWithSourceBuildParameters(staged)
staged = pr.resolveWithLastBuildParameters(staged)
staged = pr.resolveWithPreset(staged) // Preset parameters take precedence from all other parameters
return staged
}
func (pr *ParameterResolver) resolveWithPreset(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
next:
for _, presetParameter := range pr.presetParameters {
@@ -180,7 +192,26 @@ nextEphemeralParameter:
return resolved
}
func (pr *ParameterResolver) resolveWithLastBuildParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter {
func (pr *ParameterResolver) resolveWithLastBuildParameters(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
if pr.promptRichParameters {
return resolved // don't pull parameters from last build
}
next:
for _, buildParameter := range pr.lastBuildParameters {
for i, r := range resolved {
if r.Name == buildParameter.Name {
resolved[i].Value = buildParameter.Value
continue next
}
}
resolved = append(resolved, buildParameter)
}
return resolved
}
func (pr *ParameterResolver) resolveWithLastBuildParametersInParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter {
if pr.promptRichParameters {
return resolved // don't pull parameters from last build
}
@@ -216,7 +247,22 @@ next:
return resolved
}
func (pr *ParameterResolver) resolveWithSourceBuildParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter {
func (pr *ParameterResolver) resolveWithSourceBuildParameters(resolved []codersdk.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
next:
for _, buildParameter := range pr.sourceWorkspaceParameters {
for i, r := range resolved {
if r.Name == buildParameter.Name {
resolved[i].Value = buildParameter.Value
continue next
}
}
resolved = append(resolved, buildParameter)
}
return resolved
}
func (pr *ParameterResolver) resolveWithSourceBuildParametersInParameters(resolved []codersdk.WorkspaceBuildParameter, templateVersionParameters []codersdk.TemplateVersionParameter) []codersdk.WorkspaceBuildParameter {
next:
for _, buildParameter := range pr.sourceWorkspaceParameters {
tvp := findTemplateVersionParameter(buildParameter, templateVersionParameters)
+1
View File
@@ -152,6 +152,7 @@ func buildWorkspaceStartRequest(inv *serpent.Invocation, client *codersdk.Client
TemplateVersionID: version,
NewWorkspaceName: workspace.Name,
LastBuildParameters: lastBuildParameters,
Owner: workspace.OwnerID.String(),
PromptEphemeralParameters: parameterFlags.promptEphemeralParameters,
EphemeralParameters: ephemeralParameters,
+12 -12
View File
@@ -413,13 +413,13 @@ func TestUpdateValidateRichParameters(t *testing.T) {
}()
pty.ExpectMatch(stringParameterName)
pty.ExpectMatch("> Enter a value (default: \"\"): ")
pty.ExpectMatch("> Enter a value: ")
pty.WriteLine("$$")
pty.ExpectMatch("does not match")
pty.ExpectMatch("> Enter a value (default: \"\"): ")
pty.WriteLine("")
pty.ExpectMatch("> Enter a value: ")
pty.WriteLine("ABC")
pty.ExpectMatch("does not match")
pty.ExpectMatch("> Enter a value (default: \"\"): ")
pty.ExpectMatch("> Enter a value: ")
pty.WriteLine("abc")
_ = testutil.TryReceive(ctx, t, doneChan)
})
@@ -459,13 +459,13 @@ func TestUpdateValidateRichParameters(t *testing.T) {
}()
pty.ExpectMatch(numberParameterName)
pty.ExpectMatch("> Enter a value (default: \"\"): ")
pty.ExpectMatch("> Enter a value: ")
pty.WriteLine("12")
pty.ExpectMatch("is more than the maximum")
pty.ExpectMatch("> Enter a value (default: \"\"): ")
pty.WriteLine("")
pty.ExpectMatch("> Enter a value: ")
pty.WriteLine("notanumber")
pty.ExpectMatch("is not a number")
pty.ExpectMatch("> Enter a value (default: \"\"): ")
pty.ExpectMatch("> Enter a value: ")
pty.WriteLine("8")
_ = testutil.TryReceive(ctx, t, doneChan)
})
@@ -505,13 +505,13 @@ func TestUpdateValidateRichParameters(t *testing.T) {
}()
pty.ExpectMatch(boolParameterName)
pty.ExpectMatch("> Enter a value (default: \"\"): ")
pty.ExpectMatch("> Enter a value: ")
pty.WriteLine("cat")
pty.ExpectMatch("boolean value can be either \"true\" or \"false\"")
pty.ExpectMatch("> Enter a value (default: \"\"): ")
pty.WriteLine("")
pty.ExpectMatch("> Enter a value: ")
pty.WriteLine("dog")
pty.ExpectMatch("boolean value can be either \"true\" or \"false\"")
pty.ExpectMatch("> Enter a value (default: \"\"): ")
pty.ExpectMatch("> Enter a value: ")
pty.WriteLine("false")
_ = testutil.TryReceive(ctx, t, doneChan)
})
-6
View File
@@ -249,17 +249,11 @@ func dbAppToProto(dbApp database.WorkspaceApp, agent database.WorkspaceAgent, ow
func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevcontainer) []*agentproto.WorkspaceAgentDevcontainer {
ret := make([]*agentproto.WorkspaceAgentDevcontainer, len(devcontainers))
for i, dc := range devcontainers {
var subagentID []byte
if dc.SubagentID.Valid {
subagentID = dc.SubagentID.UUID[:]
}
ret[i] = &agentproto.WorkspaceAgentDevcontainer{
Id: dc.ID[:],
Name: dc.Name,
WorkspaceFolder: dc.WorkspaceFolder,
ConfigPath: dc.ConfigPath,
SubagentId: subagentID,
}
}
return ret
+19 -56
View File
@@ -37,6 +37,25 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
//nolint:gocritic // This gives us only the permissions required to do the job.
ctx = dbauthz.AsSubAgentAPI(ctx, a.OrganizationID, a.OwnerID)
parentAgent, err := a.AgentFn(ctx)
if err != nil {
return nil, xerrors.Errorf("get parent agent: %w", err)
}
agentName := req.Name
if agentName == "" {
return nil, codersdk.ValidationError{
Field: "name",
Detail: "agent name cannot be empty",
}
}
if !provisioner.AgentNameRegex.MatchString(agentName) {
return nil, codersdk.ValidationError{
Field: "name",
Detail: fmt.Sprintf("agent name %q does not match regex %q", agentName, provisioner.AgentNameRegex),
}
}
createdAt := a.Clock.Now()
displayApps := make([]database.DisplayApp, 0, len(req.DisplayApps))
@@ -64,62 +83,6 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
displayApps = append(displayApps, app)
}
parentAgent, err := a.AgentFn(ctx)
if err != nil {
return nil, xerrors.Errorf("get parent agent: %w", err)
}
// An ID is only given in the request when it is a terraform-defined devcontainer
// that has attached resources. These subagents are pre-provisioned by terraform
// (the agent record already exists), so we update configurable fields like
// display_apps rather than creating a new agent.
if req.Id != nil {
id, err := uuid.FromBytes(req.Id)
if err != nil {
return nil, xerrors.Errorf("parse id: %w", err)
}
subAgent, err := a.Database.GetWorkspaceAgentByID(ctx, id)
if err != nil {
return nil, xerrors.Errorf("get workspace agent by id: %w", err)
}
// Validate that the subagent belongs to the current parent agent to
// prevent updating subagents from other agents within the same workspace.
if !subAgent.ParentID.Valid || subAgent.ParentID.UUID != parentAgent.ID {
return nil, xerrors.Errorf("subagent does not belong to this parent agent")
}
if err := a.Database.UpdateWorkspaceAgentDisplayAppsByID(ctx, database.UpdateWorkspaceAgentDisplayAppsByIDParams{
ID: id,
DisplayApps: displayApps,
UpdatedAt: createdAt,
}); err != nil {
return nil, xerrors.Errorf("update workspace agent display apps: %w", err)
}
return &agentproto.CreateSubAgentResponse{
Agent: &agentproto.SubAgent{
Name: subAgent.Name,
Id: subAgent.ID[:],
AuthToken: subAgent.AuthToken[:],
},
}, nil
}
agentName := req.Name
if agentName == "" {
return nil, codersdk.ValidationError{
Field: "name",
Detail: "agent name cannot be empty",
}
}
if !provisioner.AgentNameRegex.MatchString(agentName) {
return nil, codersdk.ValidationError{
Field: "name",
Detail: fmt.Sprintf("agent name %q does not match regex %q", agentName, provisioner.AgentNameRegex),
}
}
subAgent, err := a.Database.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{
ID: uuid.New(),
ParentID: uuid.NullUUID{Valid: true, UUID: parentAgent.ID},
-230
View File
@@ -1132,236 +1132,6 @@ func TestSubAgentAPI(t *testing.T) {
require.Equal(t, "Custom App", apps[0].DisplayName)
})
t.Run("CreateSubAgent_UpdateExisting", func(t *testing.T) {
t.Parallel()
t.Run("OK_UpdateDisplayApps", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// Given: An existing child agent with some display apps.
childAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID},
ResourceID: agent.ResourceID,
Name: "existing-child-agent",
Directory: "/workspaces/test",
Architecture: "amd64",
OperatingSystem: "linux",
DisplayApps: []database.DisplayApp{database.DisplayAppVscode},
})
// When: We call CreateSubAgent with the existing agent's ID and new display apps.
createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: childAgent.ID[:],
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_WEB_TERMINAL,
proto.CreateSubAgentRequest_SSH_HELPER,
},
})
require.NoError(t, err)
// Then: The response contains the existing agent's details.
require.NotNil(t, createResp.Agent)
require.Equal(t, childAgent.Name, createResp.Agent.Name)
require.Equal(t, childAgent.ID[:], createResp.Agent.Id)
require.Equal(t, childAgent.AuthToken[:], createResp.Agent.AuthToken)
// And: The database agent's display apps are updated.
updatedAgent, err := db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgent.ID)
require.NoError(t, err)
require.Len(t, updatedAgent.DisplayApps, 2)
require.Contains(t, updatedAgent.DisplayApps, database.DisplayAppWebTerminal)
require.Contains(t, updatedAgent.DisplayApps, database.DisplayAppSSHHelper)
})
t.Run("Error_MalformedID", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// When: We call CreateSubAgent with malformed ID bytes (not 16 bytes).
// uuid.FromBytes requires exactly 16 bytes, so we provide fewer.
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: []byte("short"),
})
// Then: We expect an error about parsing the ID.
require.Error(t, err)
require.Contains(t, err.Error(), "parse id")
})
t.Run("Error_AgentNotFound", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// When: We call CreateSubAgent with a non-existent agent ID.
nonExistentID := uuid.New()
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: nonExistentID[:],
})
// Then: We expect an error about the agent not being found.
require.Error(t, err)
require.Contains(t, err.Error(), "get workspace agent by id")
})
t.Run("Error_ParentMismatch", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// Create a second agent (sibling) within the same workspace/resource.
// This sibling has a different parent ID (or no parent).
siblingAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ParentID: uuid.NullUUID{Valid: false}, // No parent - it's a top-level agent
ResourceID: agent.ResourceID,
Name: "sibling-agent",
Directory: "/workspaces/sibling",
Architecture: "amd64",
OperatingSystem: "linux",
})
// Create a child of the sibling agent (not our agent).
childOfSibling := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ParentID: uuid.NullUUID{Valid: true, UUID: siblingAgent.ID},
ResourceID: agent.ResourceID,
Name: "child-of-sibling",
Directory: "/workspaces/test",
Architecture: "amd64",
OperatingSystem: "linux",
})
// When: Our API (which is for `agent`) tries to update the child of `siblingAgent`.
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: childOfSibling.ID[:],
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_VSCODE,
},
})
// Then: We expect an error about the parent mismatch.
require.Error(t, err)
require.Contains(t, err.Error(), "subagent does not belong to this parent agent")
})
t.Run("OK_OtherFieldsNotModified", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// Given: An existing child agent with specific properties.
originalName := "original-child-agent"
originalDir := "/workspaces/original"
originalArch := "amd64"
originalOS := "linux"
childAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID},
ResourceID: agent.ResourceID,
Name: originalName,
Directory: originalDir,
Architecture: originalArch,
OperatingSystem: originalOS,
DisplayApps: []database.DisplayApp{database.DisplayAppVscode},
})
// When: We call CreateSubAgent with different values for name, directory, arch, and OS.
createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: childAgent.ID[:],
Name: "different-name",
Directory: "/different/path",
Architecture: "arm64",
OperatingSystem: "darwin",
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_WEB_TERMINAL,
},
})
require.NoError(t, err)
// Then: The response contains the original agent name, not the new one.
require.NotNil(t, createResp.Agent)
require.Equal(t, originalName, createResp.Agent.Name)
// And: The database agent's other fields are unchanged.
updatedAgent, err := db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgent.ID)
require.NoError(t, err)
require.Equal(t, originalName, updatedAgent.Name)
require.Equal(t, originalDir, updatedAgent.Directory)
require.Equal(t, originalArch, updatedAgent.Architecture)
require.Equal(t, originalOS, updatedAgent.OperatingSystem)
// But display apps should be updated.
require.Len(t, updatedAgent.DisplayApps, 1)
require.Equal(t, database.DisplayAppWebTerminal, updatedAgent.DisplayApps[0])
})
t.Run("Error_NoParentID", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// Given: An agent without a parent (a top-level agent).
topLevelAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ParentID: uuid.NullUUID{Valid: false}, // No parent
ResourceID: agent.ResourceID,
Name: "top-level-agent",
Directory: "/workspaces/test",
Architecture: "amd64",
OperatingSystem: "linux",
})
// When: We try to update this agent as if it were a subagent.
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: topLevelAgent.ID[:],
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_VSCODE,
},
})
// Then: We expect an error because the agent has no parent.
require.Error(t, err)
require.Contains(t, err.Error(), "subagent does not belong to this parent agent")
})
})
t.Run("ListSubAgents", func(t *testing.T) {
t.Parallel()
+39 -8
View File
@@ -3482,6 +3482,45 @@ const docTemplate = `{
}
},
"/organizations/{organization}/members/{user}": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Members"
],
"summary": "Get organization member",
"operationId": "get-organization-member",
"parameters": [
{
"type": "string",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OrganizationMemberWithUserData"
}
}
}
},
"post": {
"security": [
{
@@ -20774,14 +20813,6 @@ const docTemplate = `{
}
]
},
"subagent_id": {
"format": "uuid",
"allOf": [
{
"$ref": "#/definitions/uuid.NullUUID"
}
]
},
"workspace_folder": {
"type": "string"
}
+35 -8
View File
@@ -3059,6 +3059,41 @@
}
},
"/organizations/{organization}/members/{user}": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Members"],
"summary": "Get organization member",
"operationId": "get-organization-member",
"parameters": [
{
"type": "string",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"description": "User ID, name, or me",
"name": "user",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.OrganizationMemberWithUserData"
}
}
}
},
"post": {
"security": [
{
@@ -19082,14 +19117,6 @@
}
]
},
"subagent_id": {
"format": "uuid",
"allOf": [
{
"$ref": "#/definitions/uuid.NullUUID"
}
]
},
"workspace_folder": {
"type": "string"
}
+1
View File
@@ -1228,6 +1228,7 @@ func New(options *Options) *API {
r.Use(
httpmw.ExtractOrganizationMemberParam(options.Database),
)
r.Get("/", api.organizationMember)
r.Delete("/", api.deleteOrganizationMember)
r.Put("/roles", api.putMemberRoles)
r.Post("/workspaces", api.postWorkspacesByOrganization)
+3 -3
View File
@@ -62,7 +62,6 @@ import (
"github.com/coder/coder/v2/coderd/connectionlog"
"github.com/coder/coder/v2/coderd/cryptokeys"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbrollup"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
@@ -86,6 +85,7 @@ import (
"github.com/coder/coder/v2/coderd/usage"
"github.com/coder/coder/v2/coderd/util/namesgenerator"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/webpush"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
@@ -934,7 +934,7 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI
return role.Name
}
user, err = client.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: db2sdk.List(siteRoles, onlyName)})
user, err = client.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: slice.List(siteRoles, onlyName)})
require.NoError(t, err, "update site roles")
// isMember keeps track of which orgs the user was added to as a member
@@ -953,7 +953,7 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI
}
_, err = client.UpdateOrganizationMemberRoles(context.Background(), orgID, user.ID.String(),
codersdk.UpdateRoles{Roles: db2sdk.List(roles, onlyName)})
codersdk.UpdateRoles{Roles: slice.List(roles, onlyName)})
require.NoError(t, err, "update org membership roles")
isMember[orgID] = true
}
+21 -31
View File
@@ -31,16 +31,6 @@ import (
previewtypes "github.com/coder/preview/types"
)
// Deprecated: use slice.List
func List[F any, T any](list []F, convert func(F) T) []T {
return slice.List[F, T](list, convert)
}
// Deprecated: use slice.ListLazy
func ListLazy[F any, T any](convert func(F) T) func(list []F) []T {
return slice.ListLazy[F, T](convert)
}
func APIAllowListTarget(entry rbac.AllowListElement) codersdk.APIAllowListTarget {
return codersdk.APIAllowListTarget{
Type: codersdk.RBACResource(entry.Type),
@@ -81,7 +71,7 @@ func WorkspaceBuildParameter(p database.WorkspaceBuildParameter) codersdk.Worksp
}
func WorkspaceBuildParameters(params []database.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
return List(params, WorkspaceBuildParameter)
return slice.List(params, WorkspaceBuildParameter)
}
func TemplateVersionParameters(params []database.TemplateVersionParameter) ([]codersdk.TemplateVersionParameter, error) {
@@ -115,7 +105,7 @@ func TemplateVersionParameterFromPreview(param previewtypes.Parameter) (codersdk
Icon: param.Icon,
Required: param.Required,
Ephemeral: param.Ephemeral,
Options: List(param.Options, TemplateVersionParameterOptionFromPreview),
Options: slice.List(param.Options, TemplateVersionParameterOptionFromPreview),
// Validation set after
}
if len(param.Validations) > 0 {
@@ -237,11 +227,11 @@ func ReducedUserFromGroupMember(member database.GroupMember) codersdk.ReducedUse
}
func ReducedUsersFromGroupMembers(members []database.GroupMember) []codersdk.ReducedUser {
return List(members, ReducedUserFromGroupMember)
return slice.List(members, ReducedUserFromGroupMember)
}
func ReducedUsers(users []database.User) []codersdk.ReducedUser {
return List(users, ReducedUser)
return slice.List(users, ReducedUser)
}
func User(user database.User, organizationIDs []uuid.UUID) codersdk.User {
@@ -255,7 +245,7 @@ func User(user database.User, organizationIDs []uuid.UUID) codersdk.User {
}
func Users(users []database.User, organizationIDs map[uuid.UUID][]uuid.UUID) []codersdk.User {
return List(users, func(user database.User) codersdk.User {
return slice.List(users, func(user database.User) codersdk.User {
return User(user, organizationIDs[user.ID])
})
}
@@ -388,7 +378,7 @@ func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) cod
}
func OAuth2ProviderApps(accessURL *url.URL, dbApps []database.OAuth2ProviderApp) []codersdk.OAuth2ProviderApp {
return List(dbApps, func(dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp {
return slice.List(dbApps, func(dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp {
return OAuth2ProviderApp(accessURL, dbApp)
})
}
@@ -607,7 +597,7 @@ func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus
}
func WorkspaceAppStatuses(statuses []database.WorkspaceAppStatus) []codersdk.WorkspaceAppStatus {
return List(statuses, WorkspaceAppStatus)
return slice.List(statuses, WorkspaceAppStatus)
}
func WorkspaceAppStatus(status database.WorkspaceAppStatus) codersdk.WorkspaceAppStatus {
@@ -728,10 +718,10 @@ func RBACRole(role rbac.Role) codersdk.Role {
Name: slim.Name,
OrganizationID: slim.OrganizationID,
DisplayName: slim.DisplayName,
SitePermissions: List(role.Site, RBACPermission),
UserPermissions: List(role.User, RBACPermission),
OrganizationPermissions: List(orgPerms.Org, RBACPermission),
OrganizationMemberPermissions: List(orgPerms.Member, RBACPermission),
SitePermissions: slice.List(role.Site, RBACPermission),
UserPermissions: slice.List(role.User, RBACPermission),
OrganizationPermissions: slice.List(orgPerms.Org, RBACPermission),
OrganizationMemberPermissions: slice.List(orgPerms.Member, RBACPermission),
}
}
@@ -745,9 +735,9 @@ func Role(role database.CustomRole) codersdk.Role {
Name: role.Name,
OrganizationID: orgID,
DisplayName: role.DisplayName,
SitePermissions: List(role.SitePermissions, Permission),
UserPermissions: List(role.UserPermissions, Permission),
OrganizationPermissions: List(role.OrgPermissions, Permission),
SitePermissions: slice.List(role.SitePermissions, Permission),
UserPermissions: slice.List(role.UserPermissions, Permission),
OrganizationPermissions: slice.List(role.OrgPermissions, Permission),
}
}
@@ -783,7 +773,7 @@ func Organization(organization database.Organization) codersdk.Organization {
}
func CryptoKeys(keys []database.CryptoKey) []codersdk.CryptoKey {
return List(keys, CryptoKey)
return slice.List(keys, CryptoKey)
}
func CryptoKey(key database.CryptoKey) codersdk.CryptoKey {
@@ -894,8 +884,8 @@ func PreviewParameter(param previewtypes.Parameter) codersdk.PreviewParameter {
Mutable: param.Mutable,
DefaultValue: PreviewHCLString(param.DefaultValue),
Icon: param.Icon,
Options: List(param.Options, PreviewParameterOption),
Validations: List(param.Validations, PreviewParameterValidation),
Options: slice.List(param.Options, PreviewParameterOption),
Validations: slice.List(param.Validations, PreviewParameterValidation),
Required: param.Required,
Order: param.Order,
Ephemeral: param.Ephemeral,
@@ -911,7 +901,7 @@ func HCLDiagnostics(d hcl.Diagnostics) []codersdk.FriendlyDiagnostic {
func PreviewDiagnostics(d previewtypes.Diagnostics) []codersdk.FriendlyDiagnostic {
f := d.FriendlyDiagnostics()
return List(f, func(f previewtypes.FriendlyDiagnostic) codersdk.FriendlyDiagnostic {
return slice.List(f, func(f previewtypes.FriendlyDiagnostic) codersdk.FriendlyDiagnostic {
return codersdk.FriendlyDiagnostic{
Severity: codersdk.DiagnosticSeverityString(f.Severity),
Summary: f.Summary,
@@ -959,17 +949,17 @@ func PreviewParameterValidation(v *previewtypes.ParameterValidation) codersdk.Pr
}
func AIBridgeInterception(interception database.AIBridgeInterception, initiator database.VisibleUser, tokenUsages []database.AIBridgeTokenUsage, userPrompts []database.AIBridgeUserPrompt, toolUsages []database.AIBridgeToolUsage) codersdk.AIBridgeInterception {
sdkTokenUsages := List(tokenUsages, AIBridgeTokenUsage)
sdkTokenUsages := slice.List(tokenUsages, AIBridgeTokenUsage)
sort.Slice(sdkTokenUsages, func(i, j int) bool {
// created_at ASC
return sdkTokenUsages[i].CreatedAt.Before(sdkTokenUsages[j].CreatedAt)
})
sdkUserPrompts := List(userPrompts, AIBridgeUserPrompt)
sdkUserPrompts := slice.List(userPrompts, AIBridgeUserPrompt)
sort.Slice(sdkUserPrompts, func(i, j int) bool {
// created_at ASC
return sdkUserPrompts[i].CreatedAt.Before(sdkUserPrompts[j].CreatedAt)
})
sdkToolUsages := List(toolUsages, AIBridgeToolUsage)
sdkToolUsages := slice.List(toolUsages, AIBridgeToolUsage)
sort.Slice(sdkToolUsages, func(i, j int) bool {
// created_at ASC
return sdkToolUsages[i].CreatedAt.Before(sdkToolUsages[j].CreatedAt)
+5 -5
View File
@@ -10,11 +10,11 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
@@ -227,10 +227,10 @@ func TestInsertCustomRoles(t *testing.T) {
Name: "test-role",
DisplayName: "",
OrganizationID: uuid.NullUUID{UUID: tc.organizationID, Valid: true},
SitePermissions: db2sdk.List(tc.site, convertSDKPerm),
OrgPermissions: db2sdk.List(tc.org, convertSDKPerm),
UserPermissions: db2sdk.List(tc.user, convertSDKPerm),
MemberPermissions: db2sdk.List(tc.member, convertSDKPerm),
SitePermissions: slice.List(tc.site, convertSDKPerm),
OrgPermissions: slice.List(tc.org, convertSDKPerm),
UserPermissions: slice.List(tc.user, convertSDKPerm),
MemberPermissions: slice.List(tc.member, convertSDKPerm),
})
if tc.errorContains != "" {
require.ErrorContains(t, err, tc.errorContains)
-13
View File
@@ -5726,19 +5726,6 @@ func (q *querier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg da
return q.db.UpdateWorkspaceAgentConnectionByID(ctx, arg)
}
func (q *querier) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg database.UpdateWorkspaceAgentDisplayAppsByIDParams) error {
workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.ID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, workspace); err != nil {
return err
}
return q.db.UpdateWorkspaceAgentDisplayAppsByID(ctx, arg)
}
func (q *querier) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg database.UpdateWorkspaceAgentLifecycleStateByIDParams) error {
workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.ID)
if err != nil {
+6 -18
View File
@@ -22,7 +22,6 @@ import (
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmock"
@@ -1630,11 +1629,11 @@ func (s *MethodTestSuite) TestUser() {
Name: "",
OrganizationID: uuid.NullUUID{UUID: uuid.Nil, Valid: false},
DisplayName: "Test Name",
SitePermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
SitePermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionDelete, codersdk.ActionViewInsights},
}), convertSDKPerm),
OrgPermissions: nil,
UserPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
UserPermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}), convertSDKPerm),
}
@@ -1646,7 +1645,7 @@ func (s *MethodTestSuite) TestUser() {
Name: "name",
DisplayName: "Test Name",
OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true},
OrgPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
OrgPermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead},
}), convertSDKPerm),
}
@@ -1668,11 +1667,11 @@ func (s *MethodTestSuite) TestUser() {
arg := database.InsertCustomRoleParams{
Name: "test",
DisplayName: "Test Name",
SitePermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
SitePermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead, codersdk.ActionUpdate, codersdk.ActionDelete, codersdk.ActionViewInsights},
}), convertSDKPerm),
OrgPermissions: nil,
UserPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
UserPermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceWorkspace: {codersdk.ActionRead},
}), convertSDKPerm),
}
@@ -1684,7 +1683,7 @@ func (s *MethodTestSuite) TestUser() {
Name: "test",
DisplayName: "Test Name",
OrganizationID: uuid.NullUUID{UUID: orgID, Valid: true},
OrgPermissions: db2sdk.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
OrgPermissions: slice.List(codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceTemplate: {codersdk.ActionCreate, codersdk.ActionRead},
}), convertSDKPerm),
}
@@ -1930,17 +1929,6 @@ func (s *MethodTestSuite) TestWorkspace() {
dbm.EXPECT().UpdateWorkspaceAgentStartupByID(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(w, policy.ActionUpdate).Returns()
}))
s.Run("UpdateWorkspaceAgentDisplayAppsByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
arg := database.UpdateWorkspaceAgentDisplayAppsByIDParams{
ID: agt.ID,
DisplayApps: []database.DisplayApp{database.DisplayAppVscode},
}
dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes()
dbm.EXPECT().UpdateWorkspaceAgentDisplayAppsByID(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(w, policy.ActionUpdate).Returns()
}))
s.Run("GetWorkspaceAgentLogsAfter", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
ws := testutil.Fake(s.T(), faker, database.Workspace{})
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
@@ -3909,14 +3909,6 @@ func (m queryMetricsStore) UpdateWorkspaceAgentConnectionByID(ctx context.Contex
return r0
}
func (m queryMetricsStore) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg database.UpdateWorkspaceAgentDisplayAppsByIDParams) error {
start := time.Now()
r0 := m.s.UpdateWorkspaceAgentDisplayAppsByID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateWorkspaceAgentDisplayAppsByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateWorkspaceAgentDisplayAppsByID").Inc()
return r0
}
func (m queryMetricsStore) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg database.UpdateWorkspaceAgentLifecycleStateByIDParams) error {
start := time.Now()
r0 := m.s.UpdateWorkspaceAgentLifecycleStateByID(ctx, arg)
-14
View File
@@ -7321,20 +7321,6 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceAgentConnectionByID(ctx, arg any
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAgentConnectionByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAgentConnectionByID), ctx, arg)
}
// UpdateWorkspaceAgentDisplayAppsByID mocks base method.
func (m *MockStore) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg database.UpdateWorkspaceAgentDisplayAppsByIDParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWorkspaceAgentDisplayAppsByID", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateWorkspaceAgentDisplayAppsByID indicates an expected call of UpdateWorkspaceAgentDisplayAppsByID.
func (mr *MockStoreMockRecorder) UpdateWorkspaceAgentDisplayAppsByID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAgentDisplayAppsByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAgentDisplayAppsByID), ctx, arg)
}
// UpdateWorkspaceAgentLifecycleStateByID mocks base method.
func (m *MockStore) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg database.UpdateWorkspaceAgentLifecycleStateByIDParams) error {
m.ctrl.T.Helper()
-1
View File
@@ -738,7 +738,6 @@ type sqlcQuerier interface {
UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (WorkspaceTable, error)
UpdateWorkspaceACLByID(ctx context.Context, arg UpdateWorkspaceACLByIDParams) error
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg UpdateWorkspaceAgentDisplayAppsByIDParams) error
UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg UpdateWorkspaceAgentLifecycleStateByIDParams) error
UpdateWorkspaceAgentLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentLogOverflowByIDParams) error
UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error
+8 -9
View File
@@ -23,7 +23,6 @@ import (
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
@@ -2022,8 +2021,8 @@ func TestWorkspaceQuotas(t *testing.T) {
})
require.NoError(t, err)
require.ElementsMatch(t, db2sdk.List(everyoneMembers, groupMemberIDs),
db2sdk.List([]database.OrganizationMember{memOne, memTwo}, orgMemberIDs))
require.ElementsMatch(t, slice.List(everyoneMembers, groupMemberIDs),
slice.List([]database.OrganizationMember{memOne, memTwo}, orgMemberIDs))
// Check the quota is correct.
allowance, err := db.GetQuotaAllowanceForUser(ctx, database.GetQuotaAllowanceForUserParams{
@@ -2204,7 +2203,7 @@ func TestReadCustomRoles(t *testing.T) {
{
Name: "AllRolesByLookup",
Params: database.CustomRolesParams{
LookupRoles: db2sdk.List(allRoles, roleToLookup),
LookupRoles: slice.List(allRoles, roleToLookup),
},
Match: func(role database.CustomRole) bool {
return true
@@ -2270,8 +2269,8 @@ func TestReadCustomRoles(t *testing.T) {
}
}
a := db2sdk.List(filtered, normalizedRoleName)
b := db2sdk.List(found, normalizedRoleName)
a := slice.List(filtered, normalizedRoleName)
b := slice.List(found, normalizedRoleName)
require.Equal(t, a, b)
})
}
@@ -4260,7 +4259,7 @@ func TestGroupRemovalTrigger(t *testing.T) {
require.ElementsMatch(t, []uuid.UUID{
orgA.ID, orgB.ID, // Everyone groups
groupA1.ID, groupA2.ID, groupB1.ID, groupB2.ID, // Org groups
}, db2sdk.List(userGroups, onlyGroupIDs))
}, slice.List(userGroups, onlyGroupIDs))
// Remove the user from org A
err = db.DeleteOrganizationMember(ctx, database.DeleteOrganizationMemberParams{
@@ -4277,7 +4276,7 @@ func TestGroupRemovalTrigger(t *testing.T) {
require.ElementsMatch(t, []uuid.UUID{
orgB.ID, // Everyone group
groupB1.ID, groupB2.ID, // Org groups
}, db2sdk.List(userGroups, onlyGroupIDs))
}, slice.List(userGroups, onlyGroupIDs))
// Verify extra user is unchanged
extraUserGroups, err := db.GetGroups(ctx, database.GetGroupsParams{
@@ -4287,7 +4286,7 @@ func TestGroupRemovalTrigger(t *testing.T) {
require.ElementsMatch(t, []uuid.UUID{
orgA.ID, orgB.ID, // Everyone groups
groupA1.ID, groupA2.ID, groupB1.ID, groupB2.ID, // Org groups
}, db2sdk.List(extraUserGroups, onlyGroupIDs))
}, slice.List(extraUserGroups, onlyGroupIDs))
}
func TestGetUserStatusCounts(t *testing.T) {
-20
View File
@@ -19306,26 +19306,6 @@ func (q *sqlQuerier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg
return err
}
const updateWorkspaceAgentDisplayAppsByID = `-- name: UpdateWorkspaceAgentDisplayAppsByID :exec
UPDATE
workspace_agents
SET
display_apps = $2, updated_at = $3
WHERE
id = $1
`
type UpdateWorkspaceAgentDisplayAppsByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
DisplayApps []DisplayApp `db:"display_apps" json:"display_apps"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (q *sqlQuerier) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg UpdateWorkspaceAgentDisplayAppsByIDParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceAgentDisplayAppsByID, arg.ID, pq.Array(arg.DisplayApps), arg.UpdatedAt)
return err
}
const updateWorkspaceAgentLifecycleStateByID = `-- name: UpdateWorkspaceAgentLifecycleStateByID :exec
UPDATE
workspace_agents
@@ -180,14 +180,6 @@ SET
WHERE
id = $1;
-- name: UpdateWorkspaceAgentDisplayAppsByID :exec
UPDATE
workspace_agents
SET
display_apps = $2, updated_at = $3
WHERE
id = $1;
-- name: GetWorkspaceAgentLogsAfter :many
SELECT
*
+2 -2
View File
@@ -3,7 +3,7 @@ package sdk2db
import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
@@ -12,5 +12,5 @@ func ProvisionerDaemonStatus(status codersdk.ProvisionerDaemonStatus) database.P
}
func ProvisionerDaemonStatuses(params []codersdk.ProvisionerDaemonStatus) []database.ProvisionerDaemonStatus {
return db2sdk.List(params, ProvisionerDaemonStatus)
return slice.List(params, ProvisionerDaemonStatus)
}
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/preview"
previewtypes "github.com/coder/preview/types"
@@ -27,7 +27,7 @@ func (r *loader) staticRender(ctx context.Context, db database.Store) (*staticRe
return nil, xerrors.Errorf("template version parameters: %w", err)
}
params := db2sdk.List(dbTemplateVersionParameters, TemplateVersionParameter)
params := slice.List(dbTemplateVersionParameters, TemplateVersionParameter)
for i, param := range params {
// Update the diagnostics to validate the 'default' value.
+1 -2
View File
@@ -12,7 +12,6 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/coderd/util/ptr"
@@ -202,7 +201,7 @@ func (s AGPLIDPSync) SyncGroups(ctx context.Context, db database.Store, user dat
// determine if we have to do any group updates to sync the user's
// state.
existingGroups := userOrgs[orgID]
existingGroupsTyped := db2sdk.List(existingGroups, func(f database.GetGroupsRow) ExpectedGroup {
existingGroupsTyped := slice.List(existingGroups, func(f database.GetGroupsRow) ExpectedGroup {
return ExpectedGroup{
OrganizationID: orgID,
GroupID: &f.Group.ID,
+4 -4
View File
@@ -15,13 +15,13 @@ import (
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
@@ -590,7 +590,7 @@ func TestApplyGroupDifference(t *testing.T) {
require.NoError(t, err)
// assert
found := db2sdk.List(userGroups, func(g database.GetGroupsRow) uuid.UUID {
found := slice.List(userGroups, func(g database.GetGroupsRow) uuid.UUID {
return g.Group.ID
})
@@ -910,14 +910,14 @@ func (o *orgGroupAssert) Assert(t *testing.T, orgID uuid.UUID, db database.Store
})
if len(o.ExpectedGroupNames) > 0 {
found := db2sdk.List(userGroups, func(g database.GetGroupsRow) string {
found := slice.List(userGroups, func(g database.GetGroupsRow) string {
return g.Group.Name
})
require.ElementsMatch(t, o.ExpectedGroupNames, found, "user groups by name")
require.Len(t, o.ExpectedGroups, 0, "ExpectedGroups should be empty")
} else {
// Check by ID, recommended
found := db2sdk.List(userGroups, func(g database.GetGroupsRow) uuid.UUID {
found := slice.List(userGroups, func(g database.GetGroupsRow) uuid.UUID {
return g.Group.ID
})
require.ElementsMatch(t, o.ExpectedGroups, found, "user groups")
+2 -3
View File
@@ -11,7 +11,6 @@ import (
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/runtimeconfig"
@@ -107,7 +106,7 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u
return xerrors.Errorf("failed to get user organizations: %w", err)
}
existingOrgIDs := db2sdk.List(existingOrgs, func(org database.Organization) uuid.UUID {
existingOrgIDs := slice.List(existingOrgs, func(org database.Organization) uuid.UUID {
return org.ID
})
@@ -127,7 +126,7 @@ func (s AGPLIDPSync) SyncOrganizations(ctx context.Context, tx database.Store, u
if err != nil {
return xerrors.Errorf("failed to get expected organizations: %w", err)
}
finalExpected = db2sdk.List(expectedOrganizations, func(org database.Organization) uuid.UUID {
finalExpected = slice.List(expectedOrganizations, func(org database.Organization) uuid.UUID {
return org.ID
})
}
+2 -2
View File
@@ -11,12 +11,12 @@ import (
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/testutil"
)
@@ -173,7 +173,7 @@ func TestSyncOrganizations(t *testing.T) {
// Verify the user only exists in 2 orgs. The one they stayed, and the one they
// joined.
inIDs := db2sdk.List(orgs, func(org database.Organization) uuid.UUID {
inIDs := slice.List(orgs, func(org database.Organization) uuid.UUID {
return org.ID
})
require.ElementsMatch(t, []uuid.UUID{stays.Org.ID, joins.Org.ID}, inIDs)
+50 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
@@ -144,6 +145,54 @@ func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request
rw.WriteHeader(http.StatusNoContent)
}
// @Summary Get organization member
// @ID get-organization-member
// @Security CoderSessionToken
// @Tags Members
// @Param organization path string true "Organization ID"
// @Param user path string true "User ID, name, or me"
// @Success 200 {object} codersdk.OrganizationMemberWithUserData
// @Produce json
// @Router /organizations/{organization}/members/{user} [get]
func (api *API) organizationMember(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
organization = httpmw.OrganizationParam(r)
member = httpmw.OrganizationMemberParam(r)
)
// This is unfortunate to fetch like this, but we need the user table data.
// The listing route uses this data format, so it is just easier to reuse the
// list query.
rows, err := api.Database.OrganizationMembers(ctx, database.OrganizationMembersParams{
OrganizationID: organization.ID,
UserID: member.UserID,
IncludeSystem: false,
GithubUserID: 0,
})
if httpapi.Is404Error(err) || len(rows) == 0 {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
resp, err := convertOrganizationMembersWithUserData(ctx, api.Database, rows)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
if len(resp) != 1 {
httpapi.InternalServerError(rw, xerrors.Errorf("unexpected organization members, something went wrong"))
return
}
httpapi.Write(ctx, rw, http.StatusOK, resp[0])
}
// @Deprecated use /organizations/{organization}/paginated-members [get]
// @Summary List organization members
// @ID list-organization-members
@@ -370,7 +419,7 @@ func convertOrganizationMembers(ctx context.Context, db database.Store, mems []d
OrganizationID: m.OrganizationID,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
Roles: db2sdk.List(m.Roles, func(r string) codersdk.SlimRole {
Roles: slice.List(m.Roles, func(r string) codersdk.SlimRole {
// If it is a built-in role, no lookups are needed.
rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: r, OrganizationID: m.OrganizationID})
if err == nil {
+25 -9
View File
@@ -9,8 +9,8 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
@@ -18,17 +18,33 @@ import (
func TestAddMember(t *testing.T) {
t.Parallel()
owner := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, owner)
_, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
t.Run("AlreadyMember", func(t *testing.T) {
t.Parallel()
owner := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, owner)
_, user := coderdtest.CreateAnotherUser(t, owner, first.OrganizationID)
ctx := testutil.Context(t, testutil.WaitMedium)
// Add user to org, even though they already exist
// nolint:gocritic // must be an owner to see the user
_, err := owner.PostOrganizationMember(ctx, first.OrganizationID, user.Username)
require.ErrorContains(t, err, "already an organization member")
org, err := owner.Organization(ctx, first.OrganizationID)
require.NoError(t, err)
member, err := owner.OrganizationMember(ctx, org.Name, user.Username)
require.NoError(t, err)
require.Equal(t, member.UserID, user.ID)
})
t.Run("Me", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
member, err := owner.OrganizationMember(ctx, first.OrganizationID.String(), codersdk.Me)
require.NoError(t, err)
require.Equal(t, member.UserID, first.UserID)
})
}
@@ -76,7 +92,7 @@ func TestListMembers(t *testing.T) {
require.Len(t, members, 3)
require.ElementsMatch(t,
[]uuid.UUID{owner.UserID, orgMember.ID, orgAdmin.ID},
db2sdk.List(members, onlyIDs))
slice.List(members, onlyIDs))
})
t.Run("UserID", func(t *testing.T) {
@@ -88,7 +104,7 @@ func TestListMembers(t *testing.T) {
require.Len(t, members, 1)
require.ElementsMatch(t,
[]uuid.UUID{orgMember.ID},
db2sdk.List(members, onlyIDs))
slice.List(members, onlyIDs))
})
t.Run("IncludeSystem", func(t *testing.T) {
@@ -100,7 +116,7 @@ func TestListMembers(t *testing.T) {
require.Len(t, members, 4)
require.ElementsMatch(t,
[]uuid.UUID{owner.UserID, orgMember.ID, orgAdmin.ID, database.PrebuildsSystemUserID},
db2sdk.List(members, onlyIDs))
slice.List(members, onlyIDs))
})
t.Run("GithubUserID", func(t *testing.T) {
@@ -112,7 +128,7 @@ func TestListMembers(t *testing.T) {
require.Len(t, members, 1)
require.ElementsMatch(t,
[]uuid.UUID{anotherUser.ID},
db2sdk.List(members, onlyIDs))
slice.List(members, onlyIDs))
})
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
@@ -32,7 +33,7 @@ func (api *API) organizations(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(organizations, db2sdk.Organization))
httpapi.Write(ctx, rw, http.StatusOK, slice.List(organizations, db2sdk.Organization))
}
// @Summary Get organization by ID
+4 -3
View File
@@ -12,6 +12,7 @@ import (
"github.com/coder/coder/v2/coderd/dynamicparameters"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/websocket"
@@ -121,7 +122,7 @@ func (*API) handleParameterEvaluate(rw http.ResponseWriter, r *http.Request, ini
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
}
if result != nil {
response.Parameters = db2sdk.List(result.Parameters, db2sdk.PreviewParameter)
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
}
httpapi.Write(ctx, rw, http.StatusOK, response)
@@ -155,7 +156,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
}
if result != nil {
response.Parameters = db2sdk.List(result.Parameters, db2sdk.PreviewParameter)
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
}
err = stream.Send(response)
if err != nil {
@@ -192,7 +193,7 @@ func (api *API) handleParameterWebsocket(rw http.ResponseWriter, r *http.Request
Diagnostics: db2sdk.HCLDiagnostics(diagnostics),
}
if result != nil {
response.Parameters = db2sdk.List(result.Parameters, db2sdk.PreviewParameter)
response.Parameters = slice.List(result.Parameters, db2sdk.PreviewParameter)
}
err = stream.Send(response)
if err != nil {
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
@@ -81,7 +82,7 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(daemons, func(dbDaemon database.GetProvisionerDaemonsWithStatusByOrganizationRow) codersdk.ProvisionerDaemon {
httpapi.Write(ctx, rw, http.StatusOK, slice.List(daemons, func(dbDaemon database.GetProvisionerDaemonsWithStatusByOrganizationRow) codersdk.ProvisionerDaemon {
pd := db2sdk.ProvisionerDaemon(dbDaemon.ProvisionerDaemon)
var currentJob, previousJob *codersdk.ProvisionerDaemonJob
if dbDaemon.CurrentJobID.Valid {
+1 -1
View File
@@ -87,7 +87,7 @@ func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(jobs, convertProvisionerJobWithQueuePosition))
httpapi.Write(ctx, rw, http.StatusOK, slice.List(jobs, convertProvisionerJobWithQueuePosition))
}
// handleAuthAndFetchProvisionerJobs is an internal method shared by
+1 -1
View File
@@ -1389,7 +1389,7 @@ func TestTemplateVersionDryRun(t *testing.T) {
// This import job will never finish
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: []*proto.Response{{
ProvisionPlan: []*proto.Response{{
Type: &proto.Response_Log{
Log: &proto.Log{},
},
+2 -2
View File
@@ -1445,7 +1445,7 @@ func (api *API) organizationsByUser(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.List(organizations, db2sdk.Organization))
httpapi.Write(ctx, rw, http.StatusOK, slice.List(organizations, db2sdk.Organization))
}
// @Summary Get organization by user and organization name
@@ -1669,6 +1669,6 @@ func convertAPIKey(k database.APIKey) codersdk.APIKey {
Scopes: scopes,
LifetimeSeconds: k.LifetimeSeconds,
TokenName: k.TokenName,
AllowList: db2sdk.List(k.AllowList, db2sdk.APIAllowListTarget),
AllowList: slice.List(k.AllowList, db2sdk.APIAllowListTarget),
}
}
+11
View File
@@ -2353,6 +2353,17 @@ func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) {
return
}
// Don't allow adding new groups or users to a workspace associated with a
// task. Sharing a task workspace without sharing the task itself is a broken
// half measure that we don't want to support right now. To be fixed!
if workspace.TaskID.Valid {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Task workspaces cannot be shared.",
Detail: "This workspace is managed by a task. Task sharing has not yet been implemented.",
})
return
}
apiKey := httpmw.APIKey(r)
if _, ok := req.UserRoles[apiKey.UserID.String()]; ok {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+2 -1
View File
@@ -30,6 +30,7 @@ import (
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/tracing"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/terraform/tfparse"
"github.com/coder/coder/v2/provisionersdk"
@@ -947,7 +948,7 @@ func (b *Builder) getTemplateVersionParameters() ([]previewtypes.Parameter, erro
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return nil, xerrors.Errorf("get template version %s parameters: %w", tvID, err)
}
b.templateVersionParameters = ptr.Ref(db2sdk.List(tvp, dynamicparameters.TemplateVersionParameter))
b.templateVersionParameters = ptr.Ref(slice.List(tvp, dynamicparameters.TemplateVersionParameter))
return *b.templateVersionParameters, nil
}
-15
View File
@@ -425,20 +425,11 @@ func DevcontainerFromProto(pdc *proto.WorkspaceAgentDevcontainer) (codersdk.Work
if err != nil {
return codersdk.WorkspaceAgentDevcontainer{}, xerrors.Errorf("parse id: %w", err)
}
var subagentID uuid.NullUUID
if pdc.SubagentId != nil {
subagentID.Valid = true
subagentID.UUID, err = uuid.FromBytes(pdc.SubagentId)
if err != nil {
return codersdk.WorkspaceAgentDevcontainer{}, xerrors.Errorf("parse subagent id: %w", err)
}
}
return codersdk.WorkspaceAgentDevcontainer{
ID: id,
Name: pdc.Name,
WorkspaceFolder: pdc.WorkspaceFolder,
ConfigPath: pdc.ConfigPath,
SubagentID: subagentID,
}, nil
}
@@ -451,16 +442,10 @@ func ProtoFromDevcontainers(dcs []codersdk.WorkspaceAgentDevcontainer) []*proto.
}
func ProtoFromDevcontainer(dc codersdk.WorkspaceAgentDevcontainer) *proto.WorkspaceAgentDevcontainer {
var subagentID []byte
if dc.SubagentID.Valid {
subagentID = dc.SubagentID.UUID[:]
}
return &proto.WorkspaceAgentDevcontainer{
Id: dc.ID[:],
Name: dc.Name,
WorkspaceFolder: dc.WorkspaceFolder,
ConfigPath: dc.ConfigPath,
SubagentId: subagentID,
}
}
-1
View File
@@ -136,7 +136,6 @@ func TestManifest(t *testing.T) {
ID: uuid.New(),
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
SubagentID: uuid.NullUUID{Valid: true, UUID: uuid.New()},
},
},
}
+50
View File
@@ -7,6 +7,8 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/websocket"
)
@@ -69,6 +71,54 @@ type PreviewParameter struct {
Diagnostics []FriendlyDiagnostic `json:"diagnostics"`
}
func (p PreviewParameter) TemplateVersionParameter() TemplateVersionParameter {
tp := TemplateVersionParameter{
Name: p.Name,
DisplayName: p.DisplayName,
Description: p.Description,
DescriptionPlaintext: p.Description,
Type: string(p.Type),
FormType: string(p.FormType),
Mutable: p.Mutable,
DefaultValue: p.DefaultValue.Value,
Icon: p.Icon,
Options: slice.List(p.Options, func(o PreviewParameterOption) TemplateVersionParameterOption {
return o.TemplateVersionParameterOption()
}),
Required: p.Required,
Ephemeral: p.Ephemeral,
}
if len(p.Validations) > 0 {
valid := p.Validations[0]
tp.ValidationError = valid.Error
if valid.Monotonic != nil {
tp.ValidationMonotonic = ValidationMonotonicOrder(*valid.Monotonic)
}
if valid.Regex != nil {
tp.ValidationRegex = *valid.Regex
}
if valid.Min != nil {
//nolint:gosec
tp.ValidationMin = ptr.Ref(int32(*valid.Min))
}
if valid.Max != nil {
//nolint:gosec
tp.ValidationMax = ptr.Ref(int32(*valid.Max))
}
}
return tp
}
func (o PreviewParameterOption) TemplateVersionParameterOption() TemplateVersionParameterOption {
return TemplateVersionParameterOption{
Name: o.Name,
Description: o.Description,
Value: o.Value.Value,
Icon: o.Icon,
}
}
type PreviewParameterData struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
+24
View File
@@ -1,8 +1,12 @@
package codersdk
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/google/uuid"
"golang.org/x/xerrors"
"tailscale.com/types/ptr"
@@ -10,6 +14,26 @@ import (
"github.com/coder/terraform-provider-coder/v2/provider"
)
func (c *Client) EvaluateTemplateVersion(ctx context.Context, templateVersionID uuid.UUID, ownerID uuid.UUID, inputs map[string]string) (DynamicParametersResponse, error) {
res, err := c.Request(ctx, http.MethodPost,
fmt.Sprintf("/api/v2/templateversions/%s/dynamic-parameters/evaluate", templateVersionID),
DynamicParametersRequest{
ID: 0,
Inputs: inputs,
OwnerID: ownerID,
})
if err != nil {
return DynamicParametersResponse{}, xerrors.Errorf("do request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return DynamicParametersResponse{}, ReadBodyAsError(res)
}
var dynResp DynamicParametersResponse
return dynResp, json.NewDecoder(res.Body).Decode(&dynResp)
}
func ValidateNewWorkspaceParameters(richParameters []TemplateVersionParameter, buildParameters []WorkspaceBuildParameter) error {
return ValidateWorkspaceBuildParameters(richParameters, buildParameters, nil)
}
+13
View File
@@ -644,6 +644,19 @@ func OrganizationMembersQueryOptionGithubUserID(githubUserID int64) Organization
}
}
func (c *Client) OrganizationMember(ctx context.Context, organizationIdent, userIdent string) (OrganizationMemberWithUserData, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/members/%s", organizationIdent, userIdent), nil)
if err != nil {
return OrganizationMemberWithUserData{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return OrganizationMemberWithUserData{}, ReadBodyAsError(res)
}
var member OrganizationMemberWithUserData
return member, json.NewDecoder(res.Body).Decode(&member)
}
// OrganizationMembers lists all members in an organization
func (c *Client) OrganizationMembers(ctx context.Context, organizationID uuid.UUID, opts ...OrganizationMembersQueryOption) ([]OrganizationMemberWithUserData, error) {
var query OrganizationMembersQuery
+4 -12
View File
@@ -440,11 +440,10 @@ func (s WorkspaceAgentDevcontainerStatus) Transitioning() bool {
// WorkspaceAgentDevcontainer defines the location of a devcontainer
// configuration in a workspace that is visible to the workspace agent.
type WorkspaceAgentDevcontainer struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
WorkspaceFolder string `json:"workspace_folder"`
ConfigPath string `json:"config_path,omitempty"`
SubagentID uuid.NullUUID `json:"subagent_id,omitempty" format:"uuid"`
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
WorkspaceFolder string `json:"workspace_folder"`
ConfigPath string `json:"config_path,omitempty"`
// Additional runtime fields.
Status WorkspaceAgentDevcontainerStatus `json:"status"`
@@ -459,7 +458,6 @@ func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) boo
return d.ID == other.ID &&
d.Name == other.Name &&
d.WorkspaceFolder == other.WorkspaceFolder &&
d.SubagentID == other.SubagentID &&
d.Status == other.Status &&
d.Dirty == other.Dirty &&
(d.Container == nil && other.Container == nil ||
@@ -469,12 +467,6 @@ func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) boo
d.Error == other.Error
}
// IsTerraformDefined returns true if this devcontainer has resources defined
// in Terraform.
func (d WorkspaceAgentDevcontainer) IsTerraformDefined() bool {
return d.SubagentID.Valid
}
// WorkspaceAgentDevcontainerAgent represents the sub agent for a
// devcontainer.
type WorkspaceAgentDevcontainerAgent struct {
-170
View File
@@ -110,173 +110,3 @@ func TestWorkspaceAgentLogTextSpecialChars(t *testing.T) {
result := log.Text("main", "startup_script")
require.Equal(t, "2024-01-28T10:30:00Z [debug] [agent.main|startup_script] \033[31mError!\033[0m 🚀 Unicode: 日本語", result)
}
func TestWorkspaceAgentDevcontainerEquals(t *testing.T) {
t.Parallel()
baseID := uuid.New()
subagentID := uuid.New()
containerID := "container-123"
agentID := uuid.New()
base := codersdk.WorkspaceAgentDevcontainer{
ID: baseID,
Name: "test-dc",
WorkspaceFolder: "/workspace",
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Dirty: false,
Container: &codersdk.WorkspaceAgentContainer{ID: containerID},
Agent: &codersdk.WorkspaceAgentDevcontainerAgent{ID: agentID, Name: "agent-1"},
Error: "",
}
tests := []struct {
name string
modify func(codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer
expectEqual bool
}{
{
name: "identical",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer { return d },
expectEqual: true,
},
{
name: "different ID",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.ID = uuid.New()
return d
},
expectEqual: false,
},
{
name: "different Name",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Name = "other-dc"
return d
},
expectEqual: false,
},
{
name: "different WorkspaceFolder",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.WorkspaceFolder = "/other"
return d
},
expectEqual: false,
},
{
name: "different SubagentID (one valid, one nil)",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.SubagentID = uuid.NullUUID{Valid: true, UUID: subagentID}
return d
},
expectEqual: false,
},
{
name: "different SubagentID UUIDs",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.SubagentID = uuid.NullUUID{Valid: true, UUID: uuid.New()}
return d
},
expectEqual: false,
},
{
name: "different Status",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Status = codersdk.WorkspaceAgentDevcontainerStatusStopped
return d
},
expectEqual: false,
},
{
name: "different Dirty",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Dirty = true
return d
},
expectEqual: false,
},
{
name: "different Container (one nil)",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Container = nil
return d
},
expectEqual: false,
},
{
name: "different Container IDs",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Container = &codersdk.WorkspaceAgentContainer{ID: "different-container"}
return d
},
expectEqual: false,
},
{
name: "different Agent (one nil)",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Agent = nil
return d
},
expectEqual: false,
},
{
name: "different Agent values",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Agent = &codersdk.WorkspaceAgentDevcontainerAgent{ID: agentID, Name: "agent-2"}
return d
},
expectEqual: false,
},
{
name: "different Error",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Error = "some error"
return d
},
expectEqual: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
other := tt.modify(base)
require.Equal(t, tt.expectEqual, base.Equals(other))
})
}
}
func TestWorkspaceAgentDevcontainerIsTerraformDefined(t *testing.T) {
t.Parallel()
tests := []struct {
name string
subagentID uuid.NullUUID
expectIsTerraformDefined bool
}{
{
name: "false when SubagentID is not valid",
subagentID: uuid.NullUUID{},
expectIsTerraformDefined: false,
},
{
name: "true when SubagentID is valid",
subagentID: uuid.NullUUID{Valid: true, UUID: uuid.New()},
expectIsTerraformDefined: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dc := codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "test-dc",
WorkspaceFolder: "/workspace",
SubagentID: tt.subagentID,
}
require.Equal(t, tt.expectIsTerraformDefined, dc.IsTerraformDefined())
})
}
}
@@ -5,8 +5,7 @@ Once enabled, `coderd` runs the `aibridgeproxyd` in-memory and intercepts traffi
**Required:**
1. AI Bridge must be enabled and configured (requires a **premium** license). See [AI Bridge Setup](../setup.md) for further information.
1. AI Bridge Proxy must be [enabled](#proxy-configuration) using the server flag.
1. AI Bridge must be enabled and configured (requires a **Premium** license with the [AI Governance Add-On](../../ai-governance.md)). See [AI Bridge Setup](../setup.md) for further information.1. AI Bridge Proxy must be [enabled](#proxy-configuration) using the server flag.
1. A [CA certificate](#ca-certificate) must be configured for MITM interception.
1. Clients must be configured to trust the CA certificate and use the proxy.
+1
View File
@@ -44,6 +44,7 @@ The table below shows tested AI clients and their compatibility with AI Bridge.
| Client | OpenAI | Anthropic | Notes |
|----------------------------------|--------|-----------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Mux](./mux.md) | ✅ | ✅ | |
| [Claude Code](./claude-code.md) | - | ✅ | |
| [Codex CLI](./codex.md) | ✅ | - | |
| [OpenCode](./opencode.md) | ✅ | ✅ | |
+96
View File
@@ -0,0 +1,96 @@
# Mux
Mux makes it easy to run parallel coding agents, each with its own isolated workspace, from your browser or desktop; it is open source and provider-agnostic. For more background on Mux, see [Coder Research](../../../coder-research.md#mux).
Mux can be configured to route OpenAI- and Anthropic-compatible traffic through AI Bridge by setting a custom provider base URL and using a Coder-issued token for authentication.
## Prerequisites
- AI Bridge is enabled on your Coder deployment.
- A **[Coder session token](../../../admin/users/sessions-tokens.md#generate-a-long-lived-api-token-on-behalf-of-yourself)** or long-lived API key.
## Configuration
<div class="tabs">
### OpenAI
1. Open Mux settings (`Cmd+,` / `Ctrl+,`).
2. Go to **Providers****OpenAI**.
3. Set **API Key** to your Coder session token.
4. Set **Base URL** to `https://coder.example.com/api/v2/aibridge/openai/v1`.
### Anthropic
1. Open Mux settings (`Cmd+,` / `Ctrl+,`).
2. Go to **Providers****Anthropic**.
3. Set **API Key** to your Coder session token.
4. Set **Base URL** to `https://coder.example.com/api/v2/aibridge/anthropic`.
</div>
_Replace `coder.example.com` with your Coder deployment URL._
## Environment variables
Mux reads provider configuration from its settings UI and also from environment variables.
Environment variables are useful in CI or when running Mux inside a Coder workspace.
> [!NOTE]
> Mux treats environment variables as a fallback when a provider is not configured in settings.
> If you have already configured a provider in the UI, clear it (or update it) for env vars to take effect.
```sh
# OpenAI-compatible traffic (GPT, Codex, etc.)
export OPENAI_API_KEY="<your-coder-session-token>"
export OPENAI_BASE_URL="https://coder.example.com/api/v2/aibridge/openai/v1"
# Anthropic-compatible traffic (Claude, etc.)
export ANTHROPIC_API_KEY="<your-coder-session-token>"
export ANTHROPIC_BASE_URL="https://coder.example.com/api/v2/aibridge/anthropic"
```
## Running Mux in a Coder workspace
If you want to run Mux inside a Coder workspace (for example, as a Coder app), you can install it with the [Mux module](https://registry.coder.com/modules/coder/mux) and pre-configure AI Bridge via environment variables on the agent:
```tf
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
resource "coder_agent" "main" {
# ... other agent configuration
env = {
OPENAI_API_KEY = data.coder_workspace_owner.me.session_token
OPENAI_BASE_URL = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1"
ANTHROPIC_API_KEY = data.coder_workspace_owner.me.session_token
ANTHROPIC_BASE_URL = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic"
}
}
module "mux" {
source = "registry.coder.com/coder/mux/coder"
version = "~> 1.0" # See the module page for the latest version.
agent_id = coder_agent.main.id
}
```
## Advanced: providers.jsonc
If you prefer a file-based config, edit `~/.mux/providers.jsonc`:
```jsonc
{
"openai": {
"apiKey": "<your-coder-session-token>",
"baseUrl": "https://coder.example.com/api/v2/aibridge/openai/v1"
},
"anthropic": {
"apiKey": "<your-coder-session-token>",
"baseUrl": "https://coder.example.com/api/v2/aibridge/anthropic"
}
}
```
**References:** [Mux provider environment variables](https://mux.coder.com/config/providers#environment-variables)
+1 -1
View File
@@ -4,7 +4,7 @@ AI Bridge runs inside the Coder control plane (`coderd`), requiring no separate
**Required**:
1. A **premium** licensed Coder deployment
1. A **Premium** license with the [AI Governance Add-On](../ai-governance.md).
1. Feature must be [enabled](#activation) using the server flag
1. One or more [providers](#configure-providers) API key(s) must be configured
+20 -5
View File
@@ -7,7 +7,7 @@ development environments. As adoption grows, many enterprises also need
observability, management, and policy controls to support secure and auditable
AI rollouts.
Coders AI Governance Add-On for Premium licenses includes a set of features
The AI Governance Add-On is a per-user license that can be added to Premium seats. Each user with the add-on gets access to a set of features
that help organizations safely roll out AI tooling at scale:
- [AI Bridge](./ai-bridge/index.md): LLM gateway to audit AI sessions, central
@@ -95,6 +95,11 @@ options, reach out to your
## How Coder Tasks usage is measured
> [!NOTE]
> There is a known issue with how Agent Workspace Builds are tallied in v2.28
> and v2.29. We recommend updating to v2.28.9, v2.29.4, or v2.30 to resolve
> this issue.
The usage metric used to measure Coder Tasks consumption is called **Agent
Workspace Builds** (prev. "managed agents").
@@ -133,8 +138,18 @@ workflows.
Our [AI Governance Add-On](./ai-governance.md) includes a shared usage pool of
Agent Workspace Builds for automated workflows, along with limits that scale
proportionately with user count. Usage counts are measured and sent to Coder via
[usage data reporting](./usage-data-reporting.md). Coder Tasks or other AI
features do not break when you run over the limit.
[usage data reporting](./usage-data-reporting.md). Coder Tasks and other AI
features continue to function normally even if the limit is breached. Admins
will receive a warning to [contact their account team](https://coder.com/contact)
to remediate.
If you are approaching your deployment-wide limits,
[contact us](https://coder.com/contact) to discuss your use case with our team.
### Tracking Agent Workspace Builds
Admins can monitor Agent Workspace Build usage from the Coder dashboard.
Navigate to **Deployment** > **Licenses** to view current usage against your
entitlement limits.
![Agent Workspace Build usage](../images/admin/ai-governance-awb-usage.png)
<small>Agent Workspace Build usage showing current consumption against
entitlement limits in the Licenses page.</small>
+1 -1
View File
@@ -31,7 +31,7 @@ terminal-based agent such as Claude Code or Codex's Open Source CLI.
[Learn more about Coder Tasks](./tasks.md) for best practices and how to get
started.
## Secure Your Workflows with Agent Boundaries (Beta)
## Secure Your Workflows with Agent Boundaries
AI agents can be powerful teammates, but must be treated as untrusted and
unpredictable interns as opposed to tools. Without the right controls, they can
+1 -1
View File
@@ -9,7 +9,7 @@ Coder maintains several open-source research projects exploring the future of AI
### Features
- **Isolated workspace management**: Run multiple agents in parallel using local execution, git worktrees, or remote SSH without interference
- **Multi-model support**: Compatible with Claude (sonnet-4, opus-4), Grok, GPT-5, Ollama for local LLMs, and OpenRouter
- **Multi-model support**: Compatible with models from Anthropic, xAI, OpenAI, Ollama for local LLMs, and OpenRouter
- **Central git divergence view**: Monitor changes and potential conflicts across agent workspaces from a unified dashboard
- **Developer integration**: VS Code extension, Plan/Exec mode, vim input support, and slash commands
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+6 -1
View File
@@ -996,7 +996,7 @@
"title": "Agent Boundaries",
"description": "Understanding Agent Boundaries in Coder Tasks",
"path": "./ai-coder/agent-boundaries/index.md",
"state": ["premium", "beta"],
"state": ["premium"],
"children": [
{
"title": "NS Jail",
@@ -1047,6 +1047,11 @@
"description": "Configure Codex to use AI Bridge",
"path": "./ai-coder/ai-bridge/clients/codex.md"
},
{
"title": "Mux",
"description": "Configure Mux to use AI Bridge",
"path": "./ai-coder/ai-bridge/clients/mux.md"
},
{
"title": "OpenCode",
"description": "Configure OpenCode to use AI Bridge",
-8
View File
@@ -838,10 +838,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string"
}
],
@@ -1019,10 +1015,6 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string"
}
],
+59
View File
@@ -540,6 +540,65 @@ Status Code **200**
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get organization member
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/members/{user} \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /organizations/{organization}/members/{user}`
### Parameters
| Name | In | Type | Required | Description |
|----------------|------|--------|----------|----------------------|
| `organization` | path | string | true | Organization ID |
| `user` | path | string | true | User ID, name, or me |
### Example responses
> 200 Response
```json
{
"avatar_url": "string",
"created_at": "2019-08-24T14:15:22Z",
"email": "string",
"global_roles": [
{
"display_name": "string",
"name": "string",
"organization_id": "string"
}
],
"name": "string",
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
"roles": [
{
"display_name": "string",
"name": "string",
"organization_id": "string"
}
],
"updated_at": "2019-08-24T14:15:22Z",
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5",
"username": "string"
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.OrganizationMemberWithUserData](schemas.md#codersdkorganizationmemberwithuserdata) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Add organization member
### Code samples
-9
View File
@@ -10514,10 +10514,6 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string"
}
```
@@ -10534,7 +10530,6 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `id` | string | false | | |
| `name` | string | false | | |
| `status` | [codersdk.WorkspaceAgentDevcontainerStatus](#codersdkworkspaceagentdevcontainerstatus) | false | | Additional runtime fields. |
| `subagent_id` | [uuid.NullUUID](#uuidnulluuid) | false | | |
| `workspace_folder` | string | false | | |
## codersdk.WorkspaceAgentDevcontainerAgent
@@ -10666,10 +10661,6 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string"
}
],
+1
View File
@@ -379,6 +379,7 @@ module "mux" {
agent_id = coder_agent.dev.id
subdomain = true
display_name = "Mux"
add-project = local.repo_dir
}
module "code-server" {
@@ -12,7 +12,6 @@ import (
"cdr.dev/slog/v3/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
@@ -21,6 +20,7 @@ import (
"github.com/coder/coder/v2/coderd/idpsync"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/runtimeconfig"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/enidpsync"
"github.com/coder/coder/v2/testutil"
@@ -61,7 +61,7 @@ func TestOrganizationSync(t *testing.T) {
})
require.NoError(t, err)
foundIDs := db2sdk.List(members, func(m database.OrganizationMembersRow) uuid.UUID {
foundIDs := slice.List(members, func(m database.OrganizationMembersRow) uuid.UUID {
return m.OrganizationMember.OrganizationID
})
require.ElementsMatch(t, expected, foundIDs, "match user organizations")
+3 -3
View File
@@ -12,12 +12,12 @@ import (
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/rolestore"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -893,7 +893,7 @@ func TestGroup(t *testing.T) {
})
require.NoError(t, err)
foundIDs := db2sdk.List(found, func(g codersdk.Group) uuid.UUID {
foundIDs := slice.List(found, func(g codersdk.Group) uuid.UUID {
return g.ID
})
@@ -1009,7 +1009,7 @@ func TestGroups(t *testing.T) {
// disabled, but group membership is limited to the requesting user.
// TODO(geokat): add another test with workspace sharing disabled.
require.Len(t, user5View, 3)
user5ViewIDs := db2sdk.List(user5View, func(g codersdk.Group) uuid.UUID {
user5ViewIDs := slice.List(user5View, func(g codersdk.Group) uuid.UUID {
return g.ID
})
+7 -6
View File
@@ -15,6 +15,7 @@ import (
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
@@ -62,9 +63,9 @@ func (api *API) postOrgRoles(rw http.ResponseWriter, r *http.Request) {
UUID: organization.ID,
Valid: true,
},
SitePermissions: db2sdk.List(req.SitePermissions, sdkPermissionToDB),
OrgPermissions: db2sdk.List(req.OrganizationPermissions, sdkPermissionToDB),
UserPermissions: db2sdk.List(req.UserPermissions, sdkPermissionToDB),
SitePermissions: slice.List(req.SitePermissions, sdkPermissionToDB),
OrgPermissions: slice.List(req.OrganizationPermissions, sdkPermissionToDB),
UserPermissions: slice.List(req.UserPermissions, sdkPermissionToDB),
// Satisfy the linter (we don't support member permissions in non-system roles).
MemberPermissions: database.CustomRolePermissions{},
IsSystem: false,
@@ -154,9 +155,9 @@ func (api *API) putOrgRoles(rw http.ResponseWriter, r *http.Request) {
// to throw an error, then the story of a previously valid role
// now being invalid has to be addressed. Coder can change permissions,
// objects, and actions at any time.
SitePermissions: db2sdk.List(filterInvalidPermissions(req.SitePermissions), sdkPermissionToDB),
OrgPermissions: db2sdk.List(filterInvalidPermissions(req.OrganizationPermissions), sdkPermissionToDB),
UserPermissions: db2sdk.List(filterInvalidPermissions(req.UserPermissions), sdkPermissionToDB),
SitePermissions: slice.List(filterInvalidPermissions(req.SitePermissions), sdkPermissionToDB),
OrgPermissions: slice.List(filterInvalidPermissions(req.OrganizationPermissions), sdkPermissionToDB),
UserPermissions: slice.List(filterInvalidPermissions(req.UserPermissions), sdkPermissionToDB),
// Satisfy the linter (we don't support member permissions in non-system roles).
MemberPermissions: database.CustomRolePermissions{},
})
+4 -3
View File
@@ -13,6 +13,7 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -63,7 +64,7 @@ func TestCustomOrganizationRole(t *testing.T) {
// Changing this might mess up the UI in how it renders the roles on the
// users page. When the users endpoint is updated, this should be uncommented.
// roleNamesF := func(role codersdk.SlimRole) string { return role.Name }
// require.Contains(t, db2sdk.List(user.Roles, roleNamesF), role.Name)
// require.Contains(t, slice.List(user.Roles, roleNamesF), role.Name)
// Try to create a template version
coderdtest.CreateTemplateVersion(t, tmplAdmin, first.OrganizationID, nil)
@@ -594,8 +595,8 @@ func TestListRoles(t *testing.T) {
BuiltIn: true,
}
}
expected := db2sdk.List(c.ExpectedRoles, ignorePerms)
found := db2sdk.List(roles, ignorePerms)
expected := slice.List(c.ExpectedRoles, ignorePerms)
found := slice.List(roles, ignorePerms)
require.ElementsMatch(t, expected, found)
}
})
+1 -2
View File
@@ -15,7 +15,6 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
@@ -1122,7 +1121,7 @@ func (r *oidcTestRunner) AssertOrganizations(t *testing.T, userIdent string, inc
cpy := make([]uuid.UUID, 0, len(expected))
cpy = append(cpy, expected...)
hasDefault := false
userOrgIDs := db2sdk.List(userOrgs, func(o codersdk.Organization) uuid.UUID {
userOrgIDs := slice.List(userOrgs, func(o codersdk.Organization) uuid.UUID {
if o.IsDefault {
hasDefault = true
cpy = append(cpy, o.ID)
+4 -4
View File
@@ -7,8 +7,8 @@ import (
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -56,7 +56,7 @@ func TestEnterpriseMembers(t *testing.T) {
require.Len(t, members, 3)
require.ElementsMatch(t,
[]uuid.UUID{first.UserID, user.ID, orgAdmin.ID},
db2sdk.List(members, onlyIDs))
slice.List(members, onlyIDs))
// Add the member to some groups
_, err = orgAdminClient.PatchGroup(ctx, g1.ID, codersdk.PatchGroupRequest{
@@ -86,7 +86,7 @@ func TestEnterpriseMembers(t *testing.T) {
require.Len(t, members, 2)
require.ElementsMatch(t,
[]uuid.UUID{first.UserID, orgAdmin.ID},
db2sdk.List(members, onlyIDs))
slice.List(members, onlyIDs))
// User should now belong to 0 groups
userGroups, err = orgAdminClient.Groups(ctx, codersdk.GroupArguments{
@@ -130,7 +130,7 @@ func TestEnterpriseMembers(t *testing.T) {
require.Len(t, members, 3)
require.ElementsMatch(t,
[]uuid.UUID{first.UserID, user.ID, userAdmin.ID},
db2sdk.List(members, onlyIDs))
slice.List(members, onlyIDs))
})
t.Run("PostUserNotExists", func(t *testing.T) {
+3 -2
View File
@@ -437,7 +437,7 @@ require (
go.opentelemetry.io/collector/pdata/pprofile v0.121.0 // indirect
go.opentelemetry.io/collector/semconv v0.123.0 // indirect
go.opentelemetry.io/contrib v1.19.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
@@ -470,10 +470,11 @@ require (
)
require (
cdr.dev/slog v1.6.2-0.20251120224544-40ff19937ff2
github.com/anthropics/anthropic-sdk-go v1.19.0
github.com/brianvoe/gofakeit/v7 v7.14.0
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
github.com/coder/aibridge v1.0.1-0.20260202135542-9e2857aaac8f
github.com/coder/aibridge v1.0.1
github.com/coder/aisdk-go v0.0.9
github.com/coder/boundary v0.6.1
github.com/coder/preview v1.0.4
+6 -4
View File
@@ -1,3 +1,5 @@
cdr.dev/slog v1.6.2-0.20251120224544-40ff19937ff2 h1:M4Z9eTbnHPdZI4GpBUNCae0lSgUucY+aW5j7+zB8lCk=
cdr.dev/slog v1.6.2-0.20251120224544-40ff19937ff2/go.mod h1:NaoTA7KwopCrnaSb0JXTC0PTp/O/Y83Lndnq0OEV3ZQ=
cdr.dev/slog/v3 v3.0.0-rc1 h1:EN7Zim6GvTpAeHQjI0ERDEfqKbTyXRvgH4UhlzLpvWM=
cdr.dev/slog/v3 v3.0.0-rc1/go.mod h1:iO/OALX1VxlI03mkodCGdVP7pXzd2bRMvu3ePvlJ9ak=
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
@@ -927,8 +929,8 @@ github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y
github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
github.com/coder/aibridge v1.0.1-0.20260202135542-9e2857aaac8f h1:DaSKB6p/CgDN3RXHESvFbvsT2P9XRZm0th2+MiFImwE=
github.com/coder/aibridge v1.0.1-0.20260202135542-9e2857aaac8f/go.mod h1:M1aoiK6qmybTjD2nzcTCRPXzA/I0Ned+MAxUmz4Ju+k=
github.com/coder/aibridge v1.0.1 h1:l6MgNVLvyu9EFp/Q00OItymTlGVK16XXT/KfSuDmxBM=
github.com/coder/aibridge v1.0.1/go.mod h1:M1aoiK6qmybTjD2nzcTCRPXzA/I0Ned+MAxUmz4Ju+k=
github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo=
github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M=
github.com/coder/boundary v0.6.1 h1:hLnrincIFA8Wak5SrH/xQDIIhkKQpnHVotLwC585z7g=
@@ -2037,8 +2039,8 @@ go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY
go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0 h1:rbRJ8BBoVMsQShESYZ0FkvcITu8X8QNwJogcLUmDNNw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.3.0/go.mod h1:PWIKzi6JCp7sM0k9yZ43VX+T345uNbAkDKwHVjb2PTs=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
+11
View File
@@ -104,6 +104,17 @@ func testingWithOwnerUser(m dsl.Matcher) {
Report(`This client is operating as the owner user, which has unrestricted permissions. Consider creating a different user.`)
}
// doNotUseRawGoInAgent detects raw `go func()` in agent package.
// Use agentutil.Go() instead for panic recovery.
//
//nolint:unused,deadcode,varnamelen
func doNotUseRawGoInAgent(m dsl.Matcher) {
m.Match(`go func() { $*_ }()`, `go func($*_) { $*_ }($*_)`).
Where(m.File().PkgPath.Matches(`github\.com/coder/coder/v2/agent(/.*)?`) &&
!m.File().Name.Matches(`_test\.go$`)).
Report("Use agentutil.Go() instead of raw go func() for panic recovery")
}
// Use xerrors everywhere! It provides additional stacktrace info!
//
//nolint:unused,deadcode,varnamelen
-1
View File
@@ -6290,7 +6290,6 @@ export interface WorkspaceAgentDevcontainer {
readonly name: string;
readonly workspace_folder: string;
readonly config_path?: string;
readonly subagent_id?: string;
/**
* Additional runtime fields.
*/
-2
View File
@@ -18,8 +18,6 @@ export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverClose = PopoverPrimitive.PopoverClose;
export const PopoverContent = forwardRef<
ElementRef<typeof PopoverPrimitive.Content>,
ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
@@ -23,7 +23,7 @@ export const DashboardLayout: FC = () => {
{canViewDeployment && <LicenseBanner />}
<AnnouncementBanners />
<div className="flex flex-col h-screen justify-between">
<div className="flex flex-col min-h-screen justify-between">
<Navbar />
<div className="relative flex flex-col flex-1 min-h-0 overflow-y-auto">
@@ -1,16 +1,14 @@
import { css, type Interpolation, type Theme } from "@emotion/react";
import MenuItem from "@mui/material/MenuItem";
import { Button } from "components/Button/Button";
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { ChevronDownIcon } from "lucide-react";
import { linkToAuditing } from "modules/navigation";
import type { FC } from "react";
import { NavLink } from "react-router";
import { Link } from "react-router";
interface DeploymentDropdownProps {
canViewDeployment: boolean;
@@ -41,18 +39,15 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
}
return (
<Popover>
<PopoverTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="lg">
Admin settings
<ChevronDownIcon className="text-content-primary !size-icon-xs" />
<ChevronDownIcon className="text-content-primary !size-icon-sm" />
</Button>
</PopoverTrigger>
</DropdownMenuTrigger>
<PopoverContent
align="end"
className="bg-surface-secondary border-surface-quaternary w-[180px] min-w-auto"
>
<DropdownMenuContent align="end" className="w-[180px] min-w-auto">
<DeploymentDropdownContent
canViewDeployment={canViewDeployment}
canViewOrganizations={canViewOrganizations}
@@ -61,8 +56,8 @@ export const DeploymentDropdown: FC<DeploymentDropdownProps> = ({
canViewHealth={canViewHealth}
canViewAIBridge={canViewAIBridge}
/>
</PopoverContent>
</Popover>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -77,79 +72,35 @@ const DeploymentDropdownContent: FC<DeploymentDropdownProps> = ({
return (
<nav>
{canViewDeployment && (
<PopoverClose asChild>
<MenuItem component={NavLink} to="/deployment" css={styles.menuItem}>
Deployment
</MenuItem>
</PopoverClose>
<DropdownMenuItem asChild>
<Link to="/deployment">Deployment</Link>
</DropdownMenuItem>
)}
{canViewOrganizations && (
<PopoverClose asChild>
<MenuItem
component={NavLink}
to="/organizations"
css={styles.menuItem}
>
Organizations
</MenuItem>
</PopoverClose>
<DropdownMenuItem asChild>
<Link to="/organizations">Organizations</Link>
</DropdownMenuItem>
)}
{canViewAuditLog && (
<PopoverClose asChild>
<MenuItem
component={NavLink}
to={linkToAuditing}
css={styles.menuItem}
>
Audit Logs
</MenuItem>
</PopoverClose>
<DropdownMenuItem asChild>
<Link to={linkToAuditing}>Audit Logs</Link>
</DropdownMenuItem>
)}
{canViewConnectionLog && (
<PopoverClose asChild>
<MenuItem
component={NavLink}
to="/connectionlog"
css={styles.menuItem}
>
Connection Logs
</MenuItem>
</PopoverClose>
<DropdownMenuItem asChild>
<Link to="/connectionlog">Connection Logs</Link>
</DropdownMenuItem>
)}
{canViewAIBridge && (
<PopoverClose asChild>
<MenuItem component={NavLink} to="/aibridge" css={styles.menuItem}>
AI Bridge Logs
</MenuItem>
</PopoverClose>
<DropdownMenuItem asChild>
<Link to="/aibridge">AI Bridge Logs</Link>
</DropdownMenuItem>
)}
{canViewHealth && (
<PopoverClose asChild>
<MenuItem component={NavLink} to="/health" css={styles.menuItem}>
Healthcheck
</MenuItem>
</PopoverClose>
<DropdownMenuItem asChild>
<Link to="/health">Healthcheck</Link>
</DropdownMenuItem>
)}
</nav>
);
};
const styles = {
menuItem: (theme) => css`
text-decoration: none;
color: inherit;
gap: 8px;
padding: 8px 20px;
font-size: 14px;
&:hover {
background-color: ${theme.palette.action.hover};
transition: background-color 0.3s ease;
}
`,
menuItemIcon: (theme) => ({
color: theme.palette.text.secondary,
width: 20,
height: 20,
}),
} satisfies Record<string, Interpolation<Theme>>;
@@ -61,7 +61,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
const webPush = useWebpushNotifications();
return (
<div className="border-0 border-b border-solid h-[72px] min-h-[72px] flex items-center leading-none px-6">
<div className="sticky top-0 bg-surface-primary z-40 border-0 border-b border-solid h-[72px] min-h-[72px] flex items-center leading-none px-6">
<NavLink to="/workspaces">
{logo_url ? (
<ExternalImage className="h-7" src={logo_url} alt="Custom Logo" />
@@ -96,7 +96,7 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
"Select Proxy"
)}
<ChevronDownIcon className="text-content-primary !size-icon-lg" />
<ChevronDownIcon className="text-content-primary !size-icon-sm" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
@@ -1,10 +1,10 @@
import type * as TypesGen from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import type { FC } from "react";
import { UserDropdownContent } from "./UserDropdownContent";
@@ -22,28 +22,24 @@ export const UserDropdown: FC<UserDropdownProps> = ({
onSignOut,
}) => {
return (
<Popover>
<PopoverTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="bg-transparent border-0 cursor-pointer p-0"
>
<Avatar fallback={user.username} src={user.avatar_url} size="lg" />
</button>
</PopoverTrigger>
</DropdownMenuTrigger>
<PopoverContent
align="end"
className="min-w-auto w-[260px] bg-surface-secondary border-surface-quaternary"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuContent align="end" className="min-w-auto w-[260px]">
<UserDropdownContent
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onSignOut={onSignOut}
/>
</PopoverContent>
</Popover>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -1,20 +1,31 @@
import { MockUserOwner } from "testHelpers/entities";
import { render, waitForLoaderToBeRemoved } from "testHelpers/renderHelpers";
import { screen } from "@testing-library/react";
import { Popover } from "components/Popover/Popover";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { Language, UserDropdownContent } from "./UserDropdownContent";
const renderUserDropdownContent = (props: { onSignOut: () => void }) => {
return render(
<DropdownMenu defaultOpen>
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
<DropdownMenuContent>
<UserDropdownContent
user={MockUserOwner}
onSignOut={props.onSignOut}
supportLinks={[]}
/>
</DropdownMenuContent>
</DropdownMenu>,
);
};
describe("UserDropdownContent", () => {
it("has the correct link for the account item", async () => {
render(
<Popover>
<UserDropdownContent
user={MockUserOwner}
onSignOut={vi.fn()}
supportLinks={[]}
/>
</Popover>,
);
renderUserDropdownContent({ onSignOut: vi.fn() });
await waitForLoaderToBeRemoved();
const link = screen.getByText(Language.accountLabel).closest("a");
@@ -27,15 +38,7 @@ describe("UserDropdownContent", () => {
it("calls the onSignOut function", async () => {
const onSignOut = vi.fn();
render(
<Popover>
<UserDropdownContent
user={MockUserOwner}
onSignOut={onSignOut}
supportLinks={[]}
/>
</Popover>,
);
renderUserDropdownContent({ onSignOut });
await waitForLoaderToBeRemoved();
screen.getByText(Language.signOutLabel).click();
expect(onSignOut).toBeCalledTimes(1);
@@ -1,22 +1,18 @@
import {
type CSSObject,
css,
type Interpolation,
type Theme,
} from "@emotion/react";
import Divider from "@mui/material/Divider";
import MenuItem from "@mui/material/MenuItem";
import { PopoverClose } from "@radix-ui/react-popover";
import type * as TypesGen from "api/typesGenerated";
import { CopyButton } from "components/CopyButton/CopyButton";
import { Stack } from "components/Stack/Stack";
import {
DropdownMenuItem,
DropdownMenuSeparator,
} from "components/DropdownMenu/DropdownMenu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { useClipboard } from "hooks/useClipboard";
import {
CheckIcon,
CircleUserIcon,
CopyIcon,
LogOutIcon,
MonitorDownIcon,
SquareArrowOutUpRightIcon,
@@ -44,153 +40,94 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
supportLinks,
onSignOut,
}) => {
const { showCopiedSuccess, copyToClipboard } = useClipboard();
return (
<div>
<Stack css={styles.info} spacing={0}>
<span css={styles.userName}>{user.username}</span>
<span css={styles.userEmail}>{user.email}</span>
</Stack>
<Divider css={{ marginBottom: 8 }} />
<Link to="/install" css={styles.link}>
<PopoverClose asChild>
<MenuItem css={styles.menuItem}>
<MonitorDownIcon className="size-5 text-content-secondary" />
<span css={styles.menuItemText}>Install CLI</span>
</MenuItem>
</PopoverClose>
</Link>
<Link to="/settings/account" css={styles.link}>
<PopoverClose asChild>
<MenuItem css={styles.menuItem}>
<CircleUserIcon className="size-5 text-content-secondary" />
<span css={styles.menuItemText}>{Language.accountLabel}</span>
</MenuItem>
</PopoverClose>
</Link>
<MenuItem css={styles.menuItem} onClick={onSignOut}>
<LogOutIcon className="size-5 text-content-secondary" />
<span css={styles.menuItemText}>{Language.signOutLabel}</span>
</MenuItem>
{supportLinks && (
<>
<DropdownMenuItem
className="flex items-center gap-3 [&_img]:w-full [&_img]:h-full"
asChild
>
<Link to="/settings/account">
<div className="flex flex-col">
<span className="text-white">{user.username}</span>
<span className="text-xs font-semibold">{user.email}</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/install">
<MonitorDownIcon />
<span>Install CLI</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/settings/account">
<CircleUserIcon />
<span>Account</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={onSignOut}>
<LogOutIcon />
<span>Sign Out</span>
</DropdownMenuItem>
{supportLinks && supportLinks.length > 0 && (
<>
<Divider />
<DropdownMenuSeparator />
{supportLinks.map((link) => (
<a
href={link.target}
key={link.name}
target="_blank"
rel="noreferrer"
css={styles.link}
>
<PopoverClose asChild>
<MenuItem css={styles.menuItem}>
{link.icon && (
<SupportIcon
icon={link.icon}
className="size-5 text-content-secondary"
/>
)}
<span css={styles.menuItemText}>{link.name}</span>
</MenuItem>
</PopoverClose>
</a>
<DropdownMenuItem key={link.name} asChild>
<a href={link.target} target="_blank" rel="noreferrer">
{link.icon && <SupportIcon icon={link.icon} />}
<span>{link.name}</span>
</a>
</DropdownMenuItem>
))}
</>
)}
<Divider css={{ marginBottom: "0 !important" }} />
<Stack css={styles.info} spacing={0}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuSeparator />
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<DropdownMenuItem className="text-xs" asChild>
<a
css={[styles.footerText, styles.buildInfo]}
href={buildInfo?.external_url}
className="flex items-center gap-2"
target="_blank"
rel="noreferrer"
>
{buildInfo?.version} <SquareArrowOutUpRightIcon />
<span className="flex-1">{buildInfo?.version}</span>
<SquareArrowOutUpRightIcon className="!size-icon-xs" />
</a>
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="bottom">Browse the source code</TooltipContent>
</Tooltip>
{buildInfo?.deployment_id && (
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>
<DropdownMenuItem
className="text-xs"
onSelect={(e) => {
e.preventDefault();
copyToClipboard(buildInfo.deployment_id);
}}
>
<span className="truncate flex-1">{buildInfo.deployment_id}</span>
{showCopiedSuccess ? (
<CheckIcon className="!size-icon-xs ml-auto" />
) : (
<CopyIcon className="!size-icon-xs ml-auto" />
)}
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="bottom">Browse the source code</TooltipContent>
<TooltipContent side="bottom">
{showCopiedSuccess ? "Copied!" : "Copy deployment ID"}
</TooltipContent>
</Tooltip>
{buildInfo?.deployment_id && (
<div className="flex items-center text-xs">
<Tooltip>
<TooltipTrigger asChild>
<span className="whitespace-nowrap overflow-hidden text-ellipsis">
{buildInfo.deployment_id}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
Deployment Identifier
</TooltipContent>
</Tooltip>
<CopyButton
text={buildInfo.deployment_id}
label="Copy deployment ID"
/>
</div>
)}
<div css={styles.footerText}>{Language.copyrightText}</div>
</Stack>
</div>
)}
<DropdownMenuItem className="text-xs" disabled>
<span>{Language.copyrightText}</span>
</DropdownMenuItem>
</>
);
};
const styles = {
info: (theme) => [
theme.typography.body2 as CSSObject,
{
padding: 20,
},
],
userName: {
fontWeight: 600,
},
userEmail: (theme) => ({
color: theme.palette.text.secondary,
width: "100%",
textOverflow: "ellipsis",
overflow: "hidden",
}),
link: {
textDecoration: "none",
color: "inherit",
},
menuItem: (theme) => css`
gap: 20px;
padding: 8px 20px;
&:hover {
background-color: ${theme.palette.action.hover};
transition: background-color 0.3s ease;
}
`,
menuItemText: {
fontSize: 14,
},
footerText: (theme) => css`
font-size: 12px;
text-decoration: none;
color: ${theme.palette.text.secondary};
display: flex;
align-items: center;
gap: 4px;
& svg {
width: 12px;
height: 12px;
}
`,
buildInfo: (theme) => ({
color: theme.palette.text.primary,
}),
} satisfies Record<string, Interpolation<Theme>>;
@@ -20,7 +20,7 @@ import {
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { getPreferredProxy } from "contexts/ProxyContext";
import { screen, spyOn, userEvent, within } from "storybook/test";
import { spyOn, userEvent, within } from "storybook/test";
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
const meta: Meta<typeof AgentDevcontainerCard> = {
@@ -185,37 +185,6 @@ export const WithPortForwarding: Story = {
],
};
export const PrecreatedSubAgent: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
subagent_id: "precreated-subagent-id",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByTestId("precreated-subagent-rebuild-trigger");
await userEvent.hover(trigger);
await screen.findByRole("tooltip");
},
};
export const PrecreatedSubAgentDirty: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
subagent_id: "precreated-subagent-id",
dirty: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const outdatedStatus = canvas.getByText("Outdated");
await userEvent.hover(outdatedStatus);
await screen.findByRole("tooltip");
},
};
export const WithDeleteError: Story = {
beforeEach: () => {
spyOn(API, "deleteDevContainer").mockRejectedValue(
@@ -42,7 +42,6 @@ import { AgentButton } from "./AgentButton";
import { AgentDevcontainerMoreActions } from "./AgentDevcontainerMoreActions";
import { AgentLatency } from "./AgentLatency";
import { DevcontainerStatus } from "./AgentStatus";
import { isTerraformDefined } from "./devcontainerUtils";
import { PortForwardButton } from "./PortForwardButton";
import { AgentSSHButton } from "./SSHButton/SSHButton";
import { SubAgentOutdatedTooltip } from "./SubAgentOutdatedTooltip";
@@ -163,8 +162,6 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
const showSubAgentAppsPlaceholders =
devcontainer.status === "starting" || subAgent?.status === "connecting";
const hasPrecreatedSubagent = isTerraformDefined(devcontainer);
const handleRebuildDevcontainer = () => {
rebuildDevcontainerMutation.mutate();
};
@@ -235,31 +232,16 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
</div>
<div className="flex items-center gap-2">
{hasPrecreatedSubagent ? (
<Tooltip>
<TooltipTrigger asChild>
<span data-testid="precreated-subagent-rebuild-trigger">
<Button variant="outline" size="sm" disabled>
{rebuildButtonLabel(devcontainer)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
This dev container is defined in Terraform and cannot be rebuilt
from the UI.
</TooltipContent>
</Tooltip>
) : (
<Button
variant="outline"
size="sm"
onClick={handleRebuildDevcontainer}
disabled={isTransitioning}
>
<Spinner loading={isTransitioning} />
{rebuildButtonLabel(devcontainer)}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleRebuildDevcontainer}
disabled={isTransitioning}
>
<Spinner loading={isTransitioning} />
{rebuildButtonLabel(devcontainer)}
</Button>
{showDevcontainerControls && displayApps.includes("ssh_helper") && (
<AgentSSHButton
@@ -10,10 +10,10 @@ import {
HelpTooltipText,
HelpTooltipTitle,
} from "components/HelpTooltip/HelpTooltip";
import { Stack } from "components/Stack/Stack";
import { TooltipTrigger } from "components/Tooltip/Tooltip";
import { RotateCcwIcon } from "lucide-react";
import type { FC } from "react";
import { isTerraformDefined } from "./devcontainerUtils";
type SubAgentOutdatedTooltipProps = {
devcontainer: WorkspaceAgentDevcontainer;
@@ -33,13 +33,9 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
return null;
}
const hasPrecreatedSubagent = isTerraformDefined(devcontainer);
const title = "Dev Container Outdated";
const opener = "This Dev Container is outdated.";
const text = hasPrecreatedSubagent
? `${opener} This dev container is managed by your template. Update the template to apply changes.`
: `${opener} This can happen if you modify your devcontainer.json file after the Dev Container has been created. To fix this, you can rebuild the Dev Container.`;
const text = `${opener} This can happen if you modify your devcontainer.json file after the Dev Container has been created. To fix this, you can rebuild the Dev Container.`;
return (
<HelpTooltip>
@@ -49,24 +45,22 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
</span>
</TooltipTrigger>
<HelpTooltipContent>
<div className="flex flex-col gap-2">
<Stack spacing={1}>
<div>
<HelpTooltipTitle>{title}</HelpTooltipTitle>
<HelpTooltipText>{text}</HelpTooltipText>
</div>
{!hasPrecreatedSubagent && (
<HelpTooltipLinksGroup>
<HelpTooltipAction
icon={RotateCcwIcon}
onClick={onUpdate}
ariaLabel="Rebuild Dev Container"
>
Rebuild Dev Container
</HelpTooltipAction>
</HelpTooltipLinksGroup>
)}
</div>
<HelpTooltipLinksGroup>
<HelpTooltipAction
icon={RotateCcwIcon}
onClick={onUpdate}
ariaLabel="Rebuild Dev Container"
>
Rebuild Dev Container
</HelpTooltipAction>
</HelpTooltipLinksGroup>
</Stack>
</HelpTooltipContent>
</HelpTooltip>
);

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