Skip to content

feat: coder-attach: add support for external workspaces #19178

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 33 commits into
base: main
Choose a base branch
from

Conversation

kacpersaw
Copy link
Contributor

Depends on: coder/terraform-provider-coder#424

This pull request introduces support for external workspace management, allowing users to register and manage workspaces that are provisioned and managed outside of the Coder.

CLI changes

  • coder external-workspaces create - Creates a new external workspace (this command extends coder create)
    • Example: coder external-workspaces create ext-workspace --template=externally-managed-workspace -y
    • Checks if template has coder_external_agent resource before creating a workspace
  • coder external-workspaces list - Lists all external workspaces
  • coder external-workspaces agent-instructions <workspace name> <agent name> - Retrieves agent connection instruction
    • Example: coder external-workspaces agent-instructions ext-workspace main --output=json

coderd changes

  • GET /api/v2/init-script - Gets the agent initialization script
    • By default, it returns a script for Linux (amd64), but with query parameters (os and arch) you can get the init script for different platforms
  • GET /api/v2/workspaces/{workspace}/external-agent/{agent}/credentials - Gets credentials for an external agent

Database changes

  • Added has_external_agent field to workspace builds and template versions
  • Updated queries to filter workspaces/templates by the has_external_agent field

provisioner changes

  • Added support for coder_external_agent terraform resource
  • Updated provisioner to detect and track external agents in workspace builds

Frontend changes

  • Added a new component AgentExternal which shows instructions for connecting external agents.
  • Added redacted fields to CodeExample so you can now hide specific parts of the code instead of the full line
  • Hides workspace actions if workspace is using external agent.
image

@kacpersaw kacpersaw requested a review from deansheather August 5, 2025 11:07
@kacpersaw kacpersaw changed the title feat(coder-attach): add support for external workspaces feat: coder-attach: add support for external workspaces Aug 5, 2025
@kacpersaw kacpersaw marked this pull request as ready for review August 5, 2025 11:44
Copy link
Member

@johnstcn johnstcn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would highly recommend breaking this PR down by domain. Right now it's extremely large.

Suggested breakpoints:

  • Database
  • API/protobuf
  • CLI
  • UI

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When adding a new field to a protobuf, we increment the minor version in provisionerd/proto/version.go.

Comment on lines 138 to 142
actions: {
display: "flex",
alignItems: "center",
gap: 4,
},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pls use tailwind for new styles

@@ -19,7 +30,31 @@ export const CodeExample: FC<CodeExampleProps> = ({
// Defaulting to true to be on the safe side; you should have to opt out of
// the secure option, not remember to opt in
secret = true,

// Redact parts of the code if the user doesn't want to obfuscate the whole code
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know there was already a bad pattern here, but these comments will be much more helpful on the type definition in JSDoc format so that the language server will actually recognize them

redactPattern,
redactReplacement = "********",

// Show a button to show the redacted parts of the code
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

redactReplacement = "********",

// Show a button to show the redacted parts of the code
redactShowButton = false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a really confusing name. it kind of sounds like by setting it to true you're "redacting the show button" which doesn't really make sense. just showButton would also be confusing. what button are we showing and why?

I think these are usually called "reveal" buttons. maybe showRevealButton or enableRevealButton?

Suggested change
redactShowButton = false,
redactShowButton,

also you don't need the default. undefined is treated like false when used as a boolean.

Comment on lines 57 to 64
const styles = {
externalAgentSection: (theme) => ({
fontSize: 16,
color: theme.palette.text.secondary,
paddingBottom: 8,
lineHeight: 1.4,
}),
} satisfies Record<string, Interpolation<Theme>>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pls use tailwind for new styles

…attach

# Conflicts:
#	coderd/database/queries/templateversions.sql
Copy link
Member

@deansheather deansheather left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BE review 👍

Comment on lines 135 to 147
cliui.ChangeFormatterData(cliui.TextFormat(), func(data any) (any, error) {
agent, ok := data.(externalAgent)
if !ok {
return "", xerrors.Errorf("expected externalAgent, got %T", data)
}

var output strings.Builder
_, _ = output.WriteString(fmt.Sprintf("Please run the following commands to attach agent %s:\n", cliui.Keyword(agent.AgentName)))
_, _ = output.WriteString(fmt.Sprintf("%s\n", pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("export CODER_AGENT_TOKEN=%s", agent.AuthToken))))
_, _ = output.WriteString(pretty.Sprint(cliui.DefaultStyles.Code, fmt.Sprintf("curl -fsSL %s | sh", agent.InitScript)))

return output.String(), nil
}),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This chunk of code seems to be used multiple times, can you not reuse printExternalAgents()?

Comment on lines 74 to 77
template, err := client.TemplateByName(inv.Context(), organization.ID, templateName.Value.String())
if err != nil {
return xerrors.Errorf("get template by name: %w", err)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of these queries are going to be repeated by the original handler, could we change the createHandler function into a model where the queries get run once, then a "validate" function that gets swapped for coder create or coder external-workspaces create gets executed (and all the queried objects get passed in), then the workspace gets created.

workspace, workspaceBuild, err := createHandler(ctx, inv, createOptions{
  beforeCreate: func(ctx context.Context, template codersdk.Template, ...) error {},
})

cli/root.go Outdated
@@ -127,6 +127,9 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
r.update(),
r.whoami(),

// External Workspace Commands
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd just group this with workspaces tbh


cmd := &serpent.Command{
Use: "external-workspaces [subcommand]",
Short: "External workspace related commands",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Short: "External workspace related commands",
Short: "Create or manage external workspaces",

)

cmd := &serpent.Command{
Use: "agent-instructions [workspace name] [agent name]",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other commands use [user/]workspace[.agent] syntax for selecting an agent, defaulting to the only agent in the workspace if the agent is unspecified and there's only one. I believe there's a helper function in cli to resolve these for you.

return
}
script = strings.ReplaceAll(script, "${ACCESS_URL}", api.AccessURL.String()+"/")
script = strings.ReplaceAll(script, "${AUTH_TYPE}", "token")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't auth type be configurable e.g. with an query parameter var? Defaulting the value to "token" is fine, though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Phase 1 assumes token-based auth, so I think it can stay as it is. In the future, we can extend this to include other types of auth.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would that look like on the init script endpoint? A query parameter defaulting to token perhaps?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's the plan 👍

@@ -1731,7 +1731,17 @@ func (s *server) completeTemplateImportJob(ctx context.Context, job database.Pro
if err != nil {
return xerrors.Errorf("update template version external auth providers: %w", err)
}

err = db.UpdateTemplateVersionExternalAgentByJobID(ctx, database.UpdateTemplateVersionExternalAgentByJobIDParams{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could probably be merged with the above query.

break
}
}
err = db.UpdateWorkspaceBuildExternalAgentByID(ctx, database.UpdateWorkspaceBuildExternalAgentByIDParams{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here


for _, agent := range agents {
if agent.Name == agentName {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ExternalAgentCredentials{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the external agent uses a different authentication strategy than "token" we should probably return a 404 or something else that the frontend can detect.

{workspace.name} workspace:
</p>
<CodeExample
code={`CODER_AGENT_TOKEN="${externalAgentToken}" curl -fsSL "${initScriptURL}" | sh`}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the generation of the command should be moved to the backend and returned from the API, so we can keep it all in one place. This will need to eventually vary by OS/arch (curl | sh doesn't work on windows).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can extend the credentials endpoint and add another field for the ready-to-go command. Unless you have a different idea?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds good to me!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👍

@kacpersaw kacpersaw requested a review from deansheather August 8, 2025 13:52
Copy link
Member

@deansheather deansheather left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Backend looks good, would like to see a few quick changes then I'll stamp

@@ -234,3 +225,13 @@ FROM
WHERE
template_versions.id IN (archived_versions.id)
RETURNING template_versions.id;

-- name: UpdateTemplateVersionAITaskAndExternalAgentByJobID :exec
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe UpdateTemplateVersionFlagsByJobID? The name might get even crazier if we add more of these post-build flag columns.

@@ -253,3 +244,13 @@ WHERE
AND pj.job_status = 'failed'
ORDER BY
tv.name ASC, wb.build_number DESC;

-- name: UpdateWorkspaceBuildAITaskAndExternalAgentByID :exec
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here


script, exists := provisionersdk.AgentScriptEnv()[fmt.Sprintf("CODER_AGENT_SCRIPT_%s_%s", os, arch)]
if !exists {
rw.WriteHeader(http.StatusBadRequest)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should probably write a message here too, using httpapi.Write(..., codersdk.Response{}) is probably fine.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unknown os/arch: %s/%s

client := coderdtest.New(t, nil)
script, err := client.InitScript(context.Background(), "windows", "amd64")
require.NoError(t, err)
require.NotEmpty(t, script)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should test the replacements as well, probably search for substrings like $env:CODER_AGENT_AUTH_TYPE="token" (or whatever it's supposed to look like) etc.

client, db := coderdtest.NewWithDatabase(t, nil)
user := coderdtest.CreateFirstUser(t, client)

t.Run("Success", func(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should check the returned command matches what you expect for both non-windows and windows in separate tests.

@@ -0,0 +1,28 @@
package codersdk
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: This file should probably be renamed just initscript.go to match other files in the dir like provisionerdaemons.go. Files ending with _xyz.go are usually for showing stuff similar build tags in the filename

@@ -1,6 +1,7 @@
package cli
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Maybe this should be externalworkspaces.go too

kacpersaw and others added 2 commits August 8, 2025 16:20
Co-authored-by: Dean Sheather <dean@deansheather.com>
@kacpersaw kacpersaw requested a review from deansheather August 8, 2025 14:41
@kacpersaw kacpersaw requested a review from aslilac August 8, 2025 14:44
@aslilac
Copy link
Member

aslilac commented Aug 8, 2025

I would highly recommend breaking this PR down by domain. Right now it's extremely large.

quoting @johnstcn, I think this still needs to be addressed. I did a cursory review but it's really hard to actually feel comfortable approving a diff this large. I think this should probably be broken into at least like three chunks before it gets merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants