mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
`coder port-forward` reported usage with the deprecated `UpdateWorkspaceUsageContext`. It now reports a `port_forwarding` session, like `coder ssh` and the VS Code SSH path, but only while a forwarded TCP connection is open. **Behavior change:** CLI output is unchanged. Port-forward sessions previously only bumped the workspace's last-used time. With the hidden `workspace-usage` experiment enabled, the server now also records `port_forwarding` session counts under a new `port_forwarding` family, which the deployment banner totals under **Other**. Without the experiment, `postWorkspaceUsage` returns 204 before reading the body, so nothing changes. <details> <summary>Decisions</summary> - Added `codersdk.UsageAppNamePortForwarding` (`port_forwarding`) rather than reusing `ssh`, so port-forwarding isn't counted as interactive SSH. The name matches the arbitrary app names RFC's connection-method name. - Registered a `port_forwarding` family in `codersdk/appname.go`. Families resolve at read time, so no data migration is needed. Template insights list apps individually; telemetry, metricscache, and agentstats gauges are legacy fixed fields and stay as they are. The Prometheus insights family is left to #29906. - Only TCP connections count. UDP forwards never see EOF, so one datagram would keep the session active until exit. - Reports once at startup, like the old call, then every minute and once on shutdown. An idle tick sends an empty body, which still bumps last-used. </details> Closes [CLI-31](https://linear.app/codercom/issue/CLI-31) _This PR was generated by Coder Agents on behalf of @nickvigilante._ <details> <summary>CI note: unrelated test-go-race-pg flake</summary> The `test-go-race-pg` job failed on a data race between `tailnet.(*configMaps).updatePeerLocked` and slog formatting of the same peer value, which does not touch port-forward code. The job was rerun. </details>
361 lines
13 KiB
Go
361 lines
13 KiB
Go
package codersdk_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"path"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// Duplicated from the package so the limit stays unexported.
|
|
const maxAppNameLength = 64
|
|
|
|
func TestNormalizeAppName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{"NormalizedPassthrough", "vscode", "vscode"},
|
|
{"KnownNameCaseInsensitive", "JetBrains", "jetbrains"},
|
|
{"LegacyAlias", "reconnecting-pty", "reconnecting_pty"},
|
|
{"HyphenatedKnownName", "vscode-insiders", "vscode_insiders"},
|
|
{"UnknownHyphensFolded", "my-future-ide", "my_future_ide"},
|
|
{"UnknownLowercased", "Cursor Nightly", "cursor nightly"},
|
|
{"UnknownPreservesUnicode", "エディタ", "エディタ"},
|
|
{"StripsNullBytes", "cur\x00sor", "cursor"},
|
|
{"StripsControlCharacters", "vs\ncode\r\t", "vscode"},
|
|
// Only the escape byte itself is a control character, so the rest of
|
|
// an ANSI sequence survives as ordinary text.
|
|
{"StripsANSIEscape", "\x1b[31mvscode\x1b[0m", "[31mvscode[0m"},
|
|
{"OnlyControlCharacters", "\n\r\t\x1b", "unknown"},
|
|
{"TrimsWhitespace", " vscode\t", "vscode"},
|
|
{"Empty", "", "unknown"},
|
|
{"OnlyNullBytes", "\x00\x00", "unknown"},
|
|
{"OnlyWhitespace", " ", "unknown"},
|
|
{"TruncatesToMaxRunes", strings.Repeat("a", maxAppNameLength+10), strings.Repeat("a", maxAppNameLength)},
|
|
{"TruncatesMultibyteSafely", strings.Repeat("あ", maxAppNameLength+1), strings.Repeat("あ", maxAppNameLength)},
|
|
// Padding is trimmed before truncating, so it does not spend the budget.
|
|
{"TrimsPaddingBeforeTruncating", " " + strings.Repeat("a", maxAppNameLength) + " ", strings.Repeat("a", maxAppNameLength)},
|
|
{"TrimsWhitespaceLeftByTruncation", strings.Repeat("a", 60) + " tail", strings.Repeat("a", 60)},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, tc.want, codersdk.NormalizeAppName(tc.input))
|
|
})
|
|
}
|
|
}
|
|
|
|
// The usage-app vocabulary is declared independently of the normalized app
|
|
// names because its values are part of the HTTP API. This pins the two
|
|
// together so they cannot drift.
|
|
func TestUsageAppNamesAreNormalized(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for sdkName, want := range map[codersdk.UsageAppName]string{
|
|
codersdk.UsageAppNameVscode: "vscode",
|
|
codersdk.UsageAppNameJetbrains: "jetbrains",
|
|
codersdk.UsageAppNameReconnectingPty: "reconnecting_pty",
|
|
codersdk.UsageAppNameSSH: "ssh",
|
|
} {
|
|
require.Equal(t, want, codersdk.NormalizeAppName(string(sdkName)))
|
|
}
|
|
}
|
|
|
|
func TestAppNameFamily(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
input string
|
|
want codersdk.AppFamilyName
|
|
}{
|
|
{"VSCode", "vscode", codersdk.AppFamilyVSCode},
|
|
{"VSCodeFork", "cursor", codersdk.AppFamilyVSCode},
|
|
{"VSCodeInsidersHyphenated", "vscode-insiders", codersdk.AppFamilyVSCode},
|
|
{"CaseInsensitive", "JetBrains", codersdk.AppFamilyJetBrains},
|
|
{"SSHClientJoinsSSHFamily", "zed", codersdk.AppFamilySSH},
|
|
{"Alias", "reconnecting-pty", codersdk.AppFamilyReconnectingPTY},
|
|
{"PortForwarding", "port_forwarding", codersdk.AppFamilyPortForwarding},
|
|
{"Unknown", "SomeFutureIDE", codersdk.AppFamilyUnknown},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, tc.want, codersdk.AppNameFamily(tc.input))
|
|
})
|
|
}
|
|
}
|
|
|
|
// IDE clients report these names, so each must resolve to a registry key in
|
|
// its family. The VS Code extension sends vscode.env.uriScheme.
|
|
func TestIDEAppNamesAreRegistered(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
reported string
|
|
appName string
|
|
family codersdk.AppFamilyName
|
|
displayName string
|
|
}{
|
|
{"vscode", "vscode", codersdk.AppFamilyVSCode, "VS Code"},
|
|
{"vscode-insiders", "vscode_insiders", codersdk.AppFamilyVSCode, "VS Code Insiders"},
|
|
{"code-oss", "code_oss", codersdk.AppFamilyVSCode, "Code - OSS"},
|
|
{"vscodium", "vscodium", codersdk.AppFamilyVSCode, "VSCodium"},
|
|
{"vscodium-insiders", "vscodium_insiders", codersdk.AppFamilyVSCode, "VSCodium Insiders"},
|
|
{"positron", "positron", codersdk.AppFamilyVSCode, "Positron"},
|
|
{"cursor", "cursor", codersdk.AppFamilyVSCode, "Cursor"},
|
|
{"devin", "devin", codersdk.AppFamilyVSCode, "Devin Desktop"},
|
|
{"devin-next", "devin_next", codersdk.AppFamilyVSCode, "Devin Desktop Next"},
|
|
{"windsurf", "windsurf", codersdk.AppFamilyVSCode, "Windsurf"},
|
|
{"antigravity", "antigravity", codersdk.AppFamilyVSCode, "Antigravity"},
|
|
{"trae", "trae", codersdk.AppFamilyVSCode, "Trae"},
|
|
{"trae-cn", "trae_cn", codersdk.AppFamilyVSCode, "Trae CN"},
|
|
{"kiro", "kiro", codersdk.AppFamilyVSCode, "Kiro"},
|
|
// The JetBrains plugins map the IDE's product code to one name per IDE.
|
|
{"intellij", "intellij", codersdk.AppFamilyJetBrains, "IntelliJ IDEA"},
|
|
{"pycharm", "pycharm", codersdk.AppFamilyJetBrains, "PyCharm"},
|
|
{"goland", "goland", codersdk.AppFamilyJetBrains, "GoLand"},
|
|
{"webstorm", "webstorm", codersdk.AppFamilyJetBrains, "WebStorm"},
|
|
{"phpstorm", "phpstorm", codersdk.AppFamilyJetBrains, "PhpStorm"},
|
|
{"rubymine", "rubymine", codersdk.AppFamilyJetBrains, "RubyMine"},
|
|
{"clion", "clion", codersdk.AppFamilyJetBrains, "CLion"},
|
|
{"rider", "rider", codersdk.AppFamilyJetBrains, "Rider"},
|
|
{"rustrover", "rustrover", codersdk.AppFamilyJetBrains, "RustRover"},
|
|
{"datagrip", "datagrip", codersdk.AppFamilyJetBrains, "DataGrip"},
|
|
{"dataspell", "dataspell", codersdk.AppFamilyJetBrains, "DataSpell"},
|
|
{"mps", "mps", codersdk.AppFamilyJetBrains, "MPS"},
|
|
{"android_studio", "android_studio", codersdk.AppFamilyJetBrains, "Android Studio"},
|
|
} {
|
|
t.Run(tc.reported, func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, tc.appName, codersdk.NormalizeAppName(tc.reported))
|
|
require.Equal(t, tc.family, codersdk.AppNameFamily(tc.reported))
|
|
app := codersdk.SessionCountApps(map[string]int64{tc.appName: 1})[tc.appName]
|
|
require.Equal(t, tc.displayName, app.DisplayName)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Family sets are derived from the one registry, so callers that need the app
|
|
// names in a family do not need a second list.
|
|
func TestRegistryFamilySets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
registry := codersdk.SessionCountAppFamilies()
|
|
inFamily := func(want codersdk.AppFamilyName) []string {
|
|
var names []string
|
|
for appName, family := range registry {
|
|
if family == want {
|
|
names = append(names, appName)
|
|
}
|
|
}
|
|
slices.Sort(names)
|
|
return names
|
|
}
|
|
|
|
// Forks share the VS Code family.
|
|
vscode := inFamily(codersdk.AppFamilyVSCode)
|
|
require.Contains(t, vscode, "cursor")
|
|
require.Contains(t, vscode, "vscode")
|
|
|
|
// Zed speaks SSH, so it reports under the SSH family.
|
|
require.Equal(t, []string{"ssh", "zed"}, inFamily(codersdk.AppFamilySSH))
|
|
|
|
require.Empty(t, inFamily("no_such_family"))
|
|
}
|
|
|
|
func TestSessionCountAppFamilies(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
registry := codersdk.SessionCountAppFamilies()
|
|
require.NotEmpty(t, registry)
|
|
for appName, family := range registry {
|
|
require.Equal(t, family, codersdk.AppNameFamily(appName),
|
|
"registry entry %q must agree with lookup", appName)
|
|
}
|
|
|
|
// The registry is a copy, so a caller cannot corrupt attribution.
|
|
registry["cursor"] = codersdk.AppFamilySSH
|
|
require.Equal(t, codersdk.AppFamilyVSCode, codersdk.AppNameFamily("cursor"))
|
|
require.Equal(t, codersdk.AppFamilyVSCode, codersdk.SessionCountAppFamilies()["cursor"])
|
|
}
|
|
|
|
func TestSumByFamily(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
appCounts map[string]int64
|
|
want map[codersdk.AppFamilyName]int64
|
|
}{
|
|
{
|
|
name: "Empty",
|
|
appCounts: map[string]int64{},
|
|
want: map[codersdk.AppFamilyName]int64{},
|
|
},
|
|
{
|
|
name: "Nil",
|
|
appCounts: nil,
|
|
want: map[codersdk.AppFamilyName]int64{},
|
|
},
|
|
{
|
|
name: "SingleApp",
|
|
appCounts: map[string]int64{"jetbrains": 3},
|
|
want: map[codersdk.AppFamilyName]int64{codersdk.AppFamilyJetBrains: 3},
|
|
},
|
|
{
|
|
// Simultaneous forks are additive within their family.
|
|
name: "OverlappingAppsInOneFamily",
|
|
appCounts: map[string]int64{"vscode": 1, "cursor": 2, "windsurf": 4},
|
|
want: map[codersdk.AppFamilyName]int64{codersdk.AppFamilyVSCode: 7},
|
|
},
|
|
{
|
|
name: "UnregisteredAppIsUnknown",
|
|
appCounts: map[string]int64{"some_future_ide": 5, "ssh": 1},
|
|
want: map[codersdk.AppFamilyName]int64{
|
|
codersdk.AppFamilyUnknown: 5,
|
|
codersdk.AppFamilySSH: 1,
|
|
},
|
|
},
|
|
{
|
|
// Storage normalizes app names, but folding must not depend on it.
|
|
name: "DenormalizedNamesFold",
|
|
appCounts: map[string]int64{"VSCode-Insiders": 2, "vscode_insiders": 1},
|
|
want: map[codersdk.AppFamilyName]int64{codersdk.AppFamilyVSCode: 3},
|
|
},
|
|
{
|
|
name: "AllFamilies",
|
|
appCounts: map[string]int64{"vscode": 1, "jetbrains": 2, "zed": 3, "reconnecting_pty": 4, "": 5},
|
|
want: map[codersdk.AppFamilyName]int64{
|
|
codersdk.AppFamilyVSCode: 1,
|
|
codersdk.AppFamilyJetBrains: 2,
|
|
codersdk.AppFamilySSH: 3,
|
|
codersdk.AppFamilyReconnectingPTY: 4,
|
|
codersdk.AppFamilyUnknown: 5,
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, tc.want, codersdk.SumByFamily(tc.appCounts))
|
|
})
|
|
}
|
|
}
|
|
|
|
// A family is registered by adding app names alone, with no SQL, column, or
|
|
// second list to update.
|
|
func TestSumByFamilyCoversEveryRegisteredFamily(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
appCounts := map[string]int64{}
|
|
want := map[codersdk.AppFamilyName]int64{}
|
|
for appName, family := range codersdk.SessionCountAppFamilies() {
|
|
appCounts[appName] = 1
|
|
want[family]++
|
|
}
|
|
require.Equal(t, want, codersdk.SumByFamily(appCounts))
|
|
}
|
|
|
|
func TestDecodeAppMap(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, raw := range map[string]json.RawMessage{
|
|
"NotJSON": json.RawMessage(`{`),
|
|
"NotAnObject": json.RawMessage(`[1, 2]`),
|
|
"WrongValue": json.RawMessage(`{"vscode": "sixty"}`),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// A malformed payload must not decode to zero usage, or an
|
|
// encoding bug would look like an idle deployment.
|
|
got, err := codersdk.DecodeAppMap[int64](raw)
|
|
require.Error(t, err)
|
|
require.Nil(t, got)
|
|
})
|
|
}
|
|
|
|
t.Run("UsageSeconds", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := codersdk.DecodeAppMap[int64](json.RawMessage(`{"cursor": 60}`))
|
|
require.NoError(t, err)
|
|
require.Equal(t, map[string]int64{"cursor": 60}, got)
|
|
})
|
|
|
|
// A query with no rows aggregates to SQL NULL: no usage, not an error.
|
|
for name, raw := range map[string]json.RawMessage{
|
|
"Absent": nil,
|
|
"EmptyObject": json.RawMessage(`{}`),
|
|
"JSONNull": json.RawMessage(`null`),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := codersdk.DecodeAppMap[int64](raw)
|
|
require.NoError(t, err)
|
|
require.Equal(t, map[string]int64{}, got)
|
|
require.Zero(t, got["ssh"])
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnionByFamily(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
shared, cursorOnly, sshOnly := uuid.New(), uuid.New(), uuid.New()
|
|
got := codersdk.UnionByFamily(map[string][]uuid.UUID{
|
|
"vscode": {shared},
|
|
"cursor": {shared, cursorOnly},
|
|
"ssh": {sshOnly},
|
|
// An app the registry does not know still lands somewhere.
|
|
"some_new_ide": {cursorOnly},
|
|
})
|
|
|
|
// A template both apps saw appears once for the family.
|
|
require.ElementsMatch(t, []uuid.UUID{shared, cursorOnly}, got[codersdk.AppFamilyVSCode])
|
|
require.Equal(t, []uuid.UUID{sshOnly}, got[codersdk.AppFamilySSH])
|
|
require.Equal(t, []uuid.UUID{cursorOnly}, got[codersdk.AppFamilyUnknown])
|
|
require.Empty(t, got[codersdk.AppFamilyJetBrains])
|
|
}
|
|
|
|
func TestSessionCountApps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
apps := codersdk.SessionCountApps(map[string]int64{"cursor": 2, "vscodium": 1, "future_ide": 3})
|
|
require.Equal(t, map[string]codersdk.SessionCountApp{
|
|
"cursor": {Count: 2, DisplayName: "Cursor", Icon: "/icon/cursor.svg", Family: codersdk.AppFamilyVSCode},
|
|
"vscodium": {Count: 1, DisplayName: "VSCodium", Family: codersdk.AppFamilyVSCode},
|
|
// An app the registry does not know shows its own name.
|
|
"future_ide": {Count: 3, DisplayName: "future_ide", Family: codersdk.AppFamilyUnknown},
|
|
}, apps)
|
|
require.Equal(t, map[string]codersdk.SessionCountApp{}, codersdk.SessionCountApps(nil))
|
|
}
|
|
|
|
func TestSessionCountAppIcons(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Every icon is a bundled, clean path, never an arbitrary URL.
|
|
for name := range codersdk.SessionCountAppFamilies() {
|
|
app := codersdk.SessionCountApps(map[string]int64{name: 1})[name]
|
|
require.NotEmpty(t, app.DisplayName, name)
|
|
if app.Icon == "" {
|
|
continue
|
|
}
|
|
require.True(t, strings.HasPrefix(app.Icon, "/icon/"), name)
|
|
// The icon is a URL path, so path.Clean holds on every OS where
|
|
// filepath.Clean would rewrite the separators.
|
|
require.Equal(t, path.Clean(app.Icon), app.Icon)
|
|
require.FileExists(t, filepath.Join("..", "site", "static", app.Icon), "icon for %s must be bundled", name)
|
|
}
|
|
}
|