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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy