diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2c45b383cc90b..d96f03b6cf25a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10970,6 +10970,10 @@ const docTemplate = `{ "description": "Subdomain denotes whether the app should be accessed via a path on the\n` + "`" + `coder server` + "`" + ` or via a hostname-based dev URL. If this is set to true\nand there is no app wildcard configured on the server, the app will not\nbe accessible in the UI.", "type": "boolean" }, + "subdomain_name": { + "description": "SubdomainName is the application domain exposed on the ` + "`" + `coder server` + "`" + `.", + "type": "string" + }, "url": { "description": "URL is the address being proxied to inside the workspace.\nIf external is specified, this will be opened on the client.", "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 21fbd82b348ca..99582f5665b2a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9961,6 +9961,10 @@ "description": "Subdomain denotes whether the app should be accessed via a path on the\n`coder server` or via a hostname-based dev URL. If this is set to true\nand there is no app wildcard configured on the server, the app will not\nbe accessible in the UI.", "type": "boolean" }, + "subdomain_name": { + "description": "SubdomainName is the application domain exposed on the `coder server`.", + "type": "string" + }, "url": { "description": "URL is the address being proxied to inside the workspace.\nIf external is specified, this will be opened on the client.", "type": "string" diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 4b49c385c80f4..22218aaf83cc4 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -149,7 +149,7 @@ func (api *API) provisionerJobResources(rw http.ResponseWriter, r *http.Request, } apiAgent, err := convertWorkspaceAgent( - api.DERPMap(), *api.TailnetCoordinator.Load(), agent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout, + api.DERPMap(), *api.TailnetCoordinator.Load(), agent, convertProvisionedApps(dbApps), api.AgentInactiveDisconnectTimeout, api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), ) if err != nil { diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index acff82c7f135d..789ce0a74244d 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -64,8 +64,42 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) { }) return } + + resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace resource.", + Detail: err.Error(), + }) + return + } + build, err := api.Database.GetWorkspaceBuildByJobID(ctx, resource.JobID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace build.", + Detail: err.Error(), + }) + return + } + workspace, err := api.Database.GetWorkspaceByID(ctx, build.WorkspaceID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace.", + Detail: err.Error(), + }) + return + } + owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace owner.", + Detail: err.Error(), + }) + return + } + apiAgent, err := convertWorkspaceAgent( - api.DERPMap(), *api.TailnetCoordinator.Load(), workspaceAgent, convertApps(dbApps), api.AgentInactiveDisconnectTimeout, + api.DERPMap(), *api.TailnetCoordinator.Load(), workspaceAgent, convertApps(dbApps, workspaceAgent, owner, workspace), api.AgentInactiveDisconnectTimeout, api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), ) if err != nil { @@ -165,7 +199,7 @@ func (api *API) workspaceAgentManifest(rw http.ResponseWriter, r *http.Request) httpapi.Write(ctx, rw, http.StatusOK, agentsdk.Manifest{ AgentID: apiAgent.ID, - Apps: convertApps(dbApps), + Apps: convertApps(dbApps, workspaceAgent, owner, workspace), DERPMap: api.DERPMap(), DERPForceWebSockets: api.DeploymentValues.DERP.Config.ForceWebSockets.Value(), GitAuthConfigs: len(api.GitAuthConfigs), @@ -1281,19 +1315,40 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R } } -func convertApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp { +// convertProvisionedApps converts applications that are in the middle of provisioning process. +// It means that they may not have an agent or workspace assigned (dry-run job). +func convertProvisionedApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp { + return convertApps(dbApps, database.WorkspaceAgent{}, database.User{}, database.Workspace{}) +} + +func convertApps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, owner database.User, workspace database.Workspace) []codersdk.WorkspaceApp { apps := make([]codersdk.WorkspaceApp, 0) for _, dbApp := range dbApps { + var subdomainName string + if dbApp.Subdomain && agent.Name != "" && owner.Username != "" && workspace.Name != "" { + appSlug := dbApp.Slug + if appSlug == "" { + appSlug = dbApp.DisplayName + } + subdomainName = httpapi.ApplicationURL{ + AppSlugOrPort: appSlug, + AgentName: agent.Name, + WorkspaceName: workspace.Name, + Username: owner.Username, + }.String() + } + apps = append(apps, codersdk.WorkspaceApp{ - ID: dbApp.ID, - URL: dbApp.Url.String, - External: dbApp.External, - Slug: dbApp.Slug, - DisplayName: dbApp.DisplayName, - Command: dbApp.Command.String, - Icon: dbApp.Icon, - Subdomain: dbApp.Subdomain, - SharingLevel: codersdk.WorkspaceAppSharingLevel(dbApp.SharingLevel), + ID: dbApp.ID, + URL: dbApp.Url.String, + External: dbApp.External, + Slug: dbApp.Slug, + DisplayName: dbApp.DisplayName, + Command: dbApp.Command.String, + Icon: dbApp.Icon, + Subdomain: dbApp.Subdomain, + SubdomainName: subdomainName, + SharingLevel: codersdk.WorkspaceAppSharingLevel(dbApp.SharingLevel), Healthcheck: codersdk.Healthcheck{ URL: dbApp.HealthcheckUrl, Interval: dbApp.HealthcheckInterval, diff --git a/coderd/workspaceapps/apptest/setup.go b/coderd/workspaceapps/apptest/setup.go index 4dc8cd57eebca..3175f311dbccd 100644 --- a/coderd/workspaceapps/apptest/setup.go +++ b/coderd/workspaceapps/apptest/setup.go @@ -291,6 +291,37 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U } appURL := fmt.Sprintf("%s://127.0.0.1:%d?%s", scheme, port, proxyTestAppQuery) + protoApps := []*proto.App{ + { + Slug: proxyTestAppNameFake, + DisplayName: proxyTestAppNameFake, + SharingLevel: proto.AppSharingLevel_OWNER, + // Hopefully this IP and port doesn't exist. + Url: "http://127.1.0.1:65535", + Subdomain: true, + }, + { + Slug: proxyTestAppNameOwner, + DisplayName: proxyTestAppNameOwner, + SharingLevel: proto.AppSharingLevel_OWNER, + Url: appURL, + Subdomain: true, + }, + { + Slug: proxyTestAppNameAuthenticated, + DisplayName: proxyTestAppNameAuthenticated, + SharingLevel: proto.AppSharingLevel_AUTHENTICATED, + Url: appURL, + Subdomain: true, + }, + { + Slug: proxyTestAppNamePublic, + DisplayName: proxyTestAppNamePublic, + SharingLevel: proto.AppSharingLevel_PUBLIC, + Url: appURL, + Subdomain: true, + }, + } version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{ Parse: echo.ParseComplete, ProvisionPlan: echo.PlanComplete, @@ -306,33 +337,7 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U Auth: &proto.Agent_Token{ Token: authToken, }, - Apps: []*proto.App{ - { - Slug: proxyTestAppNameFake, - DisplayName: proxyTestAppNameFake, - SharingLevel: proto.AppSharingLevel_OWNER, - // Hopefully this IP and port doesn't exist. - Url: "http://127.1.0.1:65535", - }, - { - Slug: proxyTestAppNameOwner, - DisplayName: proxyTestAppNameOwner, - SharingLevel: proto.AppSharingLevel_OWNER, - Url: appURL, - }, - { - Slug: proxyTestAppNameAuthenticated, - DisplayName: proxyTestAppNameAuthenticated, - SharingLevel: proto.AppSharingLevel_AUTHENTICATED, - Url: appURL, - }, - { - Slug: proxyTestAppNamePublic, - DisplayName: proxyTestAppNamePublic, - SharingLevel: proto.AppSharingLevel_PUBLIC, - Url: appURL, - }, - }, + Apps: protoApps, }}, }}, }, @@ -342,7 +347,22 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U template := coderdtest.CreateTemplate(t, client, orgID, version.ID) coderdtest.AwaitTemplateVersionJob(t, client, version.ID) workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID, workspaceMutators...) - coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + workspaceBuild := coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // Verify app subdomains + for _, app := range workspaceBuild.Resources[0].Agents[0].Apps { + require.True(t, app.Subdomain) + + appURL := httpapi.ApplicationURL{ + // findProtoApp is needed as the order of apps returned from PG database + // is not guaranteed. + AppSlugOrPort: findProtoApp(t, protoApps, app.Slug).Slug, + AgentName: proxyTestAgentName, + WorkspaceName: workspace.Name, + Username: me.Username, + } + require.Equal(t, appURL.String(), app.SubdomainName) + } agentClient := agentsdk.New(client.URL) agentClient.SetSessionToken(authToken) @@ -388,6 +408,16 @@ func createWorkspaceWithApps(t *testing.T, client *codersdk.Client, orgID uuid.U return workspace, agents[0] } +func findProtoApp(t *testing.T, protoApps []*proto.App, slug string) *proto.App { + for _, protoApp := range protoApps { + if protoApp.Slug == slug { + return protoApp + } + } + require.FailNowf(t, "proto app not found (slug: %q)", slug) + return nil +} + func doWithRetries(t require.TestingT, client *codersdk.Client, req *http.Request) (*http.Response, error) { var resp *http.Response var err error diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 99bf36a33313e..6081d07580c61 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -832,7 +832,7 @@ func (api *API) convertWorkspaceBuild( for _, agent := range agents { apps := appsByAgentID[agent.ID] apiAgent, err := convertWorkspaceAgent( - api.DERPMap(), *api.TailnetCoordinator.Load(), agent, convertApps(apps), api.AgentInactiveDisconnectTimeout, + api.DERPMap(), *api.TailnetCoordinator.Load(), agent, convertApps(apps, agent, owner, workspace), api.AgentInactiveDisconnectTimeout, api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), ) if err != nil { diff --git a/codersdk/workspaceapps.go b/codersdk/workspaceapps.go index ba2e7255888cc..9c8d89b42f65a 100644 --- a/codersdk/workspaceapps.go +++ b/codersdk/workspaceapps.go @@ -41,8 +41,10 @@ type WorkspaceApp struct { // `coder server` or via a hostname-based dev URL. If this is set to true // and there is no app wildcard configured on the server, the app will not // be accessible in the UI. - Subdomain bool `json:"subdomain"` - SharingLevel WorkspaceAppSharingLevel `json:"sharing_level" enums:"owner,authenticated,public"` + Subdomain bool `json:"subdomain"` + // SubdomainName is the application domain exposed on the `coder server`. + SubdomainName string `json:"subdomain_name,omitempty"` + SharingLevel WorkspaceAppSharingLevel `json:"sharing_level" enums:"owner,authenticated,public"` // Healthcheck specifies the configuration for checking app health. Healthcheck Healthcheck `json:"healthcheck"` Health WorkspaceAppHealth `json:"health"` diff --git a/docs/api/agents.md b/docs/api/agents.md index 7bd27794bb03f..5edaeb5604ebc 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -389,6 +389,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/manifest \ "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -658,6 +659,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent} \ "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], diff --git a/docs/api/builds.md b/docs/api/builds.md index 40575dcffd498..46a825d92c0bc 100644 --- a/docs/api/builds.md +++ b/docs/api/builds.md @@ -74,6 +74,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -237,6 +238,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -539,6 +541,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/res "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -640,6 +643,7 @@ Status Code **200** | `»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»» slug` | string | false | | Slug is a unique identifier within the agent. | | `»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | +| `»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | | `»» architecture` | string | false | | | | `»» connection_timeout_seconds` | integer | false | | | @@ -798,6 +802,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -966,6 +971,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -1103,6 +1109,7 @@ Status Code **200** | `»»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»»» slug` | string | false | | Slug is a unique identifier within the agent. | | `»»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | +| `»»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | | `»»» architecture` | string | false | | | | `»»» connection_timeout_seconds` | integer | false | | | @@ -1314,6 +1321,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 4cf60a773f81d..5fb5a846cb86f 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -198,6 +198,7 @@ "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -5438,6 +5439,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -5581,6 +5583,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -5939,25 +5942,27 @@ If the schedule is empty, the user will be updated to use the default schedule.| "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| --------------- | ---------------------------------------------------------------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `command` | string | false | | | -| `display_name` | string | false | | Display name is a friendly name for the app. | -| `external` | boolean | false | | External specifies whether the URL should be opened externally on the client or not. | -| `health` | [codersdk.WorkspaceAppHealth](#codersdkworkspaceapphealth) | false | | | -| `healthcheck` | [codersdk.Healthcheck](#codersdkhealthcheck) | false | | Healthcheck specifies the configuration for checking app health. | -| `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | -| `id` | string | false | | | -| `sharing_level` | [codersdk.WorkspaceAppSharingLevel](#codersdkworkspaceappsharinglevel) | false | | | -| `slug` | string | false | | Slug is a unique identifier within the agent. | -| `subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | -| `url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | +| Name | Type | Required | Restrictions | Description | +| ---------------- | ---------------------------------------------------------------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `command` | string | false | | | +| `display_name` | string | false | | Display name is a friendly name for the app. | +| `external` | boolean | false | | External specifies whether the URL should be opened externally on the client or not. | +| `health` | [codersdk.WorkspaceAppHealth](#codersdkworkspaceapphealth) | false | | | +| `healthcheck` | [codersdk.Healthcheck](#codersdkhealthcheck) | false | | Healthcheck specifies the configuration for checking app health. | +| `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | +| `id` | string | false | | | +| `sharing_level` | [codersdk.WorkspaceAppSharingLevel](#codersdkworkspaceappsharinglevel) | false | | | +| `slug` | string | false | | Slug is a unique identifier within the agent. | +| `subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | +| `subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | +| `url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | #### Enumerated Values @@ -6051,6 +6056,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -6363,6 +6369,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -6577,6 +6584,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], diff --git a/docs/api/templates.md b/docs/api/templates.md index 85c826279a30a..2e5eac3abe7f2 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -1585,6 +1585,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/d "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -1686,6 +1687,7 @@ Status Code **200** | `»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»» slug` | string | false | | Slug is a unique identifier within the agent. | | `»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | +| `»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | | `»» architecture` | string | false | | | | `»» connection_timeout_seconds` | integer | false | | | @@ -1978,6 +1980,7 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/r "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -2079,6 +2082,7 @@ Status Code **200** | `»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»» slug` | string | false | | Slug is a unique identifier within the agent. | | `»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | +| `»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | | `»» architecture` | string | false | | | | `»» connection_timeout_seconds` | integer | false | | | diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index fd9bb1c817a83..f11f2bda3973e 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -104,6 +104,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -294,6 +295,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -483,6 +485,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -674,6 +677,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], @@ -944,6 +948,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "sharing_level": "owner", "slug": "string", "subdomain": true, + "subdomain_name": "string", "url": "string" } ], diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f7511276a081e..90398957fa91c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1416,6 +1416,7 @@ export interface WorkspaceApp { readonly command?: string; readonly icon?: string; readonly subdomain: boolean; + readonly subdomain_name?: string; readonly sharing_level: WorkspaceAppSharingLevel; readonly healthcheck: Healthcheck; readonly health: WorkspaceAppHealth; diff --git a/site/src/components/Resources/AppLink/AppLink.stories.tsx b/site/src/components/Resources/AppLink/AppLink.stories.tsx index 9229ca8f1762b..4e71c244b5e63 100644 --- a/site/src/components/Resources/AppLink/AppLink.stories.tsx +++ b/site/src/components/Resources/AppLink/AppLink.stories.tsx @@ -18,10 +18,13 @@ const meta: Meta = { @@ -40,24 +41,16 @@ export const AppLink: FC = ({ app, workspace, agent }) => { appDisplayName = appSlug; } - // The backend redirects if the trailing slash isn't included, so we add it - // here to avoid extra roundtrips. - let href = `${preferredPathBase}/@${username}/${workspace.name}.${ - agent.name - }/apps/${encodeURIComponent(appSlug)}/`; - if (app.command) { - href = `${preferredPathBase}/@${username}/${workspace.name}.${ - agent.name - }/terminal?command=${encodeURIComponent(app.command)}`; - } - - if (appsHost && app.subdomain) { - const subdomain = `${appSlug}--${agent.name}--${workspace.name}--${username}`; - href = `${window.location.protocol}//${appsHost}/`.replace("*", subdomain); - } - if (app.external) { - href = app.url; - } + const href = createAppLinkHref( + window.location.protocol, + preferredPathBase, + appsHost, + appSlug, + username, + workspace, + agent, + app, + ); let canClick = true; let icon = ; diff --git a/site/src/utils/apps.test.ts b/site/src/utils/apps.test.ts new file mode 100644 index 0000000000000..9e188efb2af35 --- /dev/null +++ b/site/src/utils/apps.test.ts @@ -0,0 +1,103 @@ +import { createAppLinkHref } from "./apps"; +import { + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceApp, +} from "testHelpers/entities"; + +describe("create app link", () => { + it("with external URL", () => { + const externalURL = "https://external-url.tld"; + const href = createAppLinkHref( + "http:", + "/path-base", + "*.apps-host.tld", + "app-slug", + "username", + MockWorkspace, + MockWorkspaceAgent, + { + ...MockWorkspaceApp, + external: true, + url: externalURL, + }, + ); + expect(href).toBe(externalURL); + }); + + it("without subdomain", () => { + const href = createAppLinkHref( + "http:", + "/path-base", + "*.apps-host.tld", + "app-slug", + "username", + MockWorkspace, + MockWorkspaceAgent, + { + ...MockWorkspaceApp, + subdomain: false, + }, + ); + expect(href).toBe( + "/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/", + ); + }); + + it("with command", () => { + const href = createAppLinkHref( + "https:", + "/path-base", + "*.apps-host.tld", + "app-slug", + "username", + MockWorkspace, + MockWorkspaceAgent, + { + ...MockWorkspaceApp, + command: "ls -la", + }, + ); + expect(href).toBe( + "/path-base/@username/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la", + ); + }); + + it("with subdomain", () => { + const href = createAppLinkHref( + "ftps:", + "/path-base", + "*.apps-host.tld", + "app-slug", + "username", + MockWorkspace, + MockWorkspaceAgent, + { + ...MockWorkspaceApp, + subdomain: true, + subdomain_name: "hellocoder", + }, + ); + expect(href).toBe("ftps://hellocoder.apps-host.tld/"); + }); + + it("with subdomain, but not apps host", () => { + const href = createAppLinkHref( + "ftps:", + "/path-base", + "", + "app-slug", + "username", + MockWorkspace, + MockWorkspaceAgent, + { + ...MockWorkspaceApp, + subdomain: true, + subdomain_name: "hellocoder", + }, + ); + expect(href).toBe( + "/path-base/@username/Test-Workspace.a-workspace-agent/apps/app-slug/", + ); + }); +}); diff --git a/site/src/utils/apps.ts b/site/src/utils/apps.ts new file mode 100644 index 0000000000000..fafa17ac82959 --- /dev/null +++ b/site/src/utils/apps.ts @@ -0,0 +1,32 @@ +import * as TypesGen from "../api/typesGenerated"; + +export const createAppLinkHref = ( + protocol: string, + preferredPathBase: string, + appsHost: string, + appSlug: string, + username: string, + workspace: TypesGen.Workspace, + agent: TypesGen.WorkspaceAgent, + app: TypesGen.WorkspaceApp, +): string => { + if (app.external) { + return app.url; + } + + // The backend redirects if the trailing slash isn't included, so we add it + // here to avoid extra roundtrips. + let href = `${preferredPathBase}/@${username}/${workspace.name}.${ + agent.name + }/apps/${encodeURIComponent(appSlug)}/`; + if (app.command) { + href = `${preferredPathBase}/@${username}/${workspace.name}.${ + agent.name + }/terminal?command=${encodeURIComponent(app.command)}`; + } + + if (appsHost && app.subdomain && app.subdomain_name) { + href = `${protocol}//${appsHost}/`.replace("*", app.subdomain_name); + } + return href; +};