feat: add new required slug property to coder_app, use in URLs (#4573)

This commit is contained in:
Dean Sheather
2022-10-29 03:41:31 +10:00
committed by GitHub
parent 90f77a3415
commit 10df2fd4fb
75 changed files with 852 additions and 460 deletions

View File

@ -331,8 +331,9 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a
Id: "something",
Auth: &proto.Agent_Token{},
Apps: []*proto.App{{
Name: "testapp",
Url: "http://localhost:3000",
Slug: "testapp",
DisplayName: "testapp",
Url: "http://localhost:3000",
}},
}},
}},
@ -372,7 +373,7 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a
"{template}": template.ID.String(),
"{fileID}": file.ID.String(),
"{workspaceresource}": workspace.LatestBuild.Resources[0].ID.String(),
"{workspaceapp}": workspace.LatestBuild.Resources[0].Agents[0].Apps[0].Name,
"{workspaceapp}": workspace.LatestBuild.Resources[0].Agents[0].Apps[0].Slug,
"{templateversion}": version.ID.String(),
"{jobID}": templateVersionDryRun.ID.String(),
"{templatename}": template.Name,

View File

@ -1861,7 +1861,7 @@ func (q *fakeQuerier) GetWorkspaceAgentsCreatedAfter(_ context.Context, after ti
return workspaceAgents, nil
}
func (q *fakeQuerier) GetWorkspaceAppByAgentIDAndName(_ context.Context, arg database.GetWorkspaceAppByAgentIDAndNameParams) (database.WorkspaceApp, error) {
func (q *fakeQuerier) GetWorkspaceAppByAgentIDAndSlug(_ context.Context, arg database.GetWorkspaceAppByAgentIDAndSlugParams) (database.WorkspaceApp, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -1869,7 +1869,7 @@ func (q *fakeQuerier) GetWorkspaceAppByAgentIDAndName(_ context.Context, arg dat
if app.AgentID != arg.AgentID {
continue
}
if app.Name != arg.Name {
if app.Slug != arg.Slug {
continue
}
return app, nil
@ -2522,7 +2522,8 @@ func (q *fakeQuerier) InsertWorkspaceApp(_ context.Context, arg database.InsertW
ID: arg.ID,
AgentID: arg.AgentID,
CreatedAt: arg.CreatedAt,
Name: arg.Name,
Slug: arg.Slug,
DisplayName: arg.DisplayName,
Icon: arg.Icon,
Command: arg.Command,
Url: arg.Url,

View File

@ -399,7 +399,7 @@ CREATE TABLE workspace_apps (
id uuid NOT NULL,
created_at timestamp with time zone NOT NULL,
agent_id uuid NOT NULL,
name character varying(64) NOT NULL,
display_name character varying(64) NOT NULL,
icon character varying(256) NOT NULL,
command character varying(65534),
url character varying(65534),
@ -408,7 +408,8 @@ CREATE TABLE workspace_apps (
healthcheck_threshold integer DEFAULT 0 NOT NULL,
health workspace_app_health DEFAULT 'disabled'::public.workspace_app_health NOT NULL,
subdomain boolean DEFAULT false NOT NULL,
sharing_level app_sharing_level DEFAULT 'owner'::public.app_sharing_level NOT NULL
sharing_level app_sharing_level DEFAULT 'owner'::public.app_sharing_level NOT NULL,
slug text NOT NULL
);
CREATE TABLE workspace_builds (
@ -548,7 +549,7 @@ ALTER TABLE ONLY workspace_agents
ADD CONSTRAINT workspace_agents_pkey PRIMARY KEY (id);
ALTER TABLE ONLY workspace_apps
ADD CONSTRAINT workspace_apps_agent_id_name_key UNIQUE (agent_id, name);
ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug);
ALTER TABLE ONLY workspace_apps
ADD CONSTRAINT workspace_apps_pkey PRIMARY KEY (id);

View File

@ -0,0 +1,5 @@
-- drop unique index on "slug" column
ALTER TABLE "workspace_apps" DROP CONSTRAINT IF EXISTS "workspace_apps_agent_id_slug_idx";
-- drop "slug" column from "workspace_apps" table
ALTER TABLE "workspace_apps" DROP COLUMN "slug";

View File

@ -0,0 +1,16 @@
BEGIN;
-- add "slug" column to "workspace_apps" table
ALTER TABLE "workspace_apps" ADD COLUMN "slug" text DEFAULT '';
-- copy the "name" column for each workspace app to the "slug" column
UPDATE "workspace_apps" SET "slug" = "name";
-- make "slug" column not nullable and remove default
ALTER TABLE "workspace_apps" ALTER COLUMN "slug" SET NOT NULL;
ALTER TABLE "workspace_apps" ALTER COLUMN "slug" DROP DEFAULT;
-- add unique index on "slug" column
ALTER TABLE "workspace_apps" ADD CONSTRAINT "workspace_apps_agent_id_slug_idx" UNIQUE ("agent_id", "slug");
COMMIT;

View File

@ -0,0 +1,34 @@
BEGIN;
-- Select all apps with an extra "row_number" column that determines the "rank"
-- of the display name against other display names in the same agent.
WITH row_numbers AS (
SELECT
*,
row_number() OVER (PARTITION BY agent_id, display_name ORDER BY display_name ASC) AS row_number
FROM
workspace_apps
)
-- Update any app with a "row_number" greater than 1 to have the row number
-- appended to the display name. This effectively means that all lowercase
-- display names remain untouched, while non-unique mixed case usernames are
-- appended with a unique number. If you had three apps called all called asdf,
-- they would then be renamed to e.g. asdf, asdf1234, and asdf5678.
UPDATE
workspace_apps
SET
display_name = workspace_apps.display_name || floor(random() * 10000)::text
FROM
row_numbers
WHERE
workspace_apps.id = row_numbers.id AND
row_numbers.row_number > 1;
-- rename column "display_name" to "name" on "workspace_apps"
ALTER TABLE "workspace_apps" RENAME COLUMN "display_name" TO "name";
-- restore unique index on "workspace_apps" table
ALTER TABLE workspace_apps ADD CONSTRAINT workspace_apps_agent_id_name_key UNIQUE ("agent_id", "name");
COMMIT;

View File

@ -0,0 +1,9 @@
BEGIN;
-- rename column "name" to "display_name" on "workspace_apps"
ALTER TABLE "workspace_apps" RENAME COLUMN "name" TO "display_name";
-- drop constraint "workspace_apps_agent_id_name_key" on "workspace_apps".
ALTER TABLE ONLY workspace_apps DROP CONSTRAINT IF EXISTS workspace_apps_agent_id_name_key;
COMMIT;

View File

@ -667,7 +667,7 @@ type WorkspaceApp struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
Name string `db:"name" json:"name"`
DisplayName string `db:"display_name" json:"display_name"`
Icon string `db:"icon" json:"icon"`
Command sql.NullString `db:"command" json:"command"`
Url sql.NullString `db:"url" json:"url"`
@ -677,6 +677,7 @@ type WorkspaceApp struct {
Health WorkspaceAppHealth `db:"health" json:"health"`
Subdomain bool `db:"subdomain" json:"subdomain"`
SharingLevel AppSharingLevel `db:"sharing_level" json:"sharing_level"`
Slug string `db:"slug" json:"slug"`
}
type WorkspaceBuild struct {

View File

@ -100,7 +100,7 @@ type sqlcQuerier interface {
GetWorkspaceAgentByInstanceID(ctx context.Context, authInstanceID string) (WorkspaceAgent, error)
GetWorkspaceAgentsByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAgent, error)
GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error)
GetWorkspaceAppByAgentIDAndName(ctx context.Context, arg GetWorkspaceAppByAgentIDAndNameParams) (WorkspaceApp, error)
GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg GetWorkspaceAppByAgentIDAndSlugParams) (WorkspaceApp, error)
GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error)
GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error)
GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error)

View File

@ -973,8 +973,8 @@ func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyPar
}
const deleteGroupByID = `-- name: DeleteGroupByID :exec
DELETE FROM
groups
DELETE FROM
groups
WHERE
id = $1
`
@ -985,8 +985,8 @@ func (q *sqlQuerier) DeleteGroupByID(ctx context.Context, id uuid.UUID) error {
}
const deleteGroupMember = `-- name: DeleteGroupMember :exec
DELETE FROM
group_members
DELETE FROM
group_members
WHERE
user_id = $1
`
@ -4773,23 +4773,23 @@ func (q *sqlQuerier) UpdateWorkspaceAgentVersionByID(ctx context.Context, arg Up
return err
}
const getWorkspaceAppByAgentIDAndName = `-- name: GetWorkspaceAppByAgentIDAndName :one
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = $1 AND name = $2
const getWorkspaceAppByAgentIDAndSlug = `-- name: GetWorkspaceAppByAgentIDAndSlug :one
SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug FROM workspace_apps WHERE agent_id = $1 AND slug = $2
`
type GetWorkspaceAppByAgentIDAndNameParams struct {
type GetWorkspaceAppByAgentIDAndSlugParams struct {
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
Name string `db:"name" json:"name"`
Slug string `db:"slug" json:"slug"`
}
func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndName(ctx context.Context, arg GetWorkspaceAppByAgentIDAndNameParams) (WorkspaceApp, error) {
row := q.db.QueryRowContext(ctx, getWorkspaceAppByAgentIDAndName, arg.AgentID, arg.Name)
func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg GetWorkspaceAppByAgentIDAndSlugParams) (WorkspaceApp, error) {
row := q.db.QueryRowContext(ctx, getWorkspaceAppByAgentIDAndSlug, arg.AgentID, arg.Slug)
var i WorkspaceApp
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.AgentID,
&i.Name,
&i.DisplayName,
&i.Icon,
&i.Command,
&i.Url,
@ -4799,12 +4799,13 @@ func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndName(ctx context.Context, arg Ge
&i.Health,
&i.Subdomain,
&i.SharingLevel,
&i.Slug,
)
return i, err
}
const getWorkspaceAppsByAgentID = `-- name: GetWorkspaceAppsByAgentID :many
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = $1 ORDER BY name ASC
SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug FROM workspace_apps WHERE agent_id = $1 ORDER BY slug ASC
`
func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error) {
@ -4820,7 +4821,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid
&i.ID,
&i.CreatedAt,
&i.AgentID,
&i.Name,
&i.DisplayName,
&i.Icon,
&i.Command,
&i.Url,
@ -4830,6 +4831,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid
&i.Health,
&i.Subdomain,
&i.SharingLevel,
&i.Slug,
); err != nil {
return nil, err
}
@ -4845,7 +4847,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid
}
const getWorkspaceAppsByAgentIDs = `-- name: GetWorkspaceAppsByAgentIDs :many
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE agent_id = ANY($1 :: uuid [ ]) ORDER BY name ASC
SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug FROM workspace_apps WHERE agent_id = ANY($1 :: uuid [ ]) ORDER BY slug ASC
`
func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error) {
@ -4861,7 +4863,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.
&i.ID,
&i.CreatedAt,
&i.AgentID,
&i.Name,
&i.DisplayName,
&i.Icon,
&i.Command,
&i.Url,
@ -4871,6 +4873,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.
&i.Health,
&i.Subdomain,
&i.SharingLevel,
&i.Slug,
); err != nil {
return nil, err
}
@ -4886,7 +4889,7 @@ func (q *sqlQuerier) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.
}
const getWorkspaceAppsCreatedAfter = `-- name: GetWorkspaceAppsCreatedAfter :many
SELECT id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level FROM workspace_apps WHERE created_at > $1 ORDER BY name ASC
SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug FROM workspace_apps WHERE created_at > $1 ORDER BY slug ASC
`
func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) {
@ -4902,7 +4905,7 @@ func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt
&i.ID,
&i.CreatedAt,
&i.AgentID,
&i.Name,
&i.DisplayName,
&i.Icon,
&i.Command,
&i.Url,
@ -4912,6 +4915,7 @@ func (q *sqlQuerier) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt
&i.Health,
&i.Subdomain,
&i.SharingLevel,
&i.Slug,
); err != nil {
return nil, err
}
@ -4932,7 +4936,8 @@ INSERT INTO
id,
created_at,
agent_id,
name,
slug,
display_name,
icon,
command,
url,
@ -4944,14 +4949,15 @@ INSERT INTO
health
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, created_at, agent_id, name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug
`
type InsertWorkspaceAppParams struct {
ID uuid.UUID `db:"id" json:"id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
Name string `db:"name" json:"name"`
Slug string `db:"slug" json:"slug"`
DisplayName string `db:"display_name" json:"display_name"`
Icon string `db:"icon" json:"icon"`
Command sql.NullString `db:"command" json:"command"`
Url sql.NullString `db:"url" json:"url"`
@ -4968,7 +4974,8 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace
arg.ID,
arg.CreatedAt,
arg.AgentID,
arg.Name,
arg.Slug,
arg.DisplayName,
arg.Icon,
arg.Command,
arg.Url,
@ -4984,7 +4991,7 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace
&i.ID,
&i.CreatedAt,
&i.AgentID,
&i.Name,
&i.DisplayName,
&i.Icon,
&i.Command,
&i.Url,
@ -4994,6 +5001,7 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace
&i.Health,
&i.Subdomain,
&i.SharingLevel,
&i.Slug,
)
return i, err
}

View File

@ -81,7 +81,7 @@ VALUES
( $1, $2, $3, $4) RETURNING *;
-- We use the organization_id as the id
-- for simplicity since all users is
-- for simplicity since all users is
-- every member of the org.
-- name: InsertAllUsersGroup :one
INSERT INTO groups (
@ -110,14 +110,14 @@ INSERT INTO group_members (
VALUES ( $1, $2);
-- name: DeleteGroupMember :exec
DELETE FROM
group_members
DELETE FROM
group_members
WHERE
user_id = $1;
-- name: DeleteGroupByID :exec
DELETE FROM
groups
DELETE FROM
groups
WHERE
id = $1;

View File

@ -1,14 +1,14 @@
-- name: GetWorkspaceAppsByAgentID :many
SELECT * FROM workspace_apps WHERE agent_id = $1 ORDER BY name ASC;
SELECT * FROM workspace_apps WHERE agent_id = $1 ORDER BY slug ASC;
-- name: GetWorkspaceAppsByAgentIDs :many
SELECT * FROM workspace_apps WHERE agent_id = ANY(@ids :: uuid [ ]) ORDER BY name ASC;
SELECT * FROM workspace_apps WHERE agent_id = ANY(@ids :: uuid [ ]) ORDER BY slug ASC;
-- name: GetWorkspaceAppByAgentIDAndName :one
SELECT * FROM workspace_apps WHERE agent_id = $1 AND name = $2;
-- name: GetWorkspaceAppByAgentIDAndSlug :one
SELECT * FROM workspace_apps WHERE agent_id = $1 AND slug = $2;
-- name: GetWorkspaceAppsCreatedAfter :many
SELECT * FROM workspace_apps WHERE created_at > $1 ORDER BY name ASC;
SELECT * FROM workspace_apps WHERE created_at > $1 ORDER BY slug ASC;
-- name: InsertWorkspaceApp :one
INSERT INTO
@ -16,7 +16,8 @@ INSERT INTO
id,
created_at,
agent_id,
name,
slug,
display_name,
icon,
command,
url,
@ -28,7 +29,7 @@ INSERT INTO
health
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING *;
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *;
-- name: UpdateWorkspaceAppHealthByID :exec
UPDATE

View File

@ -16,7 +16,7 @@ const (
UniqueProvisionerDaemonsNameKey UniqueConstraint = "provisioner_daemons_name_key" // ALTER TABLE ONLY provisioner_daemons ADD CONSTRAINT provisioner_daemons_name_key UNIQUE (name);
UniqueSiteConfigsKeyKey UniqueConstraint = "site_configs_key_key" // ALTER TABLE ONLY site_configs ADD CONSTRAINT site_configs_key_key UNIQUE (key);
UniqueTemplateVersionsTemplateIDNameKey UniqueConstraint = "template_versions_template_id_name_key" // ALTER TABLE ONLY template_versions ADD CONSTRAINT template_versions_template_id_name_key UNIQUE (template_id, name);
UniqueWorkspaceAppsAgentIDNameKey UniqueConstraint = "workspace_apps_agent_id_name_key" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_name_key UNIQUE (agent_id, name);
UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug);
UniqueWorkspaceBuildsJobIDKey UniqueConstraint = "workspace_builds_job_id_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_key UNIQUE (job_id);
UniqueWorkspaceBuildsWorkspaceIDBuildNumberKey UniqueConstraint = "workspace_builds_workspace_id_build_number_key" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_workspace_id_build_number_key UNIQUE (workspace_id, build_number);
UniqueIndexOrganizationName UniqueConstraint = "idx_organization_name" // CREATE UNIQUE INDEX idx_organization_name ON organizations USING btree (name);

View File

@ -14,8 +14,8 @@ var (
// Remove the "starts with" and "ends with" regex components.
nameRegex = strings.Trim(UsernameValidRegex.String(), "^$")
appURL = regexp.MustCompile(fmt.Sprintf(
// {PORT/APP_NAME}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
`^(?P<AppName>%[1]s)--(?P<AgentName>%[1]s)--(?P<WorkspaceName>%[1]s)--(?P<Username>%[1]s)$`,
// {PORT/APP_SLUG}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
`^(?P<AppSlug>%[1]s)--(?P<AgentName>%[1]s)--(?P<WorkspaceName>%[1]s)--(?P<Username>%[1]s)$`,
nameRegex))
validHostnameLabelRegex = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
@ -23,8 +23,8 @@ var (
// ApplicationURL is a parsed application URL hostname.
type ApplicationURL struct {
// Only one of AppName or Port will be set.
AppName string
// Only one of AppSlug or Port will be set.
AppSlug string
Port uint16
AgentName string
WorkspaceName string
@ -34,12 +34,12 @@ type ApplicationURL struct {
// String returns the application URL hostname without scheme. You will likely
// want to append a period and the base hostname.
func (a ApplicationURL) String() string {
appNameOrPort := a.AppName
appSlugOrPort := a.AppSlug
if a.Port != 0 {
appNameOrPort = strconv.Itoa(int(a.Port))
appSlugOrPort = strconv.Itoa(int(a.Port))
}
return fmt.Sprintf("%s--%s--%s--%s", appNameOrPort, a.AgentName, a.WorkspaceName, a.Username)
return fmt.Sprintf("%s--%s--%s--%s", appSlugOrPort, a.AgentName, a.WorkspaceName, a.Username)
}
// ParseSubdomainAppURL parses an ApplicationURL from the given subdomain. If
@ -51,7 +51,7 @@ func (a ApplicationURL) String() string {
//
// Subdomains should be in the form:
//
// {PORT/APP_NAME}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
// {PORT/APP_SLUG}--{AGENT_NAME}--{WORKSPACE_NAME}--{USERNAME}
// (eg. https://8080--main--dev--dean.hi.c8s.io)
func ParseSubdomainAppURL(subdomain string) (ApplicationURL, error) {
matches := appURL.FindAllStringSubmatch(subdomain, -1)
@ -60,9 +60,9 @@ func ParseSubdomainAppURL(subdomain string) (ApplicationURL, error) {
}
matchGroup := matches[0]
appName, port := AppNameOrPort(matchGroup[appURL.SubexpIndex("AppName")])
appSlug, port := AppSlugOrPort(matchGroup[appURL.SubexpIndex("AppSlug")])
return ApplicationURL{
AppName: appName,
AppSlug: appSlug,
Port: port,
AgentName: matchGroup[appURL.SubexpIndex("AgentName")],
WorkspaceName: matchGroup[appURL.SubexpIndex("WorkspaceName")],
@ -70,9 +70,9 @@ func ParseSubdomainAppURL(subdomain string) (ApplicationURL, error) {
}, nil
}
// AppNameOrPort takes a string and returns either the input string or a port
// AppSlugOrPort takes a string and returns either the input string or a port
// number.
func AppNameOrPort(val string) (string, uint16) {
func AppSlugOrPort(val string) (string, uint16) {
port, err := strconv.ParseUint(val, 10, 16)
if err != nil || port == 0 {
port = 0

View File

@ -25,7 +25,7 @@ func TestApplicationURLString(t *testing.T) {
{
Name: "AppName",
URL: httpapi.ApplicationURL{
AppName: "app",
AppSlug: "app",
Port: 0,
AgentName: "agent",
WorkspaceName: "workspace",
@ -36,7 +36,7 @@ func TestApplicationURLString(t *testing.T) {
{
Name: "Port",
URL: httpapi.ApplicationURL{
AppName: "",
AppSlug: "",
Port: 8080,
AgentName: "agent",
WorkspaceName: "workspace",
@ -47,7 +47,7 @@ func TestApplicationURLString(t *testing.T) {
{
Name: "Both",
URL: httpapi.ApplicationURL{
AppName: "app",
AppSlug: "app",
Port: 8080,
AgentName: "agent",
WorkspaceName: "workspace",
@ -111,7 +111,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
Name: "AppName--Agent--Workspace--User",
Subdomain: "app--agent--workspace--user",
Expected: httpapi.ApplicationURL{
AppName: "app",
AppSlug: "app",
Port: 0,
AgentName: "agent",
WorkspaceName: "workspace",
@ -122,7 +122,7 @@ func TestParseSubdomainAppURL(t *testing.T) {
Name: "Port--Agent--Workspace--User",
Subdomain: "8080--agent--workspace--user",
Expected: httpapi.ApplicationURL{
AppName: "",
AppSlug: "",
Port: 8080,
AgentName: "agent",
WorkspaceName: "workspace",
@ -131,9 +131,9 @@ func TestParseSubdomainAppURL(t *testing.T) {
},
{
Name: "HyphenatedNames",
Subdomain: "app-name--agent-name--workspace-name--user-name",
Subdomain: "app-slug--agent-name--workspace-name--user-name",
Expected: httpapi.ApplicationURL{
AppName: "app-name",
AppSlug: "app-slug",
Port: 0,
AgentName: "agent-name",
WorkspaceName: "workspace-name",

View File

@ -28,6 +28,7 @@ import (
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner"
"github.com/coder/coder/provisionerd/proto"
"github.com/coder/coder/provisionersdk"
sdkproto "github.com/coder/coder/provisionersdk/proto"
@ -755,6 +756,7 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
}
snapshot.WorkspaceResources = append(snapshot.WorkspaceResources, telemetry.ConvertWorkspaceResource(resource))
var appSlugs = make(map[string]struct{})
for _, prAgent := range protoResource.Agents {
var instanceID sql.NullString
if prAgent.GetInstanceId() != "" {
@ -806,6 +808,18 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
snapshot.WorkspaceAgents = append(snapshot.WorkspaceAgents, telemetry.ConvertWorkspaceAgent(dbAgent))
for _, app := range prAgent.Apps {
slug := app.Slug
if slug == "" {
return xerrors.Errorf("app must have a slug or name set")
}
if !provisioner.AppSlugRegex.MatchString(slug) {
return xerrors.Errorf("app slug %q does not match regex %q", slug, provisioner.AppSlugRegex.String())
}
if _, exists := appSlugs[slug]; exists {
return xerrors.Errorf("duplicate app slug, must be unique per template: %q", slug)
}
appSlugs[slug] = struct{}{}
health := database.WorkspaceAppHealthDisabled
if app.Healthcheck == nil {
app.Healthcheck = &sdkproto.Healthcheck{}
@ -823,11 +837,12 @@ func insertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
}
dbApp, err := db.InsertWorkspaceApp(ctx, database.InsertWorkspaceAppParams{
ID: uuid.New(),
CreatedAt: database.Now(),
AgentID: dbAgent.ID,
Name: app.Name,
Icon: app.Icon,
ID: uuid.New(),
CreatedAt: database.Now(),
AgentID: dbAgent.ID,
Slug: slug,
DisplayName: app.DisplayName,
Icon: app.Icon,
Command: sql.NullString{
String: app.Command,
Valid: app.Command != "",

View File

@ -596,7 +596,8 @@ func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp {
for _, dbApp := range dbApps {
apps = append(apps, codersdk.WorkspaceApp{
ID: dbApp.ID,
Name: dbApp.Name,
Slug: dbApp.Slug,
DisplayName: dbApp.DisplayName,
Command: dbApp.Command.String,
Icon: dbApp.Icon,
Subdomain: dbApp.Subdomain,
@ -868,7 +869,7 @@ func (api *API) postWorkspaceAppHealth(rw http.ResponseWriter, r *http.Request)
for name, newHealth := range req.Healths {
old := func() *database.WorkspaceApp {
for _, app := range apps {
if app.Name == name {
if app.DisplayName == name {
return &app
}
}

View File

@ -555,8 +555,9 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) {
// should not exist in the response.
_, appLPort := generateUnfilteredPort(t)
app := &proto.App{
Name: "test-app",
Url: fmt.Sprintf("http://localhost:%d", appLPort),
Slug: "test-app",
DisplayName: "test-app",
Url: fmt.Sprintf("http://localhost:%d", appLPort),
}
// Generate a filtered port that should not exist in the response.
@ -623,16 +624,18 @@ func TestWorkspaceAgentAppHealth(t *testing.T) {
authToken := uuid.NewString()
apps := []*proto.App{
{
Name: "code-server",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
Slug: "code-server",
DisplayName: "code-server",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
},
{
Name: "code-server-2",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
Slug: "code-server-2",
DisplayName: "code-server-2",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
Healthcheck: &proto.Healthcheck{
Url: "http://localhost:3000",
Interval: 5,

View File

@ -51,9 +51,9 @@ func (api *API) workspaceAppsProxyPath(rw http.ResponseWriter, r *http.Request)
workspace := httpmw.WorkspaceParam(r)
agent := httpmw.WorkspaceAgentParam(r)
// We do not support port proxying on paths, so lookup the app by name.
appName := chi.URLParam(r, "workspaceapp")
app, ok := api.lookupWorkspaceApp(rw, r, agent.ID, appName)
// We do not support port proxying on paths, so lookup the app by slug.
appSlug := chi.URLParam(r, "workspaceapp")
app, ok := api.lookupWorkspaceApp(rw, r, agent.ID, appSlug)
if !ok {
return
}
@ -180,8 +180,8 @@ func (api *API) handleSubdomainApplications(middlewares ...func(http.Handler) ht
agent := httpmw.WorkspaceAgentParam(r)
var workspaceAppPtr *database.WorkspaceApp
if app.AppName != "" {
workspaceApp, ok := api.lookupWorkspaceApp(rw, r, agent.ID, app.AppName)
if app.AppSlug != "" {
workspaceApp, ok := api.lookupWorkspaceApp(rw, r, agent.ID, app.AppSlug)
if !ok {
return
}
@ -251,14 +251,14 @@ func (api *API) parseWorkspaceApplicationHostname(rw http.ResponseWriter, r *htt
return app, true
}
// lookupWorkspaceApp looks up the workspace application by name in the given
// lookupWorkspaceApp looks up the workspace application by slug in the given
// agent and returns it. If the application is not found or there was a server
// error while looking it up, an HTML error page is returned and false is
// returned so the caller can return early.
func (api *API) lookupWorkspaceApp(rw http.ResponseWriter, r *http.Request, agentID uuid.UUID, appName string) (database.WorkspaceApp, bool) {
app, err := api.Database.GetWorkspaceAppByAgentIDAndName(r.Context(), database.GetWorkspaceAppByAgentIDAndNameParams{
func (api *API) lookupWorkspaceApp(rw http.ResponseWriter, r *http.Request, agentID uuid.UUID, appSlug string) (database.WorkspaceApp, bool) {
app, err := api.Database.GetWorkspaceAppByAgentIDAndSlug(r.Context(), database.GetWorkspaceAppByAgentIDAndSlugParams{
AgentID: agentID,
Name: appName,
Slug: appSlug,
})
if xerrors.Is(err, sql.ErrNoRows) {
renderApplicationNotFound(rw, r, api.AccessURL)
@ -402,12 +402,28 @@ func (api *API) verifyWorkspaceApplicationSubdomainAuth(rw http.ResponseWriter,
return false
}
hostSplit := strings.SplitN(api.AppHostname, ".", 2)
if len(hostSplit) != 2 {
// This should be impossible as we verify the app hostname on
// startup, but we'll check anyways.
api.Logger.Error(r.Context(), "could not split invalid app hostname", slog.F("hostname", api.AppHostname))
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusInternalServerError,
Title: "Internal Server Error",
Description: "The app is configured with an invalid app wildcard hostname. Please contact an administrator.",
RetryEnabled: false,
DashboardURL: api.AccessURL.String(),
})
return false
}
// Set the app cookie for all subdomains of api.AppHostname. This cookie
// is handled properly by the ExtractAPIKey middleware.
cookieHost := "." + hostSplit[1]
http.SetCookie(rw, &http.Cookie{
Name: httpmw.DevURLSessionTokenCookie,
Value: apiKey,
Domain: "." + api.AppHostname,
Domain: cookieHost,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
@ -589,21 +605,18 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
return
}
// If the app does not exist, but the app name is a port number, then
// route to the port as an "anonymous app". We only support HTTP for
// port-based URLs.
// If the app does not exist, but the app slug is a port number, then route
// to the port as an "anonymous app". We only support HTTP for port-based
// URLs.
//
// This is only supported for subdomain-based applications.
internalURL := fmt.Sprintf("http://127.0.0.1:%d", proxyApp.Port)
// If the app name was used instead, fetch the app from the database so we
// can get the internal URL.
if proxyApp.App != nil {
if !proxyApp.App.Url.Valid {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusBadRequest,
Title: "Bad Request",
Description: fmt.Sprintf("Application %q does not have a URL set.", proxyApp.App.Name),
Description: fmt.Sprintf("Application %q does not have a URL set.", proxyApp.App.Slug),
RetryEnabled: true,
DashboardURL: api.AccessURL.String(),
})

View File

@ -160,23 +160,27 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U
},
Apps: []*proto.App{
{
Name: proxyTestAppNameFake,
Slug: proxyTestAppNameFake,
DisplayName: proxyTestAppNameFake,
SharingLevel: proto.AppSharingLevel_OWNER,
// Hopefully this IP and port doesn't exist.
Url: "http://127.1.0.1:65535",
},
{
Name: proxyTestAppNameOwner,
Slug: proxyTestAppNameOwner,
DisplayName: proxyTestAppNameOwner,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: appURL,
},
{
Name: proxyTestAppNameAuthenticated,
Slug: proxyTestAppNameAuthenticated,
DisplayName: proxyTestAppNameAuthenticated,
SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
Url: appURL,
},
{
Name: proxyTestAppNamePublic,
Slug: proxyTestAppNamePublic,
DisplayName: proxyTestAppNamePublic,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
@ -624,7 +628,7 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) {
require.NoError(t, err, "get app host")
subdomain := httpapi.ApplicationURL{
AppName: appName,
AppSlug: appName,
Port: port,
AgentName: proxyTestAgentName,
WorkspaceName: workspaces[0].Name,
@ -855,7 +859,7 @@ func TestAppSharing(t *testing.T) {
proxyTestAppNamePublic: codersdk.WorkspaceAppSharingLevelPublic,
}
for _, app := range agnt.Apps {
found[app.Name] = app.SharingLevel
found[app.DisplayName] = app.SharingLevel
}
require.Equal(t, expected, found, "apps have incorrect sharing levels")

View File

@ -1435,16 +1435,18 @@ func TestWorkspaceResource(t *testing.T) {
user := coderdtest.CreateFirstUser(t, client)
apps := []*proto.App{
{
Name: "code-server",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
Slug: "code-server",
DisplayName: "code-server",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
},
{
Name: "code-server-2",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
Slug: "code-server-2",
DisplayName: "code-server-2",
Command: "some-command",
Url: "http://localhost:3000",
Icon: "/code.svg",
Healthcheck: &proto.Healthcheck{
Url: "http://localhost:3000",
Interval: 5,
@ -1487,7 +1489,7 @@ func TestWorkspaceResource(t *testing.T) {
app := apps[0]
require.EqualValues(t, app.Command, got.Command)
require.EqualValues(t, app.Icon, got.Icon)
require.EqualValues(t, app.Name, got.Name)
require.EqualValues(t, app.DisplayName, got.DisplayName)
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, got.Health)
require.EqualValues(t, "", got.Healthcheck.URL)
require.EqualValues(t, 0, got.Healthcheck.Interval)
@ -1496,7 +1498,7 @@ func TestWorkspaceResource(t *testing.T) {
app = apps[1]
require.EqualValues(t, app.Command, got.Command)
require.EqualValues(t, app.Icon, got.Icon)
require.EqualValues(t, app.Name, got.Name)
require.EqualValues(t, app.DisplayName, got.DisplayName)
require.EqualValues(t, codersdk.WorkspaceAppHealthInitializing, got.Health)
require.EqualValues(t, app.Healthcheck.Url, got.Healthcheck.URL)
require.EqualValues(t, app.Healthcheck.Interval, got.Healthcheck.Interval)