Skip to content

Commit 97e0d44

Browse files
chore(coderd/database): create db trigger to enforce agent name uniqueness
This PR creates a new database trigger to ensure an inserted workspace agent has a unique name within the scope of its workspace.
1 parent ce134bc commit 97e0d44

File tree

4 files changed

+262
-0
lines changed

4 files changed

+262
-0
lines changed

coderd/database/dump.sql

Lines changed: 42 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
DROP TRIGGER IF EXISTS workspace_agent_name_unique_trigger ON workspace_agents;
2+
DROP FUNCTION IF EXISTS check_workspace_agent_name_unique();
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
CREATE OR REPLACE FUNCTION check_workspace_agent_name_unique()
2+
RETURNS TRIGGER AS $$
3+
DECLARE
4+
workspace_id_var uuid;
5+
existing_count integer;
6+
BEGIN
7+
-- Get the workspace_id for this agent by following the relationship chain:
8+
-- workspace_agents -> workspace_resources -> provisioner_jobs -> workspace_builds -> workspaces
9+
SELECT wb.workspace_id INTO workspace_id_var
10+
FROM workspace_resources wr
11+
JOIN provisioner_jobs pj ON wr.job_id = pj.id
12+
JOIN workspace_builds wb ON pj.id = wb.job_id
13+
WHERE wr.id = NEW.resource_id;
14+
15+
-- If we couldn't find a workspace_id, allow the insert (might be a template import or other edge case)
16+
IF workspace_id_var IS NULL THEN
17+
RETURN NEW;
18+
END IF;
19+
20+
-- Check if there's already an agent with this name in this workspace
21+
SELECT COUNT(*) INTO existing_count
22+
FROM workspace_agents wa
23+
JOIN workspace_resources wr ON wa.resource_id = wr.id
24+
JOIN provisioner_jobs pj ON wr.job_id = pj.id
25+
JOIN workspace_builds wb ON pj.id = wb.job_id
26+
WHERE wb.workspace_id = workspace_id_var
27+
AND wa.name = NEW.name
28+
AND wa.id != NEW.id; -- Exclude the current agent (for updates)
29+
30+
-- If there's already an agent with this name, raise an error
31+
IF existing_count > 0 THEN
32+
RAISE EXCEPTION 'workspace agent name "%" already exists in this workspace', NEW.name
33+
USING ERRCODE = 'unique_violation';
34+
END IF;
35+
36+
RETURN NEW;
37+
END;
38+
$$ LANGUAGE plpgsql;
39+
40+
CREATE TRIGGER workspace_agent_name_unique_trigger
41+
BEFORE INSERT OR UPDATE OF name, resource_id ON workspace_agents
42+
FOR EACH ROW
43+
EXECUTE FUNCTION check_workspace_agent_name_unique();

coderd/database/querier_test.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import (
44
"context"
55
"database/sql"
66
"encoding/json"
7+
"errors"
78
"fmt"
89
"sort"
10+
"sync/atomic"
911
"testing"
1012
"time"
1113

1214
"github.com/google/uuid"
15+
"github.com/lib/pq"
1316
"github.com/prometheus/client_golang/prometheus"
1417
"github.com/stretchr/testify/assert"
1518
"github.com/stretchr/testify/require"
@@ -4705,6 +4708,178 @@ func TestGetPresetsAtFailureLimit(t *testing.T) {
47054708
})
47064709
}
47074710

4711+
func TestWorkspaceAgentNameUniqueTrigger(t *testing.T) {
4712+
t.Parallel()
4713+
4714+
var builds atomic.Int32
4715+
4716+
if !dbtestutil.WillUsePostgres() {
4717+
t.Skip("This test makes use of a database trigger not implemented in dbmem")
4718+
}
4719+
4720+
createWorkspaceWithAgent := func(t *testing.T, db database.Store, org database.Organization, agentName string) (database.WorkspaceTable, database.TemplateVersion, database.WorkspaceAgent) {
4721+
t.Helper()
4722+
4723+
user := dbgen.User(t, db, database.User{})
4724+
template := dbgen.Template(t, db, database.Template{
4725+
OrganizationID: org.ID,
4726+
CreatedBy: user.ID,
4727+
})
4728+
templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{
4729+
TemplateID: uuid.NullUUID{Valid: true, UUID: template.ID},
4730+
OrganizationID: org.ID,
4731+
CreatedBy: user.ID,
4732+
})
4733+
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
4734+
OrganizationID: org.ID,
4735+
TemplateID: template.ID,
4736+
OwnerID: user.ID,
4737+
})
4738+
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
4739+
Type: database.ProvisionerJobTypeWorkspaceBuild,
4740+
OrganizationID: org.ID,
4741+
})
4742+
build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
4743+
BuildNumber: builds.Add(1),
4744+
JobID: job.ID,
4745+
WorkspaceID: workspace.ID,
4746+
TemplateVersionID: templateVersion.ID,
4747+
})
4748+
resource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
4749+
JobID: build.JobID,
4750+
})
4751+
agent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
4752+
ResourceID: resource.ID,
4753+
Name: agentName,
4754+
})
4755+
4756+
return workspace, templateVersion, agent
4757+
}
4758+
4759+
t.Run("DuplicateNamesInSameWorkspace", func(t *testing.T) {
4760+
t.Parallel()
4761+
4762+
db, _ := dbtestutil.NewDB(t)
4763+
org := dbgen.Organization(t, db, database.Organization{})
4764+
ctx := testutil.Context(t, testutil.WaitShort)
4765+
4766+
// Given: A workspace with an agent
4767+
workspace1, templateVersion1, agent1 := createWorkspaceWithAgent(t, db, org, "duplicate-agent")
4768+
require.Equal(t, "duplicate-agent", agent1.Name)
4769+
4770+
// When: Another agent is created for that workspace with the same name.
4771+
job2 := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
4772+
Type: database.ProvisionerJobTypeWorkspaceBuild,
4773+
OrganizationID: org.ID,
4774+
})
4775+
build2 := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
4776+
BuildNumber: builds.Add(1),
4777+
JobID: job2.ID,
4778+
WorkspaceID: workspace1.ID,
4779+
TemplateVersionID: templateVersion1.ID,
4780+
})
4781+
resource2 := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
4782+
JobID: build2.JobID,
4783+
})
4784+
_, err := db.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{
4785+
ID: uuid.New(),
4786+
CreatedAt: time.Now(),
4787+
UpdatedAt: time.Now(),
4788+
Name: "duplicate-agent", // Same name as agent1
4789+
ResourceID: resource2.ID,
4790+
AuthToken: uuid.New(),
4791+
Architecture: "amd64",
4792+
OperatingSystem: "linux",
4793+
APIKeyScope: database.AgentKeyScopeEnumAll,
4794+
})
4795+
4796+
// Then: We expect it to fail.
4797+
require.Error(t, err)
4798+
var pqErr *pq.Error
4799+
require.True(t, errors.As(err, &pqErr))
4800+
require.Equal(t, pq.ErrorCode("23505"), pqErr.Code) // unique_violation
4801+
require.Contains(t, pqErr.Message, `workspace agent name "duplicate-agent" already exists in this workspace`)
4802+
})
4803+
4804+
t.Run("SameNamesInDifferentWorkspaces", func(t *testing.T) {
4805+
t.Parallel()
4806+
4807+
agentName := "same-name-different-workspace"
4808+
4809+
db, _ := dbtestutil.NewDB(t)
4810+
org := dbgen.Organization(t, db, database.Organization{})
4811+
4812+
// Given: A workspace with an agent
4813+
_, _, agent1 := createWorkspaceWithAgent(t, db, org, agentName)
4814+
require.Equal(t, agentName, agent1.Name)
4815+
4816+
// When: A second workspace is created with an agent having the same name
4817+
_, _, agent2 := createWorkspaceWithAgent(t, db, org, agentName)
4818+
require.Equal(t, agentName, agent2.Name)
4819+
4820+
// Then: We expect there to be different agents with the same name.
4821+
require.NotEqual(t, agent1.ID, agent2.ID)
4822+
require.Equal(t, agent1.Name, agent2.Name)
4823+
})
4824+
4825+
t.Run("NullWorkspaceID", func(t *testing.T) {
4826+
t.Parallel()
4827+
4828+
db, _ := dbtestutil.NewDB(t)
4829+
org := dbgen.Organization(t, db, database.Organization{})
4830+
ctx := testutil.Context(t, testutil.WaitShort)
4831+
4832+
// Given: A resource that does not belong to a workspace build (simulating template import)
4833+
orphanJob := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
4834+
Type: database.ProvisionerJobTypeTemplateVersionImport,
4835+
OrganizationID: org.ID,
4836+
})
4837+
orphanResource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
4838+
JobID: orphanJob.ID,
4839+
})
4840+
4841+
// And this resource has a workspace agent.
4842+
agent1, err := db.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{
4843+
ID: uuid.New(),
4844+
CreatedAt: time.Now(),
4845+
UpdatedAt: time.Now(),
4846+
Name: "orphan-agent",
4847+
ResourceID: orphanResource.ID,
4848+
AuthToken: uuid.New(),
4849+
Architecture: "amd64",
4850+
OperatingSystem: "linux",
4851+
APIKeyScope: database.AgentKeyScopeEnumAll,
4852+
})
4853+
require.NoError(t, err)
4854+
require.Equal(t, "orphan-agent", agent1.Name)
4855+
4856+
// When: We created another resource that does not belong to a workspace build.
4857+
orphanJob2 := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
4858+
Type: database.ProvisionerJobTypeTemplateVersionImport,
4859+
OrganizationID: org.ID,
4860+
})
4861+
orphanResource2 := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
4862+
JobID: orphanJob2.ID,
4863+
})
4864+
4865+
// Then: We expect to be able to create an agent in this new resource that has the same name.
4866+
agent2, err := db.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{
4867+
ID: uuid.New(),
4868+
CreatedAt: time.Now(),
4869+
UpdatedAt: time.Now(),
4870+
Name: "orphan-agent", // Same name as agent1
4871+
ResourceID: orphanResource2.ID,
4872+
AuthToken: uuid.New(),
4873+
Architecture: "amd64",
4874+
OperatingSystem: "linux",
4875+
APIKeyScope: database.AgentKeyScopeEnumAll,
4876+
})
4877+
require.NoError(t, err)
4878+
require.Equal(t, "orphan-agent", agent2.Name)
4879+
require.NotEqual(t, agent1.ID, agent2.ID)
4880+
})
4881+
}
4882+
47084883
func requireUsersMatch(t testing.TB, expected []database.User, found []database.GetUsersRow, msg string) {
47094884
t.Helper()
47104885
require.ElementsMatch(t, expected, database.ConvertUserRows(found), msg)

0 commit comments

Comments
 (0)