mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
Addresses https://github.com/coder/internal/issues/317. ## Changes Requirements are quoted below: > how many orgs does deployment have Adds the Organization entity to telemetry. > ensuring resources are associated with orgs All resources that reference an org already report the org id to telemetry. Adds a test to check that. > whether org sync is configured Adds the `IDPOrgSync` boolean field to the Deployment entity. ## Implementation of the org sync check While there's an `OrganizationSyncEnabled` method on the IDPSync interface, I decided not to use it directly and implemented a counterpart just for telemetry purposes. It's a compromise I'm not happy about, but I found that it's a simpler approach than the alternative. There are multiple reasons: 1. The telemetry package cannot statically access the IDPSync interface due to a circular import. 2. We can't dynamically pass a reference to the `OrganizationSyncEnabled` function at the time of instantiating the telemetry object, because our server initialization logic depends on the telemetry object being created before the IDPSync object. 3. If we circumvent that problem by passing the reference as an initially empty pointer, initializing telemetry, then IDPSync, then updating the pointer to point to `OrganizationSyncEnabled`, we have to refactor the initialization logic of the telemetry object itself to avoid a race condition where the first telemetry report is performed without a valid reference. I actually implemented that approach in https://github.com/coder/coder/pull/16307, but realized I'm unable to fully test it. It changed the initialization order in the server command, and I wanted to test our CLI with Org Sync configured with a premium license. As far as I'm aware, we don't have the tooling to do that. I couldn't figure out a way to start the CLI with a mock license, and I didn't want to go down further into the refactoring rabbit hole. So I decided that reimplementing the org sync checking logic is simpler.
359 lines
14 KiB
Go
359 lines
14 KiB
Go
package telemetry_test
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/goleak"
|
|
|
|
"github.com/coder/coder/v2/buildinfo"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/database/dbmem"
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/idpsync"
|
|
"github.com/coder/coder/v2/coderd/runtimeconfig"
|
|
"github.com/coder/coder/v2/coderd/telemetry"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
|
|
}
|
|
|
|
func TestTelemetry(t *testing.T) {
|
|
t.Parallel()
|
|
t.Run("Snapshot", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var err error
|
|
|
|
db := dbmem.New()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
|
|
org, err := db.GetDefaultOrganization(ctx)
|
|
require.NoError(t, err)
|
|
|
|
_, _ = dbgen.APIKey(t, db, database.APIKey{})
|
|
_ = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
Provisioner: database.ProvisionerTypeTerraform,
|
|
StorageMethod: database.ProvisionerStorageMethodFile,
|
|
Type: database.ProvisionerJobTypeTemplateVersionDryRun,
|
|
OrganizationID: org.ID,
|
|
})
|
|
_ = dbgen.Template(t, db, database.Template{
|
|
Provisioner: database.ProvisionerTypeTerraform,
|
|
OrganizationID: org.ID,
|
|
})
|
|
sourceExampleID := uuid.NewString()
|
|
_ = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
SourceExampleID: sql.NullString{String: sourceExampleID, Valid: true},
|
|
OrganizationID: org.ID,
|
|
})
|
|
_ = dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
OrganizationID: org.ID,
|
|
})
|
|
user := dbgen.User(t, db, database.User{})
|
|
_ = dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OrganizationID: org.ID,
|
|
})
|
|
_ = dbgen.WorkspaceApp(t, db, database.WorkspaceApp{
|
|
SharingLevel: database.AppSharingLevelOwner,
|
|
Health: database.WorkspaceAppHealthDisabled,
|
|
OpenIn: database.WorkspaceAppOpenInSlimWindow,
|
|
})
|
|
group := dbgen.Group(t, db, database.Group{})
|
|
_ = dbgen.GroupMember(t, db, database.GroupMemberTable{UserID: user.ID, GroupID: group.ID})
|
|
wsagent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{})
|
|
// Update the workspace agent to have a valid subsystem.
|
|
err = db.UpdateWorkspaceAgentStartupByID(ctx, database.UpdateWorkspaceAgentStartupByIDParams{
|
|
ID: wsagent.ID,
|
|
Version: wsagent.Version,
|
|
ExpandedDirectory: wsagent.ExpandedDirectory,
|
|
Subsystems: []database.WorkspaceAgentSubsystem{
|
|
database.WorkspaceAgentSubsystemEnvbox,
|
|
database.WorkspaceAgentSubsystemExectrace,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
|
Transition: database.WorkspaceTransitionStart,
|
|
Reason: database.BuildReasonAutostart,
|
|
})
|
|
_ = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{
|
|
Transition: database.WorkspaceTransitionStart,
|
|
})
|
|
_ = dbgen.WorkspaceAgentStat(t, db, database.WorkspaceAgentStat{})
|
|
_, err = db.InsertLicense(ctx, database.InsertLicenseParams{
|
|
UploadedAt: dbtime.Now(),
|
|
JWT: "",
|
|
Exp: dbtime.Now().Add(time.Hour),
|
|
UUID: uuid.New(),
|
|
})
|
|
assert.NoError(t, err)
|
|
_, _ = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{})
|
|
|
|
_ = dbgen.WorkspaceModule(t, db, database.WorkspaceModule{})
|
|
|
|
_, snapshot := collectSnapshot(t, db, nil)
|
|
require.Len(t, snapshot.ProvisionerJobs, 1)
|
|
require.Len(t, snapshot.Licenses, 1)
|
|
require.Len(t, snapshot.Templates, 1)
|
|
require.Len(t, snapshot.TemplateVersions, 2)
|
|
require.Len(t, snapshot.Users, 1)
|
|
require.Len(t, snapshot.Groups, 2)
|
|
// 1 member in the everyone group + 1 member in the custom group
|
|
require.Len(t, snapshot.GroupMembers, 2)
|
|
require.Len(t, snapshot.Workspaces, 1)
|
|
require.Len(t, snapshot.WorkspaceApps, 1)
|
|
require.Len(t, snapshot.WorkspaceAgents, 1)
|
|
require.Len(t, snapshot.WorkspaceBuilds, 1)
|
|
require.Len(t, snapshot.WorkspaceResources, 1)
|
|
require.Len(t, snapshot.WorkspaceAgentStats, 1)
|
|
require.Len(t, snapshot.WorkspaceProxies, 1)
|
|
require.Len(t, snapshot.WorkspaceModules, 1)
|
|
require.Len(t, snapshot.Organizations, 1)
|
|
|
|
wsa := snapshot.WorkspaceAgents[0]
|
|
require.Len(t, wsa.Subsystems, 2)
|
|
require.Equal(t, string(database.WorkspaceAgentSubsystemEnvbox), wsa.Subsystems[0])
|
|
require.Equal(t, string(database.WorkspaceAgentSubsystemExectrace), wsa.Subsystems[1])
|
|
|
|
tvs := snapshot.TemplateVersions
|
|
sort.Slice(tvs, func(i, j int) bool {
|
|
// Sort by SourceExampleID presence (non-nil comes before nil)
|
|
if (tvs[i].SourceExampleID != nil) != (tvs[j].SourceExampleID != nil) {
|
|
return tvs[i].SourceExampleID != nil
|
|
}
|
|
return false
|
|
})
|
|
require.Equal(t, tvs[0].SourceExampleID, &sourceExampleID)
|
|
require.Nil(t, tvs[1].SourceExampleID)
|
|
|
|
for _, entity := range snapshot.Workspaces {
|
|
require.Equal(t, entity.OrganizationID, org.ID)
|
|
}
|
|
for _, entity := range snapshot.ProvisionerJobs {
|
|
require.Equal(t, entity.OrganizationID, org.ID)
|
|
}
|
|
for _, entity := range snapshot.TemplateVersions {
|
|
require.Equal(t, entity.OrganizationID, org.ID)
|
|
}
|
|
for _, entity := range snapshot.Templates {
|
|
require.Equal(t, entity.OrganizationID, org.ID)
|
|
}
|
|
})
|
|
t.Run("HashedEmail", func(t *testing.T) {
|
|
t.Parallel()
|
|
db := dbmem.New()
|
|
_ = dbgen.User(t, db, database.User{
|
|
Email: "kyle@coder.com",
|
|
})
|
|
_, snapshot := collectSnapshot(t, db, nil)
|
|
require.Len(t, snapshot.Users, 1)
|
|
require.Equal(t, snapshot.Users[0].EmailHashed, "bb44bf07cf9a2db0554bba63a03d822c927deae77df101874496df5a6a3e896d@coder.com")
|
|
})
|
|
t.Run("HashedModule", func(t *testing.T) {
|
|
t.Parallel()
|
|
db, _ := dbtestutil.NewDB(t)
|
|
pj := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{})
|
|
_ = dbgen.WorkspaceModule(t, db, database.WorkspaceModule{
|
|
JobID: pj.ID,
|
|
Source: "registry.coder.com/terraform/aws",
|
|
Version: "1.0.0",
|
|
})
|
|
_ = dbgen.WorkspaceModule(t, db, database.WorkspaceModule{
|
|
JobID: pj.ID,
|
|
Source: "https://internal-url.com/some-module",
|
|
Version: "1.0.0",
|
|
})
|
|
_, snapshot := collectSnapshot(t, db, nil)
|
|
require.Len(t, snapshot.WorkspaceModules, 2)
|
|
modules := snapshot.WorkspaceModules
|
|
sort.Slice(modules, func(i, j int) bool {
|
|
return modules[i].Source < modules[j].Source
|
|
})
|
|
require.Equal(t, modules[0].Source, "ed662ec0396db67e77119f14afcb9253574cc925b04a51d4374bcb1eae299f5d")
|
|
require.Equal(t, modules[0].Version, "92521fc3cbd964bdc9f584a991b89fddaa5754ed1cc96d6d42445338669c1305")
|
|
require.Equal(t, modules[0].SourceType, telemetry.ModuleSourceTypeHTTP)
|
|
require.Equal(t, modules[1].Source, "registry.coder.com/terraform/aws")
|
|
require.Equal(t, modules[1].Version, "1.0.0")
|
|
require.Equal(t, modules[1].SourceType, telemetry.ModuleSourceTypeCoderRegistry)
|
|
})
|
|
t.Run("ModuleSourceType", func(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
source string
|
|
want telemetry.ModuleSourceType
|
|
}{
|
|
// Local relative paths
|
|
{source: "./modules/terraform-aws-vpc", want: telemetry.ModuleSourceTypeLocal},
|
|
{source: "../shared/modules/vpc", want: telemetry.ModuleSourceTypeLocal},
|
|
{source: " ./my-module ", want: telemetry.ModuleSourceTypeLocal}, // with whitespace
|
|
|
|
// Local absolute paths
|
|
{source: "/opt/terraform/modules/vpc", want: telemetry.ModuleSourceTypeLocalAbs},
|
|
{source: "/Users/dev/modules/app", want: telemetry.ModuleSourceTypeLocalAbs},
|
|
{source: "/etc/terraform/modules/network", want: telemetry.ModuleSourceTypeLocalAbs},
|
|
|
|
// Public registry
|
|
{source: "hashicorp/consul/aws", want: telemetry.ModuleSourceTypePublicRegistry},
|
|
{source: "registry.terraform.io/hashicorp/aws", want: telemetry.ModuleSourceTypePublicRegistry},
|
|
{source: "terraform-aws-modules/vpc/aws", want: telemetry.ModuleSourceTypePublicRegistry},
|
|
{source: "hashicorp/consul/aws//modules/consul-cluster", want: telemetry.ModuleSourceTypePublicRegistry},
|
|
{source: "hashicorp/co-nsul/aw_s//modules/consul-cluster", want: telemetry.ModuleSourceTypePublicRegistry},
|
|
|
|
// Private registry
|
|
{source: "app.terraform.io/company/vpc/aws", want: telemetry.ModuleSourceTypePrivateRegistry},
|
|
{source: "localterraform.com/org/module", want: telemetry.ModuleSourceTypePrivateRegistry},
|
|
{source: "APP.TERRAFORM.IO/test/module", want: telemetry.ModuleSourceTypePrivateRegistry}, // case insensitive
|
|
|
|
// Coder registry
|
|
{source: "registry.coder.com/terraform/aws", want: telemetry.ModuleSourceTypeCoderRegistry},
|
|
{source: "registry.coder.com/modules/base", want: telemetry.ModuleSourceTypeCoderRegistry},
|
|
{source: "REGISTRY.CODER.COM/test/module", want: telemetry.ModuleSourceTypeCoderRegistry}, // case insensitive
|
|
|
|
// GitHub
|
|
{source: "github.com/hashicorp/terraform-aws-vpc", want: telemetry.ModuleSourceTypeGitHub},
|
|
{source: "git::https://github.com/org/repo.git", want: telemetry.ModuleSourceTypeGitHub},
|
|
{source: "git::https://github.com/org/repo//modules/vpc", want: telemetry.ModuleSourceTypeGitHub},
|
|
|
|
// Bitbucket
|
|
{source: "bitbucket.org/hashicorp/terraform-aws-vpc", want: telemetry.ModuleSourceTypeBitbucket},
|
|
{source: "git::https://bitbucket.org/org/repo.git", want: telemetry.ModuleSourceTypeBitbucket},
|
|
{source: "https://bitbucket.org/org/repo//modules/vpc", want: telemetry.ModuleSourceTypeBitbucket},
|
|
|
|
// Generic Git
|
|
{source: "git::ssh://git.internal.com/repo.git", want: telemetry.ModuleSourceTypeGit},
|
|
{source: "git@gitlab.com:org/repo.git", want: telemetry.ModuleSourceTypeGit},
|
|
{source: "git::https://git.internal.com/repo.git?ref=v1.0.0", want: telemetry.ModuleSourceTypeGit},
|
|
|
|
// Mercurial
|
|
{source: "hg::https://example.com/vpc.hg", want: telemetry.ModuleSourceTypeMercurial},
|
|
{source: "hg::http://example.com/vpc.hg", want: telemetry.ModuleSourceTypeMercurial},
|
|
{source: "hg::ssh://example.com/vpc.hg", want: telemetry.ModuleSourceTypeMercurial},
|
|
|
|
// HTTP
|
|
{source: "https://example.com/vpc-module.zip", want: telemetry.ModuleSourceTypeHTTP},
|
|
{source: "http://example.com/modules/vpc", want: telemetry.ModuleSourceTypeHTTP},
|
|
{source: "https://internal.network/terraform/modules", want: telemetry.ModuleSourceTypeHTTP},
|
|
|
|
// S3
|
|
{source: "s3::https://s3-eu-west-1.amazonaws.com/bucket/vpc", want: telemetry.ModuleSourceTypeS3},
|
|
{source: "s3::https://bucket.s3.amazonaws.com/vpc", want: telemetry.ModuleSourceTypeS3},
|
|
{source: "s3::http://bucket.s3.amazonaws.com/vpc?version=1", want: telemetry.ModuleSourceTypeS3},
|
|
|
|
// GCS
|
|
{source: "gcs::https://www.googleapis.com/storage/v1/bucket/vpc", want: telemetry.ModuleSourceTypeGCS},
|
|
{source: "gcs::https://storage.googleapis.com/bucket/vpc", want: telemetry.ModuleSourceTypeGCS},
|
|
{source: "gcs::https://bucket.storage.googleapis.com/vpc", want: telemetry.ModuleSourceTypeGCS},
|
|
|
|
// Unknown
|
|
{source: "custom://example.com/vpc", want: telemetry.ModuleSourceTypeUnknown},
|
|
{source: "something-random", want: telemetry.ModuleSourceTypeUnknown},
|
|
{source: "", want: telemetry.ModuleSourceTypeUnknown},
|
|
}
|
|
for _, c := range cases {
|
|
require.Equal(t, c.want, telemetry.GetModuleSourceType(c.source))
|
|
}
|
|
})
|
|
t.Run("IDPOrgSync", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
db, _ := dbtestutil.NewDB(t)
|
|
|
|
// 1. No org sync settings
|
|
deployment, _ := collectSnapshot(t, db, nil)
|
|
require.False(t, *deployment.IDPOrgSync)
|
|
|
|
// 2. Org sync settings set in server flags
|
|
deployment, _ = collectSnapshot(t, db, func(opts telemetry.Options) telemetry.Options {
|
|
opts.DeploymentConfig = &codersdk.DeploymentValues{
|
|
OIDC: codersdk.OIDCConfig{
|
|
OrganizationField: "organizations",
|
|
},
|
|
}
|
|
return opts
|
|
})
|
|
require.True(t, *deployment.IDPOrgSync)
|
|
|
|
// 3. Org sync settings set in runtime config
|
|
org, err := db.GetDefaultOrganization(ctx)
|
|
require.NoError(t, err)
|
|
sync := idpsync.NewAGPLSync(testutil.Logger(t), runtimeconfig.NewManager(), idpsync.DeploymentSyncSettings{})
|
|
err = sync.UpdateOrganizationSettings(ctx, db, idpsync.OrganizationSyncSettings{
|
|
Field: "organizations",
|
|
Mapping: map[string][]uuid.UUID{
|
|
"first": {org.ID},
|
|
},
|
|
AssignDefault: true,
|
|
})
|
|
require.NoError(t, err)
|
|
deployment, _ = collectSnapshot(t, db, nil)
|
|
require.True(t, *deployment.IDPOrgSync)
|
|
})
|
|
}
|
|
|
|
// nolint:paralleltest
|
|
func TestTelemetryInstallSource(t *testing.T) {
|
|
t.Setenv("CODER_TELEMETRY_INSTALL_SOURCE", "aws_marketplace")
|
|
db := dbmem.New()
|
|
deployment, _ := collectSnapshot(t, db, nil)
|
|
require.Equal(t, "aws_marketplace", deployment.InstallSource)
|
|
}
|
|
|
|
func collectSnapshot(t *testing.T, db database.Store, addOptionsFn func(opts telemetry.Options) telemetry.Options) (*telemetry.Deployment, *telemetry.Snapshot) {
|
|
t.Helper()
|
|
deployment := make(chan *telemetry.Deployment, 64)
|
|
snapshot := make(chan *telemetry.Snapshot, 64)
|
|
r := chi.NewRouter()
|
|
r.Post("/deployment", func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, buildinfo.Version(), r.Header.Get(telemetry.VersionHeader))
|
|
w.WriteHeader(http.StatusAccepted)
|
|
dd := &telemetry.Deployment{}
|
|
err := json.NewDecoder(r.Body).Decode(dd)
|
|
require.NoError(t, err)
|
|
deployment <- dd
|
|
})
|
|
r.Post("/snapshot", func(w http.ResponseWriter, r *http.Request) {
|
|
require.Equal(t, buildinfo.Version(), r.Header.Get(telemetry.VersionHeader))
|
|
w.WriteHeader(http.StatusAccepted)
|
|
ss := &telemetry.Snapshot{}
|
|
err := json.NewDecoder(r.Body).Decode(ss)
|
|
require.NoError(t, err)
|
|
snapshot <- ss
|
|
})
|
|
server := httptest.NewServer(r)
|
|
t.Cleanup(server.Close)
|
|
serverURL, err := url.Parse(server.URL)
|
|
require.NoError(t, err)
|
|
options := telemetry.Options{
|
|
Database: db,
|
|
Logger: testutil.Logger(t),
|
|
URL: serverURL,
|
|
DeploymentID: uuid.NewString(),
|
|
}
|
|
if addOptionsFn != nil {
|
|
options = addOptionsFn(options)
|
|
}
|
|
|
|
reporter, err := telemetry.New(options)
|
|
require.NoError(t, err)
|
|
t.Cleanup(reporter.Close)
|
|
return <-deployment, <-snapshot
|
|
}
|